• 常见基本的数据结构——优先队列(堆)


    在多用户环境中,操作系统调度程序必须决定在若干进程中运行那个进程。一般一个进程只能被允许运用一个固定的时间片。一种算法是使用一个队列。开始时作业被放在队列的末尾。调度程度将反复提取队列中的第一个作业并运行它,直到运行完毕或者该作业的时间片被用完,并在作业为被用完时将其放入队列的末尾。但是一般来说,短的作业要尽可能快地结束,这一点很重要,因此在已经被运行的作业中这些短作业应该拥有优先权。此外,有些作业虽然短小但也很重要,也应该拥有优先权。这种特殊的应用似乎需要一些特殊的队列,我们称之为优先队列。

     

    模型

    优先队列应该允许至少下面的两种操作,Insert(插入),以及DeleteMin(删除最小值),它的作用是找出,返回和删除优先队列中的最小值。Insert操作相当于队列的入队,DeleteMin操作相当于队列中的出队操作。在贪婪算法中,优先队列也是重要的,因为该算法通过反复的求出最小元来进行计算。

     

    一些简单的实现

    使用简单链表可以在表头进行插入并以O(1)的时间复杂度,但是遍历最小元的话则需要O(N)的时间复杂度。另外的一种思路是始终保持排序状态,这使得插入的高昂代价O(N),而DeleteMin的代价是O(1),但是在实际中,DeleteMin的操作次数从不多于插入的操作次数。

    还可以使用二叉查找树来实现优先队列,它对这两种操作的时间都是O(logN)。反复出去左子树的节点似乎损害树的平衡,使得右子树加重,最坏的情况下,左子树为空,右子树拥有的元素最多也就是它的两倍,这只是在其期望深度上加上了一个小的常数。

    使用查找树可能有些过分,因为它支持的操作太多了实际上并不需要你这么多。我们使用的数据结构不需要指针,它以最坏的情形时间O(logN)支持上述的两种操作,插入实际上将花费常数平均时间,并且能够使用线性的时间建立具有N项的优先队列。

     

    二叉堆

    二叉堆,它的使用对于优先队列的实现是如此的普遍,以至于堆可以不加修饰使用一般是指这种数据结构的实现。和二叉查找树相类似,堆也有两个性质,即结构性和堆序性,堆的操作必须必须要到堆的所有性质被满足时才能终止。

    结构性质

    堆是一棵被完全填满的二叉树,有可能的例外只在底层,在底层从左到右的填入,这样的树称为完全二叉树。容易证明,一棵高为h的完全二叉树有2^h到2^(h+1) - 1个节点。这意味着,完全二叉树的高是logN,显然它是O(logN)。

    一个很重要的规律是完全二叉树是不需要指针的,它能够使用一个数组表示而不需要指针。对于数组中的任意的位置i上面的元素,其左儿子在位置2i上,右儿子在左儿子后的单元(2i+1)中,它的父亲则在位置[i/2]上。因此,这里面不需要指针,而且遍历该树的操作也是非常简单。这种实现的唯一问题在于需要事先确定堆的大小。

    一个堆数据结构将由一个数组,一个代表最大值的整数以及当前堆的大小组成。下面是典型的有限队列的声明:

    struct HeapStruct;
    typedef struct HeapStruct *PriorityQueue;
    
    struct HeapStruct{
        int Capacity;
        int Size;
        ElementType *Element;
    }

    堆序性质

    使操作被快速执行的性质是堆序性,由于我们想要找出最小元,因此最小元应该在根上面,那么我们考虑任意子树也是一个堆,这任意节点就应该小于它的所有后裔。

    由此,我们得到了堆序的性质。在一个堆中,对于每个节点X,X的父亲中的关键字小于X中的关键字,根节点除外。在例子中,我们假设关键字是整数,虽然可能任意复杂。

    根据堆序的性质,最小元总是在根处能够找到,因而FindMin可以以常数时间完成。

    PriorityQueue
    Initialize(int MaxElements){
        PriorityQueue H;
        if(MaxElements < MinPQSize){
            Error("queue is too small");
        }
        H = malloc(sizeof(struct HeapStruct));
        if(H == NULL){
            FatalError("out of space");
        }
        H->Elements = malloc((MaxElements + 1) * sizeof(ElementType));
        if(H->Elements == NULL){
            FatalError("out of space");
        }
        H->Capacity = MaxElements;
        H->Size = 0;
        H->Elements[0] = MinData;
        return H;
    }

    基本的堆序操作

    无论从概念上还是实际考虑,执行插入和删除最小元都需要保持堆序性。

    Insert(插入)

    为了将X插入到空穴中,我们在下一个空闲位置创建一个空穴,否则该堆将不再是完全二叉树。如果X可以放在空穴并不会破坏堆序性,那么插入完成。否则,我们把父节点上面的元素移入空穴,空穴就朝着根的方向上行了一步。继续改过程,知道空穴能够放下X为止。

    这种一般的策略叫做上虑,新元素在堆上虑直到找到正确的位置。

    插入到一个二叉堆的过程:

    void 
    Inserti(Element X, PriorityQueue H){
        int i;
        if(IsFull(H)){
            Error("Priority queue is full");
            return;
        }
        for(i = ++H->Size; H->Element[i/2] > X; i = i / 2){
            H->Elements[i] = H->Emelemts[i/2];
        }
        H->Elements[i] = X;
    }

    在实现过程中,我们总可以通过进行反复交换来进行实现,这样就需要3条赋值语句,那么上虑d层的话,交换的次数就需要3d,而在这里仅仅需要d+1次。

    如果要插入的元素是性的最小值,那么它将一直被推导顶端。在某一次时,i将是1,我们就需要跳出循环,故我们可以在0位置处放置一个很小的值,来使循环终止,这个值需要小于堆中的任何值,我们称之为标记。

    如果新插入的元素是新的最小元,那么这种高度是O(logN)。但是平均来看,执行一次插入平均需要2.607次比较,因此Insert将元素平均上移1.607层。

     

    DeleteMin(删除最小元)

    DeleteMin 以类似于插入的方式处理。找出最小元是容易的,问题的关键在于删除它。当我们要要删除一个最小元时,在根节点产生了一个空穴。由于现在堆缺少了一个元素,因此堆中的最后一个元素X必须要移动到该堆的某个地方。如果X可以被放到空穴中,那么DeleteMin就完成了。我们的做法是将空穴两个儿子中的较小者移入空穴,这样就能够把空穴向下推进,重复该步骤直到X可以放在该空穴中。综上,我们的做法是将X放置在从根开始包含最小儿子的一条路径上面的正确的位置。我们将这种策略叫做下虑,并且也可以通过覆盖来避免交换,下面是下虑的过程示意图:

    在堆中经常发生的错误是当堆中存在偶数个元素的时候,此时将遇到一个节点只有一个儿子的情况,所以我们必须要考虑节点只有一个儿子的情况,当堆的元素个数是偶数的时候,在每个开始下虑的时候,可将其值大于堆中任何元素的标记放到堆的终端的后面的位置上。下面具体的代码实现:

    ElementType
    DeleteMin(PriorityQueue H){
        int i, Chid;
        ElementType MinElement, LastElement;
        if(IsEmpty(H)){
            return H->Element[0];
        }
        MinElement = H->Elements[1];
        LastElement = H->Element[H->Size--];
        for(i = 1; i*2 < H->Size; i = Chid){
            Chid = i * 2;
            if(Chid != H->Size && H->Elements[Chid+1] < H->Elements[Chid])
                Chid++;
            if(LastElement > H->Elements[Chid])
                H->Elements[i] = H->Elements[Chid];
            else
                break;
        }
        H->Elements[i] = LastElement;
        return MinELement;
    }

    平均运行时间为O(logN)。

     

    其他的堆操作

    虽然求最小值的操作可以在常数时间完成,但是按照最小元设计的堆对于求最大元方面却是无任何帮助。事实上,一个堆蕴含的关于序的信息很少,若不会整个堆进行线性所受搜索,是不能找出特定元素的,虽然我们知道最大元在树叶上面,但是半数的元素在树叶上面。

     

    构建堆

    BuildHeap操作把N个关键字作为输入并把它们放在堆中,可以通过Insert操作进行完成,由于每个Insert操作花费的时间是O(1),总的时间是O(N)。一般的算法是将N个关键字以任意的顺序放入树中,保持结构特性,然后再开始对父节点进行下虑操作,遍历所有的父节点之后,堆将会满足堆序性。

     

    定理

    包含2^(b+1) - 1个节点,高为h的理想二叉树的节点的高度为2^(b+1) - 1 - (b+1)。

    证明:高度为b上面的节点有1个,高度b-1上面的节点2个,高度为b-2上面的节点2^2个.....一般的高度b-i上的节点2^i个,所以所有节点的高度的和是:

    S = sum(2^i)(b-i)

    通过左乘2相减相消就能够求和。

    完全树不是理想二叉树,一个完全二叉树节点树是2^b和2^(b+1)之间。

    虽然我们得到的结果对证明BulidHeap是线性的而言是充分的,但是高度的和的界却是尽可能强的。

    下面是一个完整例程:

    #include<stdio.h>
    #include<stdlib.h>
    using namespace std;
    
    
    typedef int ElementType;
    typedef struct HeadStruct *PriorityQueue;
    #define MinPQSize 10
    #define MinData 0
     
    
    
    struct HeadStruct{
        int Capacity;
        int size;
        ElementType *Elements;
    };
    
    
    // 优先队列的初始化 
    PriorityQueue Initialze(int MaxElements){
        PriorityQueue H;
        if(MaxElements < MinPQSize){
            printf("Priority QUeue is too small
    ");
        }
        H = new HeadStruct();
        H->Elements = new ElementType[MaxElements + 1];
        H->Capacity = MaxElements;
        H->size = 0;
        H->Elements[0] = MinData;
        return H;
    }
    
    
    //优先队列插入元素 
    void insert(ElementType x, PriorityQueue H){
        int i;
        if(H == NULL){
            printf("Priority queue is Full 
    ");
            return;
        }
        for(i=++H->size; H->Elements[i/2]>x; i/=2){
            H->Elements[i] = H->Elements[i/2];
        }
        H->Elements[i] = x;
    } 
    
    
    //优先队列删除最小元
    ElementType DeletMin(PriorityQueue H){
        int i, child;
        ElementType MinElement, LastElement;
        if(H == NULL){
            printf("PriorityQueue is null
    ");
            return H->Elements[0];
        }
        MinElement = H->Elements[1];
        LastElement = H->Elements[H->size--];
        for(i=1; 2*i<=H->size; i=child){
            child = i*2;
            if(child != H->size && H->Elements[child +1] < H->Elements[child]){
                child++;
            }
            if(LastElement > H->Elements[child]){
                H->Elements[i] = H->Elements[child];
            }else{
                break;
            }
        }
        H->Elements[i] = LastElement;
        return MinElement;
    } 
    
    
    void printfPriorityQueue(PriorityQueue H){
        for(int i=1; i<=H->size; i++){
            printf("%d ", H->Elements[i]);
        }
    }
    
    
    int main(){
        PriorityQueue H = Initialze(50);
        int list[10] = {13,21,16,24,31,19,68,65,26,32};
        for(int i=0; i<10; i++){
            insert(list[i], H);
        }
        insert(14, H);
        printfPriorityQueue(H);
        int min = DeletMin(H);
        printf("min: %d
    ", min);
        printfPriorityQueue(H);
    } 

    d-堆

    d堆是二叉堆的推广,它恰像一个二叉堆,只是所有的节点都有d个儿子(因此,二叉堆是2堆)。d堆显然要比二叉堆浅的多,它将Insert时间改进为O(logdN)。然而,对于大的d,DeleteMin操作费时用的多,因为虽然树浅了,但是d个儿子中的最小者是必须要找出的,使用标准的算法,这会花费d-1次。虽然仍然是一个数组,找出儿子和父亲都有个因子d,除非d是2的幂,否则将会大大的增加运行时间,因为我们不能时间二进制的移位计算了。当优先队列太大不能完全装入主存时,d堆也是很有用的。在这种情况下,d堆能够以于B树大致相同的方式发挥作用。在实践过程中,4-堆可以胜过二叉堆。

    除了不能执行Find外,堆的实现的最明显的缺点是:将两个堆合并成一个堆是困难的,这种附加的操作叫做Merge,存在许多的实现堆的方法使得Merge操作的运行时间是O(logN)。

    左式堆

    设计一种堆结构像二叉树那样高效地支持合并操作(即以O(N)时间处理一次Merge)而且只使用一个数组似乎很困难。原因在于,合并似乎需要把一个数组拷贝到另外一个数组中去,对于相同大小的堆这将花费时间O(N),正是因为如此,所有支持合并的高级数据结构都需要使用指针。

    类似二叉堆那样,左式堆也具有结构性和有序性。事实上,和所有使用的堆一样,左式堆具有时间的堆序性质。左式堆也是二叉树,左式堆和二叉树唯一的区别是:左式堆不是理想平衡的,而实际上是趋向于非常不平衡。

    左式堆的性质

    我们把任一节点的零路径长Npl定位为从X到一个没有两个儿子的节点的最短路径长。因此,具有0个或者1个儿子的节点Npl为0,而Npl(NULL)=-1。任一节点的零路径长比它的诸儿子节点的零路径长的最小值多1,这个结论也适合用少于两个儿子的节点。

    左式堆性质是:对于堆中的每一个节点X,左儿子的零路径长至少与右儿子的零路径长一样大。这个性质实际上超出了平衡的要求,因为它显然更偏向于使树向左增加深度。确实有可能存在左节点形成的长路径构成的树,因此,我们就有了左式堆。左式堆趋向于加深左路径,所以右路径应该短。否则就会存在一条路径上通过某个节点X并取得做儿子,此时X则破坏了左式堆的性质。

     

    在右路径上有r个节点的左式树必然至少有2^r - 1 个节点。

    对左式堆的基本操作是合并,插入只是合并的特殊情形,因为我们可以把插入看成是单节点堆与一个大的堆的Merge。

    下面是具体的代码实现:

    struct TreeNode;
    typedef struct TreeNode *PriorityQueue;
    
    struct TreeNode{
        ElementType Element;
        PriorityQueue Left;
        PriorityQueue Right;
        int Npl;    
    }

    合并左式堆的驱动例程

    PriorityQueue
    Merge(PriorityQueue H1, PriorityQueue H2){
        if(H1 == NULL)
            return H2;
        if(H2 == NULL)
            return H1;        
        if(H1->Element < H2->Element)
            Merge1(H1, H2);
        else
            Merge1(H2, H1);
    }
    
    static PriorityQueue
    Merge1(PriorityQueue H1, PriorityQueue H2){
        if(H1->Left == NULL)
            H1->Left = H2;
        else{
            H1->Right = Merge(H1->Right, H2);
            if(H1->Left->Npl < H1->Right->Npl)
                SwapChildren(H1);
            H1->Npl = H1->Right->Npl + 1;
        }
        return H1;
    }

    左式堆的插入例程

    PriorityQueue
    Insert1(ElementType X, PriorityQueue H){
        PriorityQueue SingleNode;
        SingleNode = malloc(sizeof(struct TreeNode));
        if(SingNode == NULL){
            FatalError("out of space");
        }else{
            SingleNode->Element = X;
            SingleNode->Npl = 0;
            SingleNode->Left = SingleNode->Right = NULL;
            H = Merge(SingleNode, H);
        }  
        return H;
    }
    
    PriorityQueue
    DeleteMin1(PriorityQueue H){
        PriorityQueue LeftHeap, RightHeap;
        if(IsEmpty(H)){
            Error("empty");
            return H;
        }
        LeftHeap = H->Left;
        RRightHeap = H->Right;
        free(H);  
        return Merge(LeftHeap, RightHeap);
    }

    斜堆

    斜堆是左式堆的自调节形式,实现起来及其简单,斜堆和左式堆的关系类似于伸展树和AVL树间的关系。斜堆是具有堆序的二叉树,但是不存在堆树的结构的限制。不同于左式堆,关于任意节点的零路径长的任何信息都不保存。斜堆的左路径在任意时刻都可以任意长,因此所有操作的最坏情形运行时间均为O(N)。然而,正如伸展树一样,任意M次操作,总的最坏运行时间是O(MlogN)。因此斜堆每次操作的摊还时间是O(logN)。

     

    二项队列

    虽然左式堆和斜堆每次操作花费O(logN)时间,这有效地支持了合并,插入和DeleteMin。但是还有改进的余地,因为二叉堆每次操作花费常数平均时间支持插入。二项队列支持所有这三种操作,每次操作的最坏情形运行时间是O(logN),而插入操作平均花费常数时间。

     

    二项队列不同于我们已经看到的所有优先队列的实现之处是,一个二项队列不是一颗堆序的树,而是堆序树的集合,称为森林。堆序树中的每一个树都是有约束的形式,叫二项树。每个高度上至多存在一颗二项树。高度为0的二项树是一颗单节点树;高度为k的二项树Bk通过一颗二项树Bk-1附接到另一颗二项树Bk-1的根上。下图显示了二项树B0,B1,B2,B3。

  • 相关阅读:
    mysql5.7.26做主从复制配置
    linux编译安装mysql5.7
    suse清除kthrotlds木马病毒
    iptables 常用命令
    抓包工具fiddler下载配置(二):浏览器设置代理
    抓包工具fiddler下载配置(三):手机设置代理
    抓包工具fiddler下载配置(一):下载/安装&信任证书
    如何录制视频生成GIF动态图?
    安装第三方包&查看python版本/第三方包版本
    python3项目打包成exe可执行程序
  • 原文地址:https://www.cnblogs.com/baby-lily/p/12215692.html
Copyright © 2020-2023  润新知