数据结构与算法复习——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 };
extern

现在,我们来介绍二项队列作为优先队列的一些操作以及实现。以维持小根为例。

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 }
merge

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 }
build

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 }
find

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 }
pop

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)$;删除堆顶是同样的分析。进而,在这个位势函数下,其它操作的复杂度也没有改变。

  

  以上就是二项队列的实现以及复杂度分析。值得注意的是,在时间复杂度上它已经足够优越,但是空间复杂度上明显不如二叉堆。

posted @ 2021-02-05 23:37  Halifuda  阅读(409)  评论(0编辑  收藏  举报