# D3, часть 1. Списки, стек, очередь, монотонный стек (урок) Это не проверка, а урок: сначала разбираем механизм, потом решаешь задачи. Код в разборах можно копировать и запускать — это образец, а не готовый ответ на задачу. ## 1. Односвязный и двусвязный список: сложности ```c 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. Разворот списка тремя указателями ```c 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 ```c 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. Цикл Флойда: обнаружение и вход в цикл ```c 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` (кольцевой буфер, ступенями).
Проверь себя 1. Список из 5 узлов (1→2→3→4→5). Что вернёт `find_middle` по алгоритму slow/fast из этого урока?
ОтветУзел 3 — ровно середина при нечётной длине 5.
2. Почему `pop` в очереди на двух стеках всё равно считается амортизированной O(1), если конкретный вызов с переливом стоит O(n)?
ОтветКаждый элемент переливается из `in` в `out` не более одного раза за всё время жизни в очереди, поэтому суммарная стоимость n операций — O(n), то есть в среднем O(1) на операцию.
3. В кольцевом буфере ёмкости 4 записали 4 элемента, сняли 2, записали ещё 3. Сколько раз `head` за это время перешёл через границу массива (сделал wrap-around) при последовательной индексации с 0?
ОтветОдин раз: после 4 записей `head` дошёл до конца и вернулся в 0, при следующих 3 записях он снова дойдёт до 3, второго заворота ещё не будет (это можно проверить руками по формуле `idx=(idx+1)%4`, начиная с `head=0`).
4. Почему монотонный стек для 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/