Лекция 4. Продвинутые структуры данных
План лекции
- Set
- Multiset
- Map
- Unordered map
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. Полезные методы
empty— является ли множество пустым (true/false).size— количество элементов.clear— очистка множества.
#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(log n) |
|---|---|
Итераторы begin и end |
insert |
| Разыменование итератора | erase |
empty |
find |
size |
count |
lower_bound и upper_bound |
Также стоит упомянуть асимптотику прохода циклом по set и асимптотику метода clear — для обоих это O(n), где n это количество элементов в множестве.
Главный вывод: все основные операции set стоят O(log n), а не 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(log n) |
|---|---|
Итераторы 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(log n) |
|---|---|
Итераторы begin и end |
Обращение через [ ] |
| Разыменование итератора | find |
empty |
lower_bound и upper_bound |
size |
Асимптотика прохода циклом по map, и асимптотика метода clear — O(n), где n это количество записей в map.
Unordered map
Unordered map — структура данных, которая помогает сопоставлять объекты.
Определение то же самое, что у map, — и это не опечатка.
Чем отличается unordered map
Единственное различие между map и unordered map — способ хранения данных. Снаружи мы пишем почти тот же код, но внутри записи лежат не в отсортированном дереве, а разбросаны по таблице. Отсюда все следствия.
Плюс: обращение по ключу и find работают за O(1) вместо O(log n).
Минусы: - Записи больше не хранятся по возрастанию ключей. Цикл выдаст их в произвольном порядке. - В связи со способом хранения данных lower_bound и upper_bound не доступны — искать «ближайший ключ» больше не по чему. - С очень маленькой вероятностью unordered_map может начать работать долго. Такое бывает, когда ключи неудачно ложатся в таблицу.
Асимптотика методов unordered map
| O(1) |
|---|
Обращение через [ ] |
Итераторы begin и end |
find |
| Разыменование итератора |
empty |
size |
Асимптотика прохода циклом по unordered map, и асимптотика метода clear — O(n), где n это количество записей в unordered map.
Как выбирать между map и unordered map: если нужен порядок ключей, lower_bound или гарантия по времени — берём map. Если нужны только «положить по ключу» и «взять по ключу», и как можно быстрее — берём unordered_map.