# Задача 11 — куча: построение за O(n) и top-K (C++) Куча **max-heap** в массиве (для элемента `i` дети — `2i+1`, `2i+2`, родитель — `(i-1)/2`), без `std::priority_queue`. ```c++ void heapify(std::vector& a); // перестроить массив в кучу int kth_largest(const std::vector& a, int k); // k >= 1 std::vector top_k(const std::vector& 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`), но интерпретируется как полное бинарное дерево через индексную арифметику: родитель и потомки вычисляются по индексу, без указателей. Инвариант 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, а не просто неверный ответ. ## Проверь себя
1. Почему bottom-up heapify — O(n), хотя высота дерева log n? Потому что работа `sift-down` в узле пропорциональна высоте именно этого узла, а не высоте всего дерева, а узлов с большой высотой экспоненциально мало (в корне — один узел высоты `log n`, листьев высоты 0 — около `n/2`). Сумма `Σ (n/2^(h+1))·h` по всем высотам сходится к `n`, умноженному на константу, а не на `log n`.
2. При n = 3 000 000, во сколько раз push-в-цикле даёт больше операций сравнения, чем bottom-up heapify? Примерно в 10 раз: `log₂(3 000 000) ≈ 21,5` (push-цикл) против константы ≈2 (bottom-up heapify) — то есть на порядок больше сравнений.
3. Почему `top()` кучи — O(1), а не O(log n)? По инварианту max-heap максимум всегда лежит в корне, то есть в начале массива — это прямое обращение по индексу `0`, без поиска и без перестройки структуры.
4. Почему для top-K на потоке используют min-heap размера k, а не max-heap на весь массив? Max-heap на весь массив требует хранить все `n` элементов в памяти и строится за O(n), что не подходит для потока, не помещающегося в память. Min-heap размера k хранит только k текущих кандидатов на top-K, сравнивает новый элемент с минимумом кучи (O(1) доступ) и заменяет его за O(log k) — суммарно O(n log k) и память O(k).
Разбор после сдачи: почему снизу вверх выходит сумма геометрической прогрессии; когда нужен min-heap размера k (top-K на потоке); чем `nth_element` отличается от кучи.