Files
eduplan-cpp-eltex/diag/tasks/11_heap/task.md
T

165 lines
15 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.
# Задача 11 — куча: построение за O(n) и top-K (C++)
Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`),
без `std::priority_queue`.
```c++
void heapify(std::vector<int>& a); // перестроить массив в кучу
int kth_largest(const std::vector<int>& a, int k); // k >= 1
std::vector<int> top_k(const std::vector<int>& a, int k); // k наибольших, по убыванию
```
Требования:
- `heapify` — **просеивание снизу вверх (bottom-up), O(n)**, а не `push` в цикле (это O(n log n));
проверка свойств кучи машинная, требование по сложности я сверяю глазами по коду — на
собеседовании спросят именно это;
- `heapify` обязан сохранить мультимножество элементов (ничего не терять и не дублировать);
- `k` в границах `1..n`; при `k > n` — `top_k` возвращает все по убыванию, `kth_largest` при `k > n`
возвращает значение минимального элемента (то есть не падает);
- 3 000 000 элементов: `heapify` быстрее 2 секунд, `kth_largest` с `k = 1000` быстрее 2 секунд.
Проверка: `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` отличается от кучи.