15 KiB
Задача 11 — куча: построение за O(n) и top-K (C++)
Куча max-heap в массиве (для элемента i дети — 2i+1, 2i+2, родитель — (i-1)/2),
без std::priority_queue.
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-upheapify→ машинная проверка свойств кучи пройдёт (инвариант тот же), но сложность — 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 отличается от кучи.