Учебные материалы: механизм вместо тезисов, блоки «Факты для карточек», Ловушки (Claude Code) + diag/cards_src.tsv

This commit is contained in:
Kodlo-chan
2026-09-24 13:38:58 +07:00
parent e0ad8e0fee
commit 4259fbce75
17 changed files with 1688 additions and 115 deletions
+141 -1
View File
@@ -1,6 +1,7 @@
# Задача 11 — куча: построение за O(n) и top-K (C++)
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`), без `std::priority_queue`.
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`),
без `std::priority_queue`.
```c++
void heapify(std::vector<int>& a); // перестроить массив в кучу
@@ -20,5 +21,144 @@ std::vector<int> top_k(const std::vector<int>& a, int k); // k наибо
Проверка: `python3 grade.py 11`. Критерий: все `ok`, сборка без предупреждений,
ASAN/UBSAN чистые.
**Факты для карточек**
- base | Индексы детей и родителя в max-heap на массиве? — дети `2i+1`, `2i+2`; родитель `(i-1)/2`
- base | Что за 2 секунды должны выполниться на 3 000 000 элементов? — `heapify` и `kth_largest` с `k=1000` (по отдельности)
- base | Какой ключевой запрет на реализацию `heapify`? — нельзя строить через `push` в цикле, только bottom-up за O(n)
Почему дальше: массив интерпретируется как дерево через индексную арифметику — дальше
нужно понять, как именно поддерживается инвариант кучи и почему bottom-up быстрее push-цикла.
## Механизм: массив как дерево, sift-down/sift-up
Массив хранится линейно (`std::vector<int>`), но интерпретируется как полное бинарное
дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без
указателей. Инвариант max-heap: значение в родителе не меньше значений в обоих детях.
**sift-up** (используется при вставке одного элемента): новый элемент кладётся в конец
массива и сравнивается с родителем; пока он больше родителя — меняются местами, индекс
сдвигается к корню. Длина пути от листа до корня — высота дерева, `O(log n)`.
**sift-down** (используется при извлечении максимума и при bottom-up heapify): элемент в
корне (или в произвольном узле) сравнивается с обоими детьми; если хотя бы один ребёнок
больше — меняется местами с большим из них, и процесс повторяется на новой позиции, пока
инвариант не восстановится или узел не станет листом. Тоже `O(log n)` на один вызов от
корня.
**top()** — доступ к максимуму — `O(1)`: по инварианту кучи максимум всегда лежит в корне,
то есть в начале массива, никакого поиска не требуется.
**Факты для карточек**
- base | Сложность доступа к максимуму (`top`)? — O(1), максимум всегда в корне по инварианту кучи
- base | Сложность одного `sift-up`/`sift-down` от произвольного узла? — O(log n) — путь ограничен высотой дерева
- core | Что сравнивается на каждом шаге `sift-down`? — узел с обоими детьми; меняется местами с бОльшим из них, если тот больше узла
## Механизм: почему bottom-up heapify — O(n), а push-цикл — O(n log n)
**Через push в цикле.** Вставка i-го элемента (при текущем размере кучи `i`) требует до
`O(log i)` шагов `sift-up`. Суммарная работа — `Σ log₂(i)` для `i = 1..n`, это `log₂(n!)`,
по формуле Стирлинга порядка `n·log₂(n)`. Итого `O(n log n)` — почти вся вставленная масса
элементов всплывает от листьев, где высота дерева максимальна (`~log n`), к своему месту.
**Через bottom-up heapify.** Алгоритм стартует с последнего нелистового узла (индекс
`n/2 - 1`) и идёт к корню (индекс `0`), вызывая `sift-down` на каждом узле. Ключевое
отличие: работа `sift-down` в узле пропорциональна **высоте этого узла `h`**, а не высоте
всего дерева. Узлов с большой высотой мало (в корне — один узел высоты `log n`), а узлов с
малой высотой — экспоненциально больше (листьев высоты 0 — примерно `n/2`, они вообще
пропускаются). Суммарная работа:
```
Σ h=0..log n (n / 2^(h+1)) · h
```
Ряд `Σ h/2^h` при `h → ∞` сходится к константе `2` (не растёт с `n`), поэтому вся сумма
ограничена `n · const = O(n)` — линейно, а не `n log n`. Для `n = 3 000 000`
(`log₂ n ≈ 21,5`) это даёт примерно на порядок (~10 раз) меньше операций сравнения, чем
push-цикл — именно поэтому требование задачи «bottom-up, а не push» не формальность, а
разница на порядок при 3 миллионах элементов.
**Факты для карточек**
- core | За какое время строится куча через n последовательных `push`? — O(n log n): сумма `Σ log₂(i)` по всем вставкам ≈ `n log₂ n`
- core | За какое время строится куча через bottom-up heapify? — O(n): работа в узле пропорциональна его высоте `h`, а ряд `Σ h/2^h` сходится к константе, а не растёт с `n`
- deep | С какого индекса начинается bottom-up heapify и в какую сторону идёт? — с последнего нелистового узла `n/2 - 1`, к корню (индекс 0)
- core | Во сколько раз push-цикл медленнее bottom-up heapify по числу сравнений при n=3 000 000? — примерно на порядок (~10 раз): `log₂(3·10⁶) ≈ 21,5` против константы ~2 у bottom-up
Почему дальше: сама куча даёт максимум за O(1) и перестройку за O(log n) — на этом строятся
`kth_largest` и `top_k`, но у них есть более эффективная альтернатива для потоковых данных.
## kth_largest и top_k
Базовый подход: `heapify` за `O(n)`, затем `k` раз `pop` (извлечь максимум, `sift-down`
корня) — каждый `pop` стоит `O(log n)`. Итого `O(n + k log n)`. При `k = 1000` и
`n = 3 000 000` слагаемое `n` доминирует — отсюда требование «быстрее 2 секунд» выполнимо
за счёт того, что сама куча строится линейно.
Граничные случаи по условию: `k > n` — `top_k` отдаёт все элементы по убыванию, а
`kth_largest` — значение минимального элемента, без падения и без обращения за границу
массива.
**Потоковый top-K.** Когда данные не помещаются в память целиком (поток), вместо
построения полной кучи на все `n` элементов держат **min-heap размера k**: каждый новый
элемент сравнивается с минимумом кучи (`top`), и если новый элемент больше — минимум
удаляется, новый добавляется. Сложность — `O(n log k)` вместо `O(n log n)` для полной
сортировки, и память — `O(k)`, а не `O(n)`.
**nth_element vs куча.** `std::nth_element` (Хоара, quickselect) находит элемент на нужной
позиции и частично упорядочивает массив вокруг него (всё левее — не больше него, всё правее
— не меньше, но без порядка внутри частей) в среднем за `O(n)` — стандарт требует линейного
времени в среднем, но не гарантирует худший случай, в отличие от кучи, которая всегда даёт
`O(n + k log n)`. Для одного k-го элемента `nth_element` быстрее кучи по константе; для
top-K как **отсортированного** списка кучу всё равно придётся досортировать после
`nth_element`, тогда как `pop` из кучи уже отдаёт элементы по убыванию.
**Факты для карточек**
- base | Сложность `kth_largest`/`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 | Чем `std::nth_element` отличается от кучи для top-K? — в среднем O(n), даёт частичный порядок вокруг k-го элемента, но не отсортированный список и без гарантии худшего случая; куча всегда O(n + k log n) и отдаёт элементы по убыванию через pop
## Ловушки
- Построить кучу через `push` в цикле вместо bottom-up `heapify` → машинная проверка
свойств кучи пройдёт (инвариант тот же), но сложность — O(n log n) вместо O(n) → на
3 000 000 элементов заметно медленнее и явно видно по коду при ревью (это как раз спросят
на собеседовании отдельным вопросом).
- Потерять или продублировать элементы при перестройке `sift-down` (например, скопировать
значение вместо обмена местами) → мультимножество элементов меняется → видно сравнением
отсортированной копии массива до и после `heapify`.
- Не ограничить `k` при `k > n` → обращение к `pop` на пустой куче или выход за границу
массива → падение или UB, видно по ASAN, а не просто неверный ответ.
## Проверь себя
<details>
<summary>1. Почему bottom-up heapify — O(n), хотя высота дерева log n?</summary>
Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте
всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты
`log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится
к `n`, умноженному на константу, а не на `log n`.
</details>
<details>
<summary>2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения,
чем bottom-up heapify?</summary>
Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up
heapify) — то есть на порядок больше сравнений.
</details>
<details>
<summary>3. Почему `top()` кучи — O(1), а не O(log n)?</summary>
По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое
обращение по индексу `0`, без поиска и без перестройки структуры.
</details>
<details>
<summary>4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь
массив?</summary>
Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что
не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k
текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и
заменяет его за O(log k) — суммарно O(n log k) и память O(k).
</details>
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда
нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.