Занятие 17. Контейнеры и алгоритмы STL
Семинар складывает карту стандартной библиотеки. Полконтейнера мы уже построили руками: динамический массив и дек — занятие 8, vector изнутри — занятие 16; сегодня достраиваем остальное и, главное, вешаем на карту настоящие ценники: О-символика молчит о константах, а константы здесь отличаются в сотни раз. Замеры реальные (Apple M-серия, clang++ -O2).
Контейнеры и их цены
Карта
| контейнер | устройство | a[i] |
в конец | в середину | поиск |
|---|---|---|---|---|---|
vector |
непрерывный буфер (зан. 8, 16) | O(1) | O(1)* | O(n) | O(n) / O(log n)† |
deque |
цепочка блоков (зан. 8: дек) | O(1) | O(1)* с двух концов | O(n) | O(n) |
list |
двусвязные узлы (зан. 8) | — | O(1) | O(1)‡ | O(n) |
set / map |
сбалансированное дерево | — | O(log n) | O(log n) | O(log n) |
unordered_set / map |
хеш-таблица | — | O(1) в среднем | O(1) в среднем | O(1) в среднем |
* — амортизированно (занятие 10) · † — O(log n) бинпоиском после сортировки (занятие 12) · ‡ — сама вставка O(1), но дойти до места — O(n). Устройство дерева и хеш-таблицы — второй семестр; сегодня это контракты с ценами.
Замер 1: миллион вставок в конец
vector/deque кладут int в готовый буфер — почти бесплатно (амортизация работает). list платит аллокацией узла за каждую вставку — ×14. set — аллокация плюс спуск по дереву, где каждый уровень — кэш-промах (занятие 8), — ×265. unordered_set — хеш, корзина и периодический rehash — ×103. Одинаковые «O(1)» и «O(1)» на практике отличаются в сотню раз.
Замер 2: миллион проверок «есть ли x?»
Оба «логарифма», а разница ×11: бинпоиск прыгает по непрерывной памяти, дерево — по узлам-указателям (занятия 8 и 12). unordered_set — в среднем один прыжок по хешу: быстрее всех, но без порядка и с худшим случаем O(n). Статичные данные + много запросов — отсортированный вектор бьёт дерево по скорости и памяти: приём «инвестиция» из занятия 15.
Правило выбора
По умолчанию — vector (+ reserve); словарь/множество — unordered_*; нужен порядок, минимум или диапазоны на живых данных — map/set; очередь с двух концов — deque.
Кто держит порядок
У set/map есть свои lower_bound/upper_bound — границы из занятия 12 за O(log n) на живом, меняющемся множестве. Ниша deque — очередь: O(1) с обоих концов без переезда. Ниша list — «почти никогда» (занятие 8: обход ×36 медленнее вектора); оправдан, когда итератор уже стоит на месте вставки и адреса обязаны быть стабильными.
Итераторы
Один интерфейс для всех контейнеров
Итератор — обобщённый указатель: у вектора это почти буквально указатель, у списка — обёртка над узлом, у дерева — позиция обхода. Диапазон — всегда полуинтервал [begin, end): конвенция занятий 12 и 15 оказалась конвенцией всей STL. end() — не последний элемент, а сентинель за краем: «не нашли» = end(), ровно как ±∞ в бинпоиске. Инвалидация из занятия 16 — общая тема: у вектора при переезде рвётся всё, у list/set/map — только итератор удалённого элемента.
Категории: что итератор умеет
| категория | умеет | кто выдаёт |
|---|---|---|
| forward | ++, *, сравнение на == |
forward_list, unordered_set/map |
| bidirectional | + -- |
list, set, map |
| random access | + it + k, it[k], it2 - it1, < |
vector, deque, string, массив |
Категория — это контракт: алгоритм объявляет минимальные требования. std::sort требует random access — ему нужны прыжки к середине. Занятие 12 говорило «бинпоиск за O(log n) — только на random access»: категория определяет не только «скомпилируется ли», но и цену.
Читаем отказ: sort не берёт list
Ошибка прилетает из недр библиотеки (make_heap.h!), не из вашей строки: sort попытался вычесть list-итераторы, а такой операции нет. Навык чтения простыни ошибок: найти свою строку и первый «invalid operands». Лечение — собственный l.sort(): сортировка слиянием по узлам. Коварнее случаи, которые компилируются: std::distance и std::advance на списке работают — но за O(n). Категория решает цену молча.
Алгоритмы
Рабочая дюжина
| вызов | что делает |
|---|---|
std::sort(v.begin(), v.end()) |
отсортировать; + компаратор третьим аргументом |
std::stable_sort(...) |
то же, равные не переставляются |
std::lower_bound / upper_bound |
границы вхождений (занятие 12) — на отсортированном |
std::min_element / max_element |
итератор на минимум/максимум |
std::count / count_if |
сколько равных x / сколько удовлетворяют предикату |
std::find / find_if |
первый равный / первый подходящий (или end) |
std::accumulate(..., init) |
сумма (свёртка) — из <numeric> |
std::reverse / std::unique |
развернуть / сжать подряд идущие дубликаты |
Все берут полуинтервал, многие — предикат-лямбду из занятия 14. Сырой цикл, заменимый одной строкой отсюда, почти всегда стоит заменить: меньше мест для ошибки на единицу.
Ловушка 1: тип аккумулятора задаёт init
Тип суммы accumulate выводит из третьего аргумента, а не из элементов. Одна буква LL отделяет верный ответ от занятия 3 с минусом. Дисциплина та же, что у префиксных сумм занятия 15: суммы живут в long long. Общий урок обобщённого кода: типы выводятся из аргументов — проверяйте, из каких.
Ловушка 2: sort не обещает порядок равных
Сортируем студентов (оценка, номер в журнале) только по оценке:
Стабильность — сохранение исходного порядка элементов с равными ключами. sort её не обещает — и уже на 40 элементах перемешал. Когда важна: каскадная сортировка «по главному ключу, внутри — по второстепенному» (сортируем по второстепенному, затем stable по главному). Цена на наших данных около нуля — но stable_sort может требовать O(n) дополнительной памяти. И осторожно: на маленьких массивах sort часто «случайно стабилен» — гарантия есть только у stable_sort.
Ловушка 3: remove ничего не удаляет
Алгоритм видит только итераторы — он не знает, в каком контейнере живёт диапазон, и не может менять его размер. Поэтому remove лишь переупаковывает нужное в начало и возвращает границу «нового конца», а отрезает хвост сам контейнер. Забытый erase — прежний размер и мусор в хвосте: классика код-ревью. C++20 закрыл вопрос парой std::erase / std::erase_if.
Компараторы
Компаратор — третий аргумент: функтор из занятия 16 или лямбда из занятия 14. std::tie даёт каскад ключей одной строкой — лексикографическое сравнение ссылок на поля. Для «естественного» порядка типа лучше operator<=> со = default (занятие 16); для ситуативного — лямбда на месте.
Компаратор обязан быть строгим
«Меньше» — да, «меньше или равно» — нельзя: с <= равные элементы отвечают «a<b и b<a — оба истинны», нарушая строгий слабый порядок, и sort вправе выйти за границы массива. Это не вопрос стиля — это UB. Быстрая проверка: cmp(x, x) обязан вернуть false.
Задачи семинара
-
Топ-3 слова. Дан текст — найти три самых частых слова.
unordered_map<string,int>для счёта → перекладка в вектор пар →sortс компаратором по убыванию счёта. Почему на счёте unordered, а не map? -
Пересечение массивов по 10⁶ — тремя способами: sort + два указателя (занятие 12),
set,unordered_set. До запуска предскажите порядок скоростей по замерам занятия. -
Дубликаты, но порядок. Удалить дубликаты, сохранив порядок первых вхождений: {3, 1, 3, 2, 1} → {3, 1, 2}.
Домашнее задание (сдача через Git)
- Частотный анализ: топ-10 слов текстового файла (unordered_map + sort с компаратором; при равной частоте — алфавитный порядок через
std::tie). - Пересечение и разность двух массивов тремя способами + таблица замеров своей машины по методике занятия 16.
- Каскадная сортировка: журнал (фамилия, группа, оценка) — по группе, внутри по убыванию оценки, внутри по фамилии: одним компаратором с
tieи последовательностью stable_sort; докажите совпадение результатов. - Чистка циклов: найдите в своих прошлых ДЗ три сырых цикла и перепишите на
count_if/min_element/accumulate; приложите ссылки на коммиты до/после. - * Хеш для пары: положите
pair<int,int>вunordered_set— напишите свою хеш-функцию и объясните, чем плохаh = x ^ y(подсказка: что будет на парах (a, a)?).
Следующее занятие — тёмная сторона: reinterpret_cast, union, неопределённое поведение и выравнивание данных. Посмотрим, что лежит под типами, — и почему компилятор вправе удалить ваш код целиком.