Chapter 4 | Leftist Heap & Skew Heap
约 4753 个字 92 行代码 20 张图片 预计阅读时间 49 分钟
Leftist Heap
link
OI Wiki: https://oi-wiki.org/ds/leftist-tree/
Wikipedia: https://en.wikipedia.org/wiki/Leftist_tree
概述
Preview of Binary Heap
简单过一下二叉堆比较重要的几个操作(以最小堆为例)
- Insert: 插入到二叉堆的末尾,再 Percolate Up:\(O(log(n))\)
- FindMin: 直接弹出最顶上的元素 :\(O(1)\)
- DeleteMin: 删除最顶上的元素,以末尾元素替代之,再 Percolate Down:\(O(log(n))\)
- BuildHeap: 可以执行连续插入 :\(nO(log(n))\); 更好的方式是全部排列好,从倒数第二层开始 Percolate Up:\(O(n)\) -- from course DS
Merge(Meld): 类似 BuildHeap,可以将一个二叉堆中的元素依次插入另一个二叉堆;但更好的方式是全部打乱再 BuildHeap:\(O(n)\)
我们不太满意 Binary Heap 的 Merge,因为全部打乱再 BuildHeap 的操作消解了两个 Heap 的特征,虽然说 \(O(n)\) 已经不错了,但是我们还想找到更好的结构来进行快速的堆合并操作,于是左偏堆 (Leftist Heap) 应运而生 , 它的堆合并操作的复杂度只要 \(O(log(n))\)
@cy's ppt
Leftist Heap: Order Property – the same Structure Property – binary tree, but unbalanced
左偏堆 (Leftlist Heap) 也是一颗二叉树,不仅具有堆的性质,并且是 「左偏」 的,但是由于它并不再是一颗完全二叉树,所以不能像维护大小堆一样用数组来维护它
Why arrays?
这里提一嘴为什么堆会用数组存储,使用数组来存储堆的节点信息,有一种天然的优势那就是节省内存空间。因为数组占用的是连续的内存空间,相对于散列存储的结构来说,数组可以节省连续的内存空间,不会将内存打乱。
定义
一个左偏堆的结点维护了左右子堆的地址、自身的键值、和一个距离 (dist)/Npl(null path length)
@cy's ppt
[Definition] The null path length,Npl(x),of any node X is the length of the shortest path from X to a node without two children. Define Npl(NULL) = -1.
[Definition] The leftist heap property is that for every node X in the heap, the null path length of the left child is at least as large as that of the right child.
简单来说,每个节点都额外存储了它到小于 2 个孩子的节点的最短距离 dist/Npl,并且对任意节点而言,它的左孩子的 dist >= 右孩子的 dist 需要注意的是,dist 不是深度,左偏树的深度没有保证,一条向左的链也符合左偏树的定义。
性质
properties
- 结点的 \(dist\) 等于 \(dist_\text{right child} + 1\)(假设 \(dist_\text{NULL} = -1\)
) ;或者可以说结点的 \(dist\) 就是最右路径的节点数 - 如果 \(dist_i = N\),则以 \(i\) 为根的子树至少是一个 \(N+1\) 层的完美二叉树,至少有 \(2^{N+1}-1\) 个结点
- 最右路径有 r 个节点的左斜堆至少有 \(2^r - 1\) 个节点 / 有 N 个节点的左斜堆的最右路径最多有 \(\log_2(N+1)\) 个节点
其中命题 2 和命题 3 事实上是等价的
证明
首先我们明确,如果一棵树的最右路径有 r 个节点,那么这棵树的 dist 就等于 r(根据性质 1)/ 甚至其实我们就可以将 dist 定义为最右路径的节点个数。 我们用数学归纳法加以证明:
-
若 r = 1,最右路径有 1 个节点,命题成立;
-
假设命题对于 2,3,4,5,....r 均成立,考虑左斜堆最右路径具有 r+1 个节点的情况:根节点的右孩子的最右路径有 r 个节点,右孩子树至少有 \(2^r - 1\) 个节点;左孩子的最右路径也至少要有 r 个节点才能满足根节点的左倾性质,因此左孩子树也至少要有 \(2^r - 1\) 个节点;因此根节点的这棵树至少有 \(2*(2^r - 1) + 1 = 2^{r+1} - 1\) 个节点
所以这个定理成立,其实也就可以证明合并的操作是 \(log(n)\) 级别的
注意,在示意图中我们省略了结点自身键值的标记,但既然作为一个堆,它就需要满足堆的性质,即结点的键值不大于(不小于)其孩子结点的键值。在实际使用过程中,键值很可能不再是单纯的数,大小关系可能转化为偏序关系。
操作
左偏堆的核心操作就是合并。而其它操作都可以看作是合并的特殊情况。因此我们首先讨论任意两个左偏堆的合并。
合并
作为左偏堆的核心操作,合并操作自然就是要在满足性质的条件下,合并两个左偏堆。大致思路就是先维护堆的性质,在回溯时维护左偏性质,所以实际上它是一个先自上而下再自下而上的过程。
按照实现方法,左偏堆的合并可以分为递归式和迭代式两种。其中前者可能更为直觉,而后者可视化后则更为直观。
递归式
递归式先比较当前两个待合并子树的根结点的键值,选择较小(较大)的那个作为根结点,其左子树依然为左子树,右子树更新为「右子树和另一个待合并子树的合并结果
当然,在递归地更新完后,我们需要检查左子树和右子树是否满足 \(dist_\text{left child} \geq dist_\text{right child}\) 的性质,如果不满足,我们则需要交换左右子树来维持性质。
LeftistHeapNode * merge(LeftistHeapNode * x, LeftistHeapNode * y) {
// Recursive exit. If any is NULL, return the other as the new root of subtree.
if (x == NULL) return y;
if (y == NULL) return x;
// If `x`'s val is smaller than `y`'s, swap them, which means we always operates on `x`.
if (x->val > y->val) {
swap(x, y);
}
// Merge `x`'s right subtree and `y`, and set `x`'s right subtree to the result.
x->rs = merge(x->rs, y);
// If `x`'s left subtree's dist is smaller than `x`'s right subtree's dist, swap them.
if (x->ls->dist == NULL || x->ls->dist < x->rs->dist) {
swap(x->ls, x->rs);
}
// Update x's dist.
x->dist = x->rs->dist + 1;
// Return x as the new root of subtree.
return x;
}
🌰
现在我们模拟一下这个过程,现在我们有下面两个左偏堆,尝试合并它们。
我们发现,经过比较,❶ 更小,所以我们将 ❶ 作为合并后的根结点,左子树不变,右子树更新为「绿树右子树和蓝树的合并结果
经过比较,❷ 更小,所以我们将 ❷ 作为合并后的根结点,左子树不变,右子树更新为「蓝树右子树和绿树的合并结果
最后还剩下两个结点啦!实际上这里直接模拟了两个步骤,首先是比较 ❺ 和 ❻,并选择了 ❺ 作为新根;接下来在递归的过程中发现需要合并 NULL
和 ❻,所以直接返回了 ❻。
然而还没有结束,我们还需要处理左右子树 dist 大小关系问题。
我们发现 ❺ 的左孩子为 NULL
,我们记 \(dist_\text{NULL} = -1\),右孩子 ❻ 有 \(dist_\text{right child}=0\),所以需要交换两个孩子。
这里也跳过了两个步骤:
往回走,发现 ❺ 的 dist 小于 ❹ 的 dist,满足性质,不需要改变。
继续往回走,发现 ❷ 和 ❸ 的 dist 相同,满足性质,也不需要改变。
从这里也可以看出来,并不是看上去更大的子树一定在左侧。
迭代式
迭代式是根据它的实现方法来命名的,但是我认为从可视化的角度来理解迭代式可能更有意思。事实上在很多题目中我觉得这个方法做题更加方便。
迭代式维护两个额外的指针,分别指向两棵树还没被合并的子树的根,并不断选择较小的那个合并进去,直到两个指针都为空。可以发现,这个过程和归并排序的后半部分非常相似,实际上当我们从可视化的角度去看这件事以后,会发现这里做的就是一个归并。
LeftistHeapNode * merge(LeftistHeapNode * x, LeftistHeapNode * y) {
// `tx` & `ty` are the pointers to the roots of the subtrees that haven't been merged.
LeftistHeapNode * tx = x, * ty = y;
// `res` is the root of the merged final tree, while `cur` is the latest node that has been merged.
LeftistHeapNode * res = NULL, * cur = NULL;
// Begin merging.
whie (tx != NULL && ty != NULL) {
// If `tx`'s val is smaller than `ty`'s, swap them, which means we always operates on `tx`.
if (tx->val > ty->val) {
swap(tx, ty);
}
// Specially mark the root on the first merge.
if (res == NULL) {
res = tx;
cur = tx;
} else {
cur->rs = tx;
cur = cur->rs;
}
// Go on.
tx = tx->rs;
}
// Merge the rest of the tree.
while (ty != NULL) {
// Specially mark the root on the first merge. (rarely happens but not impossible)
if (res == NULL) {
res = ty;
cur = ty;
} else {
cur->rs = ty;
cur = cur->rs;
}
// Go on.
ty = ty->rs;
}
// Adjust the left and right subtrees of all the nodes according to the properties of `dist`.
// It does the same work as the adjust part in the recursive version. I ignore it here.
res = adjust(res);
return res;
}
依旧是以上面进行模拟的那个合并为 🌰 进行模拟。
🌰
首先,我们对图片的排版稍微做一些改变,我们不再按照之前画堆的方式去画,而是“左偏”地去画:
可以发现,在调整之前 , 绿色和蓝色的箭头分别表示两个待合并子树尚未合并的子树的根,紫色箭头表示最近的合并发生的位置。
比较 ❶ 和 ❷,发现 ❶ 更小,所以我们将 ❶ 作为合并后的根结点,左子树随之合并进去,同时更新绿色箭头指向尚未合并进去的 ❺。
和上一步类似的,比较 ❺ 和 ❷,发现 ❷ 更小,所以我们将 ❷ 作为合并后的根结点,左子树随之合并进去,同时更新蓝色箭头指向尚未合并进去的 ❻。
依然类似地,比较 ❺ 和 ❻,发现 ❺ 更小,所以我们将 ❺ 作为合并后的根结点,左子树随之合并进去,同时更新绿色箭头指向 NULL
。
这时候我们发现已经有一个指针空了,也就是绿色指针已经指向了 NULL
,这个时候我们只需要按顺序把蓝色指针指向的内容都推进去即可。
接下来我们还需要维护 dist 信息并根据性质交换左右子树。这一部分和之前一样,就不再赘述了。
🌰/🔥
当然,这么来看可能还是很乱,联想我们之前发现它和归并排序很像,我们还可以用一个更加直观的方式来看这个过程:
同样从这张图开始,由于我们之前提到的,它类似于一个递归排序的后半部分,具体来说是指合并两个有序数列的过程。
也就是说,我们可以将这两个左偏堆改写成两个有序数列!
由于我们在合并过程中总是找右孩子,所以我们就沿着最右沿路径把没个左偏堆拆成这种“悬吊”的带状形式,每一“条”的值取决于根的键值,也就是这一“条”的最顶部。
在这张图中,我们得到的两个有序数组分别是 [1, 5] 和 [2, 6],接下来我们将它们进行排序。
经过排序,就会发现它们刚好符合我们在上面步骤得到的结果(可以对比着上面的 Frame 4 看
在做左斜堆合并的题目时,个人比较喜欢迭代式的做法,只需要沿着最右路径把每个左子树写出来再排序,最后沿着最右路径连回来即可(当然最后还要沿着最右路径对 dist 进行维护)
再次提醒,这一小节讲的部分都忽略了之后调整子树左偏性质的过程,实际上这也就单纯是一个维护堆性质的过程 -- 只需要对最右路径 Top-Down 或者 Bottom-Up 进行维护即可
插入
插入可以直接看作是和只有一个根节点的树的合并
删除
删除操作也比较简单,只需要对被删除节点的两个孩子做合并操作即可
这里贴一段代码方便理解
LeftistHeapNode * del(LeftistHeapNode * cur, ElementType x) {
if (cur->val == x) {
// Just return the merge of the children.
return merge(cur->l, cur->r);
} else {
// Not this subtree.
if (cur->val > x) return cur;
// Otherwise, search the `x`.
if (cur->l != NULL) del(cur->l, x);
if (cur->r != NULL) del(cur->r, x);
// Adjust the dist bottom-up.
adjust(cur);
}
}
Skew Heap
link
Wikipedia: https://en.wikipedia.org/wiki/Skew_heap
概述
Skew Heap 其实是 Leftist Heap 的一种自适应版本,它们二者之间的关系就类似于 AVL Tree 和 Splay Tree 的关系(事实上,发明 Skew Heap 的人和发明 Splay Tree 的人是同一个人)
Skew Heap 是一种具有堆性质的二叉树,但是并没有像 Leftist Heap 一样的对结构上的限制,并没有诸如 Npl 这样的信息存储在各个节点上,这也就意味着一个 Skew Heap 的 Right Path 可能会非常长,这导致了所有操作的最坏时间复杂度是 \(O(n)\) 级别的,但是就像 Spaly Tree 一样,所有操作在摊还意义下的时间复杂度是 \(O(logn)\) 级别的
另一种视角
让我们回顾一下左偏堆,由于需要自下而上地维护 dist,所以我们无法进行并发操作。回顾 AVL 树,同样为了维护它比较严格的平衡性质,我们也无法进行并发操作,而红黑树则通过一个能够仅仅通过变色就能调整的黑高来规避了必须自下而上维护的问题,实现了并发。
换句话来说,要想将左偏堆改变地能够进行自上而下维护,就需要改变甚至放弃它的左偏性质的严格性——而这就是斜堆的由来。
合并
斜堆也需要满足大根堆(小根堆)的性质,而它的合并和左偏堆的合并也十分类似,只不过我们这次无条件的交换左右子树,换句话来说,不管左偏性质如何变化,我们都会选择交换参与合并的左右子树,这样我们就不需要在回溯的时候才进行左右子树的交换,于是就实现了完全的自上而下。
让我们来看看 wiki 里给出的 🌰:
🌰 from wikipedia
这是我们需要合并的两个堆。
省略了中间的步骤,可以尝试模拟一下,每一次合并操作结束之后都交换左右子树。
大概的操作类似是每次先比较两个子树的根节点大小,把大的子树往小的子树合并,具体合并过程是,将小的子树的左右子树交换,把新的左子树砍掉,让被砍掉的左子树和待合并的树合并后作为新的左子树 ;
如果遇到空节点和非空树合并时,若非空节点是该树唯一的最右路径节点,那么就不需要交换左右子树;否则交换左右子树。
当然,它也是支持迭代的写法的,和是和之前的做法类似,我们可以排序每一“条”,然后再合并。Wikipedia 上提供了一系列的过程图,但是那个过程图有点自下而上的意思,但是实际上自上而下做也是一样的。如果有兴趣可以去看看 Wiki 上的过程:🔗。
摊还分析
link
这里我们采用势能法分析摊还复杂度
分析 skew heap 的均摊复杂度,主要就是分析合并操作的复杂度,因为其他操作都可以转化为合并操作。
接下来我们需要定义势能函数:
势能函数
我们定义 \(\Phi(Heap) = \text{number of heavy node in } Heap\)。
其中,额外需要定义 heavy node 和 light node:
heavy node & light node
对于一个子堆 \(H\),如果 \(size(H.\text{right.descendant}) \geq \frac{1}{2}size(H)\),则 \(H\) 是 heavy node,否则是 light node。
\@ cy'ppt
A node p is heavy if the number of descendants of p’s right subtree is at least half of the number of descendants of p, and light otherwise. Note that the number of descendants of a node includes the node itself.
显然,对于 heavy node 和 light node,以及合并操作,有这么一些性质:
properties
- 如果一个节点是 heavy node,并且在其右子树发生了合并(包括翻转
) ,那么它一定变为一个 light node; - 如果一个节点是 light node,并且在其右子树发生了合并(包括翻转
) ,那么它可能变为一个 heavy node; - 合并过程中,如果一个节点的 heavy/light 发生变化,那么它原先一定在堆的最右侧路径上;
列出公式:
其中,\(c\) 为合并操作的(最坏)复杂度,\(H_{merged}\) 为合并后的堆的势能,\(H_x\) 和 \(H_y\) 分别为合并前的两个堆的势能。
根据 property 3,在合并过程中并非所有节点都收到影响。我们可以单独记录 \(l_{x}\) 为 \(H_x\) 最右侧路径上的 light node 数量,\(h_{x}\) 为 \(H_x\) 最右侧路径上的 heavy node 数量,\(h^0_{x}\) 为 \(H_x\) 所有不在最右侧路径上的 heavy node 数量(即 \(\text{count of heavy nodes of } H_x = H_x + H^0_x\)
于是,我们可以将上式写开:
其中稍微做一些解释:
- \((1)\):\(c\) 为合并操作的(最坏)复杂度,即我们的枚举涉及了两个堆所有的右侧路径;
- \((2)\):在合并操作以后,根据 property 1 和 property 2,可以得到这个不等式;
- \((3)\) 和 \((4)\):根据势能函数的定义得到;
于是,将它们代入得到结果:
右路径 light node 的数量
证明 \(l=O(logN)\)
注:\(l\) 是堆(最)右侧路径的轻节点数
我们可以先证明:对于右侧路径上带有 \(l\) 个轻结点的斜堆,至少有 \(2l−1\) 个结点。换句话说,如果一个堆有 \(N\) 个节点,那么它右侧路径上的轻节点个数为 \(O(logN)\),即 \(l=O(logN)\),所以只要证出前者,后者自然成立。我们采用归纳法证明(证明过程类似左偏堆那个定理的证明
当 \(l=1\) 时,显然成立 假设 \(l≤n\) 时,该结论成立 那么当 \(l=n+1\) 时,我们先找到右侧路径的第二个轻节点,根据归纳假设知,以该节点为根节点的子树至少有 \(2^l−1\) 个节点 再找第一个轻节点,由轻节点的定义知,它的左子树节点数一定大于右子树节点数,而上面提到的子树位于它的右子树处,所以以第一个轻节点为根节点的子树至少有 \(2×(2^l−1)+1 = 2^{l+1} - 1\) 个节点。那么整个堆的节点个数一定大于 \(2×(2^l−1)+1 = 2^{l+1} - 1\) ,得证
一些小结论
向一个空的斜堆依次插入 \(1\text{~}2^{k−1}(k>4)\) 这几个元素后,得到的堆是一棵满二叉树
The right path of a skew heap can be arbitrarily long
skew heap 最右路径上只有轻结点受类似 leftist heap 的限制。
相对而言,leftist heap 的最右路径就不能这么任意了,它受 logN 限制
Self-adjusting Data Structure
In typical applications of data structures, it is not a single operation that is performed, but rather a sequence of operations, and the relevant complexity measure is not the time taken by one operation but the total time of a sequence. Hence instead of imposing any explicit structural constraint, we allow the data structure to be in an arbitrary state, and we design the access and update algorithms to adjust the structure in a simple, uniform way, so that the efficiency of future operations is improved. We call such a data structure self-adjusting. For example skew heaps and splay trees are such kind of structures.