165 lines
15 KiB
Markdown
165 lines
15 KiB
Markdown
# Задача 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` отличается от кучи.
|