Часто нужна не полная сортировка, а всего одно: быстро доставать наибольший (или наименьший) элемент, снова и снова, добавляя по пути новые. Именно это делает приоритетная очередь. Эффективно её реализуют на структуре, которую называют пирамидой или кучей (heap).
Слабая упорядоченность
Пирамида — это двоичное дерево, но с гораздо более мягким правилом, чем дерево поиска. Условие всего одно: ключ любого узла не меньше ключей его потомков (для max-кучи). Это называют слабой упорядоченностью: наибольший элемент гарантированно наверху, в корне, а вот левый-правый порядок между узлами не задан — куча не отсортирована целиком.
Послабление здесь не недостаток, а подгонка под задачу: нужен только максимум — и он всегда под рукой, в корне. На полный порядок куча сил не тратит, оттого вставка и выемка ей дёшевы.
Ещё требование: пирамида — полное дерево, заполненное по уровням слева направо без пропусков. Это важно для хранения.
Хранение в массиве
Пирамиду не хранят как дерево из узлов со ссылками: она заполнена по уровням слева направо без дыр, поэтому место каждого узла известно заранее и ссылки хранить незачем. Её кладут в обычный массив по уровням, а родственные связи вычисляют арифметикой по индексу: потомки узла i лежат в ячейках 2i+1 и 2i+2, родитель в (i-1)/2. Никаких объектов-узлов, только массив и формулы: меньше памяти и быстрее доступ.
Та же куча в массиве: корень в нулевой ячейке, потомки узла i в ячейках 2i+1 и 2i+2, поэтому ссылки хранить незачем.
Всплытие и погружение
Вставка (всплытие, trickle up). Новый элемент кладут в конец массива (первое свободное место) и «поднимают»: пока он больше своего родителя — меняют их местами. Путь — от листа до корня, то есть высота дерева, O(log N).
Новый лист 50 больше родителя 30 — меняются местами. Дальше 50 меньше корня 90 — всплытие останавливается: родитель ≥ потомков.
Тот же обмен в массиве: программа вставляет 30, 90, 70, 50 и печатает кучу после каждой вставки.
живой пример
import java.util.Arrays;
public class HeapInsert {
public static void main(String[] args) {
int[] heap = new int[8];
int size = 0;
for (int value : new int[]{30, 90, 70, 50}) {
int i = size++;
heap[i] = value;
while (i > 0 && heap[(i - 1) / 2] < heap[i]) {
int parent = (i - 1) / 2;
int tmp = heap[i];
heap[i] = heap[parent];
heap[parent] = tmp;
i = parent;
}
System.out.println(value + " → " + Arrays.toString(Arrays.copyOf(heap, size)));
}
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func main() {
heap := make([]int, 8)
size := 0
for _, value := range []int{30, 90, 70, 50} {
i := size
size++
heap[i] = value
for i > 0 && heap[(i-1)/2] < heap[i] {
parent := (i - 1) / 2
heap[i], heap[parent] = heap[parent], heap[i]
i = parent
}
fmt.Println(value, "→", heap[:size])
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const heap = [];
for (const value of [30, 90, 70, 50]) {
let i = heap.push(value) - 1;
while (i > 0 && heap[Math.floor((i - 1) / 2)] < heap[i]) {
const parent = Math.floor((i - 1) / 2);
[heap[i], heap[parent]] = [heap[parent], heap[i]];
i = parent;
}
console.log(`${value} → [${heap.join(", ")}]`);
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
heap: list[int] = []
for value in (30, 90, 70, 50):
heap.append(value)
i = len(heap) - 1
while i > 0 and heap[(i - 1) // 2] < heap[i]:
parent = (i - 1) // 2
heap[i], heap[parent] = heap[parent], heap[i]
i = parent
print(value, "→", heap)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Выемка максимума (погружение, trickle down). Максимум — в корне, его забирают. Дырку в корне закрывают последним элементом массива, а затем «опускают» его: пока он меньше большего из потомков — меняют местами с ним. Элемент погружается, пока правило не восстановится. Тоже O(log N).
Код погружения симметричен всплытию, только на каждом шаге выбирают большего из двух потомков:
живой пример
import java.util.Arrays;
public class HeapPoll {
static int[] heap = {90, 50, 70, 30, 40, 60};
static int size = heap.length;
static int poll() {
int top = heap[0];
heap[0] = heap[--size];
int i = 0;
while (2 * i + 1 < size) {
int child = 2 * i + 1;
if (child + 1 < size && heap[child + 1] > heap[child]) child++;
if (heap[i] >= heap[child]) break;
int tmp = heap[i]; heap[i] = heap[child]; heap[child] = tmp;
i = child;
}
return top;
}
public static void main(String[] args) {
System.out.println("забрали " + poll() + " → " + Arrays.toString(Arrays.copyOf(heap, size)));
System.out.println("забрали " + poll() + " → " + Arrays.toString(Arrays.copyOf(heap, size)));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
var heap = []int{90, 50, 70, 30, 40, 60}
func poll() int {
top := heap[0]
last := len(heap) - 1
heap[0] = heap[last]
heap = heap[:last]
i := 0
for 2*i+1 < len(heap) {
child := 2*i + 1
if child+1 < len(heap) && heap[child+1] > heap[child] {
child++
}
if heap[i] >= heap[child] {
break
}
heap[i], heap[child] = heap[child], heap[i]
i = child
}
return top
}
func main() {
fmt.Println("забрали", poll(), "→", heap)
fmt.Println("забрали", poll(), "→", heap)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const heap = [90, 50, 70, 30, 40, 60];
function poll() {
const top = heap[0];
heap[0] = heap.pop();
let i = 0;
while (2 * i + 1 < heap.length) {
let child = 2 * i + 1;
if (child + 1 < heap.length && heap[child + 1] > heap[child]) child++;
if (heap[i] >= heap[child]) break;
[heap[i], heap[child]] = [heap[child], heap[i]];
i = child;
}
return top;
}
console.log(`забрали ${poll()} → [${heap.join(", ")}]`);
console.log(`забрали ${poll()} → [${heap.join(", ")}]`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
heap = [90, 50, 70, 30, 40, 60]
def poll() -> int:
top = heap[0]
heap[0] = heap.pop()
i = 0
while 2 * i + 1 < len(heap):
child = 2 * i + 1
if child + 1 < len(heap) and heap[child + 1] > heap[child]:
child += 1
if heap[i] >= heap[child]:
break
heap[i], heap[child] = heap[child], heap[i]
i = child
return top
print("забрали", poll(), "→", heap)
print("забрали", poll(), "→", heap)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Последний элемент массива встал в корень и опустился до своего места, обмениваясь с большим потомком; каждая выемка отдаёт текущий максимум, и за две выемки ушли 90 и 70.
И добавить элемент, и достать наибольший — по O(log N). Поэтому куча — начинка приоритетной очереди, а через неё — многих алгоритмов, например поиска кратчайшего пути.
Пирамидальная сортировка
Из кучи вырастает и полноценный алгоритм сортировки — пирамидальная сортировка (heapsort). Идея прямая: свалить все элементы в кучу, а потом по одному доставать максимум — они выйдут по убыванию. Каждая выемка — O(log N), всего элементов N, отсюда O(N·log N) — на уровне быстрой сортировки.
Ловкий приём делает heapsort ещё и сортировкой «на месте»: куча и отсортированный результат живут в одном массиве. Сначала массив превращают в кучу. Стоит это, вопреки ожиданию, всего O(N), а не O(N·log N): узлы погружают снизу вверх, и почти всем падать некуда, они и так у самого низа. Затем на каждом шаге корень, то есть максимум, меняют местами с последним элементом кучи и уменьшают её размер на один: вынутые максимумы накапливаются в хвосте массива уже по порядку. В отличие от быстрой сортировки, heapsort гарантирует O(N·log N) даже в худшем случае.
Почему же не он сортировка по умолчанию, а всего лишь страховка в библиотечных сортировках: у него плохая локальность. Потомки узла i лежат в 2i+1, и с каждым уровнем погружение прыгает по массиву вдвое дальше, каждый прыжок это промах кеша процессора; быстрая сортировка идёт по массиву подряд и на тех же O(N·log N) выигрывает в константе в два-три раза. Построение кучи за O(N) объясняется просто: половина узлов это листья и не погружаются вовсе, четверть опускается не больше чем на шаг, и сумма «высота узла × число узлов» сходится к 2N.
Топ-K элементов: куча размера K
Самое частое применение кучи в бекенде это не сортировка, а вопрос «десять самых популярных запросов из миллиарда» или «сто самых дорогих заказов за день», когда весь набор в память не помещается или сортировать его целиком слишком дорого. Держат кучу размера K, причём минимальную: на вершине лежит худший из уже отобранных. Каждый новый элемент сравнивают с вершиной: если он больше, вершину выбрасывают и кладут его, если меньше, пропускают. В конце в куче ровно K лучших.
живой пример
import java.util.PriorityQueue;
public class TopK {
public static void main(String[] args) {
int[] views = {120, 7, 3400, 55, 980, 42, 1500, 61, 2200, 15};
int k = 3;
PriorityQueue<Integer> best = new PriorityQueue<>();
for (int v : views) {
if (best.size() < k) best.offer(v);
else if (v > best.peek()) {
best.poll();
best.offer(v);
}
}
System.out.println("топ-" + k + ": " + best + ", худший из лучших на вершине: " + best.peek());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"container/heap"
"fmt"
)
type minHeap []int
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *minHeap) Pop() any {
old := *h
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
func main() {
views := []int{120, 7, 3400, 55, 980, 42, 1500, 61, 2200, 15}
k := 3
best := &minHeap{}
for _, v := range views {
if best.Len() < k {
heap.Push(best, v)
} else if v > (*best)[0] {
heap.Pop(best)
heap.Push(best, v)
}
}
fmt.Printf("топ-%d: %v, худший из лучших на вершине: %d\n", k, *best, (*best)[0])
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
class MinHeap {
a = [];
get size() { return this.a.length; }
peek() { return this.a[0]; }
push(v) {
const a = this.a;
let i = a.push(v) - 1;
while (i > 0 && a[(i - 1) >> 1] > a[i]) {
const p = (i - 1) >> 1;
[a[i], a[p]] = [a[p], a[i]];
i = p;
}
}
pop() {
const a = this.a;
const top = a[0];
const last = a.pop();
if (a.length > 0) {
a[0] = last;
let i = 0;
while (2 * i + 1 < a.length) {
let c = 2 * i + 1;
if (c + 1 < a.length && a[c + 1] < a[c]) c++;
if (a[i] <= a[c]) break;
[a[i], a[c]] = [a[c], a[i]];
i = c;
}
}
return top;
}
}
const views = [120, 7, 3400, 55, 980, 42, 1500, 61, 2200, 15];
const k = 3;
const best = new MinHeap();
for (const v of views) {
if (best.size < k) best.push(v);
else if (v > best.peek()) {
best.pop();
best.push(v);
}
}
console.log(`топ-${k}: [${best.a.join(", ")}], худший из лучших на вершине: ${best.peek()}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
import heapq
views = [120, 7, 3400, 55, 980, 42, 1500, 61, 2200, 15]
k = 3
best: list[int] = []
for v in views:
if len(best) < k:
heapq.heappush(best, v)
elif v > best[0]:
heapq.heapreplace(best, v)
print(f"топ-{k}: {best}, худший из лучших на вершине: {best[0]}")
print("то же одной строкой:", heapq.nlargest(k, views))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Время O(N·log K) и память O(K): на миллиарде просмотров и K = 10 это память под десять чисел, а логарифм от десяти это три. Полная сортировка стоила бы O(N·log N) и память под всё. Ловушка в направлении: для «K наибольших» нужна минимальная куча, потому что выбрасывать надо худшего из отобранных, а он на вершине именно минимальной кучи; максимальная куча размера K здесь не работает.
Как это сделано в стандартной библиотеке
Кучу почти никогда не пишут руками, но её устройство знать приходится: у готового класса есть поведение, которое удивляет всех, кто не знает, что внутри.
Эталонная реализация — PriorityQueue: ровно та двоичная куча, что описана выше, вплоть до формул по индексу и обоих обменов. Разница с нашим примером одна — по умолчанию куча минимальная: наверху оказывается наименьший элемент по естественному порядку. Нужен максимум — передают компаратор в конструктор.
Сложность предсказуемая: offer (вставка) и poll (выемка вершины) — O(log n), потому что элемент проходит путь от листа до корня или обратно; peek (посмотреть вершину, не забирая) — O(1), это просто нулевая ячейка массива. Место очередь берёт с запасом и расширяет сама: маленький массив удваивается, большой растёт примерно в полтора раза.
Две операции стоят дорого: contains и remove(Object) — O(n). Куча не знает, где лежит конкретное значение, — ей приходится просматривать весь массив. Нужно часто удалять произвольный элемент — чистая куча не подходит.
Нет у PriorityQueue и операции «изменить приоритет элемента, который уже внутри»: поменять поле у объекта в куче нельзя, куча об этом не узнает и сломает порядок, а remove с последующим offer стоит O(n) из-за поиска. Это главная грабля Дейкстры, и как её обходят, дописывая новый элемент вместо правки старого, разбирает статья про взвешенные графы.
Главная же грабля вот какая: обход PriorityQueue не отсортирован. Ни цикл for, ни toString, ни stream не выдают элементы по возрастанию — они идут в том порядке, в каком лежат в массиве, где действует лишь слабая упорядоченность. Гарантирован только один элемент — тот, что на вершине. Порядок появляется при выемке: элементы выходят через poll уже строго по возрастанию.
живой пример
import java.util.List;
import java.util.PriorityQueue;
public class QueueOrder {
public static void main(String[] args) {
PriorityQueue<Integer> queue = new PriorityQueue<>(List.of(5, 1, 9, 3));
System.out.println(queue); // [1, 3, 9, 5] — не отсортировано
System.out.println(queue.peek()); // 1 — а вот это гарантия
System.out.println(queue.poll() + ", " + queue.poll()); // 1, 3 — по возрастанию
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Кстати о конструкторе из примера: готовую коллекцию PriorityQueue не перекладывает по одному элементу, а превращает в кучу целиком — та самая O(N) вместо O(N·log N).
Ещё две мелочи: null в очередь класть нельзя, и PriorityQueue не потокобезопасна — для нескольких потоков берут PriorityBlockingQueue.
Кучу почти никогда не пишут руками, но её устройство знать приходится: у готового пакета есть поведение, которое удивляет всех, кто не знает, что внутри.
Эталонная реализация — пакет container/heap, и устроен он непривычно: это не тип, а набор функций над вашим типом. Вы храните срез сами и реализуете пять методов интерфейса heap.Interface: Len, Less, Swap из sort.Interface плюс Push и Pop, которые добавляют и снимают последний элемент среза. Всплытие и погружение делают heap.Push и heap.Pop пакета: ровно та двоичная куча, что описана выше, вплоть до формул по индексу. Куча получается минимальной по вашему Less: наверху элемент, который «меньше» всех. Нужен максимум — переворачивают Less.
Сложность предсказуемая: heap.Push и heap.Pop — O(log n), heap.Init превращает готовый срез в кучу за O(n), а посмотреть вершину не забирая — это просто h[0], O(1). Место срез берёт с запасом и расширяет сам обычным append.
Две операции, которых нет у кучи в Java, здесь есть: heap.Fix(h, i) чинит кучу после изменения приоритета элемента с индексом i за O(log n), а heap.Remove(h, i) удаляет произвольный элемент за O(log n). Условие одно: вы должны знать индекс. Поэтому в примере из документации у элемента есть поле index, которое Swap поддерживает при каждом обмене. Без индекса поиск элемента остаётся O(n), как и везде. Это снимает главную граблю Дейкстры, разобранную в статье про взвешенные графы: приоритет можно уменьшить на месте, а не дописывать дубликат.
Главная же грабля вот какая: срез под кучей не отсортирован. Цикл for range по нему выдаёт элементы в том порядке, в каком они лежат в массиве, где действует лишь слабая упорядоченность. Гарантирован только один элемент — тот, что в h[0]. Порядок появляется при выемке: элементы выходят через heap.Pop строго по возрастанию.
живой пример
package main
import (
"container/heap"
"fmt"
)
type minHeap []int
func (h minHeap) Len() int { return len(h) }
func (h minHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *minHeap) Pop() any {
old := *h
x := old[len(old)-1]
*h = old[:len(old)-1]
return x
}
func main() {
queue := &minHeap{5, 1, 9, 3}
heap.Init(queue)
fmt.Println(*queue) // [1 3 9 5] — не отсортировано
fmt.Println((*queue)[0]) // 1 — а вот это гарантия
fmt.Println(heap.Pop(queue), heap.Pop(queue)) // 1 3 — по возрастанию
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Кстати об Init: готовый срез он не перекладывает по одному элементу, а превращает в кучу целиком — та самая O(N) вместо O(N·log N).
Ещё две мелочи: методы Push и Pop вашего типа вызывать напрямую нельзя, они для пакета, всплытие и погружение делают только heap.Push и heap.Pop; а heap.Pop возвращает any, и на выходе нужно утверждение типа. Обобщённой кучи в стандартной библиотеке нет, поэтому в проектах часто заводят свою в двадцать строк на дженериках или берут пакет. Куча не потокобезопасна, как и любой срез: для нескольких горутин её закрывают мьютексом.
Кучу в JavaScript пишут руками чаще, чем в других языках, потому что в стандартной библиотеке её нет: ни приоритетной очереди, ни heapify. Устройство знать приходится и тем, кто берёт пакет: у готовой кучи есть поведение, которое удивляет всех, кто не знает, что внутри.
Реализации две. Самодельная — класс на двадцать строк над обычным массивом, как MinHeap в примере с топ-K выше: ровно та двоичная куча, что описана выше, вплоть до формул по индексу. Готовая — пакет, например heap-js или @datastructures-js/priority-queue; внутри у них тот же массив. И там и там по умолчанию куча минимальная по функции сравнения: наверху элемент, который «меньше». Нужен максимум — переворачивают сравнение.
Сложность предсказуемая: вставка и выемка вершины — O(log n), посмотреть вершину не забирая — O(1), это просто нулевая ячейка массива, а построить кучу из готового массива можно за O(n), если делать погружение от середины к началу, а не вставлять по одному. Массив растёт сам.
Две операции стоят дорого: «есть ли элемент» и «удалить произвольный элемент» — O(n). Куча не знает, где лежит конкретное значение, ей приходится просматривать весь массив. То же с изменением приоритета: поменять поле у объекта, который уже в куче, нельзя, куча об этом не узнает и сломает порядок. Это главная грабля Дейкстры, и как её обходят, дописывая новый элемент вместо правки старого, разбирает статья про взвешенные графы.
Соблазн обойтись без кучи в JavaScript особенно велик: array.push(x); array.sort(cmp) после каждой вставки выглядит проще. Это O(n·log n) на каждую операцию вместо O(log n), и на очереди из десятков тысяч событий разница между «мгновенно» и «секунды».
Главная же грабля вот какая: массив под кучей не отсортирован. Ни for...of, ни JSON.stringify, ни join не выдают элементы по возрастанию — они идут в том порядке, в каком лежат в массиве, где действует лишь слабая упорядоченность. Гарантирован только один элемент — тот, что на вершине. Порядок появляется при выемке: элементы выходят через pop строго по возрастанию.
живой пример
function siftDown(a, i, n) {
while (2 * i + 1 < n) {
let c = 2 * i + 1;
if (c + 1 < n && a[c + 1] < a[c]) c++;
if (a[i] <= a[c]) break;
[a[i], a[c]] = [a[c], a[i]];
i = c;
}
}
function heapify(a) {
for (let i = (a.length >> 1) - 1; i >= 0; i--) siftDown(a, i, a.length);
}
function pop(a) {
const top = a[0];
const last = a.pop();
if (a.length > 0) {
a[0] = last;
siftDown(a, 0, a.length);
}
return top;
}
const queue = [5, 1, 9, 3];
heapify(queue);
console.log(queue); // [ 1, 3, 9, 5 ] — не отсортировано
console.log(queue[0]); // 1 — а вот это гарантия
console.log(pop(queue), pop(queue)); // 1 3 — по возрастанию
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Кстати о heapify в примере: готовый массив он не перекладывает по одному элементу, а превращает в кучу целиком — та самая O(N) вместо O(N·log N).
Ещё одна мелочь: в одном потоке гонок нет, и очередь задач на куче между таймерами и обработчиками безопасна без замков; между воркерами куча не разделяется.
Кучу почти никогда не пишут руками, но её устройство знать приходится: у готового модуля есть поведение, которое удивляет всех, кто не знает, что внутри.
Эталонная реализация — модуль heapq, и устроен он непривычно: это не класс, а набор функций над обычным списком. heappush и heappop делают всплытие и погружение прямо в вашем списке: ровно та двоичная куча, что описана выше, вплоть до формул по индексу. Разница с нашим примером одна — куча всегда минимальная: наверху наименьший элемент по естественному порядку. Компаратора нет вовсе. Нужен максимум — кладут элементы с минусом или кортежем (-priority, item); чтобы при равных приоритетах кортежи не сравнивали сами объекты, между ними ставят счётчик: (priority, next(counter), item).
Сложность предсказуемая: heappush и heappop — O(log n), heapify превращает готовый список в кучу за O(n), а посмотреть вершину не забирая — это просто heap[0], O(1). Две операции в одну: heapreplace снимает вершину и кладёт новый элемент одним погружением, heappushpop наоборот, и обе дешевле пары вызовов. А вопрос «топ-K» решён готовыми heapq.nlargest(k, iterable) и nsmallest, внутри у которых та самая куча размера K.
Две операции стоят дорого: x in heap и удаление произвольного элемента — O(n), это обычный список, и куча не знает, где лежит конкретное значение. Нет и операции «изменить приоритет элемента, который уже внутри»: поменять поле у объекта в куче нельзя, куча об этом не узнает и сломает порядок. Документация heapq советует помечать старую запись удалённой и класть новую, а при выемке пропускать помеченные; это главная грабля Дейкстры, разобранная в статье про взвешенные графы.
Главная же грабля вот какая: список под кучей не отсортирован. Ни for, ни print, ни срез не выдают элементы по возрастанию — они идут в том порядке, в каком лежат в списке, где действует лишь слабая упорядоченность. Гарантирован только один элемент — heap[0]. Порядок появляется при выемке: элементы выходят через heappop строго по возрастанию.
живой пример
import heapq
queue = [5, 1, 9, 3]
heapq.heapify(queue)
print(queue) # [1, 3, 9, 5] — не отсортировано
print(queue[0]) # 1 — а вот это гарантия
print(heapq.heappop(queue), heapq.heappop(queue)) # 1 3 — по возрастанию
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Кстати о heapify: готовый список он не перекладывает по одному элементу, а превращает в кучу целиком — та самая O(N) вместо O(N·log N). А sorted(heap) даёт отсортированную копию, не ломая кучу.
Ещё две мелочи: heapq не потокобезопасен, для нескольких потоков есть queue.PriorityQueue, обёртка над тем же heapq с замком; и класть в кучу несравнимые объекты нельзя, heappush упадёт с TypeError при первом же сравнении, отсюда и счётчик в кортеже.
Коротко
- Пирамида (куча) — полное двоичное дерево со слабой упорядоченностью: наибольший элемент в корне, но целиком дерево не отсортировано.
- Её хранят в массиве; связи узлов вычисляются по индексу (
2i+1,2i+2,(i-1)/2) без ссылок. - Вставка (всплытие) и выемка максимума (погружение) — обе
O(log N). Это эффективная основа приоритетной очереди. - Пирамидальная сортировка строит кучу и достаёт максимумы —
O(N·log N), «на месте», с гарантией даже в худшем случае. - Погружение симметрично всплытию: последний элемент ставят в корень и меняют с большим потомком, пока правило не восстановится.
- Топ-K из потока: минимальная куча размера K, новый элемент сравнивают с вершиной;
O(N·log K)времени иO(K)памяти. - Heapsort не стал сортировкой по умолчанию из-за прыжков по массиву и промахов кеша; построение кучи за
O(N), потому что половина узлов листья.
- В
PriorityQueueдёшевыpeek,offer,poll;containsиremove(Object)—O(n), обход не отсортирован; изменить приоритет элемента внутри нельзя; конструктор от коллекции строит кучу заO(N).
container/heapэто функции над вашим типом с пятью методами; вершина вh[0],heap.InitзаO(N), обход среза не отсортирован;heap.Fixиheap.Removeпо индексу заO(log n)— здесь приоритет менять можно.
- Кучи в языке нет: класс на двадцать строк или пакет;
sortпосле каждой вставки этоO(n·log n)вместоO(log n); массив под кучей не отсортирован, гарантирована только вершина.
heapqэто функции над списком, куча только минимальная, максимум через минус;heapifyзаO(N),nlargestиnsmallestдля топ-K,heapreplaceдешевле пары вызовов; список не отсортирован, приоритет меняют пометкой старой записи.
Что почитать дальше
- Стеки и очереди — приоритетная очередь среди соседей по ограниченному доступу.
- Нетривиальная сортировка — быстрая сортировка, ровесница heapsort по
O(N·log N). - Взвешенные графы — куча внутри поиска кратчайшего пути.
- Выбор структуры данных — когда куча, а когда дерево или хеш-таблица.