Занятие 8. Базовые структуры данных
Структура данных — это контракт операций и их цен. Одни и те же данные можно хранить по-разному, и цена операций меняется на порядки; выбор структуры — это выбор, какие операции будут дешёвыми. Сегодня строим базовый набор руками — классами, по правилам занятия 7, — и сверяем со стандартной библиотекой. Все замеры в конспекте реальные: Apple M-серия, clang++ -O2, $10^7$ элементов.
Массив и связный список
Массив: всё подряд
Непрерывный блок памяти даёт доступ по индексу за $O(1)$ (адрес = база + i·sizeof, занятие 3) и максимальную дружбу с кэшем. Вставка и удаление в середине — сдвиг хвоста, $O(n)$; размер фиксирован на этапе компиляции.
Связный список: узлы, сшитые указателями
Вставка и удаление при известном узле — $O(1)$: перешить два указателя. Доступ по индексу — $O(n)$, только пешком от головы. Каждый узел — отдельная аллокация: плюс указатель на элемент и разброс по памяти.
Список как класс — концентрат вчерашнего занятия: инкапсуляция (Node — приватная деталь), RAII (деструктор освобождает узлы), правило пяти (владеем new/delete — копирование либо глубокое, либо = delete):
Сравнение и кэш-реальность
| операция | массив | связный список |
|---|---|---|
| доступ по индексу | $O(1)$ | $O(n)$ |
| вставка/удаление в начале | $O(n)$ — сдвиг | $O(1)$ |
| вставка/удаление в середине | $O(n)$ — сдвиг | $O(1)$*, найти узел — $O(n)$ |
| добавление в конец | $O(1)$ аморт. | $O(1)$ с указателем на хвост |
| память на элемент | sizeof(T) | sizeof(T) + указатель + накладные кучи |
* Ловушка собеседований: $O(1)$ вставка — при уже найденном месте.
Теперь главный опыт занятия. Сумма $10^7$ int:
Обе операции — $\Theta(n)$, но процессор читает память кэш-линиями по 64–128 байт: у вектора следующая порция уже подъехала, а каждый переход по next в списке — потенциальный промах кэша и сотни тактов ожидания.
Скрытые константы — во плоти
Занятие 2 предупреждало: O-символика не видит констант. Вот они: «одинаковые» $\Theta(n)$ отличаются в 36 раз. Асимптотика выбирает алгоритм, кэш выбирает структуру. Практический вывод: связный список сегодня — нишевый инструмент; по умолчанию — непрерывная память.
Динамический массив
size + capacity и стратегия роста
Храним size (занято) и capacity (выделено). push_back в резерв — $O(1)$. Резерв кончился — выделяем блок побольше, переезжаем (копируем все элементы), освобождаем старый: $O(n)$. Весь вопрос — насколько побольше:
- Расти на +1: каждый push — переезд; суммарно $1 + 2 + \dots + n = \Theta(n^2)$ копирований. Миллион добавлений — полтриллиона копий.
- Расти в ×2: переезды только на степенях двойки; суммарно $1 + 2 + 4 + \dots + n \le 2n = \Theta(n)$ — геометрическая прогрессия из занятия 6. Вся история переездов стоит меньше двух проходов.
В среднем на один push_back приходится $O(1)$ работы — это амортизированная сложность. Строгий разбор (метод усреднения, банковский метод) — занятие 10.
Реальный std::vector (libc++, эта машина): capacity растёт 1, 2, 4, 8, …, 1048576 — чистое удвоение, 21 реаллокация на миллион push_back. Знаете размер заранее — reserve(n): ноль переездов.
Инвалидация — плата за переезды
Рост инвалидирует все указатели, ссылки и итераторы на элементы: они смотрят в старый, уже освобождённый блок (UB). Та же причина запрещала менять контейнер внутри range-for (занятие 5).
Свой DynArray — правило пяти в бою
operator[] возвращает ссылку (занятие 3) — поэтому работает a[i] = 5. Это ядро настоящего std::vector минус шаблоны (занятие 24), move (22) и исключения (24).
Стек, очередь, дек
Стек — LIFO
push, pop, top — все $O(1)$, работаем только с вершиной. Где живёт: стек вызовов (кадры из занятия 5!), скобочные последовательности, undo, обход в глубину, разбор выражений. Реализация — адаптер над вектором (та самая композиция из занятия 7): push_back/pop_back и есть стек.
Классика применения — проверка скобочной последовательности:
Инвариант (в духе занятия 5): в стеке — все открытые и ещё не закрытые скобки в порядке вложенности; закрывающая обязана подойти к вершине.
Очередь — FIFO и кольцевой буфер
Очереди нужны push в хвост и pop из головы за $O(1)$: обработка задач по порядку, буферы ввода, обход в ширину (второй семестр). Наивная реализация на векторе проваливается: erase(v.begin()) сдвигает весь хвост — $O(n)$, миллион операций — $\Theta(n^2)$.
Решение — кольцевой буфер: массив и два индекса, движущиеся по модулю ёмкости ((i + 1) % cap). Оба конца — $O(1)$, память непрерывная, рост — как у вектора.
Дек
Double-ended queue: push/pop с обоих концов за $O(1)$ плюс доступ по индексу. Покрывает и стек, и очередь — std::stack и std::queue по умолчанию как раз адаптеры над std::deque. Устройство: каталог указателей на блоки-страницы — непрерывность кусочная, зато рост без переезда всех элементов. Киллер-фича — «скользящее окно»: добавляем справа, выбрасываем слева.
Шпаргалка по std::
| нужно | берите | почему |
|---|---|---|
| «просто набор элементов» | std::vector |
непрерывная память, кэш — дефолт всегда |
| LIFO | std::stack или vector |
push_back/pop_back — то же самое |
| FIFO | std::queue |
адаптер над deque; для скорости — свой кольцевой буфер |
| оба конца + индексы | std::deque |
блочная структура, $O(1)$ на концах |
| вставки в середину при известной позиции | std::list |
редко! помните ×36 на обходе |
Правило курса
Начинайте с vector. Меняйте структуру только по результату профилирования или явного требования операций — теперь вы знаете цену каждой.
Задачи семинара
- Скобки: реализовать
balanced(); расширение — вернуть позицию первой ошибки. - Разворот списка за $O(n)$ времени и $O(1)$ памяти — только перешивая указатели. Подсказка: три указателя prev/cur/next и письменный инвариант «всё до cur развёрнуто».
- Кольцевой буфер: класс
Queueс ростом ×2; стресс-тест противstd::queue(методика — занятие 4).
Домашнее задание (сдача через Git)
- Дописать
DynArray: копирующее присваивание,reserve,pop_back; стресс-тест противstd::vector. - Задачи семинара (все три).
- Замерить у себя обход vector против list на $10^7$; числа и вывод — в README репозитория.
- * Глубокое копирование
IntList— правило пяти целиком.