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

15 KiB
Raw Blame History

Задача 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-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 отличается от кучи.