Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613
This commit is contained in:
@@ -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/
|
||||
Reference in New Issue
Block a user