数据结构与算法复习——6、二项队列及其分析
6、二项队列及其分析
作为摊还分析的另一个例子(实际上比斜堆更经典),这一篇我们来介绍二项队列。
一、二项树
二项队列是一列二项树组成的,先来介绍二项树:
定义:二项树是这样定义的:
1、一个单结点可以作为二项树$B_0$,其秩为$0$;
2、二项树$B_k$的秩是$k$,且是由两棵$B_{k-1}$合并而成的,其中一棵作为子树连接在另一棵的根上。
这样解释不太清楚,我们画出图来:
比较一目了然。
二项树作为一种树,它的限制比较强,因此也有非常多十分好的性质。对于我们建构二项队列来说主要有以下几点:
1、二项树$B_k$由两棵$B_{k-1}$合并即可得来;
2、二项树$B_k$总共拥有$2^k$个结点;
3、二项树$B_k$的树根总共拥有$k$个子树,并且它们分别是二项树$B_0, B_1, ... ,B_{k-1}$;
4、二项树$B_k$树根的高度是$k$。
这4条性质的证明就不赘述了,从图上可以轻易看出。另外二项树还有一个更有意思的性质:二项树$B_k$中,高度为$h$的结点共有$C_{k}^{h}$个。这个性质对二项队列的帮助有限,因此也不证明了。
有了二项树的定义,我们就可以介绍二项队列了。
二、二项队列
二项队列是一种优先队列的实现方式。二叉堆作为基本的堆已经足够好,只是合并操作的复杂度不太能接受(单次$O(N)$)。为了支持高效的合并操作,左偏树和斜堆出现。但左偏树和斜堆由于不再是完全二叉树,因此实现时必须用到指针式结构,从而它们的建堆反而不能像二叉堆一样以$O(N)$完成。二项队列则可以兼顾,既以$O(N)$完成建堆,又以$O(\log N)$完成合并,同时其它操作的复杂度不降低。因此,二项队列是十分重要的。
一个二项队列由一列满足堆序的二项树组成。所谓满足堆序,复习二叉堆时已经提到,就是每个结点与它的每个儿子都保持同一种序关系,从而二项队列是一些堆序树的森林。
除此之外,二项队列还要求:对任意正整数$k$,一个二项队列里至多有一棵二项树$B_k$。这样,相当于二项树们以二进制的形式来表示原本用一棵树来表示的结构。譬如二项队列$Q$拥有$19$个结点,由于$19 = (10011)_2$,从而$Q$就拥有(而且必然是)$B_0$、$B_1$和$B_4$三棵二项树。如果不考虑树的顺序,那么只要结点数给定,二项队列的结构就已经被决定了。进而,一个有$N$个结点的二项队列,它的二项树的棵数就是$O(\log N)$的。
由于这种性质,我们可以用一个数组来存储二项队列的诸多二项树。由于这个数据结构不能太庞大,从而数组的大小也很小。用数组来存比链表要优越得多,因为数组可以明确地保存某棵二项树的秩,并且可以让我们立刻找到二项队列里的秩为$r$的二项树。用链表的唯一好处就是省略了之间不存在二项树的位置,但是对数组来讲,这只是一个$O(1)$的损耗。
刚刚已经提到,一棵二项树$B_k$树根的子树分别是二项树$B_0$到$B_{k-1}$,这也明确地告诉我们,它的诸子树也算一个二项队列。不过,这个二项队列我们不需要快速找到每棵子树,而只需要在树根被删除时提取出来(下面会介绍到),同时它的每个位置又都有二项树,从而用一个链表是更合适的选择。
从而,二项队列里的一棵二项树的子树的存储方式就应该是一个链表(也就是表头),另外每个二项树结点自己也有可能是链表上的结点,从而也要存储链表的后继结点,这样,一个二项树结点的内容就决定了,也就是所谓的“左儿子右兄弟”的原则。二项树和二项队列的声明如下:
1 struct BinTree { 2 int val; //键值 3 int rank; //秩 4 int size; //树的大小 5 BinTree* leftSon; //最左侧的儿子 6 BinTree* rightBro; 7 //右兄弟,链表存储二项树的儿子们 8 }; 9 10 struct BinQueue { 11 int size; //队列的大小 12 BinTree* tree[21]; //二项队列的各项 13 };
现在,我们来介绍二项队列作为优先队列的一些操作以及实现。以维持小根为例。
1、合并二项队列(Merge):二项队列的核心算法。合并两个二项队列类似于二进制加法,甚至有进位这种情况。新的二项队列必须满足条件,每种二项树都至多只有一棵,所以合并的时候是如此操作的:
将$Q_1$和$Q_2$合并成新队列。从新队列的第$0$棵二项树开始逐位考虑。对于第$i$棵二项树的临时数量来分类讨论,可能是两个队列都没有,而且也没有进位,这样新队列也没有这种二项树;也有可能是只有$Q_1$拥有这种二项树,或者是只有$Q_2$有,或者只是进位,总之只有一棵,则新队列只要继承这棵二项树即可;可能是有两棵,这样新队列没有这种二项树,而向下一位进位一棵合并后的二项树;可能是有三棵,这样任选其中一棵放在新队列里即可,剩余的两棵合并后进位。
可以看出,一次合并操作的时间复杂度是$O(\log N_1 + \log N_2) = O(\log N)$的。
为了保证这一点,两棵二项树的合并必须是$O(1)$的。由于刚刚提到,二项树是用链表存储的。一棵二项树只能并到另一棵同秩的二项树上,而且合并后,它必然是新树根的最大的子树(因为树根原本的子树都是更小的)。从而告诉我们,二项树的“左儿子右兄弟”中,最左侧的表头是最大的树。这样,合并时只要把作为子树的树放在表头即可,自然是$O(1)$的。二项树的合并、二项队列的合并的一种实现如下:
1 BinTree* MergeBinTree(BinTree* T1, BinTree* T2) { 2 //合并二项树 3 if (T1->rank != T2->rank) { 4 printf("TreeMerging error: non same rank\n"); 5 return NULL; 6 } //只有同秩的才允许合并 7 8 if (T1->val > T2->val) //维持堆序 9 return MergeBinTree(T2, T1); 10 11 (T1->rank)++; //秩增1 12 (T1->size) <<= 1; //大小翻倍 13 T2->rightBro = T1->leftSon; 14 T1->leftSon = T2; 15 //把新儿子(必然是最大的放在最左侧),并维持链表 16 return T1; 17 } 18 19 BinQueue* MergeBinQueue(BinQueue* Q1, BinQueue* Q2) { 20 //合并两个二项队列 21 if (Q1->size + Q2->size >= 1024 * 1024) { //超限 22 printf("QueueMerging error: exceeded\n"); 23 return NULL; 24 } 25 26 BinTree *temp1 = NULL, *temp2 = NULL, *pushin = NULL; 27 28 (Q1->size) += (Q2->size); 29 int S = Q1->size; 30 int cas; 31 for (int i = 0, j = 1; j <= S; i++, j <<= 1) { 32 temp1 = Q1->tree[i]; 33 temp2 = Q2->tree[i]; 34 cas = (!!temp1) + 2 * (!!temp2) + 4 * (!!pushin); 35 switch (cas) { //二进制加法,在某一位上有8种情况 36 case 0: //无秩i树 37 case 1: //只有Q1有秩i树 38 break; 39 case 2: //只有Q2有秩i树,转移到Q1上 40 Q1->tree[i] = temp2; 41 Q2->tree[i] = NULL; 42 break; 43 case 3: // Q1、Q2都有但没有进位,则进位并清空Q1、Q2 44 pushin = MergeBinTree(temp1, temp2); 45 Q1->tree[i] = NULL; 46 Q2->tree[i] = NULL; 47 break; 48 case 4: //只有进位 49 Q1->tree[i] = pushin; 50 pushin = NULL; 51 break; 52 case 5: //有Q1和进位,但无Q2 53 pushin = MergeBinTree(temp1, pushin); 54 Q1->tree[i] = NULL; 55 break; 56 case 6: //有Q2和进位,但无Q1 57 pushin = MergeBinTree(temp2, pushin); 58 Q2->tree[i] = NULL; 59 break; 60 case 7: //三者都有,任选一个即可 61 pushin = MergeBinTree(temp2, pushin); 62 Q2->tree[i] = NULL; 63 break; 64 } 65 } 66 return Q1; 67 }
2、插入(Insert):既然合并是$O(\log N)$的,插入则以一次合并完成即可。后面会介绍到,插入的摊还时间复杂度可以达到$O(1)$。
3、建立二项队列(Build):从单个数或者单个二项树建立二项队列的方法是平凡的。从一个数组开始建立二项队列的方法就是$N$次插入或者$N$次合并。之后会分析到,这样操作的时间复杂度是$O(N)$。下面是一种可行的实现:
1 BinQueue* BuildBinQueue(int A[], int size) { 2 //从数组建二项队列,进行n次合并即可 3 BinQueue* Q = BuildBinQueue_NULL(); 4 if(size<=0) 5 return Q; 6 7 Q->size = 1; 8 Q->tree[0] = BuildBinTreeNode(A[0]); 9 BinQueue* tQ = BuildBinQueue_NULL(); 10 tQ->size = 1; 11 for (int i = 1; i < size; i++) { 12 tQ->tree[0] = BuildBinTreeNode(A[i]); 13 Q=MergeBinQueue(Q,tQ); 14 } 15 return Q; 16 }
4、找到堆顶(FindMin):二项树是满足堆序的,但二项队列不记录整体的堆序,从而找到堆顶需要遍历二项树的堆顶,时间复杂度是$O(\log N)$。某种实现如下:
1 BinTree* FindMin(BinQueue* Q) { 2 //找到最小元 3 if (Q->size <= 0) { 4 printf("Finding error: empty\n"); 5 return NULL; 6 } 7 BinTree* res = NULL; 8 int minval = 0x7ffffff7; 9 10 for (int i = 0, j = 1; j <= Q->size; i++, j <<= 1) { 11 if (Q->tree[i] == NULL) 12 continue; 13 14 if (Q->tree[i]->val < minval) { 15 minval = Q->tree[i]->val; 16 res = Q->tree[i]; 17 } 18 } 19 return res; 20 }
5、删除堆顶(Pop):删除最值只是删除一个结点,它的诸子树并不应该从结构里删除,从而这些子树应该并入二项队列。刚刚介绍到,某棵二项树树根的诸子树也是一个二项队列,从而这一操作也是合并。由于二项树$B_k$有$k-1$棵子树,这样的合并也是$O(\log N)$的,从而整体的操作也是$O(\log N)$的。某种实现如下:
1 void Pop(BinQueue* Q) { 2 //删除最小元,把它的诸儿子合并进二项队列 3 if (Q->size <= 0) 4 return; 5 6 int res = -1; 7 int minval = 0x7fffffff; 8 9 for (int i = 0, j = 1; j <= Q->size; i++, j <<= 1) { 10 if (Q->tree[i] == NULL) 11 continue; 12 13 if (Q->tree[i]->val < minval) { 14 minval = Q->tree[i]->val; 15 res = i; 16 } 17 } 18 19 if (res == -1) { 20 printf("Poping error: cant find top\n"); 21 return; 22 } 23 24 BinQueue* tQ = BuildBinQueue_NULL(); 25 BinTree* T = Q->tree[res]; 26 tQ->size = (T->size) - 1; 27 BinTree* temp = T->leftSon; 28 29 for (int i = (T->rank) - 1; i >= 0 && temp != NULL; i--) { 30 tQ->tree[i] = temp; 31 temp = temp->rightBro; 32 } 33 34 Q->tree[res] = NULL; 35 (Q->size) -= T->size; 36 delete T; 37 Q = MergeBinQueue(Q, tQ); 38 return; 39 }
6、其它操作:如果我们能找到需要修改的结点的位置,那么减小键值只要像二叉堆一样向上过滤就好,不过这需要我们额外保存父指针。删除特定结点只需减小键值使之成为堆顶,再删除堆顶即可。这些操作就不给出实现了。增加键值的消耗是很大的。介绍二项树时提到,高度是$h$的结点的总数是$C_{k}^{h}$,从而向下过滤的最坏复杂度是$O(N)$。
三、二项队列的分析
1、二项队列的很多操作的分析都很简单,但插入和建堆的操作的确切的界并不好分析,这需要我们用上摊还分析。
首先我们应该找到位势函数,为此,我们来看看一次插入会发生什么:
假如向一个二项队列$Q$里插入一个结点。如果$Q$没有$B_0$,自然,这个结点就成为了$B_0$;否则,两个二项树就需要合并,成为了一个$B_1$,如果$Q$没有$B_1$,插入也结束了,但如果有,则还要合并然后进位。
很显然,如果$Q$拥有从$0$到$k$之间所有二项树,但没有$B_{k+1}$,那么每个二项树都会被合并,最后成为了一棵$B_{k+1}$,然后插入结束。显然,这次插入花费的时间$T = k$。这样,最坏的情况当然是$O(\log N)$。不过很有意思的是,如果$Q$拥有每种二项树,那么一次插入后,它就只剩下一棵最大的二项树了。
如果一次插入花费了$k$次合并,那么最后$Q$就少了原本的$k$棵树,然后多了一棵$B_{k+1}$。我们考虑用$Q$中二项树的棵数做位势函数:
若$T=k$,则$\Delta \phi = 1-k$。另外,显然任意时刻$\phi > 0$,而二项队列未建立时$\phi_0 = 0$,从而这个位势函数指示了时间复杂度的一个上界。进而一次插入的均摊时间$T^*$满足:
$T^* = T + \Delta \phi = 1$
从而单次插入的$O(1)$复杂度就得到了证明。
建堆执行$N$次插入(也就是$N$次合并),它的均摊时间复杂度自然是:
$T_{1}^{*} = T_1 + (\phi_1 - \phi_0)$
$T_{2}^{*} = T_2 + (\phi_2 - \phi_1)$
$...$
$T_{N}^{*} = T_{N} + (\phi_N - \phi_{N-1})$
加上前面得到的单次公式$T^* = T + \Delta \phi = 1$,得到:
$\sum T^* = N$
进而建堆的均摊复杂度就是$O(N)$。
可以看到,这个位势函数非常优秀,它使得单次均摊时间居然是一个常数。因此,二项队列是摊还分析的一个经典而简单的例子,比斜堆更简单,分析斜堆时,我们还额外去证明单次均摊时间的上界。
2、虽然插入和建堆用上摊还分析后,时间已经得到证明,而且这个位势函数极其优秀,但是我们还应该照顾到可能改变位势的其它操作。合并操作,由于合并后队列里至多有$O(\log N)$个树,从而位势的改变也至多是$O(\log N)$的,从而它的均摊时间也是$O(\log N)$;删除堆顶是同样的分析。进而,在这个位势函数下,其它操作的复杂度也没有改变。
以上就是二项队列的实现以及复杂度分析。值得注意的是,在时间复杂度上它已经足够优越,但是空间复杂度上明显不如二叉堆。