Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+307
View File
@@ -0,0 +1,307 @@
# 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/