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

288 lines
33 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.
# D2, часть 1. Деревья, BST, куча (урок)
Дерево — рекурсивная структура: узел + набор поддеревьев. На собеседовании не спрашивают
определение, спрашивают следствия: как способ хранения (указатели vs массив) и выбранный
инвариант (порядок BST vs экстремум кучи) напрямую определяют сложность операций. Порядок
разбора: как дерево лежит в памяти → как его обходят → что даёт BST-инвариант → что даёт
heap-инвариант → куда это применяется в top-K, LCA, валидации.
## 1. Представление: узлы с указателями vs массив
**Узлы с указателями.** Типичный `TreeNode` — значение плюс два указателя на детей:
```c++
struct TreeNode {
int val;
TreeNode *left, *right;
};
```
На x86-64 `sizeof(TreeNode) == 24`: `val` (`int`, 4 байта) лежит по смещению 0, дальше 4
байта паддинга — указатель должен быть выровнен на 8 байт, — `left` по смещению 8 (8 байт),
`right` по смещению 16 (8 байт). Итого 24, а не 20 — та же механика паддинга, что и в
структурах вообще (`char+int+char` → 12 байт по тем же правилам выравнивания). Каждый узел —
отдельная аллокация в куче, значит отдельный вызов аллокатора и произвольный адрес — узлы
дерева обычно не лежат рядом в памяти, отсюда промахи кэша при обходе.
**Массив.** Для узла с индексом `i` дети — `2i+1` и `2i+2`, родитель — `(i-1)/2` (целочисленное
деление) — индексная арифметика заменяет указатели. Представление компактно только для
**полного или близкого к полному** дерева (все уровни заполнены слева направо без пропусков,
как в куче): тогда индексы плотно покрывают массив без дыр. Для разреженного дерева такое
представление может потребовать до `2^h` ячеек массива, из которых заполнена малая часть —
это и есть причина, по которой куча всегда хранится в массиве, а произвольное дерево (BST,
дерево поиска) — почти всегда через указатели.
**Факты для карточек**
- base | Из чего состоит узел бинарного дерева при представлении указателями? — значение + два указателя (left, right)
- core | Размер `struct TreeNode { int val; TreeNode *left, *right; }` на x86-64? — 24 байта: 4 байта `val` + 4 байта паддинга (выравнивание указателя на 8) + 8 + 8 байт под указатели
- core | Когда массивное представление дерева компактно, а когда нет? — компактно только для полного/близкого к полному дерева (куча); для разреженного дерева индексы `2i+1/2i+2` требуют до `2^h` ячеек, большинство из которых пустует
- deep | Почему куча хранится в массиве, а не через указатели? — куча всегда почти полное дерево (заполнена по уровням слева направо без пропусков), индексная арифметика не тратит память впустую и не требует отдельной аллокации на узел
Почему дальше: раз дерево можно линейно уместить в массив только при полном заполнении по уровням, как вообще перебирают узлы произвольного дерева — обходы.
## 2. Обходы: DFS (preorder/inorder/postorder) и BFS
Три порядка DFS отличаются местом, где посещается сам узел относительно детей:
- **preorder** (node, left, right) — узел раньше детей; используется, когда нужно
восстановить структуру дерева по порядку посещения (сериализация, копирование).
- **inorder** (left, node, right) — для BST даёт значения **по возрастанию** (см. раздел 3).
- **postorder** (left, right, node) — оба ребёнка раньше узла; используется, когда узел
нельзя обработать/освободить до того, как обработаны/освобождены его дети (удаление дерева
снизу вверх, вычисление арифметического дерева выражений).
**DFS рекурсией** использует неявный стек вызовов, глубина = высота дерева `h` → память
`O(h)`. **DFS итеративно** — явный `std::stack<TreeNode*>`, та же асимптотика `O(h)`, но без
риска переполнения стека потока: кадр функции тяжелее записи в explicit-стеке (адрес возврата
+ сохранённые регистры против одного указателя, 8 байт), а стек потока на Linux ограничен
(обычно порядка нескольких мегабайт) — на сильно вырожденном дереве (`h` порядка `n`)
рекурсия может упереться в этот лимит раньше, чем закончится память под явный стек.
**BFS** обходит уровень за уровнем через очередь; память — `O(w)`, где `w` — максимальная
ширина уровня, а не высота. Для полного сбалансированного дерева из `n` узлов высота
`h ≈ log₂ n`, но ширина последнего уровня — около `n/2`: при `n = 1 000 000` это `h ≈ 20`
(`log₂10⁶ ≈ 20`) против ширины последнего уровня ≈ 500 000. То есть BFS-очередь на пике может
требовать памяти на порядки больше, чем DFS-стек, для одного и того же дерева.
**Ловушки**
- Не проверить `node == nullptr` перед обращением к `node->val` в рекурсии → разыменование нулевого указателя → SIGSEGV, UBSAN отдельно ловит это с `-fsanitize=null`.
- В итеративном DFS перепутать порядок `push` детей (класть left раньше right вместо наоборот при эмуляции preorder через стек — стек разворачивает порядок) → обход посещает поддеревья не в том порядке, тихий баг, виден только сравнением с рекурсивным эталоном.
- Использовать рекурсивный DFS на сильно несбалансированном дереве (`h ≈ n`, например после вставки отсортированной последовательности) → глубина рекурсии `n` → переполнение стека потока, а не просто медленная работа.
**Факты для карточек**
- base | Порядок посещения в inorder-обходе? — left, node, right
- base | Какой обход даёт отсортированный порядок для BST? — inorder
- core | Память DFS (рекурсия или явный стек) по глубине дерева? — O(h) — пропорционально высоте
- core | Память BFS (очередь)? — O(w) — пропорционально максимальной ширине уровня
- core | Для полного сбалансированного дерева из 10⁶ узлов: высота и ширина последнего уровня? — h ≈ 20 (log₂10⁶≈20), ширина последнего уровня ≈ 500 000 — BFS может требовать памяти на порядки больше, чем DFS
- deep | Какой обход используют, чтобы освободить дерево снизу вверх? — postorder (сначала оба ребёнка, потом сам узел)
Почему дальше: обходы работают одинаково независимо от порядка значений в узлах; BST добавляет конкретный порядковый инвариант — разберём, что именно он даёт.
## 3. BST-инвариант и что он даёт
Инвариант: для каждого узла все значения в левом поддереве меньше значения узла, все значения
в правом — больше. Отсюда напрямую:
- **Поиск/вставка/удаление — O(h).** На каждом узле решение однозначно (значение меньше —
влево, больше — вправо), путь до цели или до `nullptr` не длиннее высоты дерева.
- **Inorder-обход = отсортированный порядок.** Прямое следствие инварианта: в любом
поддереве все значения левой части меньше корня, все значения правой — больше, рекурсивно
это верно на каждом уровне, поэтому обход left→node→right посещает значения строго по
возрастанию.
Высота `h` — не константа, а зависит от формы дерева: у сбалансированного BST (например,
построенного случайными вставками или явно балансирующегося, как красно-чёрное дерево)
`h ≈ log₂ n`; у **вырожденного** — `h = n`. Конкретный пример вырождения: вставка значений
`1, 2, 3, ..., n` по порядку в обычный (не самобалансирующийся) BST — каждый новый узел
становится правым ребёнком предыдущего максимума, дерево превращается в цепочку, и поиск,
ожидаемо `O(log n)`, на деле деградирует до `O(n)` — то есть до линейного перебора связного
списка.
**Факты для карточек**
- base | Инвариант BST? — для каждого узла все значения в левом поддереве меньше значения узла, все значения в правом — больше
- base | Сложность поиска в BST? — O(h), где h — высота дерева
- core | Что даёт inorder-обход BST? — значения по возрастанию — прямое следствие инварианта
- core | Высота BST в лучшем и худшем случае для n узлов? — сбалансированное: h ≈ log₂n; вырожденное: h = n
- deep | Что произойдёт при вставке 1,2,...,n по порядку в обычный (небалансирующийся) BST? — дерево вырождается в цепочку (каждый новый узел — правый ребёнок предыдущего), поиск деградирует до O(n)
Почему дальше: раз наивный BST может выродиться в список, встают два практических вопроса — как правильно проверить дерево на BST-инвариант и как искать общего предка, используя (или не используя) этот инвариант.
## 4. Валидация BST и LCA
**Частая ошибка валидации** — сравнивать узел только с непосредственным родителем
(`node->left->val < node->val` и `node->right->val > node->val` на каждом шаге). Контрпример:
корень `5`, его левый ребёнок `3`, а правый ребёнок узла `3` — `6`. Локальная проверка
`6 > 3` проходит (это правый ребёнок `3`, и `6` больше `3`), но `6` находится в левом
поддереве корня `5`, а `6 > 5` — инвариант BST для всего дерева нарушен. Локальное сравнение
с родителем этого не ловит, потому что оно ничего не знает про предков выше.
**Правильная валидация** — рекурсия с передачей вниз границ `(min, max)`: каждый узел должен
строго лежать между ними; при спуске влево верхняя граница ужесточается до значения узла
(`max = node->val`), при спуске вправо ужесточается нижняя (`min = node->val`). Альтернатива —
inorder-обход с проверкой строгого возрастания относительно последнего посещённого значения
(`O(n)` время, `O(1)` дополнительной памяти, если не копить весь список, а сравнивать на лету).
**LCA в BST** использует порядок значений: начиная с корня, если оба искомых значения меньше
текущего узла — идти влево, если оба больше — вправо, иначе текущий узел и есть точка, где
пути к двум значениям расходятся, то есть LCA. Сложность `O(h)` — та же логика, что у поиска.
**LCA в произвольном бинарном дереве** (без порядка) не может отбросить половину дерева на
каждом шаге — порядка, который это позволяет, нет. Стандартное решение — рекурсия postorder:
если узел `nullptr` или совпадает с одним из искомых — вернуть его; рекурсивно получить
результат из левого и правого поддерева; если оба непустые — текущий узел и есть LCA;
иначе вернуть тот результат, который непустой (продвинуть найденный узел вверх). Сложность
`O(n)` — в худшем случае нужно посетить каждый узел один раз; память `O(h)` на стек рекурсии.
**Ловушки**
- Валидировать BST сравнением только с прямым родителем → пропускает нарушения через поколение (контрпример: корень 5, левый 3, правый потомок 3 равен 6) → тест с таким деревом должен упасть, а наивная проверка его пропустит.
- Использовать нестрогое сравнение (`<=` вместо `<`) без явного решения, допустимы ли дубликаты и в какое поддерево они кладутся → BST с равными значениями валидируется непредсказуемо.
- В LCA по BST не проверить, что оба узла реально принадлежат дереву → функция вернёт правдоподобный, но неверный узел вместо явной ошибки.
**Факты для карточек**
- base | Почему нельзя валидировать BST, сравнивая узел только с непосредственным родителем? — нарушение инварианта может возникнуть через поколение (правый потомок левого поддерева больше корня, но меньше своего прямого родителя) — локальная проверка это не ловит
- core | Как правильно валидировать BST рекурсивно? — передавать вниз границы (min, max): узел должен строго лежать между ними, для левого поддерева ужесточается верхняя граница, для правого — нижняя
- core | Сложность LCA в BST и почему? — O(h): если оба искомых значения меньше текущего узла — влево, оба больше — вправо, иначе текущий узел и есть LCA
- core | Сложность LCA в обычном бинарном дереве без порядка? — O(n): нет инварианта, отсекающего часть дерева, нужна рекурсия postorder по потенциально всем узлам
- deep | Альтернативный способ валидации BST без явных границ (min, max)? — inorder-обход с проверкой строгого возрастания относительно предыдущего посещённого значения
Почему дальше: и валидация, и LCA используют дерево как структуру поиска по значению; для задач top-K важнее не порядок всех элементов, а быстрый доступ к экстремуму — для этого нужен другой инвариант, куча.
## 5. Куча: инвариант и индексы
Инвариант max-heap: значение в родителе не меньше значений в обоих детях. Хранится в массиве
(см. раздел 1): для индекса `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`.
- **`top()` — O(1).** По инварианту максимум всегда лежит в корне, то есть в начале массива —
прямое обращение по индексу 0, без поиска.
- **`sift-up`/`sift-down` — O(log n).** Оба переставляют элемент вдоль одного пути от узла до
корня или от корня до листа; длина этого пути ограничена высотой дерева.
**Bottom-up heapify — O(n), а не O(n log n).** Через `n` последовательных `push` (каждая —
`sift-up` до `O(log i)` на i-й вставке) суммарная стоимость — `Σ log₂(i)` для `i = 1..n`, это
порядка `n log₂ n`. Bottom-up heapify стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс 0), вызывая `sift-down` на каждом узле; работа `sift-down`
в узле пропорциональна **высоте именно этого узла `h`**, а не высоте всего дерева — узлов
большой высоты экспоненциально мало (в корне — один узел высоты `log n`, листьев высоты 0 —
около `n/2`, они вообще пропускаются). Сумма `Σ h/2^h` по всем высотам сходится к константе
(≈2) при росте `n`, поэтому суммарная работа — `O(n)`, а не `O(n log n)`.
**Факты для карточек**
- base | Индексы детей и родителя в куче на массиве для узла i? — дети 2i+1, 2i+2; родитель (i-1)/2 (целочисленное деление)
- base | Сложность доступа к максимуму (top) в max-heap? — O(1) — максимум всегда в корне по инварианту
- core | Сложность sift-up/sift-down? — O(log n) — путь ограничен высотой дерева
- core | За какое время строится куча через bottom-up heapify против n последовательных push? — O(n) против O(n log n): работа sift-down в узле пропорциональна его высоте h, а сумма Σ h/2^h по всем узлам сходится к константе
- deep | С какого индекса стартует bottom-up heapify и куда идёт? — с последнего нелистового узла, индекс n/2 - 1, к корню (индекс 0)
Почему дальше: куча даёт O(1) доступ к экстремуму и O(log n) перестройку — на этом строится вся группа задач top-K, и у кучи есть конкурент по сложности — quickselect.
## 6. priority_queue, top-K и nth_element
`std::priority_queue` по умолчанию — max-heap поверх `std::vector` (сравнение `std::less`,
для min-heap передают `std::greater<>` третьим параметром шаблона).
Три способа получить top-K:
1. **Полная куча.** `heapify` за `O(n)`, затем `k` раз `pop` (`sift-down` корня) по `O(log n)`
каждый — итого `O(n + k log n)`.
2. **Потоковый (min-heap размера k).** Когда данные не помещаются в память целиком: держат
min-heap ровно из `k` элементов, каждый новый элемент сравнивают с минимумом кучи (`top`,
O(1)), и если новый больше — минимум вытесняется, новый добавляется. Сложность
`O(n log k)`, память `O(k)` вместо `O(n)`.
3. **`nth_element` (quickselect).** Партиционирует диапазон вокруг опорного элемента и
рекурсивно спускается только в ту половину, где лежит нужный ранг: в среднем `T(n) =
T(n/2) + O(n) → O(n)`, в худшем случае (устойчиво плохой выбор опорного, та же причина,
что и у quicksort) — `O(n²)`. Результат — частичный порядок (всё слева не больше опорного,
всё справа не меньше, но без порядка внутри половин), не отсортированный список: для
готового top-K по убыванию `nth_element` придётся досортировать `k`-элементный префикс,
тогда как `pop` из кучи уже отдаёт элементы по убыванию сам по себе.
**Top K Frequent** — отдельный паттерн: подсчёт частот хеш-таблицей за `O(n)`, затем либо
heap размера `k` (`O(n log k)`), либо **bucket sort по частоте**: частота любого элемента не
превышает `n`, поэтому заводят массив корзин размера `n + 1`, кладут элемент в
`bucket[частота]` и сканируют от максимальной частоты к минимальной — `O(n)` без
логарифмического множителя вообще.
**Ловушки**
- Строить кучу через `push` в цикле вместо bottom-up `heapify` → результат корректен (инвариант тот же), но `O(n log n)` вместо `O(n)` — на миллионах элементов заметно медленнее, и это отдельный вопрос на собеседовании.
- Использовать `nth_element` там, где нужен полностью отсортированный top-K, и забыть досортировать k-элементный префикс → порядок в выводе неверный, хотя набор элементов правильный.
- В потоковом top-K сравнивать новый элемент с максимумом кучи вместо минимума (перепутать min-heap и max-heap для этой задачи) → кандидаты вытесняются в обратном порядке, итоговый top-K — неверный набор элементов.
**Факты для карточек**
- base | Что такое `std::priority_queue` по умолчанию? — max-heap поверх vector; для min-heap передают компаратор std::greater<>
- core | Сложность top-K через полную кучу? — O(n + k log n): O(n) heapify + k раз pop по O(log n)
- core | Когда используют min-heap размера k вместо полной кучи? — на потоке данных, не помещающемся в память целиком: min-heap размера k хранит только k текущих кандидатов, O(n log k), память O(k)
- core | Средняя и худшая сложность `nth_element` (quickselect)? — в среднем O(n), в худшем O(n²) — та же причина, что у quicksort: устойчиво плохой выбор опорного
- deep | Почему top-K через nth_element не даёт готовый отсортированный список? — nth_element только частично упорядочивает вокруг k-го элемента без порядка внутри половин; нужна досортировка k-элементного префикса
- deep | Как получить top-K частых элементов за O(n) без log-фактора? — подсчёт хеш-таблицей O(n), затем bucket sort по частоте: частота не превышает n, массив корзин размера n+1 индексируется частотой напрямую
Почему дальше: дерево, BST-инвариант и куча — это и есть механизмы за стандартными задачами интервью; дальше — какая задача проверяет какой именно из них.
## 7. Задачи-триггеры
- **Invert Binary Tree** — рекурсивно поменять местами `left`/`right` у каждого узла: `O(n)`
время (каждый узел посещается один раз), `O(h)` память на стек рекурсии.
- **Validate BST** — рекурсия с границами `(min, max)`, передаваемыми вниз (раздел 4).
- **Kth Largest** — min-heap размера `k` (`O(n log k)`) или `nth_element`/quickselect
(в среднем `O(n)`) (раздел 6).
- **Top K Frequent** — хеш-таблица + bucket sort по частоте (`O(n)`) или heap размера `k`
(`O(n log k)`) (раздел 6).
Ссылка на задачу дня: `tasks/11_heap` — там же ключевое требование «`heapify` только
bottom-up, не `push` в цикле», прямая проверка раздела 5.
## Проверь себя
<details>
<summary>1. Почему нельзя проверить BST, сравнивая каждый узел только с прямым родителем?</summary>
Потому что нарушение инварианта может произойти через поколение: узел может быть больше
своего прямого родителя, но при этом лежать в поддереве более раннего предка, для которого
он слишком велик (например, правый потомок левого ребёнка корня оказывается больше самого
корня). Правильная проверка передаёт вниз границы (min, max), актуальные для всего пути от
корня, а не только для одного родителя.
</details>
<details>
<summary>2. Почему bottom-up heapify — O(n), хотя каждый sift-down в отдельности — O(log n)?</summary>
Потому что работа sift-down в конкретном узле пропорциональна высоте именно этого узла, а не
высоте всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел
высоты log n, листьев высоты 0 — около n/2, для них sift-down почти ничего не делает). Сумма
Σ h/2^h по всем высотам сходится к константе, поэтому суммарная работа растёт линейно с n.
</details>
<details>
<summary>3. Чем LCA в BST отличается по сложности от LCA в обычном бинарном дереве и почему?</summary>
В BST — O(h): порядок значений позволяет на каждом шаге однозначно решить, идти влево, вправо
или остановиться. В обычном дереве такого порядка нет, поэтому приходится рекурсивно
обходить дерево (postorder) и в худшем случае посетить все n узлов — O(n).
</details>
<details>
<summary>4. Почему nth_element в среднем O(n), но не гарантирует худший случай?</summary>
Потому что рекурсия спускается только в ту часть, где лежит искомый ранг, отбрасывая
остальное после каждого партиционирования — в среднем это даёт T(n) = T(n/2) + O(n) → O(n).
Но при устойчиво плохом выборе опорного элемента (та же причина, что и у худшего случая
quicksort) партиционирование каждый раз отбрасывает лишь один элемент, и сложность
деградирует до O(n²).
</details>
<details>
<summary>5. Почему массив — плохое представление для сильно несбалансированного (не полного) дерева?</summary>
Потому что индексы 2i+1/2i+2 предполагают позицию узла в полном дереве соответствующей
глубины; если дерево заполнено не полностью (например, вырожденная цепочка), индексы,
которые физически используются, разбросаны на диапазон до 2^h, и большая часть массива
такого размера останется пустой.
</details>
## Задачи дня
- `tasks/11_heap` — куча: bottom-up `heapify` за O(n), `kth_largest`/`top_k`; ключевая
проверка — запрет строить кучу через `push` в цикле.
## Материалы
- Документация `std::priority_queue` (куча в top-K) — https://en.cppreference.com/w/cpp/container/priority_queue
- Документация `std::nth_element` (quickselect для k-го элемента) — https://en.cppreference.com/w/cpp/algorithm/nth_element
- cppreference: undefined behavior — https://en.cppreference.com/w/cpp/language/ub
- GCC: флаги инструментации (ASAN/UBSAN) — https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html