Т-Банк

Алгоритмы.

Мини-курс по C++

Лекция 4. Продвинутые структуры данных

План лекции


Set

Set — структура данных, которая хранит набор уникальных элементов.

Если мы попробуем вставить в set элемент, который там уже есть, ничего не произойдёт — множество останется прежним. Элементы внутри всегда хранятся в порядке возрастания, независимо от того, в каком порядке мы их вставляли.

Чтобы пользоваться set, нужно подключить заголовок <set>.

1. Объявление, вставка и удаление

#include <set>

using namespace std;

int main() {
    // Объявляем set
    set<int> s;

    // Вставляем элементы
    s.insert(13);
    s.insert(57);

    // Удаляем элементы
    s.erase(13);
    s.erase(76);
}

Обратите внимание на строку s.erase(76): числа 76 в множестве нет, но это не ошибка. Удаление несуществующего элемента просто ничего не делает.

2. Цикл по множеству

Проход по set циклом for даёт элементы в порядке возрастания — это одно из главных свойств этой структуры.

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;

    s.insert(13);
    s.insert(57);
    s.insert(121);

    // Пробегаем по элементам s
    for (int element: s) {
        cout << element << ' ';
    }
}

Программа выведет 13 57 121.

3. Итераторы

Напомним: итератор — это объект, который указывает на какой-либо элемент коллекции. У set есть два ключевых итератора: - s.begin() — итератор на первый (то есть наименьший) элемент. - s.end() — итератор на позицию после последнего элемента.

Разыменование — получение значения, на которое указывает итератор. Пишется через звёздочку: *iter.

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;

    s.insert(13);
    s.insert(57);

    // Объявляем итераторы
    set<int>::iterator fullInit = s.find(57);
    auto simpleInit = s.find(99);

    // Ключевые итераторы
    set<int>::iterator beg = s.begin();
    set<int>::iterator end = s.end();

    // Берем значение по итераторам
    int x = *fullInit; // x = 57
    int y = *beg; // y = 13
}

Полное название типа итератора — set<int>::iterator. Оно длинное, поэтому чаще пишут auto.

4. Поиск элементов

Есть два способа узнать, есть ли элемент в множестве.

count — возвращает количество вхождений элемента. Для set это всегда 0 или 1, потому что элементы уникальны. find — возвращает итератор на элемент. Если элемента нет, возвращается s.end().

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;

    s.insert(13);
    s.insert(57);

    // Проверяем вхождение в s
    s.count(13); // 1
    s.count(179); // 0

    // Находим элементы
    auto iter1 = s.find(57);
    auto iter2 = s.find(81); // iter2 = s.end()
}

5. Поиск по критериям

Так как set хранит элементы отсортированными, по нему можно искать не только точное значение, но и «ближайшее подходящее» — ровно как бинпоиском по массиву.

lower_bound — итератор на первый элемент заданного. upper_bound — итератор на первый элемент, строго больший заданного.

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;

    s.insert(13);
    s.insert(57);
    s.insert(76);

    // Ищем первого большего
    auto iter1 = s.upper_bound(23); // *iter1 = 57
    auto iter2 = s.upper_bound(57); // *iter2 = 76

    // Ищем первого большего или равного
    auto iter3 = s.lower_bound(23); // *iter3 = 57;
    auto iter4 = s.lower_bound(57); // *iter4 = 57;
}

Разница видна на строках с числом 57: lower_bound(57) находит само число, а upper_bound(57) перешагивает через него к следующему.

6. Полезные методы

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;

    s.insert(13), s.insert(57);
    // s = {13, 57}

    // Отчистим множество
    s.clear();
    // s = {}

    // Посмотрим на размер
    s.empty(); // true
    s.size(); // 0

    s.insert(16), s.insert(44), s.insert(33);
    // s = {16, 33, 44}

    s.empty(); // false
    s.size(); // 3
}

7. Асимптотика методов set

O(1) O(logn)
Итераторы begin и end insert
Разыменование итератора erase
empty find
size count
lower_bound и upper_bound

Также стоит упомянуть асимптотику прохода циклом по set и асимптотику метода clear — для обоих это O(n), где n это количество элементов в множестве.

Главный вывод: все основные операции set стоят O(logn), а не O(1). Это плата за то, что элементы всегда лежат отсортированными.


Multiset

Multiset — структура данных, которая хранит набор элементов (не обязательно уникальных).

Чем отличается multiset

Отличие ровно одно: одинаковые элементы больше не «схлопываются». Всё остальное — итераторы, порядок возрастания, набор методов — работает так же, как в set.

1. Вставка и удаление

Здесь прячется главная ловушка multiset. У erase два разных поведения: - ms.erase(значение) — удаляет все вхождения этого значения. - ms.erase(итератор) — удаляет ровно одно вхождение.

#include <iostream>
#include <set>

using namespace std;

int main() {
    set<int> s;
    multiset<int> ms;

    s.insert(29), s.insert(29);
    ms.insert(29), ms.insert(29);
    // s = {29}, ms = {29, 29}

    ms.insert(14), ms.insert(27);
    // ms = {14, 27, 29, 29}

    ms.erase(29); // {14, 27, 29, 29} -> {14, 27}
    ms.erase(ms.find(29)); // {14, 27, 29, 29} -> {14, 27, 29}
}

Первые строки — та самая иллюстрация разницы: две вставки числа 29 дают в set один элемент, а в multiset — два.

2. Поиск элементов

count в multiset становится по-настоящему полезным: теперь он возвращает не только 0 или 1, а реальное количество вхождений.

#include <iostream>
#include <set>

using namespace std;

int main() {
    multiset<int> ms;

    ms.insert(29), ms.insert(29);
    ms.insert(14), ms.insert(27);
    // ms = {14, 27, 29, 29}

    ms.count(29); // 2
    ms.count(55); // 0
    ms.count(14); // 1

    set<int>::iterator iter1 = ms.find(27); // iter = {14, -> 27, 29, 29}
    set<int>::iterator iter2 = ms.find(29); // iter = {14, 27, -> 29, 29}
    set<int>::iterator iter3 = ms.lower_bound(25); // iter = {14, 27, -> 29, 29}
}

Стрелка в комментариях показывает, на какой элемент указывает полученный итератор. Если одинаковых элементов несколько, find даёт итератор на первое из вхождений.

3. Асимптотика методов multiset

O(1) O(logn)
Итераторы begin и end insert
Разыменование итератора erase
empty find
size count
lower_bound и upper_bound

Асимптотика multiset такая же, как и у set.


Map

Map — структура данных, которая помогает сопоставлять объекты.

Проще говоря, map хранит пары «ключ — значение». По ключу мы мгновенно получаем значение: по букве — число, по числу — вектор, по строке — что угодно. Ключи внутри map, как и элементы set, хранятся отсортированными и уникальными.

Подключается заголовком <map>.

1. Запись сопоставлений

При объявлении в угловых скобках пишут два типа: сначала тип ключа, потом тип значения. Типы могут быть любыми, в том числе составными.

#include <iostream>
#include <map>
#include <vector>
#include <string>

using namespace std;

int main() {
    map<bool, bool> m1;
    map<int, vector<int>> m2;
    map<vector<int>, string> m3;

    map<char, int> m;
    m['q'] = 21;
    m['d'] = 34;
    m['k'] = 1;
    // m = {{'q', 21}, {'d', 34}, {'k', 1}}

    m['q'] = 77;
    // m = {{'q', 77}, {'d', 34}, {'k', 1}}
}

Запись и изменение выглядят одинаково — через квадратные скобки. Если ключа не было, запись создаст новую пару; если был — просто заменит значение.

2. Циклы по map

Элемент map — это пара, поэтому в цикле мы получаем не одно значение, а два: p.first — ключ, p.second — значение. Записать это можно тремя способами.

#include <iostream>
#include <map>

using namespace std;

int main() {
    map<char, int> m;
    m['q'] = 21;
    m['d'] = 34;
    m['k'] = 1;
    // m = {{'q', 77}, {'d', 34}, {'k', 1}}

    // Пробегаем по записям
    for (pair<char, int> p: m) {
        cout << p.first << ' ' << p.second << '\n';
    }

    for (auto p: m) {
        cout << p.first << ' ' << p.second << '\n';
    }

    for (auto [key, val]: m) {
        cout << key << ' ' << val << '\n';
    }
}

Первый цикл — явно говорим, что перебираем пары. Второй — заменили длинный тип на auto. Третий — сразу дали элементам пары имена; так читать удобнее всего.

3. Работа с ключами

Здесь ещё одна ловушка, и она серьёзнее, чем кажется. Обращение по несуществующему ключу не даёт ошибку — оно создаёт эту запись со значением по умолчанию (для int это 0).

#include <iostream>
#include <map>

using namespace std;

int main() {
    map<char, int> m;
    m['q'] = 21;
    m['d'] = 34;
    m['k'] = 1;
    // m = {{'d', 34}, {'k', 1}, {'q', 77}}

    // Смотрим по несуществующему ключу
    int x = m['x']; // x = 0
    // m = {{'d', 34}, {'k', 1}, {'q', 77}, {'x', 0}};

    m.find('k'); // {{'d', 34}, -> {'k', 1}, {'q', 77}, {'x', 0}};

    int y = 0;
    if (m.find('y') != m.end()) {
        y = m['y'];
    }
    // m = {{'d', 34}, {'k', 1}, {'q', 77}, {'x', 0}};
}

Мы всего лишь прочитали m['x'] — а в map появилась лишняя запись, и size вырос. Поэтому проверять существование ключа нужно через find: как видно в последних строках, после проверки на y в map ничего не добавилось.

Обратите внимание и на порядок в комментариях: записи идут 'd', 'k', 'q' — по возрастанию ключей, а не в порядке вставки.

4. Поиск по критериям

lower_bound и upper_bound работают по ключам, точно так же, как в set.

#include <iostream>
#include <map>

using namespace std;

int main() {
    map<char, int> m;
    m['q'] = 21;
    m['d'] = 34;
    m['k'] = 1;
    m['a'] = 13;
    // m = {{'a', 13}, {'d', 34}, {'k', 1}, {'q', 77}}

    auto iter1 = m.lower_bound('c'); // {{'a', 13}, -> {'d', 34}, {'k', 1}, {'q', 77}}
    auto iter2 = m.lower_bound('k'); // {{'a', 13}, {'d', 34}, -> {'k', 1}, {'q', 77}}
    auto iter3 = m.upper_bound('k'); // {{'a', 13}, {'d', 34}, {'k', 1}, -> {'q', 77}}
}

5. Полезные методы

Те же, что и у set: empty, size, clear.

#include <iostream>
#include <map>

using namespace std;

int main() {
    map<char, int> m;
    m['q'] = 21;
    m['d'] = 34;
    // m = {{'d', 34}, {'q', 77}}

    m.clear();
    // m = {}

    m.empty(); // true
    m.size(); // 0

    m['k'] = 1;
    m['a'] = 13;
    // m = {{'a', 13}, {'k', 1}}

    m.empty(); // false
    m.size(); // 2
}

6. Асимптотика методов map

O(1) O(logn)
Итераторы begin и end Обращение через [ ]
Разыменование итератора find
empty lower_bound и upper_bound
size

Асимптотика прохода циклом по map, и асимптотика метода clearO(n), где n это количество записей в map.


Unordered map

Unordered map — структура данных, которая помогает сопоставлять объекты.

Определение то же самое, что у map, — и это не опечатка.

Чем отличается unordered map

Единственное различие между map и unordered map — способ хранения данных. Снаружи мы пишем почти тот же код, но внутри записи лежат не в отсортированном дереве, а разбросаны по таблице. Отсюда все следствия.

Плюс: обращение по ключу и find работают за O(1) вместо O(logn).

Минусы: - Записи больше не хранятся по возрастанию ключей. Цикл выдаст их в произвольном порядке. - В связи со способом хранения данных lower_bound и upper_bound не доступны — искать «ближайший ключ» больше не по чему. - С очень маленькой вероятностью unordered_map может начать работать долго. Такое бывает, когда ключи неудачно ложатся в таблицу.

Асимптотика методов unordered map

O(1)
Обращение через [ ]
Итераторы begin и end
find
Разыменование итератора
empty
size

Асимптотика прохода циклом по unordered map, и асимптотика метода clearO(n), где n это количество записей в unordered map.

Как выбирать между map и unordered map: если нужен порядок ключей, lower_bound или гарантия по времени — берём map. Если нужны только «положить по ключу» и «взять по ключу», и как можно быстрее — берём unordered_map.