Двоичная и d-арная куча
Про приоритетные очереди
С помощью линейных структур нельзя реализовать эффективную приоритетную очередь.
Двоичная куча
Давайте создадим бинарное дерево, в котором каждая вершина будет больше своих потомков. При этом, для достижения минимальной высоты , сделаем дерево полным.
Полное бинарное дерево можно хорошо хранить в массиве h длины (индексация, напомню, с ).
Тогда у элемента с индексом детьми являются элементы с индексами и ,
а родителем — элемент с индексом .
По нашему построению видно, что максимальный элемент в куче имеет индекс , а минимальный элемент находится в одном из листьев. А так же для всех элементов выполнено «свойство кучи»:
Теперь мы можем производить какие-то операции с кучей. А именно, вставлять элементы, изменять элементы и извлекать минимум (про тонкости реализации позже).
После изменения элементов свойство кучи может нарушиться. Поэтому нам надо научиться быстро восстанавливать его.
Восстановление свойства кучи
Пусть какой-то один элемент мешает свойству кучи быть выполненным. Как мы увидим позже, любые операции над кучей в итоге оставляют ровно один плохой элемент.
Если этот элемент слишком большой, то его надо пропихнуть вверх по куче. Если же он наоборот, слишком маленький, то его надо пропихнуть вниз. Вот эти две операции пропихивания вверх и вниз называются по-научному просеиванием.
Просеивание вниз (shift down).
- Если просеиваемый элемент меньше, чем его дети, то меняем его с наибольшим из его детей.
- Продолжаем его просеивать, пока он не встанет на нормальное место или пока он не станет листом.
function shift_down(mutable array h, int i):
const int n = length(h)
while 2 * i + 1 < n:
left = 2 * i + 1
right = 2 * i + 2
if right < n and h[right] > h[left]:
j = right
else:
j = left
break if h[i] >= h[j]
swap h[i], h[j]
i = jЕсли просеиваемый вниз элемент находился на высоте , то выполнение операции потребует не более обменов и не более сравнений. Константа вылезает потому, что сравниваем мы каждым ребенком.
Просеивание вверх (shift up).
- Если просеиваемый элемент меньше, чем его родитель, то меняем его с его родителем.
- Продолжаем его просеивать, пока он не встанет на нормальное место или пока он не станет корнем.
Здесь нет никаких ветвлений, в отличие от просеивания вниз, поэтому код будет более красивым
function shift_up(mutable array h, int i):
while h[i] > h[(i - 1) / 2]:
swap h[i], h[(i - 1) / 2]
i = (i - 1) / 2Обе операции работают за время .
Вставка
Вставка нового элемента в кучу происходит очень просто:
мы добавляем в конец массива h новый элемент и просеиваем его вверх.
После просеивания новый элемент займет корректное положение.
Извлечение максимума
При извлечении минимума можно воспользоваться примерно тем же трюком, что и со вставкой. Поместим на место корня элемент , который гарантированно меньше любого другого элемента. Теперь осталось просто просеять вниз этот элемент.
Можно обойтись и без введения особого элемента, если вспомнить, что листы — наименьшие элементы в куче. Можно на место корня ставить не , а какой-то лист, например последний. Эффект от просеивания этого элемента вниз будет точно таким же, как и от просеивания : свойство кучи сохранено, максимум извлечён.
Построение кучи
Представим, что у нас есть неупорядоченный массив, который мы хотим превратить в кучу. Можно действовать в лоб: создать пустую кучу и добавлять туда элементы из массива. Такой подход имеет временную сложность .
Но в куче очень много беспорядков. Можно этим воспользоваться. Построить кучу из массива может операция heapify.
Представим, что в исходном массиве уже записано полное бинарное дерево. Оно не удовлетворяет свойству кучи. Давайте просеем вниз все узлы, которые имеют хотя бы одного ребёнка. При этом будем просеивать снизу вверх, то есть начнем с элемента на позиции , а закончим корнем, который находится на позиции .
В итоге мы из массива получим нормальную кучу. До просеивания какого-то узла оба его поддерева удовлетворяли свойству кучи. После просеивания этого узла он вместе со своими поддеревьями будет образовывать кучу. Значит, после просеивания всех узлов у нас получится куча.
function heapify(mutable array h):
const int n = length(h)
for int i = (n-1)/2; i >= 0; i--:
sift_down(h, i)Время работы этой операции .
Число вершин на высоте не более . Для каждой из них будет вызвана операция просеивания вниз, которая потребует не более обменов и не более сравнений. Значит, количество сравнений в операции heapify равно
А количество обменов в раза меньше, .
Анализ алгоритмов на куче
Одна из самых важных характеристик кучи — размеры её поддеревьев. Просто так получить все значения, к сожалению нельзя. Проблемы возникают только из-за того, что куча — полное дерево, но не всегда абсолютно сбалансированное.
В куче есть «особый путь», соединяющий корень кучи с последним листом. На картинке я его отметил красным.
Давайте все узлы, лежащие на особом пути, называть особыми. И поддеревья с корнями в особых узлах тоже будем называть особыми.
Все неособые поддеревья являются абсолютно сбалансированными, значит их размер всегда равен . А количество неособых поддеревьев очень легко вычисляется:
Осталось разобраться с особыми узлами. Пусть двоичное представление числа
Тогда размеры особых поддеревьев можно явно выразить через эти цифры. Напишу сверху вниз
Теперь мы можем посчитать количество всевозможных куч на элементах. Пусть — размер поддерева с корнем в . Тогда всего куч
Пирамидальная сортировка
С помощью кучи можно реализовать прекрасный алгоритм сортировки, называемый пирамидальной сортировкой. Названия действительно не совпадают. По-русски куча это пирамида, но такое название приоритетной очереди не прижилось в профессиональных кругах. А сортировка, работающая с помощью кучи, по-прежнему называется пирамидальной. В английском языке все хорошо: структура данных heap, сортировка heap sort.
Нам дали массив, который нужно отсортировать. Применим операцию heapify, превратив массив в кучу на максимум за сравнений и обменов. Затем будем извлекать максимум из кучи, пока та не кончится.
Heapify работает без дополнительной памяти, а вытаскиваемый максимум помещается в самый конец массива. Поэтому, можно с каждой операцией извлечения максимума уменьшать логический размер массива. В итоге максимумы будут складываться с конца в порядке убывания, и в итоге мы получим отсортированный массив.
function heap_sort(mutable array h):
int n = length(h)
heapify()
repeat n times:
swap h[0] = h[n-1]
n -= 1
shift_down(0)Если в алгоритм пирамидальной сортировки даётся случайная перестановка чисел , то при heapify может равновероятно получиться любая из возможных куч.
Построение кучи, как мы уже выяснили, требует сравнений и обменов. После этого мы раз извлекаем максимум, который находится в корне, на высоте . Эта операция просто вызывает просеивание вниз, а значит требует обменов и сравнений. В итоге пирамидальная сортировка требует
Огрубив, говорим, что пирамидальная сортировка работает за .
-арная куча
Есть смысл рассматривать не только бинарные деревья для представления кучи, а вообще любые -арные. Высота таких деревьев .
Точно так же, как и для двоичной кучи, у -арной кучи есть свойство кучи: значение любого узла больше значений всех его поддеревьев или, что то же самое, больше значений всех его детей.
Хранить полное -арное дерево можно точно так же в массиве. У элемента с индексом детьми являются элементы с индексами , а родителем — элемент с индексом .
Все операции: просеивание, вставка, извлечение максимума и построение выполняются точно так же, как и с обычной двоичной кучей.
Просеивание вверх требует максимум обменов и сравнений, а просеивание вниз требует максимум обменов и максимум сравнений.
Разберем операцию построения -арной кучи с помощью heapify. Количество сравнений для операции heapify равно
А количество обменов в раз меньше, .
То есть операции выполняются за время
- вставка —
- извлечение максимума —
- построение -арной кучи —
При этом эффективность -арной кучи сильно растёт только при маленьких . Посмотрите на график зависимости числа сравнений при вставке от размера кучи . Разными цветами обозначены графики для разных значений : от синего при до красного при .
Упражнения
heapify(array a)— создать кучу на массивеinsert(object x)— добавить объектxв кучуchange(int i, object x)— по заданному индексуiизменить значение элемента в куче наxextract_min()— извлечь из кучи минимальный элементpeek_min()— посмотреть, какой в куче минимальный элемент, не извлекая его
Реализуйте бинарную кучу на массиве.
Необходимо реализовать поддержку следующих операций
Напишите бенчмарки, которые замеряют время выполнения и количество операций сравнения
для insert(object x), change(int i, object x) и extract_min() при разном размере кучи и при разных сценариях работы
(много вставок, затем много извлечений; поочерёдные вставки и извлечения; случайные операции и так далее).
Постройте графики зависимости времени операции от и от .
Проведите аналогичные замеры для операции heapify(array a) и постройте график зависимости от при разных входных данных
(почти отсортированный массив, обратно отсортированный, случайная перестановка и так далее).
Напишите код, который вычисляет точное количество куч, которые можно построить на массиве . Постройте график зависимости логарифма количества куч от .