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

      

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

  • 相关阅读:
    CSS3动画设置后台登录页背景切换图片
    类别联动下拉菜单
    自己写的一个逻辑分页
    TP5多入口设置
    zabbix安装配置界面点击next step没反应
    nginx访问不了zabbix安装配置界面
    mount挂载问题
    linux下ssh的几种验证方式
    linux下nat配置
    Linux命令行上程序执行的那一刹那!
  • 原文地址:https://www.cnblogs.com/halifuda/p/14380013.html
Copyright © 2020-2023  润新知