Двоичная куча: различия между версиями
СВ (обсуждение | вклад) (Новая страница: «== TL;DR == Двоичная куча (binary heap; синоним — «бинарная пирамида») — почти полное двоичное дер...») |
СВ (обсуждение | вклад) |
||
| Строка 582: | Строка 582: | ||
# Wikipedia: Binary heap. URL: https://en.wikipedia.org/wiki/Binary_heap. | # Wikipedia: Binary heap. URL: https://en.wikipedia.org/wiki/Binary_heap. | ||
# Sedgewick R., Wayne K. ''Algorithms''. 4th ed. — Addison-Wesley, 2011. — Гл. 2.4 «Priority Queues». | # Sedgewick R., Wayne K. ''Algorithms''. 4th ed. — Addison-Wesley, 2011. — Гл. 2.4 «Priority Queues». | ||
[[Category:Алгоритмы и структуры данных]] | |||
[[Category:Деревья поиска]] | |||
Версия 00:32, 9 октября 2026
TL;DR
Двоичная куча (binary heap; синоним — «бинарная пирамида») — почти полное двоичное дерево, в котором ключ каждого узла не меньше ключей детей (max-куча), упакованное в обычный массив без указателей. Родитель и дети находятся по формулам [math]\displaystyle{ \lfloor i/2\rfloor }[/math], [math]\displaystyle{ 2i }[/math], [math]\displaystyle{ 2i+1 }[/math], поэтому структура занимает ровно [math]\displaystyle{ n }[/math] ячеек и не требует дополнительной памяти. Куча даёт очередь с приоритетом: чтение максимума за [math]\displaystyle{ O(1) }[/math], вставка и извлечение за [math]\displaystyle{ O(\log n) }[/math], а построение из готового массива — за [math]\displaystyle{ O(n) }[/math]. На её основе работают пирамидальная сортировка за [math]\displaystyle{ O(n\log n) }[/math] на месте, алгоритмы Дейкстры и Прима, коды Хаффмана, слияние [math]\displaystyle{ k }[/math] списков и поддержание медианы.
| Свойство | Значение |
|---|---|
| Класс | Двоичное дерево (почти полное), упакованное в массив; реализация очередь с приоритетом |
| Изобретение | Дж. Уильямс, 1964 (heapsort и куча как структура); Р. Флойд, 1964 (линейное построение) [3][4] |
| Чтение максимума | [math]\displaystyle{ O(1) }[/math] |
| Вставка (худший / амортизированная) | [math]\displaystyle{ O(\log n) }[/math] / [math]\displaystyle{ O(1) }[/math] для случайных данных |
| Извлечение максимума (худший) | [math]\displaystyle{ O(\log n) }[/math] |
| Построение из массива | [math]\displaystyle{ O(n) }[/math] (Флойд), не [math]\displaystyle{ O(n\log n) }[/math] |
| Сортировка на месте | [math]\displaystyle{ O(n\log n) }[/math] времени, [math]\displaystyle{ O(1) }[/math] памяти, неустойчивая |
| Память | [math]\displaystyle{ n }[/math] ячеек массива; никаких указателей |
| Упорядоченность | Частичная: максимум в корне, полного порядка нет |
| Стабильность | Неприменима (куча — не сортировка); heapsort неустойчив |
| Альтернативы | d-арная куча, биномиальная куча, фибоначчиева куча, splay-дерево, сбалансированное BST, отсортированный массив |
| Где встречается | heapq (Python), std::priority_queue (C++), java.util.PriorityQueue, Дейкстра/Прим, Хаффман, k-way merge, top-k, медиана
|
Определения
Почти полное двоичное дерево (nearly complete binary tree) — двоичное дерево, в котором все уровни, кроме, возможно, последнего, заполнены полностью, а последний заполняется слева направо без пропусков. Именно это позволяет упаковать дерево в массив без пустых ячеек.
Двоичная куча (binary heap) — почти полное двоичное дерево, удовлетворяющее свойству кучи: для каждого узла, кроме корня, ключ родителя не меньше ключа узла:
[math]\displaystyle{ \text{key}[\text{parent}(i)] \;\ge\; \text{key}[i]. }[/math]
Такая куча называется max-кучей (максимум в корне). Если знак заменить на обратный ([math]\displaystyle{ \text{key}[\text{parent}(i)] \le \text{key}[i] }[/math]), получается min-куча (минимум в корне). Все рассуждения симметричны; ниже по умолчанию max-куча (как в пирамидальной сортировке).
Массивное представление. Куча хранится в массиве [math]\displaystyle{ A[1..n] }[/math] (нумерация с 1): узел с индексом [math]\displaystyle{ i }[/math] имеет родителя [math]\displaystyle{ \text{parent}(i) = \lfloor i/2 \rfloor }[/math] и детей [math]\displaystyle{ \text{left}(i) = 2i }[/math], [math]\displaystyle{ \text{right}(i) = 2i+1 }[/math]. Дети существуют, только если соответствующая формула не превосходит [math]\displaystyle{ n }[/math]. При нумерации с 0: [math]\displaystyle{ \text{parent}(i) = \lfloor (i-1)/2 \rfloor }[/math], [math]\displaystyle{ \text{left}(i)=2i+1 }[/math], [math]\displaystyle{ \text{right}(i)=2i+2 }[/math].
Высота кучи — высота дерева, равная [math]\displaystyle{ \lfloor \log_2 n \rfloor }[/math] (число рёбер от корня до листа). heap-size — число элементов в куче; может быть меньше длины массива, если часть массива уже отсортирована (как в heapsort).
Мотивация и интуиция
Многие задачи сводятся к повторяющемуся вопросу «дай текущий минимум (или максимум) и добавь/ удали элемент»: планировщик выбирает ближайшую по времени задачу, Дейкстра — ближайшую непомеченную вершину, кодек — два самых редких символа. Хранить всё в обычном массиве и каждый раз искать минимум линейно — [math]\displaystyle{ O(n) }[/math] на запрос; поддерживать полностью отсортированный массив — [math]\displaystyle{ O(n) }[/math] на вставку. Оба варианта плохи при больших [math]\displaystyle{ n }[/math].
Куча даёт компромисс. Достаточно, чтобы максимум был в корне, а не полный порядок: тогда чтение максимума мгновенно ([math]\displaystyle{ O(1) }[/math]), а после изменения одного элемента нарушение локально и лечится спуском или подъёмом по одному пути длиной не более высоты дерева, то есть [math]\displaystyle{ O(\log n) }[/math]. При этом полный порядок нигде не поддерживается — экономия и есть источник эффективности.
Ключевая идея: почти полная форма дерева снимает необходимость в указателях (связи вычисляются из индексов), а свойство кучи делает нарушения локальными (правка одного узла затрагивает только путь до корня или до листа).
Устройство
Двоичная куча — это массив [math]\displaystyle{ A }[/math] и две вещи поверх него:
- Форма: элементы заполняют уровни дерева слева направо, поэтому дерево представимо массивом без пропусков. Число элементов на каждом уровне (кроме последнего) ровно удваивается.
- Свойство кучи: [math]\displaystyle{ \text{key}[\text{parent}(i)] \ge \text{key}[i] }[/math].
Из свойства кучи следует, что максимум всегда в корне ([math]\displaystyle{ A[1] }[/math]), но никакой другой порядок не гарантирован: второй по величине элемент — обязательно один из двух детей корня, [math]\displaystyle{ k }[/math]-й по величине может лежать где угодно в нижних уровнях. Полного упорядочивания нет.
Форма дерева важна для сложности: высота почти полного дерева равна [math]\displaystyle{ \lfloor \log_2 n \rfloor }[/math], поэтому любой путь от листа к корню содержит не более [math]\displaystyle{ \lfloor \log_2 n \rfloor }[/math] шагов, а на последнем уровне находится около половины всех элементов — именно этот факт делает построение кучи линейным (см. §10).
Операции и алгоритмы
Операция: просеивание вниз (Max-Heapify)
Идея. Если узел меньше большего из своих детей, свойство кучи нарушено только в этом месте. Меняем узел с большим ребёнком: больший поднимается, узел опускается, — и повторяем в поддереве, куда он попал. Нарушение «стекает» вниз по одному пути.
Пошаговое описание (максимизирующее просеивание).
- Среди узла [math]\displaystyle{ i }[/math] и его детей найти индекс наибольшего ключа (
largest). - Если
largest[math]\displaystyle{ \ne i }[/math] — обменять [math]\displaystyle{ A[i] }[/math] и [math]\displaystyle{ A[\text{largest}] }[/math]. - Продолжить просеивание из позиции
largest.
Инвариант. Перед обработкой позиции [math]\displaystyle{ i }[/math] поддеревья обоих детей [math]\displaystyle{ i }[/math] уже являются кучами. После обмена единственное возможное нарушение — в позиции largest.
Псевдокод.
Алгоритм Max-Heapify(A, i)
Вход: A — массив; i — индекс узла; поддеревья детей i — уже кучи
Выход: поддерево с корнем i стало max-кучей
Инвариант: единственное возможное нарушение свойства кучи — в текущей позиции i
1 l ← 2i
2 r ← 2i + 1
3 largest ← i
4 if l ≤ heap-size[A] and A[l] > A[largest]
5 largest ← l
6 if r ≤ heap-size[A] and A[r] > A[largest]
7 largest ← r
8 if largest ≠ i
9 swap(A[i], A[largest])
10 Max-Heapify(A, largest)
Сложность: O(log n) времени (глубина пути вниз), O(log n) стека при рекурсии или O(1) в цикле.
Корректность: индукция по высоте поддерева i. Обмен ставит на место i максимум тройки, после чего нарушение может остаться только в поддереве largest, где инвариант восстанавливается рекурсивным вызовом.
Пример. Узел [math]\displaystyle{ A[i]=4 }[/math] с детьми [math]\displaystyle{ 14 }[/math] и [math]\displaystyle{ 7 }[/math]: больший ребёнок 14 > 4, меняем их. Результат — узел 14 с детьми 4 и 7 (рис. 2).
Крайние случаи. Узел-лист (нет детей) — обменов нет. Один ребёнок (бывает только у предпоследнего узла при чётном [math]\displaystyle{ n }[/math]) — сравниваем с ним. Дубликаты не мешают: сравнения строгие.
Операция: вставка (Max-Heap-Insert)
Идея. Новый элемент добавляется в конец массива (это сохраняет форму дерева), после чего просеивается вверх: пока он больше родителя, меняется с ним местами. Нарушение поднимается к корню.
Пошаговое описание.
- Увеличить
heap-sizeи положить ключ в последнюю ячейку. - Пока ключ больше родителя — обменять с родителем и подняться на уровень выше.
- Как только родитель не меньше ключа или достигнут корень — остановиться.
Инвариант. До каждой итерации свойство кучи выполнено всюду, кроме, возможно, позиции текущего элемента; все остальные узлы уже корректны.
Псевдокод.
Алгоритм Max-Heap-Insert(A, key)
Вход: A — max-куча; key — добавляемый ключ
Выход: куча содержит key, свойство кучи восстановлено
1 heap-size[A] ← heap-size[A] + 1
2 A[heap-size[A]] ← key
3 i ← heap-size[A]
4 while i > 1 and A[parent(i)] < A[i]
5 swap(A[i], A[parent(i)])
6 i ← parent(i)
Сложность: O(log n) времени, O(1) памяти. Для случайных вставок ожидаемое число обменов константно (амортизированная O(1)).
Корректность: обмен с родителем восстанавливает свойство на новом месте, а нарушение может остаться только выше; цикл завершается, когда родитель не меньше ключа.
Пример. В массив [math]\displaystyle{ [16,14,10,8,7,9,3] }[/math] добавляем 15 (позиция 8). [math]\displaystyle{ 15 \gt A[4]=8 }[/math] — обмен; [math]\displaystyle{ 15 \gt A[2]=14 }[/math] — обмен; [math]\displaystyle{ A[1]=16 \ge 15 }[/math] — стоп. Результат [math]\displaystyle{ [16,15,10,14,7,9,3,8] }[/math] (рис. 3).
Крайние случаи. Вставка в пустую кучу — сразу корень. Вставка меньшего, чем корень, ключа — одного сравнения достаточно.
Операция: извлечение максимума (Extract-Max)
Идея. Максимум — в корне. Забираем его, ставим на место корня последний элемент (форма сохраняется, размер уменьшается на 1) и просеиваем корень вниз.
Пошаговое описание.
- Запомнить [math]\displaystyle{ A[1] }[/math] (результат).
- Переместить последний элемент в корень, уменьшить
heap-size. - Выполнить
Max-Heapify(A, 1).
Инвариант. После уменьшения размера свойство кучи выполнено всюду, кроме корня: оба поддерева корня — кучи.
Псевдокод.
Алгоритм Heap-Extract-Max(A)
Вход: A — max-куча, heap-size[A] ≥ 1
Выход: максимум A[1]; куча без этого элемента
1 if heap-size[A] < 1
2 error "куча пуста"
3 max ← A[1]
4 A[1] ← A[heap-size[A]]
5 heap-size[A] ← heap-size[A] − 1
6 if heap-size[A] ≥ 1
7 Max-Heapify(A, 1)
8 return max
Сложность: O(log n) времени, O(1) памяти.
Корректность: поддеревья корня — кучи (инвариант), поэтому Max-Heapify восстанавливает свойство во всём дереве.
Крайние случаи. Единственный элемент: после уменьшения размера просеивать нечего (строка 6). Пустая куча — ошибка.
Операции: чтение максимума и изменение ключа
Peek возвращает [math]\displaystyle{ A[1] }[/math] за [math]\displaystyle{ O(1) }[/math] и ничего не меняет.
Increase-Key([math]\displaystyle{ A, i, key) }[/math] (увеличение ключа) заменяет [math]\displaystyle{ A[i] }[/math] на большее значение и просеивает узел вверх — как при вставке. Требуется, чтобы новый ключ не был меньше текущего.
Алгоритм Heap-Increase-Key(A, i, key)
1 if key < A[i]
2 error "новый ключ меньше текущего"
3 A[i] ← key
4 while i > 1 and A[parent(i)] < A[i]
5 swap(A[i], A[parent(i)])
6 i ← parent(i)
Сложность: O(log n).
Decrease-Key (уменьшение ключа) аналогично просеивает узел вниз: [math]\displaystyle{ O(\log n) }[/math]. Именно increase-key для max-кучи (или decrease-key для min-кучи) нужен в алгоритмах на графах.
Операция: удаление произвольного элемента
Идея. Зная индекс [math]\displaystyle{ i }[/math], можно удалить элемент, скопировав на его место последний и затем восстановив свойство кучи в нужную сторону.
Алгоритм Heap-Delete(A, i)
1 A[i] ← A[heap-size[A]]
2 heap-size[A] ← heap-size[A] − 1
3 if i ≤ heap-size[A]
4 if i > 1 and A[i] > A[parent(i)]
5 Max-Heap-Increase-Key-подъём(A, i) # просеивание вверх
6 else
7 Max-Heapify(A, i) # просеивание вниз
Сложность: O(log n).
Крайний случай. Удаление корня сводится к Heap-Extract-Max, но без возврата значения.
Операция: построение кучи (Build-Max-Heap)
Идея. Не вставлять элементы по одному (это дало бы [math]\displaystyle{ O(n\log n) }[/math]), а просеять вниз все внутренние узлы от середины массива к началу: листья уже являются корректными кучами высоты 0. Как показывает §10, суммарная стоимость — [math]\displaystyle{ O(n) }[/math].
Пошаговое описание.
- Установить
heap-sizeравным длине массива. - Для [math]\displaystyle{ i }[/math] от [math]\displaystyle{ \lfloor n/2 \rfloor }[/math] вниз до 1 выполнить
Max-Heapify(A, i).
Инвариант. Перед итерацией [math]\displaystyle{ i }[/math] каждый узел с индексом больше [math]\displaystyle{ i }[/math] является корнем max-кучи. После Max-Heapify(A, i) узел [math]\displaystyle{ i }[/math] тоже становится корнем max-кучи. По окончании [math]\displaystyle{ i=1 }[/math] — всё дерево является кучей.
Псевдокод.
Алгоритм Build-Max-Heap(A)
Вход: A — произвольный массив длины n
Выход: A — max-куча
Инвариант: узлы i+1..n — корни max-куч
1 heap-size[A] ← length[A]
2 for i ← ⌊length[A]/2⌋ downto 1
3 Max-Heapify(A, i)
Сложность: O(n) времени (см. §10), O(log n) стека или O(1) при итеративной реализации.
Корректность: индукция по убыванию i; листья (i > ⌊n/2⌋) — тривиальные кучи, а Max-Heapify опирается на то, что поддеревья уже кучи.
Пример. Для [math]\displaystyle{ A=[4,1,3,2,16,9,10] }[/math] обработка [math]\displaystyle{ i=3,2,1 }[/math] даёт max-кучу [math]\displaystyle{ [16,2,10,4,1,9,3] }[/math] (рис. 4 и таблица в §9).
Крайние случаи. [math]\displaystyle{ n \le 1 }[/math] — цикл не выполняется. Отсортированный по возрастанию массив перестраивается за линейное время.
Операция: пирамидальная сортировка (Heapsort)
Идея. Построить max-кучу за [math]\displaystyle{ O(n) }[/math], затем [math]\displaystyle{ n-1 }[/math] раз поменять корень (максимум) с последним элементом кучи, уменьшить heap-size и просеять новый корень вниз. Отсортированный «хвост» массива растёт справа налево.
Пошаговое описание.
Build-Max-Heap(A).- Для [math]\displaystyle{ i }[/math] от [math]\displaystyle{ n }[/math] вниз до 2: обменять [math]\displaystyle{ A[1] }[/math] и [math]\displaystyle{ A[i] }[/math]; уменьшить
heap-size;Max-Heapify(A, 1).
Инвариант. В начале итерации с параметром [math]\displaystyle{ i }[/math] элементы [math]\displaystyle{ A[i+1..n] }[/math] содержат [math]\displaystyle{ n-i }[/math] наибольших ключей в неубывающем порядке, а [math]\displaystyle{ A[1..i] }[/math] — max-кучу с оставшимися элементами.
Псевдокод.
Алгоритм Heapsort(A)
Вход: A — массив длины n
Выход: A отсортирован по неубыванию
Инвариант: A[i+1..n] — наибольшие ключи по возрастанию; A[1..i] — max-куча
1 Build-Max-Heap(A)
2 for i ← length[A] downto 2
3 swap(A[1], A[i])
4 heap-size[A] ← heap-size[A] − 1
5 Max-Heapify(A, 1)
Сложность: O(n log n) времени в лучшем, среднем и худшем случае; O(1) памяти.
Корректность: корень кучи — максимум из оставшихся, поэтому на позицию i встаёт правильный элемент; инвариант сохраняется.
Свойства. Heapsort работает на месте ([math]\displaystyle{ O(1) }[/math] памяти сверх массива) и имеет гарантированные [math]\displaystyle{ O(n\log n) }[/math] без риска деградации, но неустойчив и на практике обычно проигрывает быстрой сортировке из-за плохой локальности обращений к памяти.
Реализация структуры на C++
Класс с настраиваемым компаратором: по умолчанию std::less даёт max-кучу, std::greater — min-кучу. Хранится std::vector, индексация с 0. Метод isHeap() проверяет свойство кучи.
#include <iostream>
#include <vector>
#include <cassert>
#include <algorithm>
#include <functional>
template <typename T, typename Compare = std::less<T>>
class BinaryHeap {
std::vector<T> a_;
Compare comp_; // comp_(x, y): x имеет более низкий приоритет, чем y (как в std::priority_queue)
void siftUp(std::size_t i) {
while (i > 0) {
std::size_t p = (i - 1) / 2;
if (!comp_(a_[p], a_[i])) break; // родитель не ниже — на месте
std::swap(a_[i], a_[p]);
i = p;
}
}
void siftDown(std::size_t i, std::size_t n) {
while (true) {
std::size_t l = 2 * i + 1, r = 2 * i + 2, best = i;
if (l < n && comp_(a_[best], a_[l])) best = l; // ребёнок приоритетнее
if (r < n && comp_(a_[best], a_[r])) best = r;
if (best == i) break;
std::swap(a_[i], a_[best]);
i = best;
}
}
public:
BinaryHeap() = default;
explicit BinaryHeap(std::vector<T> v) : a_(std::move(v)) { // Build-Heap за O(n)
for (std::size_t i = a_.size() / 2; i-- > 0; ) siftDown(i, a_.size());
}
void push(const T& x) { a_.push_back(x); siftUp(a_.size() - 1); }
const T& top() const { return a_.front(); }
T pop() {
T res = a_.front();
a_.front() = a_.back();
a_.pop_back();
if (!a_.empty()) siftDown(0, a_.size());
return res;
}
std::size_t size() const { return a_.size(); }
bool empty() const { return a_.empty(); }
bool isHeap() const {
for (std::size_t i = 1; i < a_.size(); ++i)
if (comp_(a_[(i - 1) / 2], a_[i])) return false; // нарушение: родитель ниже
return true;
}
};
namespace detail {
template <typename T, typename Compare>
void siftDownRange(std::vector<T>& a, std::size_t i, std::size_t n, Compare comp) {
while (true) {
std::size_t l = 2 * i + 1, r = 2 * i + 2, best = i;
if (l < n && comp(a[best], a[l])) best = l;
if (r < n && comp(a[best], a[r])) best = r;
if (best == i) return;
std::swap(a[i], a[best]);
i = best;
}
}
} // namespace detail
template <typename T>
void heapSort(std::vector<T>& a) {
std::size_t n = a.size();
std::less<T> higher; // max-куча
for (std::size_t i = n / 2; i-- > 0; ) // Build-Heap
detail::siftDownRange(a, i, n, higher);
for (std::size_t end = n; end > 1; --end) {
std::swap(a[0], a[end - 1]);
detail::siftDownRange(a, 0, end - 1, higher);
}
}
int main() {
// 1) построение из массива за O(n) и проверка свойства кучи
std::vector<int> v = {4, 1, 3, 2, 16, 9, 10};
BinaryHeap<int> h(v);
assert(h.isHeap());
// извлечение даёт ключи по убыванию
std::vector<int> out;
while (!h.empty()) out.push_back(h.pop());
assert(out.front() == 16);
assert(std::is_sorted(out.rbegin(), out.rend()));
// 2) min-куча через std::greater
BinaryHeap<int, std::greater<int>> mn;
for (int x : {5, 3, 8, 1, 9, 2}) mn.push(x);
assert(mn.top() == 1);
assert(mn.isHeap());
// 3) сортировка на месте
std::vector<int> w = {5, 3, 8, 1, 9, 2, 7, 4, 6, 0};
heapSort(w);
assert(std::is_sorted(w.begin(), w.end()));
std::cout << "ok\n";
return 0;
}
Замечания по реализации.
- В стандартной библиотеке C++ та же структура доступна через
std::priority_queue<T>(max-куча),std::priority_queue<T, std::vector<T>, std::greater<T>>(min-куча), а также через алгоритмыstd::make_heap,std::push_heap,std::pop_heapнад обычнымstd::vector. std::priority_queueне даёт доступа к произвольному элементу и не поддерживаетdecrease-key; если он нужен (Дейкстра), либо реализуют свою кучу, либо применяют ленивое удаление: кладут повторные записи и отбрасывают устаревшие.- Для больших индексов дети считаются как
2*i+1,2*i+2; при использовании знакового типа возможно переполнение — беритеstd::size_t. - Копирование контейнера копирует массив целиком и корректно; это дёшево по сравнению с узловыми структурами.
Сводная таблица сложности
| Операция | Худший | Средний | Амортизированная | Память |
|---|---|---|---|---|
| Peek (чтение корня) | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Insert | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] (ожидаемо [math]\displaystyle{ O(1) }[/math]) | [math]\displaystyle{ O(1) }[/math] для случайных вставок | [math]\displaystyle{ O(1) }[/math] |
| Extract-Max / Extract-Min | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Increase-Key / Decrease-Key | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] при уменьшении | [math]\displaystyle{ O(1) }[/math] |
| Delete (по индексу) | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Build-Heap из массива | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Heapsort | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Merge двух куч | [math]\displaystyle{ \Theta(n) }[/math] | — | — | [math]\displaystyle{ O(1) }[/math] |
Примечание. Оценки детерминированы: [math]\displaystyle{ O(\log n) }[/math] — это худший случай, а не средний. Единственная «приятность по вероятности» — ожидаемое [math]\displaystyle{ O(1) }[/math] обменов при случайной вставке (новый элемент обычно остаётся почти на месте). Слияние двух двоичных куч требует перестроения, поэтому [math]\displaystyle{ \Theta(n) }[/math]; для частых слияний используют биномиальные и фибоначчиевы кучи.
Трассировка примера
Построим max-кучу из [math]\displaystyle{ A=[4,1,3,2,16,9,10] }[/math] ([math]\displaystyle{ n=7 }[/math], начинаем с [math]\displaystyle{ i=\lfloor 7/2\rfloor=3 }[/math]).
| i | До обработки | Действие | После |
|---|---|---|---|
| — | [4, 1, 3, 2, 16, 9, 10] | исходный массив | [4, 1, 3, 2, 16, 9, 10] |
| 3 | [4, 1, 3, 2, 16, 9, 10] | 3 < 10 → swap(A[3], A[7]) | [4, 1, 10, 2, 16, 9, 3] |
| 2 | [4, 1, 10, 2, 16, 9, 3] | 1 < 16, ребёнок 16 > 10 → swap(A[2], A[5]) | [4, 16, 10, 2, 1, 9, 3] |
| 1 | [4, 16, 10, 2, 1, 9, 3] | 4 < 16 → swap(A[1], A[2]); затем 4 < 2 → swap(A[2], A[4]) | [16, 2, 10, 4, 1, 9, 3] |
Итог: [math]\displaystyle{ [16, 2, 10, 4, 1, 9, 3] }[/math] — max-куча. Проверки: [math]\displaystyle{ 16 \ge \{2,10\} }[/math], [math]\displaystyle{ 2 \ge \{4,1\} }[/math], [math]\displaystyle{ 10 \ge \{9,3\} }[/math].
Далее heapsort (рис. 5): swap корня с последним и просеивание: [math]\displaystyle{ [16,2,10,4,1,9,3] \to [14,8,10,3,7,9 \mid 16] \to [10,8,9,3,7 \mid 14,16] \to [9,8,7,3 \mid 10,14,16] \to \dots \to }[/math] отсортированный по возрастанию массив. Алгоритм выполняет [math]\displaystyle{ n-1 }[/math] извлечений, каждое за [math]\displaystyle{ O(\log n) }[/math].
Корректность и оценка сложности
Лемма 1 (корректность Max-Heapify). Если поддеревья детей узла [math]\displaystyle{ i }[/math] — max-кучи, то после Max-Heapify(A, i) поддерево с корнем [math]\displaystyle{ i }[/math] — тоже max-куча.
Доказательство. Индукция по высоте поддерева. В базовом случае (лист) доказывать нечего. Пусть узел [math]\displaystyle{ i }[/math] обменялся с ребёнком [math]\displaystyle{ \text{largest} }[/math]. На новой позиции [math]\displaystyle{ i }[/math] стоит наибольший ключ среди трёх узлов, поэтому свойство выполнено для [math]\displaystyle{ i }[/math] и его детей; в поддереве второго ребёнка ничего не менялось, а поддерево [math]\displaystyle{ \text{largest} }[/math] отличается от прежнего лишь тем, что его корень получил меньший ключ, — там применимо индуктивное предположение. [math]\displaystyle{ \blacksquare }[/math]
Лемма 2 (корректность Build-Max-Heap). После выполнения Build-Max-Heap(A) массив [math]\displaystyle{ A }[/math] является max-кучей.
Доказательство. Инвариант: перед итерацией [math]\displaystyle{ i }[/math] каждый узел с индексом [math]\displaystyle{ \gt i }[/math] — корень max-кучи. База: листья [math]\displaystyle{ i \gt \lfloor n/2\rfloor }[/math] кучами являются. Шаг: поддеревья детей узла [math]\displaystyle{ i }[/math] имеют корни с индексами [math]\displaystyle{ 2i, 2i+1 \gt i }[/math], то есть по инварианту это кучи; по лемме 1 Max-Heapify(A, i) делает корнем кучи и узел [math]\displaystyle{ i }[/math]. При [math]\displaystyle{ i=1 }[/math] вся структура — куча. [math]\displaystyle{ \blacksquare }[/math]
Теорема 3 (линейное построение). Build-Max-Heap работает за [math]\displaystyle{ O(n) }[/math].
Доказательство. Узел высоты [math]\displaystyle{ h }[/math] просеивается не более чем за [math]\displaystyle{ O(h) }[/math] шагов. В почти полном дереве число узлов высоты [math]\displaystyle{ h }[/math] не превосходит [math]\displaystyle{ \lceil n/2^{h+1}\rceil }[/math]. Поэтому
[math]\displaystyle{ T(n) \;\le\; \sum_{h=0}^{\lfloor\log_2 n\rfloor} \left\lceil \frac{n}{2^{h+1}} \right\rceil\!\!\cdot O(h) \;=\; O\!\left(n \sum_{h\ge 0} \frac{h}{2^{h+1}}\right) \;=\; O(n), }[/math]
так как ряд [math]\displaystyle{ \sum_{h\ge 0} h/2^{h+1} = 1 }[/math] (см. рис. 6). [math]\displaystyle{ \blacksquare }[/math]
Следствия. Insert, Extract-Max, Increase-Key, Delete проходят один путь длины не более [math]\displaystyle{ \lfloor\log_2 n\rfloor }[/math]: [math]\displaystyle{ O(\log n) }[/math]. Heapsort = [math]\displaystyle{ O(n) }[/math] на построение плюс [math]\displaystyle{ n-1 }[/math] извлечение по [math]\displaystyle{ O(\log n) }[/math]: [math]\displaystyle{ O(n\log n) }[/math], причём и в худшем случае.
Нижняя граница. Для любой структуры, реализующей очередь с приоритетом через сравнения, последовательность из [math]\displaystyle{ n }[/math] вставок и [math]\displaystyle{ n }[/math] извлечений требует [math]\displaystyle{ \Omega(n\log n) }[/math] сравнений, то есть в среднем [math]\displaystyle{ \Omega(\log n) }[/math] на операцию. Иначе говоря, [math]\displaystyle{ O(\log n) }[/math] двоичной кучи для извлечения асимптотически оптимальна с точностью до константы (улучшение возможно лишь в амортизированных границах и для decrease-key, как в фибоначчиевых кучах) ⚠️ требует проверки точной формулировки.
Сравнение с альтернативами
| Структура | Insert | Extract-Min | Decrease-Key | Find-Min | Build | Слияние |
|---|---|---|---|---|---|---|
| Двоичная куча | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ \Theta(n) }[/math] |
| d-арная куча | [math]\displaystyle{ O(\log_d n) }[/math] | [math]\displaystyle{ O(d\log_d n) }[/math] | [math]\displaystyle{ O(\log_d n) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ \Theta(n) }[/math] |
| Биномиальная куча | аморт. [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] |
| Фибоначчиева куча | [math]\displaystyle{ O(1) }[/math] | аморт. [math]\displaystyle{ O(\log n) }[/math] | аморт. [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
| Отсортированный массив | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(n) }[/math] | [math]\displaystyle{ O(1) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n) }[/math] |
| Сбалансированное BST | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n) }[/math] |
Вердикт. Двоичная куча — «рабочая лошадка»: простая, компактная, с гарантированными [math]\displaystyle{ O(\log n) }[/math], но без быстрого decrease-key и слияния. Для Дейкстры на разреженных графах теоретически нужны фибоначчиевы кучи ([math]\displaystyle{ O(m + n\log n) }[/math] против [math]\displaystyle{ O(m\log n) }[/math]), однако на практике двоичная куча часто быстрее из-за меньших констант и локальности. d-арная куча ускоряет вставку и decrease-key ценой более дорогого извлечения. Для частых слияний берут биномиальные кучи.
Где применяется
heapqв Python. Модуль реализует min-кучу поверх обычного списка функциямиheappush,heappop,heapify([math]\displaystyle{ O(n) }[/math]); выбрана именно двоичная куча за простоту и компактность в интерпретаторе.std::priority_queueв C++. Контейнер-адаптер по умолчанию — max-куча наstd::vector; для min-кучи передаютstd::greater. Та же структура лежит в основеstd::make_heap,std::push_heap,std::pop_heap.java.util.PriorityQueueв Java. Двоичная min-куча; используется в планировщиках задач, обработке событий, а также внутриTreeMap-независимых алгоритмов выбора.- Алгоритм Дейкстры и Прима. Очередь с приоритетом хранит вершины по текущей оценке расстояния;
extract-minдаёт ближайшую непомеченную вершину,decrease-key— обновление оценки. На двоичной куче Дейкстра работает за [math]\displaystyle{ O((n+m)\log n) }[/math]. - Коды Хаффмана. На каждом шаге берутся два символа с наименьшими частотами — классический случай двух
extract-minи последующей вставки; куча даёт сжатие за [math]\displaystyle{ O(n\log n) }[/math]. - Слияние [math]\displaystyle{ k }[/math] отсортированных списков (k-way merge). Куча из первых элементов списков выдаёт минимум за [math]\displaystyle{ O(\log k) }[/math], что даёт [math]\displaystyle{ O(N\log k) }[/math] вместо [math]\displaystyle{ O(Nk) }[/math]; так работает внешняя сортировка.
- Потоковые top-k и медиана. Для «[math]\displaystyle{ k }[/math] наибольших в потоке» держат min-кучу размера [math]\displaystyle{ k }[/math]; для медианы — две кучи (max для меньшей половины, min для большей) с балансировкой.
- Планировщики и очереди событий. Задачи упорядочивают по ближайшему времени срабатывания; куча даёт быстрый выбор «ближайшего» и вставку новых таймеров. Некоторые планировщики ОС (например, CFS) для родственной задачи предпочитают сбалансированные деревья из-за нужды в упорядоченном обходе ⚠️ требует проверки деталей.
Историческая справка
- 1964. Дж. У. Дж. Уильямс предложил двоичную кучу и сортировку на её основе (heapsort) в заметке «Algorithm 232: Heapsort» [3]. Это одна из первых структур с гарантией [math]\displaystyle{ O(n\log n) }[/math] сортировки на месте.
- 1964. Р. У. Флойд показал, что кучу из массива можно построить за линейное время, простым просеиванием снизу вверх («Algorithm 245: Treesort») [4]. Именно поэтому
Build-Heapлинеен, а не [math]\displaystyle{ O(n\log n) }[/math]. - 1978. Ж. Вюйемен ввёл биномиальные кучи, поддерживающие быстрое слияние [5].
- 1987. М. Фредман и Р. Тарджан предложили фибоначчиевы кучи с амортизированным [math]\displaystyle{ O(1) }[/math] на
decrease-key, что теоретически ускорило Дейкстру и Прима [6]. - Название «куча» (heap) закрепилось за структурой именно из-за heapsort; не путать с «heap» как областью динамической памяти.
Типичные ошибки и подводные камни
- Путаница индексации. Формулы [math]\displaystyle{ 2i, 2i+1, \lfloor i/2\rfloor }[/math] — для нумерации с 1; при нумерации с 0 это [math]\displaystyle{ 2i+1, 2i+2, \lfloor(i-1)/2\rfloor }[/math]. Смешение даёт незаметные ошибки.
- Построение кучи вставками. [math]\displaystyle{ n }[/math] вызовов
Insertдают [math]\displaystyle{ O(n\log n) }[/math];Build-Heap— [math]\displaystyle{ O(n) }[/math]. На больших [math]\displaystyle{ n }[/math] разница примерно десятикратная (рис. 7). - Забыть просеять новый корень. После
swap(A[1], A[heap-size])вExtract-MaxбезMax-Heapify(A, 1)свойство кучи ломается. - Порядок операций при
decrease-key. Уменьшать ключ и просеивать вниз для max-кучи; для увеличения — вверх. Перепутанное направление оставит нарушение. - Переполнение индекса.
2*iдля большогоiв знаковомintпереполняется; используйтеstd::size_t. - Ожидание полной упорядоченности. Куча даёт только экстремум в корне; [math]\displaystyle{ k }[/math]-й по величине элемент за [math]\displaystyle{ O(1) }[/math] из неё не достать, а обход не даёт сортированной последовательности.
std::priority_queueбезdecrease-key. Многие ожидают метод и не находят его; нужен ленивый подход (дубликаты + проверка устаревших записей) или собственная куча.- Неустойчивость heapsort. Равные ключи могут поменяться местами; если устойчивость важна, heapsort не подходит.
- Изменение ключа «на месте» без sift. Прямая запись в середину массива без
increase/decrease-keyнарушает инвариант.
Задачи для практики
- Проверка кучи. За [math]\displaystyle{ O(n) }[/math] проверьте, что массив — max-куча. Ограничения: [math]\displaystyle{ n\le 10^6 }[/math]. Подсказка: сравните каждый [math]\displaystyle{ A[i] }[/math] с родителем [math]\displaystyle{ A[\lfloor i/2\rfloor] }[/math]. Ответ: один проход.
- Min-куча из max-кучи. Превратите реализацию в min-кучу, изменив только компаратор. Подсказка: все формулы те же, меняются лишь сравнения.
- [math]\displaystyle{ k }[/math]-й наименьший элемент. Найдите [math]\displaystyle{ k }[/math]-й наименьший за [math]\displaystyle{ O(n + k\log n) }[/math]. Подсказка: постройте min-кучу и выполните [math]\displaystyle{ k }[/math] извлечений. Ответ: [math]\displaystyle{ O(n + k\log n) }[/math].
- Слияние [math]\displaystyle{ k }[/math] списков. Слейте [math]\displaystyle{ k }[/math] отсортированных списков суммарной длины [math]\displaystyle{ N }[/math] за [math]\displaystyle{ O(N\log k) }[/math]. Подсказка: куча из текущих голов каждого списка.
- Потоковый top-k. Для потока чисел держите [math]\displaystyle{ k }[/math] наибольших за [math]\displaystyle{ O(\log k) }[/math] на элемент. Подсказка: min-куча размера [math]\displaystyle{ k }[/math]; вытесняйте корень, если пришёл больший.
- Медиана двух куч. Поддерживайте медиану потока операциями
InsertиGetMedian. Подсказка: max-куча левой половины и min-куча правой; балансируйте размеры. - Докажите [math]\displaystyle{ O(n) }[/math] построения. Выведите оценку [math]\displaystyle{ \sum_h h\cdot\lceil n/2^{h+1}\rceil }[/math] и докажите сходимость ряда. Подсказка: [math]\displaystyle{ \sum_{h\ge0} h x^h = x/(1-x)^2 }[/math] при [math]\displaystyle{ x=1/2 }[/math].
- d-арная куча. Реализуйте кучу с [math]\displaystyle{ d }[/math] детьми и сравните число сравнений с двоичной при [math]\displaystyle{ n=10^6 }[/math]. Подсказка: глубина [math]\displaystyle{ \log_d n }[/math], но выбор минимума среди [math]\displaystyle{ d }[/math] детей стоит [math]\displaystyle{ d }[/math].
Связанные темы
- Сортировки — место heapsort среди алгоритмов сортировки.
- Быстрая сортировка — альтернатива heapsort: быстрее в среднем, без гарантии в худшем.
- Пирамидальная сортировка — heapsort подробно: build-heap, sift-down, bottom-up.
- Алгоритм Дейкстры — очередь с приоритетом на графах.
- Алгоритм Прима — минимальное остовное дерево.
- Код Хаффмана — жадное сжатие на куче.
- Биномиальная куча — быстрое слияние за [math]\displaystyle{ O(\log n) }[/math].
- Фибоначчиева куча — амортизированное [math]\displaystyle{ O(1) }[/math] на
decrease-key. - Очередь с приоритетом — абстрактный тип данных.
- Двоичное дерево поиска — полное упорядочивание против частичного.
- Асимптотическая сложность — нотация [math]\displaystyle{ O }[/math], [math]\displaystyle{ \Omega }[/math], [math]\displaystyle{ \Theta }[/math].
Источники
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 6 «Heapsort», гл. 19 «Fibonacci Heaps».
- Knuth D. E. The Art of Computer Programming. Vol. 3: Sorting and Searching. — Addison-Wesley, 2nd ed., 1998. — §5.2.3 «Sorting by Selection» (heapsort).
- Williams J. W. J. Algorithm 232: Heapsort // Communications of the ACM. — 1964. — Vol. 7, No. 6. — P. 347–348.
- Floyd R. W. Algorithm 245: Treesort // Communications of the ACM. — 1964. — Vol. 7, No. 12. — P. 701.
- Vuillemin J. A Data Structure for Manipulating Priority Queues // Communications of the ACM. — Vol. 21, No. 4. — P. 309–315.
- Fredman M. L., Tarjan R. E. Fibonacci Heaps and Their Uses in Improved Network Optimization Algorithms // Journal of the ACM. — 1987. — Vol. 34, No. 3. — P. 596–615.
- Wikipedia: Binary heap. URL: https://en.wikipedia.org/wiki/Binary_heap.
- Sedgewick R., Wayne K. Algorithms. 4th ed. — Addison-Wesley, 2011. — Гл. 2.4 «Priority Queues».