Фибоначчиева куча
Давайте вспомним, как работает биномиальная куча. Биномиальная куча представляет собой лес из куч. Слияние двух биномиальных куч реализовывалось соединением списков корней деревьев в обоих лесах. Потом мы, конечно, восстанавливали свойства биномиальной кучи после слияния, что как раз и давало нам временную сложность . Но сама идея реализации слияния двух куч через обычное соединение списков корней не такая уж и плохая.
Давайте уберем жёсткую структуру леса и каждого дерева в лесе. У нас получилась основа для фибоначчиевой кучи, которую можно доделать и превратить в приоритетную очередь с очень хорошими оценками времени работы.
Фибоначчиева куча
Фибоначчиева куча — набор деревьев, каждое из которых удовлетворяет свойству кучи: приоритет каждого узла не меньше приоритетов его детей.
Степенью фибоначчиевой кучи будем называть максимальную степень узла в этой куче:
Хранить фибоначчиеву кучу будем почти так же, как биномиальную. Единственное, из-за свободной структуры, нам не будет хватать односвязного списка для хранения детей узла. Чтобы была возможность удалять узлы из произвольного места и вставлять узлы в произвольное место, будем хранить детей в циклическом двузвязном списке.
Ещё нам понадобится хранить бинарную метку для каждой вершины. Это нужно для эффективной реализации операций удаления узла и изменения приоритета узла.
struct node:
node* parent # ссылка на родителя
node* left # ссылка на левого брата
node* right # ссылка на правого брата
node* child # ссылка на какого-то ребёнка
int degree
bool mark
object priorityДля доступа ко всем деревьям леса нам достаточно всего одного указателя, ведь корни тоже хранятся в двусвязном списке. Разумно хранить именно указатель на элемент с максимальным приоритетом. Такой элемент всегда является корнем одного из деревьев в лесу.
Также будем хранить размер кучи для обеспечения константного времени получения этого размера
class heap:
int size
node* maxНам очень часто нужно будет объединять списки узлов, поэтому стоит оформить эту операцию как отдельную функцию
function union_lists(node* first, node* second):
node* left = first.left
node* right = second.right
second.right = first
first.left = second
left.right = right
right.left = leftПри анализе времени работы операций будем использовать метод потенциалов. Пусть потенциал кучи будет равен
где — количество деревьев в корневом списке кучи ,
и — количество отмеченных вершин в куче ,
то есть узлов, у которых поле marked установлено в true.
Создание пустой кучи
Для создания пустой кучи достаточно просто создать объект класса heap,
у которого и .
initialize(self):
self.size = 0
self.max = nullПотенциал только что созданной кучи равен , потому что в ней нет ни деревьев, ни тем более помеченных вершин.
Сама операция создания пустой кучи выполняется за .
Слияние
Слияние двух фибоначчиевых куч, как мы и хотели, состоит в обычном соединении связных списков корней деревьев в обоих кучах и обновлении указателя на максимум.
method merge(heap self, heap other):
if other.size == 0:
return
if self.size == 0:
self.max = other.max
self.size = other.size
return
union_lists(self.max, other.max)
self.size += other.size
if other.max > self.max:
self.max = other.maxПотенциал не меняется, поскольку до слияния куч и общий потенциал был равен , а после слияния потенциал у полученной кучи равен . Общее число узлов в корневых списках и общее число помеченных вершин остаётся тем же.
Временная сложность работы операции слияния .
Вставка
Вставка нового элемента в кучу очень просто реализуется слиянием. Создаём новую биномиальную кучу, состоящую только из одного элемента — вставляемого. Далее сливаем нашу новую кучу с исходной кучей, в которую вставляем элемент.
Можно оптимизировать эту операцию, избавившись от парочки ветвлений и присвоений указателей. Нам надо просто создать новый узел и добавить его в список корней, пересчитав максимум.
method insert(heap self, object priority):
node new_node = {
priority = priority,
degree = 0,
child = null,
mark = false,
}
if size == 0:
self.max = new_node
self.max.left = new_node
self.max.right = new_node
else:
node previous_right = self.max.right
self.max.right = new_node
new_node.left = self.max
new_node.right = previous_right
previous_right.left = node_node
if node_node.priority > self.max.priority:
self.max = node_node
self.size++В отличие от вставки в биномиальную кучу, здесь мы не выполняем никаких операций объединения деревьев. То есть если мы подряд вставим новых узлов в фибоначчиеву кучу, то список корней увеличится на элементов.
После этой операции в корневой список добавилась один узел, значит потенциал увеличился на . Время работы операции вставки составляет .
Извлечение максимума
Максимум в куче находится по ссылке max.
Можно разрушить дерево с корнем max, удалить узел max,
а получившийся список детей рассмотреть как новую кучу.
Тогда просто слить эту кучу из детей с исходной кучей без дерева с корнем в max.
Проблема в том, что после удаления старого максимума нам нужно найти новый максимум. Также именно на этом этапе нам нужно перестроить кучу, чтобы деревьев в ней было не слишком много, и будущие операции занимали небольшое количество времени. Обе эти задачи может решить операция consolidate (уплотнение), которую я буду именовать окучиванием. Окучивание разберем чуть позже, а пока код самого извлечения:
method extract_max(heap self) -> node:
node z = self.max
if z is null: return null
if z.child is not null:
node child = z.child
node start_child = child
do:
node next_child = child.right
# Добавляем child в корневой список
child.parent = null
child.left = self.max
child.right = self.max.right
self.max.right.left = child
self.max.right = child
child = next_child
while child != start_child
# Удаляем z из корневого списка
z.left.right = z.right
z.right.left = z.left
# Обновляем self.max
if z.right == z: # В корневом списке был только z
self.max = null
else:
self.max = z.right
self.consolidate()
self.size--
return zОкучивание
Вспомогательная операция окучивания принимает на вход фибоначчиеву кучу и преобразует её таким образом, что в списке корней деревьев результирующей кучи будет содержаться не более узлов.
При окучивании будут соединяться пары деревьев с корнями одинаковой степенями. Мы будем соединять деревья до тех пор, пока в списке корней будут оставаться узлы одинаковой степени.
Заведём таблицу , в которой будем хранить ссылки на корни. Точнее, будет содержать в себе ссылку на корень дерева, степень которого равна .
Теперь мы можем организовать процесс, аналогичный процессу слияния биномиальных куч. Проходимся поочередно по всем корням деревьев в кусе, смотря на его степень. Пусть степень текущего корня равна . Если в ячейке нет ссылки, то записываем туда ссылку на текущий корень. Если там что-то есть, то соединяем то дерево, на которое ссылается и текущее дерево, и включим получившееся дерево в наш проход следующим.
Два дерева надо соединять так, чтобы сохранялось свойство кучи. То есть мы выбираем то дерево, у которого корень имеет максимальный приоритет, и к нему подвешиваем второе дерево.
method consolidate(heap self):
table[node*] p[]
node* current = self.max
node* start = self.max
do:
node* next = current.right
degree = current.degree
while p[degree] is not null:
node* conflict = p[degree]
if conflict.priority > current.priority:
swap current, conflict
# удалить узел conflict из списка корней
conflict.left.right = conflict.right
conflict.right.left = conflict.left
conflict.parent = null
# добавить к current ребёнка conflict
conflict.parent = current
conflict.left = conflict
conflict.right = conflict
if current.child == null:
current.child = conflict
else:
union_lists(current.child, conflict)
current.degree++
p[degree] = null
degree += 1
p[degree] = current
if current.priority > self.max.priority:
self.max = current
current = next
while current != startПосмотрим, сколько времени занимает операция извлечения максимума. Пусть до извлечения максимума в куче было деревьев и помеченных узлов. И пусть у максимального элемента в куче степень равна .
До разрушения дерева с максимальным корнем потенциал кучи был равен . После разрушения и добавления детей потенциал стал равен . Получается, что разность потенциалов равна , а реальная стоимость чистого извлечения равна .
После этого шага в списке корней у нас находится узлов. После окучивания останется не более деревьев. Тогда разность потенциалов , а реальная стоимость окучивания .
Получается, что суммарное изменение потенциала
Получаем, что амортизированная сложность извлечения максимума вместе с окучванием
Поскольку — максимальная степень узла, то . Получаем верхнюю оценку
Изменение приоритетов
Увеличение приоритета
Нам бы очень сильно хотелось, чтобы учётная стоимость операции увеличения приоритета была . Для этого нам нужно, чтобы узел не всплывал до корня, тогда дерево не нужно будет сильно перестраивать.
Если приоритет узла после увеличения остался меньше приоритета родителя, то ничего делать не надо. В противном случае будем вырезать поддерево полностью и перемещать его в корневой список.
Если мы позволим постоянно вырезать узлы без перестраивания дерева, то дерево быстро выродится в бамбук, и у нас потеряются хорошие оценки на степени вершин.
Будем следить, чтобы у каждой вершины, не входящих в корневой список, удалялось не более одного ребёнка.
Для этого мы и заводили бинарную метку mark.
Если у узла удаляли ребёнка, то .
По этому после вырезания ребёнка у узла этот узел помечается,
если он ранее не был отмечен и если он не является корнем.
Если же он был отмечен, то он тоже вырезается, и процедура уходит дальше по родителям.
Описанная процедура последовательного вырезания отмеченных вершин называется каскадным вырезанием.
method promote(heap self, node* x, object new_priority):
if new_priority > x.parent.key:
x.priority = new_priority
return
node parent = x.parent
self.cut(x)
self.cascading_cut(parent)Вырезание узла.
Когда мы вырезаем узел, мы убираем его из списка детей его родителя, уменьшаем степень родителя на и вставляем его в корневой список. Обязательно нужно снять метку, так как узел стал корнём.
После нам нужно обновить максимум кучи. Максимальный приоритет может быть или предыдущий максимальный узел, или тот узел, приоритет которого мы повышали. При каскадном вырезании максимум поменяться не может.
method cut(heap self, node* x)
node left = x.left
node right = x.right
right.left = left
left.right = right
x.parent.degree--
if x.parent.child == x:
if x.right == x:
x.parent.child
else:
x.parent.child = x.right
x.right = x
x.left = x
x.parent = null
x.mark = false
union_lists(self.min, x) # вставляем наше поддерево в корневой списокКаскадное вырезание.
Как я уже описывал, мы поднимаемся вверх по ссылкам на родителей и вырезаем все отмеченные узлы. Как только мы дойдем до узла, который не был помечен, мы его пометим.
method cascading_cut(heap self, node* x)
while x.mark == true:
self.cut(x)
x = x.parent
x.mark = trueПосмотрим, сколько времени занимает операция увеличения приоритета. Внутри себя эта операция вызывает еще вспомогательные операции вырезания и каскадного вырезания.
Реальная стоимость всех этих операций описывается длиной цепочки рекурсивных вызовов. Обозначим эту длину за . А сама цепочка рекурсивных вызовов соответствует цепочке помеченных узлов, каждый из которых является ребёнком следующего. Все вершины из этой цепочки помещаются в корень и остаются непомеченными, так что реальная стоимость всей операции увеличения приоритета равна .
Анализ алгоритмов
Давайте теперь оценим размеры поддеревьев.
Лемма о размере поддеревьев в фибоначчиевой куче
Пусть — какой-то узел в фибоначчиевой куче, — количество детей этого узла , и — минимально возможный размер поддерева с корнем в .
Минимальный возможный размер поддерева с корнем степени не меньше , то есть
Доказать эту лемму можно индукцией по . База : дерево состоит только из корня, , и : дерево состоит из корня и одного его ребёнка, .
Пусть у узла детьми являются узлы , при этом дети упорядочены по времени добавления. Когда узел стал ребёнком узла , у узла уже было детей, поэтому в момент добавления , поскольку соединяются деревья с одинаковой степенью корня.
Далее у узла могли вырезать ребёнка, если выполнялась операция увеличения приоритета. Но у узла могли вырезать максимум одного ребёнка, ведь после вырезания первого ребёнка узел стал помеченным, то есть , а при удалении второго ребёнка сработало бы каскадное вырезание, и узел перестал бы быть ребёнком узла . Значит, за всё время у узла могли удалить только одного ребёнка из тех, что были в момент добавления, то есть
Получается, что по предположению индукции,
Тогда
Используем результат леммы, чтобы оценить , максимальная степень узла.
Суммарное количество узлов в куче не меньше размера поддерева с корнем в узле, имеющем максимальную степень. То есть, если в куче элементов, то
Отсюда можно получить оценку для , логарифмируя неравенство
Запишем отдельно наш результат, из которого следуют временные оценки сложности операций извлечения минимума и увеличения приоритета