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