数据结构与算法复习——7、斐波那契堆和配对堆

7、更多的堆

  前面从二叉堆开始对经典的堆进行了复习,包括左偏树、斜堆和二项队列。这一篇介绍两种更优秀(也很经典)的堆:斐波那契堆和配对堆。

一、斐波那契堆的前置知识

  之前提到,如果我们能找到堆中某个结点的位置,调整其键值也是一个非常重要的操作。对小根堆来讲(做为例子),增加键值是很难维持复杂度的,一般只可以减小键值,即Decrease。

我们已经知道,二叉堆和二项队列的Decrease操作都是$O(\log N)$的,但某些时候我们会频繁地需要Decrease,这就使我们期待一个更好的时间复杂度。斐波那契堆就是解决这个问题的一个经典办法。不过在介绍斐波那契堆之前,我们先来介绍两个操作,它们是斐波那契堆的灵感来源。

1、左偏树的Decrease

  二叉堆和二项队列都可以用向上过滤来进行Decrease,但左偏树不可以。因为左偏树只保证右路径的长度,它的左路径可以任意长,从而如果对一个左路径上的结点向上过滤,最坏的复杂度将是$O(N)$。为了维持一个好的复杂度,左偏树用切除(Cut)来解决Decrease操作:

当Decrease操作应用于结点$X$时,如果它是树根则不需理会,否则将它从它的父亲$F$上切除,原树成为了两个树$T_X$和$T_F$。显然,$T_X$是一个左偏树,但是$T_F$已经不是了,我们需要对它做一些调整。我们知道,如果一个树的左右子树都是左偏树但它本身不是左偏树,那么它只需要交换左右子树,就可以成为左偏树。从而对$T_F$,我们从$F$开始向上改造,只要祖先结点$A$不满足左偏树的性质,就交换它的左右子树。

这个向上改造显然不能一直改造到根,否则与向上过滤没有区别。实际上,我们只需要向上改造至多$\log N$个结点即可。因为(即使经过了切除)$T_F$的右路径总是不长于$\log N$的。当我们向上改造了$\log N$个结点,这条路径就必然长于右路径。由于$F$刚刚被切除子树,它的$npl = 0$,无论$F$在某个祖先的左路径上还是右路径上,这次切除都不会改变更上面的祖先结点的左偏树性质,于是我们就只需要改造至多$\log N$个结点。

这样改造完之后,将$T_X$和$T_F$Merge即可。可见这一操作的复杂度是$O(\log N)$。

这就是左偏树实现Decrease的办法。

2、二项队列的懒惰合并

二项队列的合并是$O(\log N)$的。实际上我们可以将它改造成$O(1)$,只需要执行懒惰合并(Lazy Merge)。具体方法就是:合并二项队列$Q_1$和$Q_2$,只需要将它们的二项树列表连接在一起。这明显是$O(1)$的。

可见,这样做有两个后果:1、新队列不再满足二项队列的性质,这是懒惰合并的必然代价,我们称用上了懒惰合并的二项队列为懒惰二项队列。2、多次合并之后,Pop和Top操作的复杂度将不再是$O(\log N)$的。

对于第二个后果,我们没有直接的解决办法,但我们可以改造Pop(以及Top)的操作,使得它们的摊还复杂度达到$O(\log N)$。改进方法如下:

Pop时,先删除最值结点,把它的子树并到队列里(Top则不需要这步),然后将队列里的二项树按照秩$R$(子树个数)分类。我们就有了若干个数组,每个数组只保存相同秩的若干二项树。之后,从$R=0$开始,不断执行:从数组中取出两个二项树,合并之,将新树放到$R+1$的数组中去,直到$R$数组中的二项树不超过1个,然后对之后的数组继续做此操作,直到懒惰二项队列满足二项队列的性质。

这一操作的摊还复杂度这样来证明:

同样用队列里的树的棵数做位势。假设Pop前$\phi_1 = T$,被删除的结点有$R$棵子树,由于是二项树,有$R = O(\log N)$。一次删除增加了$R$棵树。现在执行二项树的合并。总共有$T+R$棵树,而最后至多剩下$\log N$棵树,从而这一操作的实际时间是$T+R$(如果算上检查则还要加上$\log N$),导致位势变化$\Delta \phi = \log N - T$。从而摊还时间就是$T^* = c \log N + R = O(\log N)$。

值得注意的是,证明告诉我们懒惰二项队列里找到最小节点的复杂度不应该太高,否则摊还界就失效了。由于懒惰二项队列没办法用数组维护而只能用链表,一个较好的方法可能是维护一个指针,每次Insert、Merge和Pop后尝试改变。

二、斐波那契堆

  有了前面两个操作为基础,我们就来介绍斐波那契堆。对于堆的经典操作,除了Pop是$O(\log N)$的,其余的操作斐波那契堆都以$O(1)$完成。

从设计的思维来讲,斐波那契堆就是“懒惰二项队列+左偏树切除”。当然,左偏树的切除带来了很多后果:首先,一次切除就把二项树的结构破坏了。这是没办法避免的,因此斐波那契堆里的树甚至不是二项树。更重要的是,各种切除使得斐波那契堆里的树的秩$R = O(\log N)$没办法保证,从而Pop操作的时间复杂度就没有保证了。斐波那契堆用级联切除(Cascading Cut)来解决这两个问题。

不过在介绍具体的办法之前,总而言之,斐波那契堆是这样一种结构:

1、它是一些满足堆序的树的森林;

2、森林里的树并不是二项树,但是它们的合并还遵循二项树的法则:将键值更大的树作为键值更小的树的子树,而且只有秩相同时,两棵树才能合并。我们不妨叫这种树为“斐波那契树”,后面会看到这种命名的意义;

3、斐波那契堆通过级联切除和懒惰合并维持均摊复杂度。

现在来看斐波那契堆的操作:

Merge:斐波那契堆的合并也是懒惰合并,从而时间复杂度是$O(1)$的。

Insert:特殊的Merge。

Decrease:这一操作遵循级联切除。级联切除是这样应用的:

首先,如同左偏树一样,一个被Decrease的结点需要从它的父节点切除,然后直接进入斐波那契树森林中。

之后,对于它的父亲$F$:如果此前$F$没有被切除子树过,那么就给$F$打上标记#;如果$F$曾经被切除过子树(这时它一定有标记#了),那么就把$F$也切除掉(此时$F$的父亲也要打上标记#)。$F$直接进入森林,但是标记#需要去掉:森林里的任何根结点都不能标上标记#(因为它们没法再被切除了)。

对于$F$的父亲,也同样地检查和操作,即若$F$被切除时父亲已经有标记#,则也要切除父亲,直到不需要再切除或到根节点为止。

可以看到这和左偏树的向上改造有些类似,都是向上对祖先的检查。正因此,这一操作的时间复杂度没法直接保证,后面和Pop一起,我们介绍它的摊还复杂度。

PopTop:类似懒惰二项队列。同样,复杂度仍需要摊还分析。

在进行摊还分析之前,我们先来介绍两个引理:

引理1:一棵斐波那契树$X$的第$i$年轻的子树$C$的秩至少是$i-2$。其中第$i$年轻表示它是第$i$个被并入的,秩即子树棵数。

证明:由于斐波那契树的合并也需要遵循同秩的原则,因此当$C$并入$X$时,由于$X$已经有了至少$i-1$、至多$i$棵子树,从而$C$的秩至少是$i-1$。从此之后,$C$只能被切除子树1次,否则它将会被从$X$切除,从而就不会是$X$的儿子了,因此$C$的秩至少是$i-2$。定理得证。

引理1是引理2的引理:

引理2:一棵秩为$R$的斐波那契树$X$,它的大小至少是$F_{R+1}$,其中$F_i$指斐波那契数列第$i$项。

证明:我们设$S_R$是秩为$R$的斐波那契树大小的最小值。显然,$S_0 = 1$、$S_1 = 2$。对于$S_R$,它一定有$R$棵子树,从而有第$0,1,...,R-1$年轻的子树。由于引理1,它一定有秩(至少)为$0,1,...,R-2$的子树,注意这里只枚举了$R-1$项,所以它还有一棵大小至少为$1$的子树,算上树根,因此有:

$S_R = 1+1+ \sum_{i=0}^{R-2} S_{i}$

由于$S_R - S_{R-1} = S_{R-2}$,而首项又符合要求,则$S_R = F_{R+1}$。从而引理2得证。

引理2是斐波那契堆的命名来源。

现在来分析Decrease和Pop的摊还复杂度:

如果还令位势函数为树的棵数,显然无法分析了。我们希望Decrease是$O(1)$的(这是斐波那契堆这么复杂的结构存在的几乎全部意义),但它的实际时间显然达不到$O(1)$。注意Decrease操作减少了很多标记#,但是又增加了树,所以位势函数定义如下:$\phi = T + 2L$,其中$T$是树的棵数,$L$是标记数。容易看到$\phi_0 = 0$,而任意时刻位势非负,因此位势函数可以应用。

Decrease分析:假设级联切除总共切除了$c$次。每一次都增加了一棵树;除了第一次和最后一次,每一次都消去一个标记;最后一次可能会增加一个标记。那么位势变化的上界即

$\Delta \phi = - 2(c-2) + c$

算上切除的时间$c$,从而摊还复杂度$T^* = c + \Delta \phi = 4 = O(1)$。

Pop分析:从懒惰二项队列的分析得知,这种Pop的摊还复杂度一定是$c \log N + R$。更换了位势函数后,由于Pop操作不会增加标记个数(标记只能是因为Decrease操作的切除而增加),因此摊还复杂度不会更劣。由于引理2,一棵大小为$N$的树,它的秩至多为$i$,其中$i$满足$F_{i-1} \leq N \leq F_i$。众所周知斐波那契数列以指数增长,因此$R = O(\log N)$。从而Pop的摊还复杂度是$O(\log N)$。

Merge不引起位势的变化,因此分析起来是容易的。

从而斐波那契堆的时间复杂度已经得到证明。只有Pop(包括Top)的复杂度是$O(\log N)$,其余的操作都是$O(1)$。可见它是一个(理论上)极其优秀的堆。

  斐波那契堆的理论价值比较大,而实用价值不高。因为我们可以看到,实现它将非常麻烦:首先它应该像二项队列一样记录树的各种信息,而森林应该用链表表示。其次斐波那契树不像二项树,更为混乱,从而它的存储要麻烦很多。但左儿子右兄弟没法全部满足,因为级联切除要向上寻找,所以每个节点还要记录父指针,大量指针会显著降低它的效率。最后,懒惰合并的实现也是一个难点。

由于这些问题,本人就不在此实现斐波那契堆了。

三、配对堆

  实践中,配对堆是一种简单的(但不严格的)斐波那契堆。实际经验表示在要求Decrease和Merge的情况下,配对堆的效率与理论中的斐波那契堆相当(甚至好于实际实现的斐波那契堆),但多种分析表明它的复杂度没有这么好。它的过于简单的实现可能是它的优秀表现的主要来源。

由于没有实现斐波那契堆,配对堆我也不打算实现了,但它非常简单。下面将介绍。

配对堆是一棵堆序树(而不是森林)。它是一棵普通的树,不是二项树、斐波那契树或者二叉树、$d$叉树。从而,它的表示要不然是左儿子右兄弟,要不然是邻接表+父指针这类存储。但这不会影响它的效率。

配对堆的操作是这样的(以小根堆为例):

Merge:两棵配对堆$H_1$和$H_2$合并,不妨设键值$H_1 \leq H_2$,那么令$H_2$成为$H_1$的子树即可。显然实际复杂度是$O(1)$。

Insert:特殊的合并。

Decrease:如果一次Decrease没有改变堆序,则不予理会;否则将这棵子树切除,并与原来的堆合并。显然也是$O(1)$。

Top:返回堆顶即可。也是$O(1)$。

前面的操作十分简单,都是$O(1)$,但大家可能看到,这根本没有做任何维持复杂度的努力。其实,在Decrease中,切除操作是需要一些方法的:将$u$从$f$切除,我们只是把$u$的父指针置为$NULL$,但不把它从$f$的儿子中删去。因为$f$的儿子有可能很多,去删除是比较浪费的。只有当需要遍历$f$的儿子(比如$f$作为根被删除了),我们才检查,是否存在之前已经被切除过的“假儿子”。

这一操作显然只能维持这一次Decrease的复杂度。至于整体的复杂度,我们还是像斐波那契堆一样,将希望寄托在Pop上:

Pop:删除堆顶,将堆顶的所有儿子(不包括假儿子)记入队列里,然后把它们合并成一棵堆序树。

这里合并的方法就十分讲究。如果方法不好,Pop操作的复杂度就退化为$O(N)$,但即使方法很好,也没法证明它的均摊复杂度,只是实践经验中,它的效率很好,好到了$O(\log N)$级别而已。实践中的一种好方法是配对合并,这也是配对堆的命名来源:

对于队列里的树$T_1$到$T_N$,我们先从左往右遍历,两两配对合并,即$T_1$与$T_2$合并、$T_3$与$T_4$合并……如果$N$是奇数,在$T_{N-2}$与$T_{N-1}$合并后,再与$T_N$合并一次。这样队列里就只剩下一半的树,我们记作$D_1,D_2,...,D_M$。之后我们从右往左合并,令$D_M$与$D_{M-1}$合并,然后合并的结果再与$D_{M-2}$合并……直到只剩一棵树。

若原树有$T$棵子树,那么新树的子树至多为$T/2-1$,好的时候能到$\log T$,但不管怎么样,证明依然困难。上面的方法叫两趟合并。另一种方法就是不断配对合并,这样能保证儿子数是$\log T$。

有人认为Decrease由于增加了根的子树,摊还复杂度应该是$O(\log N)$。另外一些摊还分析给出了更严格的但是很复杂的界,而且与实践经验不是太相符。总而言之,复杂度的证明很困难。

  以上就是两种更优秀的堆。其中一种很复杂,而另一种很简单。简单的数据结构当然不必然是劣的,这是我们需要明白的。

posted @ 2021-02-08 17:48  Halifuda  阅读(518)  评论(0编辑  收藏  举报