27 KiB
D3, часть 1. Списки, стек, очередь, монотонный стек (урок)
Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах можно копировать и запускать — это образец, а не готовый ответ на задачу.
1. Односвязный и двусвязный список: сложности
struct Node { int value; Node* next; }; // односвязный
struct DNode { int value; DNode* prev, *next; }; // двусвязный
Вставка и удаление узла, когда указатель на нужное место уже есть, — O(1): операция это
перелинковка 2–3 указателей соседей, без сдвига остальных элементов. Поиск по значению или
по номеру — всегда O(n): у списка нет арифметики адреса, только последовательный проход по
next от головы.
Двусвязный список хранит ещё и prev, поэтому удаление узла по указателю на него самого не
требует отдельно знать предыдущий узел — это цена дополнительного указателя (8 байт на 64-бит
системе) в каждом узле. Узлы списка разбросаны по куче отдельными аллокациями — отсюда плохая
локальность памяти и частые промахи кэша процессора по сравнению с массивом, где элементы
лежат подряд.
Факты для карточек
- base | Сложность вставки в список, если указатель на место уже есть? — O(1)
- base | Сложность поиска элемента в связном списке? — O(n)
- core | Почему список медленнее массива на практике, хотя вставка O(1) на бумаге? — узлы разбросаны по куче, промахи кэша при обходе
- core | Чем двусвязный список платит за удаление по указателю на сам узел без знания предыдущего? — лишним указателем
prevв каждом узле
Почему дальше: раз вставка/удаление — это перелинковка указателей, логично разобрать классическую операцию на этих указателях — разворот списка.
2. Разворот списка тремя указателями
Node* reverse_list(Node* head) {
Node* prev = nullptr;
Node* curr = head;
while (curr) {
Node* next = curr->next; // сохранить хвост ДО перезаписи
curr->next = prev;
prev = curr;
curr = next;
}
return prev; // новая голова
}
Три указателя нужны потому, что curr->next перезаписывается на prev, а без сохранённого
next связь с остальной частью списка потеряется безвозвратно. Один проход, O(n) по времени,
O(1) дополнительной памяти — ни одной аллокации, только перестановка указателей.
Ловушки
- Забыть сохранить
nextдо перезаписиcurr->next→ потеря хвоста списка → утечка памяти, видна по ASAN/LeakSanitizer. - Вернуть
currвместоprevкак новую голову →currв конце цикла равенnullptr, функция вернёт пустой список.
Факты для карточек
- base | Сложность разворота списка тремя указателями? — O(n) по времени, O(1) по памяти
- core | Зачем нужен третий указатель
next? — сохранить связь с остатком списка до перезаписиcurr->next
Почему дальше: раз можно пройти список одним указателем, следующий вопрос — как найти элемент на заданном расстоянии от конца за один проход, не зная длины заранее.
3. k-й элемент с конца
Идея: два указателя с фиксированным сдвигом в k узлов. Сначала продвигаем «ведущий» указатель на k шагов вперёд от головы, затем двигаем оба указателя одновременно по одному шагу, пока ведущий не дойдёт до конца — «отстающий» указатель в этот момент стоит на k-м узле с конца.
Один проход, O(n) по времени (ведущий указатель проходит список один раз, с задержкой в k шагов для отстающего), O(1) дополнительной памяти — длину списка знать заранее не нужно.
Факты для карточек
- base | Сколько проходов по списку нужно, чтобы найти k-й элемент с конца без знания длины? — один
- core | На сколько шагов вперёд продвигают ведущий указатель перед стартом совместного движения? — на k шагов
Почему дальше: тот же приём с двумя указателями разной скорости, а не разного стартового сдвига, даёт середину списка и обнаружение цикла — логично разобрать оба.
4. Середина списка: find_middle через slow/fast
Node* find_middle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) { // fast не может продвинуться на 2 — стоп
slow = slow->next;
fast = fast->next->next;
}
return slow; // для чётной длины — второй из двух средних
}
fast движется на 2 узла за шаг, slow — на 1: когда fast доходит до конца, slow
прошёл ровно половину пути. Для списка из 4 узлов (1→2→3→4) цикл даёт slow на узле 3 — это
второй из двух средних (2 и 3), что совпадает с требованием задачи 02_list. Условие цикла
fast && fast->next — единственно верное: без проверки fast->next обращение
fast->next->next на последнем узле разыменует nullptr.
Факты для карточек
- base | На сколько узлов за шаг двигается
fastв поиске середины? — на 2,slow— на 1 - core | Что вернёт
find_middleдля списка из 4 узлов (1→2→3→4)? — узел 3 (второй из двух средних) - deep | Почему условие цикла —
fast && fast->next, а не толькоfast? — безfast->nextобращениеfast->next->nextна последнем узле разыменуетnullptr
Почему дальше: та же пара slow/fast, если список зацикленный, не выходит из цикла вовсе — это и есть способ обнаружить цикл в списке.
5. Цикл Флойда: обнаружение и вход в цикл
bool has_cycle(Node* head) {
Node* slow = head;
Node* fast = head;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true; // встретились внутри цикла
}
return false;
}
Почему указатели вообще встречаются: если в списке есть цикл, fast внутри цикла обгоняет
slow с относительной скоростью 1 узел за шаг (двигаясь на 2, а slow — на 1), поэтому
расстояние между ними в цикле сокращается на 1 каждый шаг и обязательно дойдёт до 0 — не
позже чем за c шагов, где c — длина цикла. Без цикла fast просто первым дойдёт до
nullptr и цикл остановится по условию fast && fast->next.
Поиск входа в цикл — отдельный шаг после обнаружения встречи. Пусть a — расстояние от
головы до входа в цикл, b — от входа до точки встречи, c — длина цикла. В момент встречи
slow прошёл a+b, а fast — вдвое больше шагов, чем slow, то есть 2(a+b) = a+b + n·c
для какого-то целого n (лишние n полных обхода цикла). Отсюда a = (n-1)·c + (c-b) — то
есть путь длины a от головы совпадает по конечной точке с путём длины c-b от точки
встречи (остаток цикла до входа). Практический вывод: после обнаружения встречи ставим один
указатель обратно на голову, второй оставляем в точке встречи, двигаем оба по одному узлу за
шаг — они встретятся ровно на входе в цикл.
Факты для карточек
- base | С какой относительной скоростью
fastдогоняетslowвнутри цикла? — 1 узел за шаг - core | Сколько указателей нужно сбросить в голову списка, чтобы найти вход в цикл после первой встречи? — один, второй остаётся в точке встречи, оба идут дальше по 1 шагу
- deep | Почему после первой встречи путь от головы длиной
aи путь от точки встречи длинойc-bсходятся в одной точке? — из равенства2(a+b) = a+b+n·c, откудаa = (n-1)c + (c-b)
Ловушки
- Сравнивать значения узлов вместо указателей (
slow->value == fast->value) → ложное срабатывание на списке с повторяющимися значениями без реального цикла. - Забыть проверку
fast && fast->nextпередfast->next->next→ падение поnullptrна списке без цикла нечётной/чётной длины на границе.
Почему дальше: и разворот, и середина, и обнаружение цикла работают без единой аллокации — логично разобрать приём, который избавляет от лишних проверок на границах списка ещё до самого алгоритма — dummy-узел.
6. Зачем dummy-узел
Dummy (sentinel) — фиктивный узел перед настоящей головой списка, который никогда не несёт полезных данных. Механизм: без dummy операции вставки/удаления в начало списка требуют отдельной ветки кода («если это голова — обнови указатель head отдельно»), а с dummy у любого настоящего узла всегда есть предшественник, и вставка/удаление в начало становится тем же кодом, что и вставка/удаление в середину — специальный случай исчезает.
Типичное применение — задачи слияния двух списков и удаления узлов по условию: результат
собирают, привязывая новые узлы к dummy->next, а в конце возвращают dummy->next как
настоящую голову.
Факты для карточек
- base | Что решает dummy-узел? — убирает отдельную ветку кода для вставки/удаления в начало списка
- core | Что возвращают в конце вместо dummy? —
dummy->nextкак настоящую голову результата
Почему дальше: список — это структура с O(1) вставкой по указателю, но что если нужен доступ только с одного (или двух) концов — это уже стек и очередь.
7. Стек и очередь на массиве; очередь на двух стеках
Стек на массиве — индекс вершины top плюс сам массив: push пишет по top и увеличивает
индекс, pop уменьшает индекс и отдаёт значение — O(1), без аллокаций, LIFO (last in, first
out).
Очередь на массиве наивно требует сдвига элементов при каждом pop с начала — O(n). Решение
без сдвигов — два индекса head/tail с заворотом по модулю ёмкости (разбор в следующем
разделе, кольцевой буфер).
Альтернативная конструкция без ёмкости заранее — очередь на двух стеках (in, out):
push всегда кладёт в in — O(1). pop: если out пуст, целиком переливаем in в out
(это разворачивает порядок — самый старый элемент in окажется на вершине out), затем
берём с вершины out; если out не пуст — берём сразу. Каждый элемент физически
перекладывается из in в out не более одного раза за всё время своей жизни в очереди,
поэтому суммарная стоимость n операций — O(n), то есть амортизированная O(1) на
операцию, хотя отдельный вызов pop с переливом стоит O(n).
Факты для карточек
- base | Сложность push/pop у стека на массиве? — O(1)
- core | Сколько раз за свою жизнь в очереди на двух стеках элемент перекладывается между
inиout? — не более одного раза - core | Средняя (амортизированная) сложность pop в очереди на двух стеках? — O(1), несмотря на то что отдельный вызов с переливом стоит O(n)
Почему дальше: очередь на массиве без сдвигов элементов — это ровно то, что нужно для сетевых буферов, где сдвигать байты на каждый пакет непозволительно дорого. Это кольцевой буфер.
8. Кольцевой буфер: head/tail, wrap-around, полный/пустой
Массив фиксированного размера capacity плюс два индекса: head — куда пишем следующим,
tail — откуда читаем следующим. Когда индекс доходит до конца массива, он возвращается в
начало: idx = (idx + 1) % capacity, либо быстрее без деления — if (++idx == capacity) idx = 0;.
Сдвигать элементы не нужно никогда — в этом весь смысл структуры: O(1) на push/pop.
Ключевая ловушка, из-за которой структура — частый вопрос на собеседовании: если head и
tail совпали, буфер пуст или полон? Оба состояния дают одинаковое совпадение индексов.
Решения — либо отдельный счётчик size (тогда empty() — это size == 0, full() — это
size == capacity), либо сознательно держать одну ячейку всегда свободной и не использовать
её для данных.
Именно эта структура лежит в основе буферов приёма/передачи пакетов и буферов DMA в
драйверах: аппаратура пишет в буфер по одному индексу, программа читает по другому, оба
двигаются по кругу независимо, без перемещения самих данных в памяти. В однопоточном
варианте head/tail — обычные переменные; если буфер общий между потоком и прерыванием
или между двумя потоками, индексы нужно защищать (атомарными операциями или мьютексом) —
это разбирается в уроке про многопоточность.
Факты для карточек
- base | Формула перехода индекса на начало массива в кольцевом буфере? —
idx = (idx + 1) % capacity - base | Сложность push/pop в кольцевом буфере? — O(1), без сдвига элементов
- core | Как отличить полный кольцевой буфер от пустого, если
head == tail? — счётчикsize, либо держать одну ячейку всегда свободной - core | Более быстрая замена
% capacityбез деления? —if (++idx == capacity) idx = 0; - deep | Где кольцевой буфер встречается в драйверах? — буферы DMA и приёма/передачи пакетов, head/tail двигаются независимо без перемещения данных
Ловушки
- Сдвиг элементов вместо движения индексов → теряется весь смысл O(1), фактически O(n) на операцию.
- Путаница «пусто/полно» при совпавших
head/tailбез отдельного счётчика → неверный ответempty()/full()на границе. - Инкремент индекса до записи/чтения вместо после → значения идут в неправильном порядке после первого же заворота.
Почему дальше: если элементов много и нужно быстро находить «следующий больший» для каждого из них за один проход, наивный перебор даёт O(n²) — монотонный стек снижает это до O(n).
9. Монотонный стек: Next Greater Element, Daily Temperatures
Задача Next Greater Element: для каждого элемента массива найти первый элемент справа от
него, который больше. Наивно — вложенный цикл, O(n²). Монотонный стек решает за O(n): идём
слева направо, в стеке храним индексы элементов, для которых ответ ещё не найден (значения в
стеке убывают снизу вверх). Для каждого нового элемента a[i]: пока стек не пуст и
a[i] > a[top] — это и есть «следующий больший» для индекса на вершине, снимаем его со
стека и записываем ответ; затем кладём индекс i в стек.
Daily Temperatures — та же схема, только вместо значения элемента в ответ пишут расстояние (число дней) до дня с более высокой температурой.
Почему это O(n), хотя внутри есть вложенный while: каждый индекс попадает в стек ровно один
раз (push вызывается n раз суммарно) и снимается со стека не более одного раза (pop
вызывается максимум n раз суммарно за весь проход) — суммарно операций со стеком не больше
2n, поэтому общая стоимость всех итераций внешнего цикла вместе с внутренним while — O(n).
Это классический пример амортизированного анализа: отдельная итерация внешнего цикла
может выполнить несколько pop, но в сумме по всему проходу лишних операций не набегает.
Факты для карточек
- base | Наивная сложность Next Greater Element вложенным циклом? — O(n²)
- base | Сложность Next Greater Element через монотонный стек? — O(n)
- core | Почему монотонный стек даёт O(n), если внутри есть вложенный
while? — каждый индекс кладётся в стек и снимается не более одного раза, суммарно ≤2n операций за весь проход - core | Что хранит монотонный стек в задаче Next Greater Element — значения или индексы? — индексы (значения по ним убывают снизу вверх)
- deep | Чем Daily Temperatures отличается от Next Greater Element по сути алгоритма? — тем же алгоритмом, но в ответ пишут расстояние в днях, а не значение
Почему дальше: списки и кольцевые буферы из этого урока становятся общими структурами между
несколькими потоками в задаче 06_threads — там начинается вопрос синхронизации доступа к
ним.
Ссылки на задачи этого дня: tasks/02_list (разворот, середина, цикл), tasks/03_ring
(кольцевой буфер, ступенями).
Проверь себя
-
Список из 5 узлов (1→2→3→4→5). Что вернёт
find_middleпо алгоритму slow/fast из этого урока?Ответ
Узел 3 — ровно середина при нечётной длине 5. -
Почему
popв очереди на двух стеках всё равно считается амортизированной O(1), если конкретный вызов с переливом стоит O(n)?Ответ
Каждый элемент переливается из `in` в `out` не более одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n), то есть в среднем O(1) на операцию. -
В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз
headза это время перешёл через границу массива (сделал wrap-around) при последовательной индексации с 0?Ответ
Один раз: после 4 записей `head` дошёл до конца и вернулся в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`). -
Почему монотонный стек для Next Greater Element хранит убывающую последовательность значений, а не произвольную?
Ответ
Как только встречается элемент больше вершины стека, это и есть ответ для всех подходящих элементов на вершине — их снимают со стека; то, что остаётся в стеке, по построению всегда убывает сверху вниз, иначе элемент уже был бы снят раньше.
Материалы
- Clang: документация AddressSanitizer (утечки при потере хвоста списка) — https://clang.llvm.org/docs/AddressSanitizer.html
- cppreference:
std::mutex— https://en.cppreference.com/w/cpp/thread/mutex - cppreference:
std::atomicиmemory_order— https://en.cppreference.com/w/cpp/atomic/memory_order - Linux Kernel Module Programming Guide (кольцевые буферы в драйверах) — https://sysprog21.github.io/lkmpg/
- Linux kernel: Driver APIs (DMA-буферы) — https://docs.kernel.org/driver-api/