Files
eduplan-cpp-eltex/lessons/D3_algo.md
T

308 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/