2.2.1.6 — гарантированный Θ(n log n) in-place, без O(n) памяти merge sort2.2.1.6 — guaranteed in-place Θ(n log n), without merge sort's O(n) memory

Heapsort

Строит max-heap за O(n), затем n раз извлекает максимум — гарантированно Θ(n log n) и in-place, как quicksort в лучшем случае, но без риска Θ(n²) и без дополнительной памяти merge sort. Плата — нестабильность и худшая работа с кэшем процессора.Builds a max-heap in O(n), then extracts the maximum n times — guaranteed Θ(n log n) and in-place, like quicksort's best case, but without the risk of Θ(n²) and without merge sort's extra memory. The price is instability and worse CPU cache behavior.

Тот же алгоритм в разных источниках называют по-разному: heapsort — оригинальный термин Дж. Уильямса (1964); пирамидальная сортировка / сортировка пирамидой — устоявшийся перевод в русскоязычных курсах и части переводных учебников (heap ⇄ "пирамида", отсюда и дерево на диаграмме ниже действительно рисуют в виде пирамиды слоями); сортировка кучей — более буквальный и сейчас более распространённый перевод, используемый в этой статье. Все три — одно и то же: build-heap + n извлечений максимума.The same algorithm goes by different names across sources: heapsort — the original term coined by J. W. J. Williams (1964); in Russian-language courses and some translated textbooks it's traditionally called "pyramidal sort" (heap ⇄ "pyramid" in translation, which is also why the tree below is drawn as a layered pyramid); "heap sort" is the more literal and now more common translation, used throughout this article. All three name the same thing: build-heap plus n max-extractions.

нестабильнаnot stable in-place гарантированный худший случайguaranteed worst case

Идея: куча как отсортированный "конвейер" максимумовIdea: a heap as a "conveyor" of maximums

Куча (heap, подробно — 2.3.3) — это структура, из которой максимум можно извлечь за O(log n). Selection sort (2.2.1.2) на каждом шаге ищет минимум/максимум остатка за O(n) линейным сканированием; heapsort делает то же самое, но с O(log n) на извлечение вместо O(n) — отсюда и разница в итоговой сложности: O(n) шагов × O(log n) на извлечение = O(n log n), вместо O(n) шагов × O(n) сканирования = O(n²) у selection sort.A heap (details in 2.3.3) is a structure from which the maximum can be extracted in O(log n). Selection sort (2.2.1.2) finds the remainder's minimum/maximum at each step via an O(n) linear scan; heapsort does the same thing but with O(log n) per extraction instead of O(n) — hence the difference in total complexity: O(n) steps × O(log n) per extraction = O(n log n), versus selection sort's O(n) steps × O(n) scan = O(n²).

Алгоритм в два этапа: (1) build-heap — превратить весь массив в max-heap за O(n) (не O(n log n) — просеивание снизу вверх амортизированно линейно, потому что большинство узлов кучи находятся близко к листьям и просеиваются на маленькую глубину); (2) n раз подряд — поменять местами корень кучи (текущий максимум) с последним элементом "живой" части кучи, уменьшить размер кучи на 1, восстановить свойство кучи (sift-down/heapify) за O(log n).The algorithm has two phases: (1) build-heap — turn the whole array into a max-heap in O(n) (not O(n log n) — bottom-up sifting is amortized linear because most heap nodes sit close to the leaves and sift down only a small depth); (2) n times in a row — swap the heap's root (the current maximum) with the last element of the "live" part of the heap, shrink the heap by 1, restore the heap property (sift-down/heapify) in O(log n).

9 максимум — всегда в корне 6 7 1. swap(корень, последний живой) → 2. уменьшить кучу → 3. sift-down O(log n)

каждое извлечение максимума — O(log n): дерево имеет глубину log n, sift-down проходит не более одного пути от корня до листаeach max extraction is O(log n): the tree has depth log n, sift-down traverses at most one root-to-leaf path

void sift_down(std::vector<int>& a, int n, int i) {
    int largest = i, l = 2*i + 1, r = 2*i + 2;
    if (l < n && a[l] > a[largest]) largest = l;
    if (r < n && a[r] > a[largest]) largest = r;
    if (largest != i) {
        std::swap(a[i], a[largest]);
        sift_down(a, n, largest); // продолжить вниз по дереву// continue down the tree
    }
}

void heapsort(std::vector<int>& a) {
    int n = a.size();
    for (int i = n / 2 - 1; i >= 0; --i) sift_down(a, n, i); // build-heap: O(n)// build-heap: O(n)
    for (int i = n - 1; i > 0; --i) {
        std::swap(a[0], a[i]);       // максимум уходит на своё место// the max moves to its final spot
        sift_down(a, i, 0);           // куча сужается на 1// the heap shrinks by 1
    }
}

Демонстрация вживуюLive demonstration

Верхний ряд — те же значения как дерево (родитель i, дети 2i+1 и 2i+2). Нижний ряд — тот же массив линейно. Оранжевая рамка — узел i, с которого начат sift-down. Красная рамка — "largest" на этом шаге. Жёлтая заливка — корень кучи. Зелёная — уже на финальном месте.The top row is the same values drawn as a tree (parent i, children 2i+1 and 2i+2). The bottom row is the same array laid out linearly. The orange outline is node i, where sift-down started. Red is "largest" at this step. Yellow fill is the heap's root. Green marks a value already in its final spot.

сравнений:comparisons: 0   обменов:swaps: 0

нажмите «Вперёд» или «Играть», чтобы начатьpress "Forward" or "Play" to begin

Почему медленнее quicksort на практике при той же асимптотикеWhy it's slower than quicksort in practice at the same asymptotics

Куча хранится в массиве, но логически это дерево — sift_down прыгает по индексам i, 2i+1, 2i+2, которые быстро расходятся по памяти на больших n (в отличие от quicksort, обходящего массив в основном последовательно). Эти скачки плохо ложатся на кэш процессора: каждый доступ к "далёкому" индексу — потенциальный cache miss. Именно поэтому heapsort, будучи асимптотически не хуже quicksort, на практике почти всегда медленнее — тот же класс компромисса, что constant factors против асимптотики в 2.1.1.The heap is stored as an array, but logically it's a tree — sift_down jumps between indices i, 2i+1, 2i+2, which quickly scatter across memory for large n (unlike quicksort, which mostly scans the array sequentially). These jumps are unfriendly to the CPU cache: every access to a "distant" index is a potential cache miss. This is why heapsort, while asymptotically no worse than quicksort, is almost always slower in practice — the same class of trade-off as constant factors vs. asymptotics from 2.1.1.

Где встречаетсяWhere it shows up

01

Страховочная стратегия в IntroSortThe fallback strategy in IntroSort

std::sort переключается на heapsort, когда глубина рекурсии quicksort превышает 2·log₂n — это гарантирует, что итоговая сложность никогда не станет Θ(n²), какой бы adversarial ни был вход.std::sort switches to heapsort when quicksort's recursion depth exceeds 2·log₂n — this guarantees the overall complexity never becomes Θ(n²), no matter how adversarial the input is.

02

Встраиваемые системы с жёстким лимитом памятиEmbedded systems with a hard memory limit

Гарантированный in-place Θ(n log n) без риска Θ(n²) и без O(n) дополнительной памяти делает heapsort разумным выбором там, где память жёстко ограничена, а предсказуемость важнее сырой средней скорости.A guaranteed in-place Θ(n log n) with no risk of Θ(n²) and no O(n) extra memory makes heapsort sensible where memory is tightly constrained and predictability matters more than raw average speed.