Фибоначчиева куча

Давайте вспомним, как работает биномиальная куча. Биномиальная куча представляет собой лес из куч. Слияние двух биномиальных куч реализовывалось соединением списков корней деревьев в обоих лесах. Потом мы, конечно, восстанавливали свойства биномиальной кучи после слияния, что как раз и давало нам временную сложность O(logn)O(\log n). Но сама идея реализации слияния двух куч через обычное соединение списков корней не такая уж и плохая.

Давайте уберем жёсткую структуру леса и каждого дерева в лесе. У нас получилась основа для фибоначчиевой кучи, которую можно доделать и превратить в приоритетную очередь с очень хорошими оценками времени работы.

Фибоначчиева куча

Фибоначчиева куча — набор деревьев, каждое из которых удовлетворяет свойству кучи: приоритет каждого узла не меньше приоритетов его детей.

Степенью фибоначчиевой кучи HH будем называть максимальную степень узла в этой куче:

degH   ⁣=def   ⁣max{degvvH}=D\deg H \defeq \max\limits \{ \deg v \mid \forall\, v \in H \} = D

Хранить фибоначчиеву кучу будем почти так же, как биномиальную. Единственное, из-за свободной структуры, нам не будет хватать односвязного списка для хранения детей узла. Чтобы была возможность удалять узлы из произвольного места и вставлять узлы в произвольное место, будем хранить детей в циклическом двузвязном списке.

Ещё нам понадобится хранить бинарную метку для каждой вершины. Это нужно для эффективной реализации операций удаления узла и изменения приоритета узла.

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

При анализе времени работы операций будем использовать метод потенциалов. Пусть потенциал кучи HH будет равен

Φ(H)   ⁣=def   ⁣t(H)+2m(H)\Phi(H) \defeq t(H) + 2 \cdot m(H)

где t(H)t(H) — количество деревьев в корневом списке кучи HH, и m(H)m(H) — количество отмеченных вершин в куче HH, то есть узлов, у которых поле marked установлено в true.

Создание пустой кучи

Для создания пустой кучи достаточно просто создать объект класса heap, у которого size=0\code{size} = 0 и max=null\code{max} = \code{\htmlClass{boolean}{null}}.

initialize(self): self.size = 0 self.max = null

Потенциал только что созданной кучи равен 00, потому что в ней нет ни деревьев, ни тем более помеченных вершин.

Сама операция создания пустой кучи выполняется за O(1)O(1).

Слияние

Слияние двух фибоначчиевых куч, как мы и хотели, состоит в обычном соединении связных списков корней деревьев в обоих кучах и обновлении указателя на максимум.

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

Потенциал не меняется, поскольку до слияния куч H1H_1 и H2H_2 общий потенциал был равен Φ(H1)+Φ(H2)\Phi(H_1) + \Phi(H_2), а после слияния потенциал у полученной кучи равен Φ(H1H2)=Φ(H1)+Φ(H2)\Phi(H_1 \union H_2) = \Phi(H_1) + \Phi(H_2). Общее число узлов в корневых списках и общее число помеченных вершин остаётся тем же.

Временная сложность работы операции слияния O(1)O(1).

Вставка

Вставка нового элемента в кучу очень просто реализуется слиянием. Создаём новую биномиальную кучу, состоящую только из одного элемента — вставляемого. Далее сливаем нашу новую кучу с исходной кучей, в которую вставляем элемент.

Можно оптимизировать эту операцию, избавившись от парочки ветвлений и присвоений указателей. Нам надо просто создать новый узел и добавить его в список корней, пересчитав максимум.

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++

В отличие от вставки в биномиальную кучу, здесь мы не выполняем никаких операций объединения деревьев. То есть если мы подряд вставим kk новых узлов в фибоначчиеву кучу, то список корней увеличится на kk элементов.

После этой операции в корневой список добавилась один узел, значит потенциал увеличился на 11. Время работы операции вставки составляет O(1)O(1).

Извлечение максимума

Максимум в куче находится по ссылке 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

Окучивание

Вспомогательная операция окучивания принимает на вход фибоначчиеву кучу HH и преобразует её таким образом, что в списке корней деревьев результирующей кучи будет содержаться не более D+1D + 1 узлов.

При окучивании будут соединяться пары деревьев с корнями одинаковой степенями. Мы будем соединять деревья до тех пор, пока в списке корней будут оставаться узлы одинаковой степени.

Заведём таблицу p[0D]\code{p} \bigl[ 0 \dots D \bigr], в которой будем хранить ссылки на корни. Точнее, p[j]\code{p}[j] будет содержать в себе ссылку на корень дерева, степень которого равна jj.

Теперь мы можем организовать процесс, аналогичный процессу слияния биномиальных куч. Проходимся поочередно по всем корням деревьев в кусе, смотря на его степень. Пусть степень текущего корня равна dd. Если в ячейке p[d]\code{p}[d] нет ссылки, то записываем туда ссылку на текущий корень. Если там что-то есть, то соединяем то дерево, на которое ссылается p[d]\code{p}[d] и текущее дерево, и включим получившееся дерево в наш проход следующим.

Два дерева надо соединять так, чтобы сохранялось свойство кучи. То есть мы выбираем то дерево, у которого корень имеет максимальный приоритет, и к нему подвешиваем второе дерево.

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

Посмотрим, сколько времени занимает операция извлечения максимума. Пусть до извлечения максимума в куче было tt деревьев и mm помеченных узлов. И пусть у максимального элемента в куче степень равна dd.

До разрушения дерева с максимальным корнем потенциал кучи был равен t+2mt + 2m. После разрушения и добавления детей потенциал стал равен (t1+d)+2m(t-1+d) + 2m. Получается, что разность потенциалов равна ΔΦ1=((t1+d)+2m)(t+2m)=d1\Delta \Phi_1 = \bigl( (t-1+d) + 2m \bigr) - (t + 2m) = d-1, а реальная стоимость чистого извлечения равна c1=d+1c_1 = d+1.

После этого шага в списке корней у нас находится t=t1+dt' = t-1+d узлов. После окучивания останется не более D+1D+1 деревьев. Тогда разность потенциалов ΔΦ2(D+1+2m)(t+2m)=D+1t\Delta \Phi_2 \le (D + 1 + 2m) - (t' + 2m) = D+1-t', а реальная стоимость окучивания c2=t+Dc_2 = t' + D.

Получается, что суммарное изменение потенциала

ΔΦ=ΔΦ1+ΔΦ2(d1)+(D+1t)=d+D(t1+d)=Dt+1\Delta \Phi = \Delta \Phi_1 + \Delta \Phi_2 \le (d-1) + (D+1-t') = d + D - (t-1+d) = D-t+1

Получаем, что амортизированная сложность извлечения максимума вместе с окучванием

A=c1+c2+ΔΦ(d+1)+(t+D)+(Dt+1)=2d+2D+1A = c_1 + c_2 + \Delta \Phi \le (d + 1) + (t' + D) + (D - t + 1) = 2d + 2D + 1

Поскольку D=degHD = \deg H — максимальная степень узла, то dDd \le D. Получаем верхнюю оценку

A4D+1A \le 4D + 1

Изменение приоритетов

Увеличение приоритета

Нам бы очень сильно хотелось, чтобы учётная стоимость операции увеличения приоритета была O(1)O(1). Для этого нам нужно, чтобы узел не всплывал до корня, тогда дерево не нужно будет сильно перестраивать.

Если приоритет узла после увеличения остался меньше приоритета родителя, то ничего делать не надо. В противном случае будем вырезать поддерево полностью и перемещать его в корневой список.

Если мы позволим постоянно вырезать узлы без перестраивания дерева, то дерево быстро выродится в бамбук, и у нас потеряются хорошие оценки на степени вершин.

Будем следить, чтобы у каждой вершины, не входящих в корневой список, удалялось не более одного ребёнка. Для этого мы и заводили бинарную метку mark. Если у узла xx удаляли ребёнка, то x.mark=truex.\code{mark} = \true. По этому после вырезания ребёнка у узла этот узел помечается, если он ранее не был отмечен и если он не является корнем. Если же он был отмечен, то он тоже вырезается, и процедура уходит дальше по родителям. Описанная процедура последовательного вырезания отмеченных вершин называется каскадным вырезанием.

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)

Вырезание узла.

Когда мы вырезаем узел, мы убираем его из списка детей его родителя, уменьшаем степень родителя на 11 и вставляем его в корневой список. Обязательно нужно снять метку, так как узел стал корнём.

После нам нужно обновить максимум кучи. Максимальный приоритет может быть или предыдущий максимальный узел, или тот узел, приоритет которого мы повышали. При каскадном вырезании максимум поменяться не может.

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

Посмотрим, сколько времени занимает операция увеличения приоритета. Внутри себя эта операция вызывает еще вспомогательные операции вырезания и каскадного вырезания.

Реальная стоимость всех этих операций описывается длиной цепочки рекурсивных вызовов. Обозначим эту длину за cc. А сама цепочка рекурсивных вызовов соответствует цепочке помеченных узлов, каждый из которых является ребёнком следующего. Все вершины из этой цепочки помещаются в корень и остаются непомеченными, так что реальная стоимость всей операции увеличения приоритета равна cc.

Анализ алгоритмов

Давайте теперь оценим размеры поддеревьев.

Лемма о размере поддеревьев в фибоначчиевой куче

Пусть xx — какой-то узел в фибоначчиевой куче, degx\deg x — количество детей этого узла xx, и sizex\size x — минимально возможный размер поддерева с корнем в xx.

Минимальный возможный размер поддерева с корнем степени kk не меньше Fk+2F_{k+2}, то есть

degx=k    sizexFk+2\deg x = k \implies \size x \ge F_{k+2}

Доказать эту лемму можно индукцией по kk. База k=0k=0: дерево состоит только из корня, size(x)=1=F2\size(x) = 1 = F_2, и k=1k=1: дерево состоит из корня и одного его ребёнка, size(x)=2=F3\size(x) = 2 = F_3.

Пусть у узла xx детьми являются узлы y1,y2,,yky_1, y_2, \dotsc, y_k, при этом дети упорядочены по времени добавления. Когда узел yjy_j стал ребёнком узла xx, у узла xx уже было j1j-1 детей, поэтому в момент добавления degyj=j1\deg y_j = j - 1, поскольку соединяются деревья с одинаковой степенью корня.

Далее у узла yjy_j могли вырезать ребёнка, если выполнялась операция увеличения приоритета. Но у узла yjy_j могли вырезать максимум одного ребёнка, ведь после вырезания первого ребёнка узел yjy_j стал помеченным, то есть yj.mark=truey_j.\code{mark} = \code{\htmlClass{boolean}{true}}, а при удалении второго ребёнка сработало бы каскадное вырезание, и узел yjy_j перестал бы быть ребёнком узла xx. Значит, за всё время у узла yjy_j могли удалить только одного ребёнка из тех, что были в момент добавления, то есть

degyjj2для всех 1jk в любой момент после добавления\deg y_j \ge j-2 \quad\text{для всех}~ 1 \le j \le k ~\text{в любой момент после добавления}

Получается, что по предположению индукции,

size(yj)Fdegyj+2   ⁣   ⁣F(j2)+2=Fj\size(y_j) \ge F_{\deg y_j + 2} \;\! \ge \;\! F_{(j-2)+2} = F_j

Тогда

sizex1+j=1ksizeyj   ⁣   ⁣1+j=1kFj=Fk+2\size x \ge 1 + \sum\limits_{j=1}^k \size y_j \;\! \ge \;\! 1 + \sum\limits_{j=1}^k F_j = F_{k+2}

Используем результат леммы, чтобы оценить DD, максимальная степень узла.

Суммарное количество узлов в куче не меньше размера поддерева с корнем в узле, имеющем максимальную степень. То есть, если в куче nn элементов, то

nsize(узел степени D)FD+2n \ge \size (\text{узел степени}~ D) \ge F_{D+2}

Отсюда можно получить оценку для DD, логарифмируя неравенство nFD+2n \ge F_{D+2}

logφnlogφFD+2=D+2logφ5+O(φ2D)    D   ⁣   ⁣logφn+logφ52+o(1)\log_\varphi n \ge \log_\varphi F_{D+2} = D + 2 - \log_\varphi \sqrt{5} + O(\varphi^{-2D}) \implies D \;\! \le \;\! \log_\varphi n + \log_\varphi \sqrt{5} - 2 + o(1)

Запишем отдельно наш результат, из которого следуют временные оценки сложности операций извлечения минимума и увеличения приоритета

Dlogφn+logφ52+o(1)иDlogφn+1D \le \log_\varphi n + \log_\varphi \sqrt{5} - 2 + o(1) \quad\text{и}\quad D \le \lfloor \log_\varphi n \rfloor + 1