Пирамидальная сортировка: различия между версиями
СВ (обсуждение | вклад) (Новая страница: «== TL;DR == '''Пирамидальная сортировка''' (heapsort) сортирует массив в два этапа: сначала превращ...») |
СВ (обсуждение | вклад) |
||
| Строка 1: | Строка 1: | ||
== TL;DR == | == TL;DR == | ||
'''Пирамидальная сортировка''' (heapsort) сортирует массив в два этапа: сначала превращает его в '''max-кучу''' процедурой '''build-heap''' за <math>O(n)</math>, затем <math>n-1</math> раз меняет местами корень (текущий максимум) с последним элементом кучи и восстанавливает кучу '''sift-down''' за <math>O(\log n)</math>. Итог — гарантированное <math>O(n\log n)</math> '''в худшем случае''' при <math>O(1)</math> дополнительной памяти (данные переставляются на месте). По сравнению с [[Быстрая сортировка | '''Пирамидальная сортировка''' (heapsort) сортирует массив в два этапа: сначала превращает его в '''max-кучу''' процедурой '''build-heap''' за <math>O(n)</math>, затем <math>n-1</math> раз меняет местами корень (текущий максимум) с последним элементом кучи и восстанавливает кучу '''sift-down''' за <math>O(\log n)</math>. Итог — гарантированное <math>O(n\log n)</math> '''в худшем случае''' при <math>O(1)</math> дополнительной памяти (данные переставляются на месте). По сравнению с [[Быстрая сортировка|быстрой сортировкой]] heapsort проигрывает в среднем из-за плохой локальности (прыжки по индексам дерева), зато не имеет «плохих» входов; по сравнению с сортировкой слиянием — не требует буфера, но '''неустойчив'''. Ключевые факты: '''build-heap линеен''' (сумма высот узлов даёт <math>O(n)</math>, а не <math>O(n\log n)</math>), а оптимизация Флойда ('''bottom-up heapsort''') экономит сравнения (на случайных данных — около 40 %). Подробнее о самой куче как структуре данных — в [[Двоичная куча]]; здесь разбирается именно '''сортировка'''. | ||
{| class="wikitable" | {| class="wikitable" | ||
| Строка 23: | Строка 23: | ||
| Устойчивость || нет | | Устойчивость || нет | ||
|- | |- | ||
| Число сравнений || <math>\approx 2n\log_2 n</math> (стандартный), <math>\approx 1. | | Число сравнений || <math>\approx 2n\log_2 n</math> в худшем (стандартный); bottom-up — меньше, на случайных данных <math>\approx 1.0\,n\log_2 n</math> | ||
|- | |- | ||
| Структура || массив как полное двоичное дерево (max-куча) | | Структура || массив как почти полное (complete) двоичное дерево (max-куча) | ||
|- | |- | ||
| Родственники || [[Быстрая сортировка|quicksort]], mergesort, [[Двоичная куча|priority queue]] | | Родственники || [[Быстрая сортировка|quicksort]], mergesort, [[Двоичная куча|priority queue]] | ||
| Строка 32: | Строка 32: | ||
== Определения == | == Определения == | ||
'''Куча (heap)''' — полное двоичное дерево, в котором у каждого узла ключ не меньше ключей потомков ('''max-куча''') или не больше ('''min-куча'''). Здесь рассматривается max-куча: максимум всегда в корне. Подробнее — в [[Двоичная куча]]. | '''Куча (heap)''' — почти полное (complete) двоичное дерево, в котором у каждого узла ключ не меньше ключей потомков ('''max-куча''') или не больше ('''min-куча'''). Здесь рассматривается max-куча: максимум всегда в корне. Подробнее — в [[Двоичная куча]]. | ||
''' | '''Почти полное (complete) двоичное дерево''' — все уровни, кроме, возможно, последнего, заполнены, а последний — слева направо. Такое дерево укладывается в массив без указателей (см. рис. 1). | ||
'''Представление массивом (0-базное).''' Для узла <math>i</math>: левый ребёнок <math>2i+1</math>, правый <math>2i+2</math>, родитель <math>\lfloor (i-1)/2 \rfloor</math>. Узел <math>i</math> — '''лист''', если <math>2i+1 \ge n</math>. | '''Представление массивом (0-базное).''' Для узла <math>i</math>: левый ребёнок <math>2i+1</math>, правый <math>2i+2</math>, родитель <math>\lfloor (i-1)/2 \rfloor</math>. Узел <math>i</math> — '''лист''', если <math>2i+1 \ge n</math>. | ||
| Строка 46: | Строка 46: | ||
'''Пирамидальная сортировка (heapsort).''' Два этапа: (1) build-heap; (2) цикл извлечения максимума в конец массива с уменьшением размера кучи (см. рис. 5). | '''Пирамидальная сортировка (heapsort).''' Два этапа: (1) build-heap; (2) цикл извлечения максимума в конец массива с уменьшением размера кучи (см. рис. 5). | ||
'''Bottom-up heapsort.''' Оптимизация Флойда: вместо двух сравнений на уровень (выбор ребёнка и сравнение с ним значения) сначала спускаются к листу, а затем поднимаются вверх, пока значение меньше | '''Bottom-up heapsort.''' Оптимизация Флойда: вместо двух сравнений на уровень (выбор ребёнка и сравнение с ним значения) сначала спускаются к листу, а затем поднимаются вверх, пока значение '''больше''' родителя (родитель меньше значения), — экономит сравнения: на случайных данных около 40 %, в худшем случае меньше. | ||
'''Обозначения.''' <math>n</math> — размер массива; <math>h = \lfloor \log_2 n \rfloor</math> — высота дерева; <math>i, end</math> — индексы; <math>\log</math> — двоичный. | '''Обозначения.''' <math>n</math> — размер массива; <math>h = \lfloor \log_2 n \rfloor</math> — высота дерева; <math>i, end</math> — индексы; <math>\log</math> — двоичный. | ||
| Строка 52: | Строка 52: | ||
== Мотивация и интуиция == | == Мотивация и интуиция == | ||
Нужен алгоритм, который одновременно даёт '''гарантию <math>O(n\log n)</math>''' и '''не тратит лишней памяти'''. Сортировка слиянием гарантирует <math>O(n\log n)</math>, но требует буфер <math>O(n)</math>; [[Быстрая сортировка | Нужен алгоритм, который одновременно даёт '''гарантию <math>O(n\log n)</math>''' и '''не тратит лишней памяти'''. Сортировка слиянием гарантирует <math>O(n\log n)</math>, но требует буфер <math>O(n)</math>; [[Быстрая сортировка|quicksort]] работает на месте, но в худшем случае даёт <math>\Theta(n^2)</math>. Heapsort закрывает обе потребности: куча — это массив, её максимум в корне, а восстановление кучи после извлечения стоит <math>O(\log n)</math>. | ||
Изюминка heapsort — '''линейное построение кучи'''. Наивно можно было бы вставлять элементы по одному через sift-up (<math>O(n\log n)</math>), но просеивание '''снизу вверх''' справляется за <math>O(n)</math>: большая часть узлов расположена высоко над листьями и почти не работает (см. рис. 2). Это редкий пример, когда | Изюминка heapsort — '''линейное построение кучи'''. Наивно можно было бы вставлять элементы по одному через sift-up (<math>O(n\log n)</math>), но просеивание '''снизу вверх''' справляется за <math>O(n)</math>: большая часть узлов расположена высоко над листьями и почти не работает (см. рис. 2). Это редкий пример, когда обработка снизу вверх даёт оценку лучше наивной. | ||
Ключевая идея в одном абзаце: '''куча даёт мгновенный доступ к максимуму''' (он в корне), а обмен корня с последним элементом постепенно переносит максимумы в отсортированный хвост. После каждого обмена размер кучи уменьшается, и sift-down восстанавливает её за высоту дерева. Цена — локальность: обращения к детям <math>2i+1, 2i+2</math> прыгают по памяти. | Ключевая идея в одном абзаце: '''куча даёт мгновенный доступ к максимуму''' (он в корне), а обмен корня с последним элементом постепенно переносит максимумы в отсортированный хвост. После каждого обмена размер кучи уменьшается, и sift-down восстанавливает её за высоту дерева. Цена — локальность: обращения к детям <math>2i+1, 2i+2</math> прыгают по памяти. | ||
[[Файл:hs-01-heap.svg| | [[Файл:hs-01-heap.svg|thumb|700px|Рис. 1. Куча <code>[9,7,5,3,1,4,2]</code> как почти полное двоичное дерево и как массив: узел <math>i</math> лежит в <math>a[i]</math>, его дети — в <math>a[2i+1]</math> и <math>a[2i+2]</math>.]] | ||
[[Файл:hs-02-complexity.svg| | [[Файл:hs-02-complexity.svg|thumb|700px|Рис. 2. Число узлов высоты <math>h</math> около <math>n/2^{h+1}</math>, каждый просеивается не выше <math>h</math>; суммарная работа build-heap — <math>O(n)</math>, а не <math>O(n\log n)</math>.]] | ||
== Устройство == | == Устройство == | ||
| Строка 66: | Строка 66: | ||
Пирамидальная сортировка опирается на две процедуры: '''sift-down''' (просеивание вниз) и '''build-heap''' (построение). Sift-down предполагает, что оба поддерева узла — уже кучи, и «проваливает» нарушителя вниз, всегда меняя его с '''большим''' ребёнком (см. рис. 3). Build-heap применяет sift-down к внутренним узлам от последнего к корню: к моменту обработки узла его поддеревья уже являются кучами, поэтому инвариант sift-down соблюдён. | Пирамидальная сортировка опирается на две процедуры: '''sift-down''' (просеивание вниз) и '''build-heap''' (построение). Sift-down предполагает, что оба поддерева узла — уже кучи, и «проваливает» нарушителя вниз, всегда меняя его с '''большим''' ребёнком (см. рис. 3). Build-heap применяет sift-down к внутренним узлам от последнего к корню: к моменту обработки узла его поддеревья уже являются кучами, поэтому инвариант sift-down соблюдён. | ||
[[Файл:hs-03-siftdown.svg| | [[Файл:hs-03-siftdown.svg|thumb|700px|Рис. 3. Sift-down для <code>[3,9,7,5,4]</code>: 3 меняется с большим ребёнком 9, затем с 5 и становится листом; результат <code>[9,5,7,3,4]</code>.]] | ||
== Операции и алгоритмы == | == Операции и алгоритмы == | ||
| Строка 130: | Строка 130: | ||
'''Разбор.''' <code>[3,9,2,1,4,5]</code> → после <math>i=2</math>: <code>[3,9,5,1,4,2]</code>; <math>i=1</math>: без изменений; <math>i=0</math>: <code>[9,4,5,1,3,2]</code> (см. рис. 4). | '''Разбор.''' <code>[3,9,2,1,4,5]</code> → после <math>i=2</math>: <code>[3,9,5,1,4,2]</code>; <math>i=1</math>: без изменений; <math>i=0</math>: <code>[9,4,5,1,3,2]</code> (см. рис. 4). | ||
'''Сложность.''' <math>O(n)</math>: узлов высоты <math>h</math> около <math>n/2^{h+1}</math>, стоимость <math>\le h</math>; <math>\sum_h (n/2^{h+1})h = n\sum h/2^{h+1} = O(n)</math>. | '''Сложность.''' <math>O(n)</math>: число узлов высоты <math>h</math> около <math>n/2^{h+1}</math>, стоимость <math>\le h</math>; <math>\sum_h (n/2^{h+1})h = n\sum h/2^{h+1} = O(n)</math>. | ||
'''Крайние случаи.''' <math>n \le 1</math> — цикл пуст. Массив, уже являющийся кучей, всё равно просеивается, но без обменов. | '''Крайние случаи.''' <math>n \le 1</math> — цикл пуст. Массив, уже являющийся кучей, всё равно просеивается, но без обменов. | ||
[[Файл:hs-04-buildheap.svg| | [[Файл:hs-04-buildheap.svg|thumb|700px|Рис. 4. Build-heap для <code>[3,9,2,1,4,5]</code>: i=2, затем i=1 (без изменений), затем i=0; после просеивания корня — <code>[9,4,5,1,3,2]</code>.]] | ||
=== Операция: Heapsort (основной цикл) === | === Операция: Heapsort (основной цикл) === | ||
| Строка 151: | Строка 151: | ||
Вход: массив a[0..n−1] | Вход: массив a[0..n−1] | ||
Выход: a отсортирован по неубыванию | Выход: a отсортирован по неубыванию | ||
Сложность: | Сложность: Θ(n log n) в лучшем, среднем и худшем случае; память O(1) | ||
1 Build-Max-Heap(a) | 1 Build-Max-Heap(a) | ||
| Строка 163: | Строка 163: | ||
'''Сложность.''' <math>n-1</math> извлечений по <math>O(\log n)</math> → <math>O(n\log n)</math>; память <math>O(1)</math>. | '''Сложность.''' <math>n-1</math> извлечений по <math>O(\log n)</math> → <math>O(n\log n)</math>; память <math>O(1)</math>. | ||
'''Крайние случаи.''' <math>n = 0, 1</math> — ничего. Дубликаты обрабатываются корректно | '''Крайние случаи.''' <math>n = 0, 1</math> — ничего. Дубликаты обрабатываются корректно: значения встают по неубыванию, хотя порядок равных не сохраняется. | ||
[[Файл:hs-05-heapsort.svg| | [[Файл:hs-05-heapsort.svg|thumb|700px|Рис. 5. Первые два шага heapsort для кучи <code>[9,4,5,1,3,2]</code>: максимум уходит в конец, sift-down восстанавливает кучу; хвост растёт справа.]] | ||
=== Оптимизация: Bottom-up Heapsort (Флойд) === | === Оптимизация: Bottom-up Heapsort (Флойд) === | ||
'''Идея.''' Обычный sift-down на каждом уровне делает до двух сравнений (выбрать большего ребёнка и сравнить с ним значение). В bottom-up-варианте сначала '''спускаются к листу''', сравнивая только детей (одно сравнение на уровень), а затем '''поднимаются''' от листа, сравнивая значение с родителями и останавливаясь, как только оно встанет на место. | '''Идея.''' Обычный sift-down на каждом уровне делает до двух сравнений (выбрать большего ребёнка и сравнить с ним значение). В bottom-up-варианте сначала '''спускаются к листу''', сравнивая только детей (одно сравнение на уровень), а затем '''поднимаются''' от листа, сравнивая значение с родителями и останавливаясь, как только оно встанет на место. | ||
'''Пошаговое описание:''' | |||
# Сохранить <code>val = a[i]</code> и <code>start = i</code>. | |||
# '''Фаза 1 — спуск.''' Пока у текущего узла есть ребёнок: выбрать большего из детей, перенести его значение в текущий узел и перейти в этого ребёнка. Значение <code>val</code> ждёт в стороне и в сравнениях фазы 1 не участвует. | |||
# '''Фаза 2 — подъём.''' Начиная с листа, поднимать <code>val</code>: пока родитель меньше <code>val</code>, переносить родителя на место текущего узла; остановиться, когда родитель не меньше <code>val</code> или когда достигнут <code>start</code>. | |||
# Записать <code>val</code> в найденную позицию. | |||
'''Инвариант.''' Поддеревья исходного узла <code>start</code> — кучи. В фазе 1 путь от <code>start</code> до текущего узла содержит значения, сдвинутые вверх, а <code>val</code> хранится отдельно. В фазе 2 все узлы ниже <code>j</code> на этом пути уже удовлетворяют свойству кучи, а <code>val</code> ищет место не выше <code>start</code>. | |||
<syntaxhighlight lang="text"> | <syntaxhighlight lang="text"> | ||
| Строка 187: | Строка 196: | ||
9 c ← 2i + 1 | 9 c ← 2i + 1 | ||
10 j ← i # i — лист, сюда можно поместить val | 10 j ← i # i — лист, сюда можно поместить val | ||
11 while j > start # фаза 2: подъём | 11 while j > start # фаза 2: подъём, пока parent < val | ||
12 parent ← (j − 1) / | 12 parent ← ⌊(j − 1) / 2⌋ | ||
13 if a[parent] ≥ val | 13 if a[parent] ≥ val | ||
14 break | 14 break | ||
| Строка 196: | Строка 205: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
'''Разбор.''' | '''Разбор.''' Возьмём <code>[1,4,1,3,3]</code>, <math>n = 5</math>, <math>i = 1</math>; тогда <code>val = 4</code>, <code>start = 1</code>. | ||
# Фаза 1: <code>c = 3</code>, правый ребёнок <code>a[4] = 3</code> не больше левого <code>a[3] = 3</code>, поэтому <code>c</code> остаётся равным 3; <code>a[1] ← a[3] = 3</code>, массив <code>[1,3,1,3,3]</code>, <code>i = 3</code> — лист, цикл заканчивается. | |||
# Фаза 2: <code>j = 3</code>, <code>parent = 1</code>; <code>a[1] = 3 < 4</code>, поэтому <code>a[3] ← 3</code>, <code>j = 1</code>; цикл останавливается, потому что <code>j</code> дошёл до <code>start = 1</code>, а не потому, что родитель оказался не меньше <code>val</code>. | |||
# <code>a[1] ← val = 4</code>, массив снова <code>[1,4,1,3,3]</code>. | |||
Фаза 2 останавливается по достижении исходного корня, а не на первом сравнении; итог совпадает с исходным массивом, потому что значение уже стояло правильно. | |||
'''Сложность.''' <math>O(\log n)</math>; на случайных массивах сравнений примерно на 40 % меньше, чем у стандартного heapsort (измерено кодом; зависит от входа). Для <math>n = 4096</math> счётчики дают около <math>1.74\,n\log_2 n</math> у стандартного варианта и около <math>1.03\,n\log_2 n</math> у bottom-up, отношение <math>\approx 0.59</math>. | |||
''' | '''Крайние случаи.''' <code>i</code> — лист: фаза 1 не выполняется, фаза 2 тоже, <code>val</code> остаётся на месте. <code>val</code> уже не меньше детей: фаза 1 всё равно спускается к листу, а фаза 2 возвращает значение наверх. <math>n = 0</math> или <math>1</math> — сортировать нечего. Дубликаты: сравнение строгое, устойчивость не гарантируется. | ||
Bottom-up решает ту же задачу, что sift-down (см. рис. 3), но другим порядком обходов: сначала спуск к листу, затем подъём значения. | |||
== Сводная таблица сложности == | == Сводная таблица сложности == | ||
| Строка 208: | Строка 227: | ||
| <code>Sift-Down</code> || <math>\Theta(1)</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || <math>O(1)</math> | | <code>Sift-Down</code> || <math>\Theta(1)</math> || <math>O(\log n)</math> || <math>O(\log n)</math> || <math>O(1)</math> | ||
|- | |- | ||
| <code>Sift-Up</code> (вставка в кучу) || <math>\Theta(1)</math> || <math> | | <code>Sift-Up</code> (вставка в кучу) || <math>\Theta(1)</math> || <math>\Theta(1)</math> || <math>O(\log n)</math> || <math>O(1)</math> | ||
|- | |- | ||
| <code>Build-Max-Heap</code> || <math>\Theta(n)</math> || <math>\Theta(n)</math> || <math>\Theta(n)</math> || <math>O(1)</math> | | <code>Build-Max-Heap</code> || <math>\Theta(n)</math> || <math>\Theta(n)</math> || <math>\Theta(n)</math> || <math>O(1)</math> | ||
| Строка 214: | Строка 233: | ||
| <code>Heapsort</code> || <math>\Theta(n\log n)</math> || <math>\Theta(n\log n)</math> || <math>\Theta(n\log n)</math> || <math>O(1)</math> | | <code>Heapsort</code> || <math>\Theta(n\log n)</math> || <math>\Theta(n\log n)</math> || <math>\Theta(n\log n)</math> || <math>O(1)</math> | ||
|} | |} | ||
Сноска к <code>Sift-Up</code>: для случайной вставки ожидаемое число подъёмов ограничено константой (вероятность подняться на <math>k</math> уровней <math>\approx 2^{-k}</math>); <math>O(\log n)</math> — худший случай. | |||
Оговорка: heapsort не имеет «лёгких» входов — даже на отсортированном массиве выполняется полная работа, но и «тяжёлых» входов нет: оценка всегда <math>O(n\log n)</math>. | Оговорка: heapsort не имеет «лёгких» входов — даже на отсортированном массиве выполняется полная работа, но и «тяжёлых» входов нет: оценка всегда <math>O(n\log n)</math>. | ||
| Строка 240: | Строка 261: | ||
{| class="wikitable" | {| class="wikitable" | ||
|- | |- | ||
! <code>end</code> !! swap корня и <code>a[end]</code> !! | ! <code>end</code> !! swap корня и <code>a[end]</code> !! <code>Sift-Down(a, end, 0)</code> !! Массив !! Хвост | ||
|- | |- | ||
| 5 || <code>[2,4,5,1,3,9]</code> || 2→5 || <code>5 4 2 1 3 9</code> || <code>9</code> | | 5 || <code>[2,4,5,1,3,9]</code> || 2→5 || <code>5 4 2 1 3 9</code> || <code>9</code> | ||
| Строка 265: | Строка 286: | ||
=== Корректность Build-Max-Heap === | === Корректность Build-Max-Heap === | ||
''Индукция.'' Листья — кучи тривиально. При обработке узла <math>i</math> | ''Индукция.'' Листья — кучи тривиально. При обработке узла <math>i</math> справа налево по индексам — от последнего внутреннего узла к корню — его поддеревья уже кучи (индексы <math>> i</math> обработаны), поэтому по лемме выше поддерево <math>i</math> становится кучей. После <math>i=0</math> весь массив — куча. <math>\square</math> | ||
=== Корректность Heapsort === | === Корректность Heapsort === | ||
''Инвариант цикла.'' <code>a[0..end | ''Инвариант цикла.'' Перед итерацией с номером <code>end</code> массив <code>a[0..end]</code> — куча, а <code>a[end+1..n-1]</code> отсортирован и содержит <math>n-1-end</math> наибольших элементов. После <code>swap(a[0], a[end])</code> и <code>Sift-Down(a, end, 0)</code> куча — <code>a[0..end-1]</code>, а <code>a[end..n-1]</code> отсортирован. | ||
''База.'' Сразу после build-heap при <code>end = n-1</code> весь массив <code>a[0..n-1]</code> — куча; хвост пуст, то есть отсортирован и содержит <math>0</math> наибольших элементов. | |||
''Шаг.'' Пусть перед итерацией <code>a[0..end]</code> — куча. Тогда <code>a[0]</code> — максимум <code>a[0..end]</code>, и после обмена он оказывается в <code>a[end]</code>; вместе с прежним хвостом <code>a[end+1..n-1]</code> это даёт отсортированный <code>a[end..n-1]</code> из <math>n-end</math> наибольших элементов. Sift-down восстанавливает кучу в <code>a[0..end-1]</code> — условие инварианта для следующей итерации (<code>end-1</code>) выполнено. | |||
''Завершение.'' При <code>end = 0</code> куча — <code>a[0..0]</code>, а <code>a[1..n-1]</code> отсортирован; значит, отсортирован весь массив. <math>\square</math> | |||
=== Линейность build-heap === | === Линейность build-heap === | ||
Узел высоты <math>h</math> имеет не более <math>h</math> шагов sift-down. Число узлов высоты <math>h</math> в полном дереве из <math>n</math> узлов примерно <math>n/2^{h+1}</math>. Тогда | Узел высоты <math>h</math> имеет не более <math>h</math> шагов sift-down. Число узлов высоты <math>h</math> в почти полном дереве из <math>n</math> узлов примерно <math>n/2^{h+1}</math>. Тогда | ||
<math display="block">\sum_{h=0}^{\log n} \frac{n}{2^{h+1}}\, h | <math display="block">\sum_{h=0}^{\log n} \frac{n}{2^{h+1}}\, h | ||
| Строка 285: | Строка 312: | ||
{| class="wikitable" | {| class="wikitable" | ||
|- | |- | ||
! Алгоритм !! Средний !! Худший !! Память !! | ! Алгоритм !! Средний !! Худший !! Память !! Устойчивость !! Локальность | ||
|- | |- | ||
| Пирамидальная (heapsort) || <math>O(n\log n)</math> || <math>O(n\log n)</math> || <math>O(1)</math> || нет || плохая | | Пирамидальная (heapsort) || <math>O(n\log n)</math> || <math>O(n\log n)</math> || <math>O(1)</math> || нет || плохая | ||
| Строка 298: | Строка 325: | ||
|} | |} | ||
Память quicksort указана средняя; в худшем случае стек рекурсии занимает <math>O(n)</math>. | |||
'''Вердикт.''' Когда важна гарантия и ограничена память (встраиваемые системы, ядра) — heapsort. Когда важна средняя скорость — [[Быстрая сортировка | [[Файл:hs-06-compare.svg|frame|none|700px|Рис. 6. Heapsort против quicksort, mergesort и introsort: гарантия <math>O(n\log n)</math> и <math>O(1)</math> памяти, но плохая локальность и неустойчивость.]] | ||
'''Вердикт.''' Когда важна гарантия и ограничена память (встраиваемые системы, ядра) — heapsort (см. рис. 6). Когда важна средняя скорость — [[Быстрая сортировка|quicksort]]/introsort. Когда нужна устойчивость — mergesort/Timsort (см. [[Быстрая сортировка]]). | |||
== Где применяется == | == Где применяется == | ||
Куча и heapsort встречаются там, где нужен быстрый доступ к экстремуму или сортировка без лишней памяти (см. рис. 7). | |||
# '''Очереди с приоритетом.''' Двоичная куча — основа <code>std::priority_queue</code>, <code>heapq</code> (Python), <code>PriorityQueue</code> (Java) [8][9]; вставка/извлечение — <math>O(\log n)</math>, просмотр максимума — <math>O(1)</math>. Почему: куча хранит экстремум в корне и не требует полной сортировки [1][5]. | |||
# '''Сортировка на месте.''' Heapsort применяют там, где нельзя тратить <math>O(n)</math> буфера: встроенные системы, реального времени, ядра, обработка больших потоков. Почему: гарантия <math>O(n\log n)</math> при <math>O(1)</math> памяти [5]. | # '''Сортировка на месте.''' Heapsort применяют там, где нельзя тратить <math>O(n)</math> буфера: встроенные системы, реального времени, ядра, обработка больших потоков. Почему: гарантия <math>O(n\log n)</math> при <math>O(1)</math> памяти [5]. | ||
# '''Частичная сортировка и «k наибольших».''' Top-k через кучу размера <math>k</math> стоит <math>O(n\log k)</math> [4]; в Python это <code>heapq.nlargest</code>/<code>nsmallest</code> [9], тот же приём работает в потоковой обработке. | |||
# '''Частичная сортировка и «k наибольших».''' | |||
# '''Алгоритмы на графах.''' Дейкстра и Прим на двоичной куче дают <math>O((n+m)\log n)</math>; Хаффман строит префиксный код на мини-куче. Почему: нужна повторяющаяся выборка минимума среди меняющегося множества [1][5]. | # '''Алгоритмы на графах.''' Дейкстра и Прим на двоичной куче дают <math>O((n+m)\log n)</math>; Хаффман строит префиксный код на мини-куче. Почему: нужна повторяющаяся выборка минимума среди меняющегося множества [1][5]. | ||
# '''Приоритетные планировщики.''' Обработка событий по времени, таймеры, арбитраж задач — всё, где нужен «ближайший» элемент; куча даёт логарифмические операции вставки/извлечения [5]. | # '''Приоритетные планировщики.''' Обработка событий по времени, таймеры, арбитраж задач — всё, где нужен «ближайший» элемент; куча даёт логарифмические операции вставки/извлечения [5]. | ||
[[Файл:hs-07-applications.svg|frame|none|700px|Рис. 7. | [[Файл:hs-07-applications.svg|frame|none|700px|Рис. 7. Где применяется куча и heapsort: приоритетные очереди, сортировка на месте, top-k, алгоритмы на графах.]] | ||
== Историческая справка == | == Историческая справка == | ||
Пирамидальную сортировку предложил '''Дж. У. Дж. Уильямс''' (J. W. J. Williams) в 1964 году (публикация ''Algorithm 232: Heapsort'', Communications of the ACM); он же ввёл понятие пирамиды (кучи) [2]. В том же 1964 году '''Роберт Флойд''' (R. W. Floyd) показал, что кучу можно строить '''за линейное время''' (алгоритм ''Treesort''), и предложил оптимизацию sift-down, известную как '''bottom-up heapsort''' [3]. Эти две работы сделали heapsort одним из стандартных алгоритмов сортировки; подробный анализ дал '''Дональд Кнут''' в ''The Art of Computer Programming'', т. 3 (сортировка и поиск) [ | Пирамидальную сортировку предложил '''Дж. У. Дж. Уильямс''' (J. W. J. Williams) в 1964 году (публикация ''Algorithm 232: Heapsort'', Communications of the ACM); он же ввёл понятие пирамиды (кучи) [2]. В том же 1964 году '''Роберт Флойд''' (R. W. Floyd) показал, что кучу можно строить '''за линейное время''' (алгоритм ''Treesort''), и предложил оптимизацию sift-down, известную как '''bottom-up heapsort''' [3]. Эти две работы сделали heapsort одним из стандартных алгоритмов сортировки; подробный анализ дал '''Дональд Кнут''' в ''The Art of Computer Programming'', т. 3 (сортировка и поиск) [5]. | ||
Связанные улучшения: «слабые кучи» (weak heaps) и '''smoothsort''' Э. Дейкстры (1981), а также '''фибоначчиевы''' и '''биномиальные''' | Связанные улучшения: «слабые кучи» (weak heaps) и '''smoothsort''' Э. Дейкстры (1981), а также '''фибоначчиевы кучи''' (Фредман и Тарьян, 1987) [6] и '''биномиальные кучи''' (Ж. Вюйемен, 1978) [1] для быстрого слияния и <code>decrease-key</code> — см. [[Фибоначчиева куча]], [[Биномиальная куча]]. Название «пирамидальная сортировка» — русский перевод английского heap («пирамида, куча»). | ||
== Типичные ошибки и подводные камни == | == Типичные ошибки и подводные камни == | ||
| Строка 329: | Строка 356: | ||
* '''Забыть уменьшить размер кучи.''' В цикле heapsort sift-down вызывается для размера <code>end</code>, а не <math>n</math>; иначе отсортированный хвост снова попадёт в кучу. | * '''Забыть уменьшить размер кучи.''' В цикле heapsort sift-down вызывается для размера <code>end</code>, а не <math>n</math>; иначе отсортированный хвост снова попадёт в кучу. | ||
* '''Sift-down без условия останова.''' Цикл должен завершаться, когда узел не меньше детей или стал листом. | * '''Sift-down без условия останова.''' Цикл должен завершаться, когда узел не меньше детей или стал листом. | ||
* '''Ожидание устойчивости.''' Heapsort неустойчив | * '''Ожидание устойчивости.''' Heapsort неустойчив: обмен корня с последним меняет порядок равных. Пример: <code>[2a, 2b, 1]</code> → после heapsort <code>[1, 2b, 2a]</code> — равные <code>2a</code> и <code>2b</code> поменялись местами. | ||
* '''Ошибка в bottom-up-варианте.''' Нужны обе фазы (спуск к листу и подъём с подстановкой значения в «дырку») и ограничение подъёма исходным корнем; иначе теряются элементы. | * '''Ошибка в bottom-up-варианте.''' Нужны обе фазы (спуск к листу и подъём с подстановкой значения в «дырку») и ограничение подъёма исходным корнем; иначе теряются элементы. | ||
* '''Использование heapsort там, где важна скорость.''' Из-за локальности он обычно медленнее quicksort; для сортировки «по умолчанию» берут introsort. | * '''Использование heapsort там, где важна скорость.''' Из-за локальности он обычно медленнее quicksort; для сортировки «по умолчанию» берут introsort. | ||
| Строка 338: | Строка 365: | ||
# '''Sift-down.''' Реализуйте sift-down и проверьте, что он восстанавливает кучу для всех внутренних узлов снизу вверх. ''Подсказка:'' предусловие — поддеревья уже кучи. | # '''Sift-down.''' Реализуйте sift-down и проверьте, что он восстанавливает кучу для всех внутренних узлов снизу вверх. ''Подсказка:'' предусловие — поддеревья уже кучи. | ||
# '''Build-heap за O(n).''' Постройте кучу из случайного массива и подсчитайте сравнения; докажите, что их <math>O(n)</math>. ''Подсказка:'' сумма высот узлов. | # '''Build-heap за O(n).''' Постройте кучу из случайного массива и подсчитайте сравнения; докажите, что их <math>O(n)</math>. ''Подсказка:'' сумма высот узлов. | ||
# '''Heapsort.''' Реализуйте heapsort и сравните с <code>std::sort</code> по корректности и числу сравнений. ''Подсказка:'' не забудьте уменьшать размер кучи. | # '''Heapsort.''' Реализуйте heapsort и сравните с <code>std::sort</code> по корректности и числу сравнений. ''Подсказка:'' не забудьте уменьшать размер кучи. | ||
# '''Худший случай.''' Почему у heapsort нет <math>\Theta(n^2)</math>? Приведите аргумент через высоту. ''Подсказка:'' sift-down ≤ <math>h</math> шагов. | # '''Худший случай.''' Почему у heapsort нет <math>\Theta(n^2)</math>? Приведите аргумент через высоту. ''Подсказка:'' sift-down ≤ <math>h</math> шагов. | ||
# '''Bottom-up.''' Реализуйте bottom-up heapsort и измерьте экономию сравнений; на случайных данных ожидается порядка 40 %. ''Подсказка:'' две фазы, «дырка» в листе. | |||
# '''Bottom-up.''' Реализуйте bottom-up heapsort и измерьте экономию сравнений | |||
# '''Top-k.''' Найдите <math>k</math> наибольших за <math>O(n\log k)</math> с кучей размера <math>k</math>. Ограничение: <math>O(k)</math> памяти. ''Подсказка:'' мини-куча на <math>k</math> элементов. | # '''Top-k.''' Найдите <math>k</math> наибольших за <math>O(n\log k)</math> с кучей размера <math>k</math>. Ограничение: <math>O(k)</math> памяти. ''Подсказка:'' мини-куча на <math>k</math> элементов. | ||
# '''Средний случай.''' Постройте кучу из массива, отсортированного по возрастанию; сколько обменов делает sift-down? ''Подсказка:'' проанализируйте каждый уровень. | # '''Средний случай.''' Постройте кучу из массива, отсортированного по возрастанию; сколько обменов делает sift-down? ''Подсказка:'' проанализируйте каждый уровень. | ||
# '''Decrease-key.''' Объясните, чем <code>decrease-key</code> в куче отличается от сортировки, и почему для Дейкстры нужна поддержка этого (см. [[Очередь с приоритетом]]). ''Подсказка:'' sift-up. | # '''Decrease-key.''' Объясните, чем <code>decrease-key</code> в куче отличается от сортировки, и почему для Дейкстры нужна поддержка этого (см. [[Очередь с приоритетом]]). ''Подсказка:'' sift-up. | ||
| Строка 356: | Строка 376: | ||
* [[Двоичная куча]] — куча как структура данных и очередь с приоритетом. | * [[Двоичная куча]] — куча как структура данных и очередь с приоритетом. | ||
* [[Биномиальная куча]], [[Фибоначчиева куча]] — кучи с быстрым слиянием. | * [[Биномиальная куча]] (план), [[Фибоначчиева куча]] (план) — кучи с быстрым слиянием. | ||
* [[Быстрая сортировка]] — главный конкурент по скорости. | * [[Быстрая сортировка]] (план) — главный конкурент по скорости. | ||
* [[Сортировка слиянием]] — устойчивая гарантия <math>O(n\log n)</math> ценой памяти <math>O(n)</math>. | * [[Сортировка слиянием]] (план) — устойчивая гарантия <math>O(n\log n)</math> ценой памяти <math>O(n)</math>. | ||
* [[Асимптотическая сложность]] — суммарные оценки и телескопирование. | * [[Асимптотическая сложность]] (план) — суммарные оценки и телескопирование. | ||
* [[Очередь с приоритетом]] — абстрактный тип. | * [[Очередь с приоритетом]] (план) — абстрактный тип. | ||
* [[Алгоритм Дейкстры]] — применение кучи на графах. | * [[Алгоритм Дейкстры]] (план) — применение кучи на графах. | ||
== Источники == | == Источники == | ||
| Строка 373: | Строка 393: | ||
# Wikipedia: Heapsort. URL: https://en.wikipedia.org/wiki/Heapsort. | # Wikipedia: Heapsort. URL: https://en.wikipedia.org/wiki/Heapsort. | ||
# cppreference: <code>std::push_heap</code>, <code>std::sort_heap</code>, <code>std::priority_queue</code>. URL: https://en.cppreference.com/w/cpp/algorithm/sort_heap. | # cppreference: <code>std::push_heap</code>, <code>std::sort_heap</code>, <code>std::priority_queue</code>. URL: https://en.cppreference.com/w/cpp/algorithm/sort_heap. | ||
# Python documentation: heapq — Heap queue algorithm. URL: https://docs.python.org/3/library/heapq.html. | |||
Текущая версия на 22:54, 26 октября 2026
TL;DR
Пирамидальная сортировка (heapsort) сортирует массив в два этапа: сначала превращает его в max-кучу процедурой build-heap за [math]\displaystyle{ O(n) }[/math], затем [math]\displaystyle{ n-1 }[/math] раз меняет местами корень (текущий максимум) с последним элементом кучи и восстанавливает кучу sift-down за [math]\displaystyle{ O(\log n) }[/math]. Итог — гарантированное [math]\displaystyle{ O(n\log n) }[/math] в худшем случае при [math]\displaystyle{ O(1) }[/math] дополнительной памяти (данные переставляются на месте). По сравнению с быстрой сортировкой heapsort проигрывает в среднем из-за плохой локальности (прыжки по индексам дерева), зато не имеет «плохих» входов; по сравнению с сортировкой слиянием — не требует буфера, но неустойчив. Ключевые факты: build-heap линеен (сумма высот узлов даёт [math]\displaystyle{ O(n) }[/math], а не [math]\displaystyle{ O(n\log n) }[/math]), а оптимизация Флойда (bottom-up heapsort) экономит сравнения (на случайных данных — около 40 %). Подробнее о самой куче как структуре данных — в Двоичная куча; здесь разбирается именно сортировка.
| Свойство | Значение |
|---|---|
| Класс | Сортировка сравнением; на месте (in-place) |
| Изобретение | Дж. Уильямс (J. W. J. Williams), 1964; линейный build-heap — Р. Флойд, 1964 |
| Средний случай | [math]\displaystyle{ O(n\log n) }[/math] |
| Худший случай | [math]\displaystyle{ O(n\log n) }[/math] |
| Лучший случай | [math]\displaystyle{ O(n\log n) }[/math] |
| Build-heap | [math]\displaystyle{ O(n) }[/math] |
| Память | [math]\displaystyle{ O(1) }[/math] (не считая массива) |
| Устойчивость | нет |
| Число сравнений | [math]\displaystyle{ \approx 2n\log_2 n }[/math] в худшем (стандартный); bottom-up — меньше, на случайных данных [math]\displaystyle{ \approx 1.0\,n\log_2 n }[/math] |
| Структура | массив как почти полное (complete) двоичное дерево (max-куча) |
| Родственники | quicksort, mergesort, priority queue |
Определения
Куча (heap) — почти полное (complete) двоичное дерево, в котором у каждого узла ключ не меньше ключей потомков (max-куча) или не больше (min-куча). Здесь рассматривается max-куча: максимум всегда в корне. Подробнее — в Двоичная куча.
Почти полное (complete) двоичное дерево — все уровни, кроме, возможно, последнего, заполнены, а последний — слева направо. Такое дерево укладывается в массив без указателей (см. рис. 1).
Представление массивом (0-базное). Для узла [math]\displaystyle{ i }[/math]: левый ребёнок [math]\displaystyle{ 2i+1 }[/math], правый [math]\displaystyle{ 2i+2 }[/math], родитель [math]\displaystyle{ \lfloor (i-1)/2 \rfloor }[/math]. Узел [math]\displaystyle{ i }[/math] — лист, если [math]\displaystyle{ 2i+1 \ge n }[/math].
Sift-down (просеивание вниз, Max-Heapify). Восстановление свойства кучи в поддереве с корнем [math]\displaystyle{ i }[/math], если оба его поддерева уже являются кучами: нарушитель обменивается с большим из детей и опускается дальше (см. рис. 3).
Sift-up (просеивание вверх). Восстановление кучи при вставке нового элемента в конец: элемент поднимается, пока больше родителя. Нужно для insert в очередь с приоритетом, но не для сортировки.
Build-heap (построение кучи). Превращение произвольного массива в кучу просеиванием вниз всех внутренних узлов от [math]\displaystyle{ \lfloor n/2 \rfloor - 1 }[/math] до [math]\displaystyle{ 0 }[/math] (см. рис. 4).
Пирамидальная сортировка (heapsort). Два этапа: (1) build-heap; (2) цикл извлечения максимума в конец массива с уменьшением размера кучи (см. рис. 5).
Bottom-up heapsort. Оптимизация Флойда: вместо двух сравнений на уровень (выбор ребёнка и сравнение с ним значения) сначала спускаются к листу, а затем поднимаются вверх, пока значение больше родителя (родитель меньше значения), — экономит сравнения: на случайных данных около 40 %, в худшем случае меньше.
Обозначения. [math]\displaystyle{ n }[/math] — размер массива; [math]\displaystyle{ h = \lfloor \log_2 n \rfloor }[/math] — высота дерева; [math]\displaystyle{ i, end }[/math] — индексы; [math]\displaystyle{ \log }[/math] — двоичный.
Мотивация и интуиция
Нужен алгоритм, который одновременно даёт гарантию [math]\displaystyle{ O(n\log n) }[/math] и не тратит лишней памяти. Сортировка слиянием гарантирует [math]\displaystyle{ O(n\log n) }[/math], но требует буфер [math]\displaystyle{ O(n) }[/math]; quicksort работает на месте, но в худшем случае даёт [math]\displaystyle{ \Theta(n^2) }[/math]. Heapsort закрывает обе потребности: куча — это массив, её максимум в корне, а восстановление кучи после извлечения стоит [math]\displaystyle{ O(\log n) }[/math].
Изюминка heapsort — линейное построение кучи. Наивно можно было бы вставлять элементы по одному через sift-up ([math]\displaystyle{ O(n\log n) }[/math]), но просеивание снизу вверх справляется за [math]\displaystyle{ O(n) }[/math]: большая часть узлов расположена высоко над листьями и почти не работает (см. рис. 2). Это редкий пример, когда обработка снизу вверх даёт оценку лучше наивной.
Ключевая идея в одном абзаце: куча даёт мгновенный доступ к максимуму (он в корне), а обмен корня с последним элементом постепенно переносит максимумы в отсортированный хвост. После каждого обмена размер кучи уменьшается, и sift-down восстанавливает её за высоту дерева. Цена — локальность: обращения к детям [math]\displaystyle{ 2i+1, 2i+2 }[/math] прыгают по памяти.
Устройство
Пирамидальная сортировка опирается на две процедуры: sift-down (просеивание вниз) и build-heap (построение). Sift-down предполагает, что оба поддерева узла — уже кучи, и «проваливает» нарушителя вниз, всегда меняя его с большим ребёнком (см. рис. 3). Build-heap применяет sift-down к внутренним узлам от последнего к корню: к моменту обработки узла его поддеревья уже являются кучами, поэтому инвариант sift-down соблюдён.
Операции и алгоритмы
Операция: Sift-Down (Max-Heapify)
Идея. Сравнить узел с двумя детьми; если кто-то больше, обменять с большим и продолжить вниз от новой позиции.
Пошаговое описание:
- Среди [math]\displaystyle{ i }[/math], [math]\displaystyle{ 2i+1 }[/math], [math]\displaystyle{ 2i+2 }[/math] (в пределах [math]\displaystyle{ n }[/math]) найти индекс максимума
largest. - Если
largest = i— остановиться (куча восстановлена). - Иначе обменять
a[i]иa[largest], перейти кi = largest.
Инвариант. Перед каждым шагом поддеревья узла [math]\displaystyle{ i }[/math] — кучи; после шага свойство кучи в них сохранено, нарушение может остаться только в новом положении [math]\displaystyle{ i }[/math].
Процедура Sift-Down(a, n, i)
Вход: массив a; размер кучи n; индекс i (0 ≤ i < n)
Выход: поддерево с корнем i — куча
Предусловие: поддеревья a[2i+1] и a[2i+2] — кучи
Сложность: O(log n)
1 while true
2 l ← 2i + 1 ; r ← 2i + 2 ; largest ← i
3 if l < n and a[l] > a[largest]
4 largest ← l
5 if r < n and a[r] > a[largest]
6 largest ← r
7 if largest = i
8 return
9 swap(a[i], a[largest])
10 i ← largest
Разбор. Для кучи [3,9,7,5,4] sift-down от корня меняет 3 с 9, затем 3 с 5, давая [9,5,7,3,4] (см. рис. 3).
Сложность. Не более [math]\displaystyle{ h = \lfloor\log_2 n\rfloor }[/math] шагов; на каждом — до двух сравнений.
Крайние случаи. Лист ([math]\displaystyle{ 2i+1 \ge n }[/math]) — ничего не делает. Один ребёнок — сравнение с ним.
Операция: Build-Max-Heap
Идея. Обработать внутренние узлы справа налево, вызывая sift-down; листья уже кучи.
Пошаговое описание:
- Для
iот [math]\displaystyle{ \lfloor n/2 \rfloor - 1 }[/math] до [math]\displaystyle{ 0 }[/math] вызватьSift-Down(a, n, i).
Инвариант. После обработки узла [math]\displaystyle{ i }[/math] поддерево с корнем [math]\displaystyle{ i }[/math] — куча.
Процедура Build-Max-Heap(a)
Вход: массив a[0..n−1]
Выход: a — max-куча
Сложность: O(n)
1 n ← length[a]
2 for i ← ⌊n/2⌋ − 1 downto 0
3 Sift-Down(a, n, i)
Разбор. [3,9,2,1,4,5] → после [math]\displaystyle{ i=2 }[/math]: [3,9,5,1,4,2]; [math]\displaystyle{ i=1 }[/math]: без изменений; [math]\displaystyle{ i=0 }[/math]: [9,4,5,1,3,2] (см. рис. 4).
Сложность. [math]\displaystyle{ O(n) }[/math]: число узлов высоты [math]\displaystyle{ h }[/math] около [math]\displaystyle{ n/2^{h+1} }[/math], стоимость [math]\displaystyle{ \le h }[/math]; [math]\displaystyle{ \sum_h (n/2^{h+1})h = n\sum h/2^{h+1} = O(n) }[/math].
Крайние случаи. [math]\displaystyle{ n \le 1 }[/math] — цикл пуст. Массив, уже являющийся кучей, всё равно просеивается, но без обменов.
Операция: Heapsort (основной цикл)
Идея. Максимум кучи стоит в a[0]. Меняем его с последним элементом кучи, уменьшаем размер кучи и восстанавливаем её sift-down; максимум оказывается в отсортированном хвосте.
Пошаговое описание:
Build-Max-Heap(a).- Для
endот [math]\displaystyle{ n-1 }[/math] до [math]\displaystyle{ 1 }[/math]:swap(a[0], a[end]);Sift-Down(a, end, 0).
Инвариант. После итерации end элементы a[end..n-1] — отсортированы и не меньше всех остальных; a[0..end-1] — куча.
Алгоритм Heapsort(a)
Вход: массив a[0..n−1]
Выход: a отсортирован по неубыванию
Сложность: Θ(n log n) в лучшем, среднем и худшем случае; память O(1)
1 Build-Max-Heap(a)
2 for end ← length[a] − 1 downto 1
3 swap(a[0], a[end])
4 Sift-Down(a, end, 0)
Разбор. Куча [9,4,5,1,3,2]: end=5 → [5,4,2,1,3,9]; end=4 → [4,3,2,1,5,9]; end=3 → [3,1,2,4,5,9]; end=2 → [2,1,3,…]; end=1 → [1,2,3,4,5,9] (см. рис. 5).
Сложность. [math]\displaystyle{ n-1 }[/math] извлечений по [math]\displaystyle{ O(\log n) }[/math] → [math]\displaystyle{ O(n\log n) }[/math]; память [math]\displaystyle{ O(1) }[/math].
Крайние случаи. [math]\displaystyle{ n = 0, 1 }[/math] — ничего. Дубликаты обрабатываются корректно: значения встают по неубыванию, хотя порядок равных не сохраняется.
Оптимизация: Bottom-up Heapsort (Флойд)
Идея. Обычный sift-down на каждом уровне делает до двух сравнений (выбрать большего ребёнка и сравнить с ним значение). В bottom-up-варианте сначала спускаются к листу, сравнивая только детей (одно сравнение на уровень), а затем поднимаются от листа, сравнивая значение с родителями и останавливаясь, как только оно встанет на место.
Пошаговое описание:
- Сохранить
val = a[i]иstart = i. - Фаза 1 — спуск. Пока у текущего узла есть ребёнок: выбрать большего из детей, перенести его значение в текущий узел и перейти в этого ребёнка. Значение
valждёт в стороне и в сравнениях фазы 1 не участвует. - Фаза 2 — подъём. Начиная с листа, поднимать
val: пока родитель меньшеval, переносить родителя на место текущего узла; остановиться, когда родитель не меньшеvalили когда достигнутstart. - Записать
valв найденную позицию.
Инвариант. Поддеревья исходного узла start — кучи. В фазе 1 путь от start до текущего узла содержит значения, сдвинутые вверх, а val хранится отдельно. В фазе 2 все узлы ниже j на этом пути уже удовлетворяют свойству кучи, а val ищет место не выше start.
Процедура Sift-Down-Bottom-Up(a, n, i)
Вход: массив a; размер кучи n; индекс i (поддеревья — кучи)
Выход: поддерево с корнем i — куча
Сложность: O(log n), сравнений меньше, чем у обычного sift-down
1 start ← i
2 val ← a[i]
3 c ← 2i + 1
4 while c < n # фаза 1: путь к листу
5 if c + 1 < n and a[c + 1] > a[c]
6 c ← c + 1
7 a[i] ← a[c]
8 i ← c
9 c ← 2i + 1
10 j ← i # i — лист, сюда можно поместить val
11 while j > start # фаза 2: подъём, пока parent < val
12 parent ← ⌊(j − 1) / 2⌋
13 if a[parent] ≥ val
14 break
15 a[j] ← a[parent]
16 j ← parent
17 a[j] ← val
Разбор. Возьмём [1,4,1,3,3], [math]\displaystyle{ n = 5 }[/math], [math]\displaystyle{ i = 1 }[/math]; тогда val = 4, start = 1.
- Фаза 1:
c = 3, правый ребёнокa[4] = 3не больше левогоa[3] = 3, поэтомуcостаётся равным 3;a[1] ← a[3] = 3, массив[1,3,1,3,3],i = 3— лист, цикл заканчивается. - Фаза 2:
j = 3,parent = 1;a[1] = 3 < 4, поэтомуa[3] ← 3,j = 1; цикл останавливается, потому чтоjдошёл доstart = 1, а не потому, что родитель оказался не меньшеval. a[1] ← val = 4, массив снова[1,4,1,3,3].
Фаза 2 останавливается по достижении исходного корня, а не на первом сравнении; итог совпадает с исходным массивом, потому что значение уже стояло правильно.
Сложность. [math]\displaystyle{ O(\log n) }[/math]; на случайных массивах сравнений примерно на 40 % меньше, чем у стандартного heapsort (измерено кодом; зависит от входа). Для [math]\displaystyle{ n = 4096 }[/math] счётчики дают около [math]\displaystyle{ 1.74\,n\log_2 n }[/math] у стандартного варианта и около [math]\displaystyle{ 1.03\,n\log_2 n }[/math] у bottom-up, отношение [math]\displaystyle{ \approx 0.59 }[/math].
Крайние случаи. i — лист: фаза 1 не выполняется, фаза 2 тоже, val остаётся на месте. val уже не меньше детей: фаза 1 всё равно спускается к листу, а фаза 2 возвращает значение наверх. [math]\displaystyle{ n = 0 }[/math] или [math]\displaystyle{ 1 }[/math] — сортировать нечего. Дубликаты: сравнение строгое, устойчивость не гарантируется.
Bottom-up решает ту же задачу, что sift-down (см. рис. 3), но другим порядком обходов: сначала спуск к листу, затем подъём значения.
Сводная таблица сложности
| Операция | Лучший | Средний | Худший | Память |
|---|---|---|---|---|
Sift-Down |
[math]\displaystyle{ \Theta(1) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
Sift-Up (вставка в кучу) |
[math]\displaystyle{ \Theta(1) }[/math] | [math]\displaystyle{ \Theta(1) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
Build-Max-Heap |
[math]\displaystyle{ \Theta(n) }[/math] | [math]\displaystyle{ \Theta(n) }[/math] | [math]\displaystyle{ \Theta(n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
Heapsort |
[math]\displaystyle{ \Theta(n\log n) }[/math] | [math]\displaystyle{ \Theta(n\log n) }[/math] | [math]\displaystyle{ \Theta(n\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] |
Сноска к Sift-Up: для случайной вставки ожидаемое число подъёмов ограничено константой (вероятность подняться на [math]\displaystyle{ k }[/math] уровней [math]\displaystyle{ \approx 2^{-k} }[/math]); [math]\displaystyle{ O(\log n) }[/math] — худший случай.
Оговорка: heapsort не имеет «лёгких» входов — даже на отсортированном массиве выполняется полная работа, но и «тяжёлых» входов нет: оценка всегда [math]\displaystyle{ O(n\log n) }[/math].
Трассировка примера
Массив a = [3, 9, 2, 1, 4, 5], [math]\displaystyle{ n = 6 }[/math].
Построение кучи:
| Шаг | Узел | Действие | Массив |
|---|---|---|---|
| старт | — | — | 3 9 2 1 4 5
|
| i = 2 | 2 | 2 < 5 → swap | 3 9 5 1 4 2
|
| i = 1 | 9 | 9 > 1, 9 > 4 — стоп | 3 9 5 1 4 2
|
| i = 0 | 3 | 3 < 9 → swap с 9; 3 < 4 → swap с 4 | 9 4 5 1 3 2
|
Основной цикл (куча [9,4,5,1,3,2]):
end |
swap корня и a[end] |
Sift-Down(a, end, 0) |
Массив | Хвост |
|---|---|---|---|---|
| 5 | [2,4,5,1,3,9] |
2→5 | 5 4 2 1 3 9 |
9
|
| 4 | [3,4,2,1,5,9] |
3→4 | 4 3 2 1 5 9 |
5 9
|
| 3 | [1,3,2,4,5,9] |
1→3 | 3 1 2 4 5 9 |
4 5 9
|
| 2 | [2,1,3,…] |
2 ≥ 1 — стоп | 2 1 3 4 5 9 |
3 4 5 9
|
| 1 | [1,2,3,…] |
— | 1 2 3 4 5 9 |
всё |
Итог — [1,2,3,4,5,9]. Трассировка сверена с реализацией из раздела «Служебное».
Корректность и оценка сложности
Корректность Sift-Down
Утверждение. Если оба поддерева узла [math]\displaystyle{ i }[/math] — кучи, то после Sift-Down поддерево с корнем [math]\displaystyle{ i }[/math] — куча.
Доказательство. Если [math]\displaystyle{ a[i] }[/math] не меньше обоих детей, свойство выполняется (и в поддеревьях — по предположению). Иначе [math]\displaystyle{ a[i] }[/math] меняется с большим ребёнком [math]\displaystyle{ c }[/math]: теперь [math]\displaystyle{ a[i] \ge }[/math] прежнее значение ребёнка [math]\displaystyle{ \ge }[/math] второго ребёнка, значит, узел [math]\displaystyle{ i }[/math] удовлетворяет свойству. Поддерево [math]\displaystyle{ c }[/math] теряет свойство лишь в корне, но по индукции по высоте его восстанавливает рекурсивный вызов. [math]\displaystyle{ \square }[/math]
Корректность Build-Max-Heap
Индукция. Листья — кучи тривиально. При обработке узла [math]\displaystyle{ i }[/math] справа налево по индексам — от последнего внутреннего узла к корню — его поддеревья уже кучи (индексы [math]\displaystyle{ \gt i }[/math] обработаны), поэтому по лемме выше поддерево [math]\displaystyle{ i }[/math] становится кучей. После [math]\displaystyle{ i=0 }[/math] весь массив — куча. [math]\displaystyle{ \square }[/math]
Корректность Heapsort
Инвариант цикла. Перед итерацией с номером end массив a[0..end] — куча, а a[end+1..n-1] отсортирован и содержит [math]\displaystyle{ n-1-end }[/math] наибольших элементов. После swap(a[0], a[end]) и Sift-Down(a, end, 0) куча — a[0..end-1], а a[end..n-1] отсортирован.
База. Сразу после build-heap при end = n-1 весь массив a[0..n-1] — куча; хвост пуст, то есть отсортирован и содержит [math]\displaystyle{ 0 }[/math] наибольших элементов.
Шаг. Пусть перед итерацией a[0..end] — куча. Тогда a[0] — максимум a[0..end], и после обмена он оказывается в a[end]; вместе с прежним хвостом a[end+1..n-1] это даёт отсортированный a[end..n-1] из [math]\displaystyle{ n-end }[/math] наибольших элементов. Sift-down восстанавливает кучу в a[0..end-1] — условие инварианта для следующей итерации (end-1) выполнено.
Завершение. При end = 0 куча — a[0..0], а a[1..n-1] отсортирован; значит, отсортирован весь массив. [math]\displaystyle{ \square }[/math]
Линейность build-heap
Узел высоты [math]\displaystyle{ h }[/math] имеет не более [math]\displaystyle{ h }[/math] шагов sift-down. Число узлов высоты [math]\displaystyle{ h }[/math] в почти полном дереве из [math]\displaystyle{ n }[/math] узлов примерно [math]\displaystyle{ n/2^{h+1} }[/math]. Тогда
[math]\displaystyle{ \sum_{h=0}^{\log n} \frac{n}{2^{h+1}}\, h = \frac{n}{2}\sum_{h=0}^{\infty} \frac{h}{2^{h}} = \frac{n}{2}\cdot 2 = O(n). }[/math]
Значит, построение кучи линейно. Оценка heapsort: [math]\displaystyle{ O(n) + (n-1)O(\log n) = O(n\log n) }[/math].
Сравнение с альтернативами
| Алгоритм | Средний | Худший | Память | Устойчивость | Локальность |
|---|---|---|---|---|---|
| Пирамидальная (heapsort) | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] | нет | плохая |
| Bottom-up heapsort | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(1) }[/math] | нет | плохая (но меньше сравнений) |
| Быстрая | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n^2) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | нет | отличная |
| Слиянием (mergesort) | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n) }[/math] | да | хорошая |
| Introsort | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(n\log n) }[/math] | [math]\displaystyle{ O(\log n) }[/math] | нет | отличная |
Память quicksort указана средняя; в худшем случае стек рекурсии занимает [math]\displaystyle{ O(n) }[/math].
Вердикт. Когда важна гарантия и ограничена память (встраиваемые системы, ядра) — heapsort (см. рис. 6). Когда важна средняя скорость — quicksort/introsort. Когда нужна устойчивость — mergesort/Timsort (см. Быстрая сортировка).
Где применяется
Куча и heapsort встречаются там, где нужен быстрый доступ к экстремуму или сортировка без лишней памяти (см. рис. 7).
- Очереди с приоритетом. Двоичная куча — основа
std::priority_queue,heapq(Python),PriorityQueue(Java) [8][9]; вставка/извлечение — [math]\displaystyle{ O(\log n) }[/math], просмотр максимума — [math]\displaystyle{ O(1) }[/math]. Почему: куча хранит экстремум в корне и не требует полной сортировки [1][5]. - Сортировка на месте. Heapsort применяют там, где нельзя тратить [math]\displaystyle{ O(n) }[/math] буфера: встроенные системы, реального времени, ядра, обработка больших потоков. Почему: гарантия [math]\displaystyle{ O(n\log n) }[/math] при [math]\displaystyle{ O(1) }[/math] памяти [5].
- Частичная сортировка и «k наибольших». Top-k через кучу размера [math]\displaystyle{ k }[/math] стоит [math]\displaystyle{ O(n\log k) }[/math] [4]; в Python это
heapq.nlargest/nsmallest[9], тот же приём работает в потоковой обработке. - Алгоритмы на графах. Дейкстра и Прим на двоичной куче дают [math]\displaystyle{ O((n+m)\log n) }[/math]; Хаффман строит префиксный код на мини-куче. Почему: нужна повторяющаяся выборка минимума среди меняющегося множества [1][5].
- Приоритетные планировщики. Обработка событий по времени, таймеры, арбитраж задач — всё, где нужен «ближайший» элемент; куча даёт логарифмические операции вставки/извлечения [5].
Историческая справка
Пирамидальную сортировку предложил Дж. У. Дж. Уильямс (J. W. J. Williams) в 1964 году (публикация Algorithm 232: Heapsort, Communications of the ACM); он же ввёл понятие пирамиды (кучи) [2]. В том же 1964 году Роберт Флойд (R. W. Floyd) показал, что кучу можно строить за линейное время (алгоритм Treesort), и предложил оптимизацию sift-down, известную как bottom-up heapsort [3]. Эти две работы сделали heapsort одним из стандартных алгоритмов сортировки; подробный анализ дал Дональд Кнут в The Art of Computer Programming, т. 3 (сортировка и поиск) [5].
Связанные улучшения: «слабые кучи» (weak heaps) и smoothsort Э. Дейкстры (1981), а также фибоначчиевы кучи (Фредман и Тарьян, 1987) [6] и биномиальные кучи (Ж. Вюйемен, 1978) [1] для быстрого слияния и decrease-key — см. Фибоначчиева куча, Биномиальная куча. Название «пирамидальная сортировка» — русский перевод английского heap («пирамида, куча»).
Типичные ошибки и подводные камни
- Путаница 0- и 1-базной индексации. Формулы детей/родителя различаются; смешение даёт выход за границу и «почти отсортированный» массив.
- Сравнение не с тем ребёнком. В sift-down нужно брать большего ребёнка (для max-кучи), иначе инвариант рушится.
- Неверное начало build-heap. Начинать надо с [math]\displaystyle{ \lfloor n/2\rfloor - 1 }[/math] (последний внутренний узел), а не с [math]\displaystyle{ n-1 }[/math]: листья уже кучи.
- Забыть уменьшить размер кучи. В цикле heapsort sift-down вызывается для размера
end, а не [math]\displaystyle{ n }[/math]; иначе отсортированный хвост снова попадёт в кучу. - Sift-down без условия останова. Цикл должен завершаться, когда узел не меньше детей или стал листом.
- Ожидание устойчивости. Heapsort неустойчив: обмен корня с последним меняет порядок равных. Пример:
[2a, 2b, 1]→ после heapsort[1, 2b, 2a]— равные2aи2bпоменялись местами. - Ошибка в bottom-up-варианте. Нужны обе фазы (спуск к листу и подъём с подстановкой значения в «дырку») и ограничение подъёма исходным корнем; иначе теряются элементы.
- Использование heapsort там, где важна скорость. Из-за локальности он обычно медленнее quicksort; для сортировки «по умолчанию» берут introsort.
- Куча вместо кучи.
std::make_heap/push_heap/pop_heapиstd::priority_queue— разные инструменты; для сортировки всего массива естьstd::sort_heap. - Переполнение индексов. При больших [math]\displaystyle{ n }[/math]
2*i+2на граниint; следите за типом.
Задачи для практики
- Sift-down. Реализуйте sift-down и проверьте, что он восстанавливает кучу для всех внутренних узлов снизу вверх. Подсказка: предусловие — поддеревья уже кучи.
- Build-heap за O(n). Постройте кучу из случайного массива и подсчитайте сравнения; докажите, что их [math]\displaystyle{ O(n) }[/math]. Подсказка: сумма высот узлов.
- Heapsort. Реализуйте heapsort и сравните с
std::sortпо корректности и числу сравнений. Подсказка: не забудьте уменьшать размер кучи. - Худший случай. Почему у heapsort нет [math]\displaystyle{ \Theta(n^2) }[/math]? Приведите аргумент через высоту. Подсказка: sift-down ≤ [math]\displaystyle{ h }[/math] шагов.
- Bottom-up. Реализуйте bottom-up heapsort и измерьте экономию сравнений; на случайных данных ожидается порядка 40 %. Подсказка: две фазы, «дырка» в листе.
- Top-k. Найдите [math]\displaystyle{ k }[/math] наибольших за [math]\displaystyle{ O(n\log k) }[/math] с кучей размера [math]\displaystyle{ k }[/math]. Ограничение: [math]\displaystyle{ O(k) }[/math] памяти. Подсказка: мини-куча на [math]\displaystyle{ k }[/math] элементов.
- Средний случай. Постройте кучу из массива, отсортированного по возрастанию; сколько обменов делает sift-down? Подсказка: проанализируйте каждый уровень.
- Decrease-key. Объясните, чем
decrease-keyв куче отличается от сортировки, и почему для Дейкстры нужна поддержка этого (см. Очередь с приоритетом). Подсказка: sift-up.
Связанные темы
- Двоичная куча — куча как структура данных и очередь с приоритетом.
- Биномиальная куча (план), Фибоначчиева куча (план) — кучи с быстрым слиянием.
- Быстрая сортировка (план) — главный конкурент по скорости.
- Сортировка слиянием (план) — устойчивая гарантия [math]\displaystyle{ O(n\log n) }[/math] ценой памяти [math]\displaystyle{ O(n) }[/math].
- Асимптотическая сложность (план) — суммарные оценки и телескопирование.
- Очередь с приоритетом (план) — абстрактный тип.
- Алгоритм Дейкстры (план) — применение кучи на графах.
Источники
- Cormen T. H., Leiserson C. E., Rivest R. L., Stein C. Introduction to Algorithms (CLRS). 3rd ed. — MIT Press, 2009. — Гл. 6 «Heapsort» (heapify, build-heap, анализ).
- Williams J. W. J. Algorithm 232: Heapsort // Communications of the ACM. — 1964. — Vol. 7, № 6.
- Floyd R. W. Algorithm 245: Treesort // Communications of the ACM. — 1964. — Vol. 7, № 12 (линейное построение кучи; bottom-up).
- Sedgewick R., Wayne K. Algorithms. 4th ed. — Addison-Wesley, 2011. — Гл. 2.4 (приоритетные очереди, heapsort).
- Knuth D. E. The Art of Computer Programming. Vol. 3: Sorting and Searching. — Addison-Wesley, 2nd ed., 1998. — §5.2.3 (heapsort).
- Fredman M. L., Tarjan R. E. Fibonacci Heaps and Their Uses in Improved Network Optimization Algorithms // Journal of the ACM. — 1987. — Vol. 34, № 3.
- Wikipedia: Heapsort. URL: https://en.wikipedia.org/wiki/Heapsort.
- cppreference:
std::push_heap,std::sort_heap,std::priority_queue. URL: https://en.cppreference.com/w/cpp/algorithm/sort_heap. - Python documentation: heapq — Heap queue algorithm. URL: https://docs.python.org/3/library/heapq.html.