Алгоритмы · СправочникПриоритетная очередь или куча
SilverTests.ruheap · priority_queue
Приоритетная очередь или куча
Куча, которая всегда знает, где лучшее — и обновляется за log n
1Зачем нужна куча
Представь приёмный покой в больнице. Пациенты приходят в случайном порядке, но лечить нужно сначала самых тяжёлых. Как врачу быстро находить «самого срочного» — особенно когда список большой и меняется каждую минуту?
Два плохих решения
Искать максимум в массиве каждый раз. Для тысячи пациентов — тысяча сравнений на каждое «кого лечить?». Для миллиона — миллион. Каждое действие в одиночку быстрое, но суммарно всё ужасно медленно.
Держать массив отсортированным. Максимум найти легко (он в конце), но вставка нового пациента — сдвиг половины массива. Снова линейное время.
Хорошее решение — куча
Куча (heap, priority queue) — структура данных, которая умеет ровно три вещи:
| Операция |
Что делает |
Сколько работает |
push(x) |
добавить элемент |
O(log n) |
top() |
узнать верх (max или min) |
O(1) |
pop() |
извлечь верх |
O(log n) |
Чего куча НЕ умеет. Найти произвольный элемент, «а какое там пятое по величине число?», удалить элемент из середины — для всего этого куча не подходит. Она знает только про самый верх. Нужен поиск — бери set, dict или сортированный массив.
2Устройство кучи
Куча — это бинарное дерево с одним простым правилом:
Правило кучи. Родитель ≥ обоих своих детей (для max-heap). Для min-heap наоборот: родитель ≤ обоих детей.
Обрати внимание: между братьями (левым и правым ребёнком) никаких отношений нет. Левый может быть больше правого, меньше или равен — куче всё равно. Важно только, чтобы каждый ребёнок был не больше своего родителя.
Дерево заполняется по уровням слева направо, без дыр. Это называется полное бинарное дерево. Именно благодаря этому высота дерева всегда ≈ log₂(n), и операции такие быстрые.
Хранение в массиве
Самый неожиданный факт: никакое «настоящее дерево» с указателями и ссылками не создаётся. Куча живёт в обычном массиве. Индексы связаны простыми формулами:
# Нумерация с 0 (так и в Python, и в C++)
parent(i) = (i - 1) // 2
left(i) = 2*i + 1
right(i) = 2*i + 2
Попробуй — индексы родителя и детей
для i =—
Нажми кнопку, чтобы посмотреть, кто родитель и дети у элемента
У корня (i = 0) родителя нет. У листьев (элементов с большим индексом) детей нет — формулы вернут индексы за пределами массива.
3Операции push и pop
Как куча сохраняет своё правило, когда мы добавляем или удаляем элементы? Вся магия — в двух процедурах: всплытие (sift up) и погружение (sift down).
Вставка: всплытие (sift up)
Новый элемент кладём в конец массива (последний лист дерева). Потом сравниваем с родителем: если новый больше — меняем местами. Повторяем, пока новый элемент не «всплывёт» на своё место. В худшем случае он дойдёт до корня — это log n шагов.
Извлечение: погружение (sift down)
Максимум — это всегда корень. Мы его забираем. Но дерево не может остаться без корня, поэтому на его место ставим последний элемент (бывший самый дальний лист). Скорее всего, он нарушает правило. Тогда сравниваем его с детьми, меняем с бóльшим из них, и так «тонем» вниз, пока не встанем на место.
Лаборатория — добавляй и извлекай, смотри как перестраивается
› Нажми «демо», чтобы посмотреть построение кучи с нуля, или вводи свои числа.
Обрати внимание, что снизу одновременно показан массив. Дерево и массив — это одно и то же: корень лежит в [0], его дети в [1] и [2], и так далее.
4Почему это работает за log n
Полное бинарное дерево на n вершинах имеет высоту ≈ log₂(n). Всплытие и погружение — это пути по высоте: каждый шаг либо вверх к родителю, либо вниз к одному из детей. Значит, и то, и другое — не более log n сравнений.
| n |
log₂(n) ≈ |
Время push/pop |
| 10 |
≈ 3 |
3 сравнения |
| 1 000 |
≈ 10 |
10 сравнений |
| 1 000 000 |
≈ 20 |
20 сравнений |
| 1 000 000 000 |
≈ 30 |
30 сравнений |
Для сравнения: поиск максимума в несортированном массиве на миллион элементов — это миллион сравнений. Куча быстрее в 50 000 раз.
5Готовые реализации
В обоих основных языках куча уже встроена — своё реализовывать не надо. Переключайся между вкладками, чтобы посмотреть код на нужном языке (переключение действует сразу во всех блоках ниже).
Базовый API — push, top, pop
import heapq
pq = [] # обычный список
heapq.heappush(pq, 5)
heapq.heappush(pq, 2)
heapq.heappush(pq, 8)
pq[0] # 2 — верх (min-heap!)
heapq.heappop(pq) # вернёт 2 и удалит
# Из готового списка — O(n)
data = [5, 2, 8, 1, 9]
heapq.heapify(data) # data стал min-heap-ом
Ловушка Python. heapq — это всегда min-heap. Если нужен max-heap — клади -x, а при извлечении бери -heappop(pq). Ещё вариант — использовать пары (-x, x), где первый элемент кортежа — инвертированный приоритет.
#include <queue>
#include <vector>
priority_queue<int> pq; // max-heap по умолчанию
pq.push(5); pq.push(2); pq.push(8);
pq.top(); // 8 — верх (max-heap!)
pq.pop(); // удалить 8
// min-heap — через greater
priority_queue<int,
vector<int>,
greater<int>> pq2;
// Построить кучу из готового вектора — O(n)
vector<int> data = {5, 2, 8, 1, 9};
priority_queue<int> pq3(data.begin(), data.end());
Ловушка C++. priority_queue<T> по умолчанию — max-heap (противоположно Python!). Для min-heap обязателен третий шаблонный параметр greater<T>. Либо — снова кладём отрицательные значения.
Куча из пар
Часто в куче хранится и приоритет, и «полезная нагрузка» (имя, ID, что угодно). Пары сравниваются поэлементно: сначала по первому полю, при равенстве — по второму.
pq = []
heapq.heappush(pq, (3, "Иван"))
heapq.heappush(pq, (7, "Маша"))
heapq.heappush(pq, (1, "Пётр"))
heapq.heappop(pq) # (1, 'Пётр')
heapq.heappop(pq) # (3, 'Иван')
Если второй элемент кортежа — объект, который нельзя сравнить (например, dict), и приоритеты двух элементов совпали — будет ошибка. В таком случае добавляют третье уникальное поле-счётчик: (приоритет, номер, значение).
// min-heap из пар (приоритет, имя)
priority_queue<
pair<int, string>,
vector<pair<int, string>>,
greater<>
> pq;
pq.push({3, "Иван"});
pq.push({7, "Маша"});
pq.push({1, "Пётр"});
pq.top(); // {1, "Пётр"}
pq.pop();
pq.top(); // {3, "Иван"}
У pair автоматически работает operator< — сначала сравниваются first, при равенстве second. То же самое и в Python с кортежами.
6Жадность с выбором лучшего
Самое красивое применение кучи — жадные алгоритмы. Это такие алгоритмы, где решение строится по шагам, и на каждом шаге мы берём локально лучший элемент (максимум, минимум — смотря по задаче). Если на каждом шаге нужно находить лучший среди оставшихся, куча даёт это за log n.
Классика — задача о верёвках
Условие. У тебя n верёвок разной длины. За одно действие можно связать две верёвки — получится одна длиной a + b. Стоимость связывания равна длине получившейся верёвки. Надо связать все верёвки в одну и минимизировать суммарную стоимость всех связываний.
Идея. Каждая верёвка, которую мы связываем рано, будет «участвовать» в длине всех следующих связываний — её длина добавится к сумме много раз. Поэтому длинные верёвки хочется связывать в последнюю очередь, а короткие — в первую. Жадный шаг: всегда бери две самые короткие. Для этого идеально подходит min-heap: за O(log n) — найти пару минимумов, за O(log n) — вернуть новую длину обратно в очередь.
Попробуй сам — кликай две верёвки, чтобы связать
Кликни по двум верёвкам подряд, чтобы связать их. Попробуй получить минимальную сумму — или жми «жадный шаг», чтобы увидеть правильное решение.
Код жадного решения
import heapq
def min_cost(ropes):
heapq.heapify(ropes) # превратить в min-heap за O(n)
total = 0
while len(ropes) > 1:
a = heapq.heappop(ropes) # самая короткая
b = heapq.heappop(ropes) # вторая самая короткая
cost = a + b
total += cost
heapq.heappush(ropes, cost) # вернуть связку обратно
return total
min_cost([4, 3, 2, 6]) # → 29
#include <queue>
#include <vector>
long long min_cost(vector<int> ropes) {
// min-heap, сразу построенный из вектора — O(n)
priority_queue<int, vector<int>, greater<int>> pq(
ropes.begin(), ropes.end()
);
long long total = 0;
while (pq.size() > 1) {
int a = pq.top(); pq.pop(); // самая короткая
int b = pq.top(); pq.pop(); // вторая самая короткая
int cost = a + b;
total += cost;
pq.push(cost); // вернуть связку обратно
}
return total;
}
min_cost({4, 3, 2, 6}); // → 29
Без кучи пришлось бы на каждом шаге заново искать два минимума — это O(n²). С кучей — O(n log n). Для n = 100 000 это разница между мгновенно и считай до утра.
7Где ещё встречается куча
Слияние k отсортированных списков
Есть k уже отсортированных списков общей длиной N. Чтобы слить их в один отсортированный, кладём в min-heap по одному элементу с начала каждого списка. Достаём минимум, добавляем в результат, кладём в кучу следующий элемент из того же списка. Итого O(N log k) — быстрее, чем O(N log N) от обычной сортировки.
Алгоритм Дейкстры
Поиск кратчайших путей в графе. На каждом шаге мы берём вершину с минимальным текущим расстоянием и обновляем её соседей. Без кучи — O(n²). С min-heap — O((n + m) log n).
Топ-k элементов в потоке
Из огромного потока данных найти k самых больших. Держим min-heap размера k. Новый элемент сравниваем с верхом кучи: если больше — меняем. Память всего O(k), не O(n). Работает, даже когда все данные в память не помещаются.
Симуляция событий
Моделирование работы светофоров, аэропорта, обработки запросов сервером. В куче хранятся будущие события, отсортированные по времени. Достаём ближайшее событие, обрабатываем, при необходимости планируем новые события — и снова в кучу.
8Что запомнить
Главное о куче. Куча — это полное бинарное дерево с правилом «родитель ≥ детей» (или ≤ для min-heap), хранящееся в массиве: parent = (i-1)//2, left = 2i+1, right = 2i+2. Вставка и извлечение верха — O(log n). Поиск произвольного элемента — нет, для этого куча не подходит. В Python heapq — min-heap, в C++ priority_queue — max-heap. Бери кучу, когда на каждом шаге нужно «лучшее среди оставшихся» — это почти всегда признак жадного алгоритма.