zoukankan      html  css  js  c++  java
  • 优先级队列之堆的分析与实现

    设计动机以及基本框架

    在现实应用中,我们有这样一种需求,就是选取出当前队列中优先级最高的元素,比如操作系统中的线程调度,当前线程时间片用完的时候,需要从就绪队列中选出优先级最高的线程,对于一个无序队列,我们需要遍历所有的元素,那么时间复杂度就是O(n)。研究优先级队列的目的就是找到一种数据结构和对应的算法,实现高效的动态和静态操作。这里的动态操作指的是插入和删除元素,静态操作指的是查找。需要注意的是,删除操作和查找操作都只是针对优先级最高的元素。于是我们可以给出一个优先级队列的虚基类的定义。

    template <typename T> struct PQ{
        virtual void insert(T)=0;//插入新元素
        virtual T getMax()=0;//选取优先级最高的元素
        virtual T delMax()=0;//删除优先级最高的元素
    }//其实这只是一种ADT(abstract data type),需要根据不同的应用场景具体实现,对应的效率也不相同
    

    基本实现

    优先级队列只是一种概念上的范围,我们可以有多种实现方法,这里介绍几个最简单的方法,分别用向量、有序向量、BBST(balanced binary search tree,平衡二叉查找树)来实现PQ(priority queue,下面简称PQ),下表列出了插入、删除和查找优先级最高元素的时间复杂度。

    实现类型抽象接口 insert() getMax() delMax()
    向量 O(1) O(n) O(n)
    有序向量 O(n) O(1) O(1)
    BBST O(logn) O(logn) O(logn)

    这里简单说一下为什么时间复杂度如上表所示。对于无序向量来说,插入元素很简单,直接插到末尾就行,所以是O(1),getMax和delMax都需要遍历所有元素,其中delMax在删除元素之后还要移动后续元素,可以说,delMax在最坏情况下需要做接近2n次操作,但是在近似意义下都是O(n)。对于有序向量来说,我们可以按从小到大排序,这时候插入元素在最坏情况下需要移动接近n个元素,所以是O(n),而getMax和delMax都可以直接对最后一个元素操作(按照从小到大排序,最后一个元素优先级最高),所以是O(1)。BBST比较复杂,我们知道对于BBST来说,我们是search,insert还是remove接口(BBST的三个标准接口,在实现insert,getMax和delMax时可以直接调用)的时间复杂度都是O(logn),但是BBST本身实现起来过于复杂,而且它相对于PQ来说功能过于强大,比方说,对于insert接口来说两者功能类似,但是对于getMax和delMax来说,PQ只需要一种偏序关系,即PQ只需要对优先级最高的元素进行操作,但是BBST可以对任何元素操作。我们希望找到一种简单的数据结构,它既可以容易实现与维护,又能高效实现上述三个功能接口,下面我们介绍完全二叉堆。

    完全二叉堆

    完全二叉堆实际上就是在上述需求背景下产生的,事实上它同时结合了向量的形和树的神,向量的形是指它的存储结构和向量一样,树的神是指从逻辑上我们把它理解成树。一个向量如果想理解成树,事实上只有在完全二叉树的情况下才有可能,这时树是按层次遍历的顺序存在向量里的,完全二叉堆的一个重要特征是堆序性,简单来说就是父节点一定不小于子节点,因此整个堆中的最大节点就一定是根节点,所以getMax接口非常容易实现,只需要返回_elem[0]即可,时间复杂度是O(1),接下来我们分析框架类里实现的另两种接口的实现方案和实现效率。
    对于insert(),实际上是一个上滤的过程,新插入的节点首先放在向量的末尾,相当于在完全二叉树的最后一个节点,如果它有父亲节点的话,就和父亲节点比较,如果大于父亲节点就互换,然后继续和新的父亲节点比较直至小于新的父节点或者抵达根节点,下面是对应代码,首先我们先给出用完全二叉堆的类定义。

    template <typename T> class PQ_ComplHead:public PQ<T>,public vector<T>{
    protected:    int percolateDown(int n,int i);//下滤
                  int percolateUp(int i);//上滤
                  void heapify(int n);//Floyd建堆算法
    public:       PQ_ComplHeap(T *A,int n)  
                  void insert(T);
                  T getMax(){return _elem[0]};
                  T delMax();
    }
    

    接下来我们来看insert接口的实现

    template <typename T> void PQ_ComlHeap<T>::insert(T e){
        Vector<T>::insert(e);
        percolateUp(_size-1);
    }
    
    template <typename T> int PQ_ComplHeap<T>::percolateUp(int i){
        while(ParentValid(i)){
            int j=Parent(i);
            if(_elem[i]<=_elem[j])
                break;
            swap(_elem[i],_elem[j]);
            i=j;
        }
        return i;
    }
    

    接下来分析删除操作,删除操作只是针对最大元素,所以只需要删除掉_elem[0]即可,问题的关键是,在删除掉_elem[0]之后如何保持堆序性。原理是:首先将最后一个节点放在根节点的位置,然后执行下滤操作。无论是上滤操作还是下滤操作,虽然不是一次就能完成,但是在操作的过程中能保证单调性,即问题始终在逐渐简化,下面给出代码。

    template <typename T> T PQ_ComplHeap<T>::delMax(){
        T maxElem=_elem[0];
        _elem[0]=_elem[--size];
        percolateDown(_size,0);
        return maxElem;
    }
    
    template <typename T> int PQ_ComplHead<T>::percolateDown(int n,int i){
        int j;
        while(i!=(j=ProperParent(_elem,n,i))){//这里的ProperParent是返回i和它最多两个孩子中最大者的秩
            swap(_elem[i],_elem[j]);
            i=j;
        }
        return i;
    }
    

    OK,到这里完全二叉堆的基本框架和算法时间都完成了,下面我们分析性能。

    性能分析

    这里我们和之前的三种实现方式做以对比,这里先给出时间复杂度,再介绍这个时间复杂度是如何算出来的。

    实现类型抽象接口 insert() getMax() delMax()
    向量 O(1) O(n) O(n)
    有序向量 O(n) O(1) O(1)
    BBST O(logn) O(logn) O(logn)
    完全二叉堆 O(logn) O(1) O(logn)

    对于getMax来说,时间复杂度是O(1)应该很好理解,因为只需要取_elem[0]就行了,insert操作其实最主要的就是一个上滤过程,这个过程每次都会至少上升一层,每次上升只需要做常数次比较操作,而完全二叉堆是严格意义上最多只有logn层,所以它的时间复杂度是O(logn)。其实delMax()操作和insert类似,需要做一个下滤操作,在每层也需要做常数次比较操作,然后下降一层,在最坏情况下也不过是下降logn层,所以也是O(logn)。
    由堆衍生而来的堆排序算法思路也非常简单,不过是每次取出最大的元素然后输出就可以了,每次取出最大元素都要执行一次getMax()和delMax(),时间复杂度为O(logn+1),也就是O(logn),一共执行n次,所以总体的时间复杂度是O(n*logn),这也是全排序算法中效率最高的情况了,当然我们可以对它做常数意义下的优化。
    接下来我们介绍另一种堆,它是为了弥补二叉完全堆在某些特定应用场景下的不足应运而生的。

    左式堆

    左式堆是为了高效的实现两个堆的合并(merge)操作,设想一下,如果用完全二叉堆我们如何实现两个堆的合并呢?假设有两个堆,堆A和堆B,我们可以首先判对一下它们的长度,不失一般性,假设size(A)>size(B),一种很简单的算法就是不断输出B堆的数据插入到A堆中去,每次插入的时间复杂度都是logn,假设b堆有m个元素,那就是mlogn,一般我们认为n和m是同阶的,那就是O(nlogn),这种性能开销我们是无法接受的,因为我们把堆A和堆B中的元素混搭在一起,重新排序也就是O(n*logn)的时间复杂度,而且堆是一种偏序关系(偏序关系体现在只要能找到最大或最小元素即可),那么它的开销应该小于全排序才行,所以我们引入左式堆。
    这里我们引入一个NPL的概念,NPL是Null Path Length的缩写,它是指对应节点到外部节点的最近距离,这里引入两条规则:

    1. npl(NULL)=0;
    2. npl(x)=1+min(npl(lc(x)),npl(rc(x)));

    我们首先给出左式堆的类定义,代码如下。

    template <typename T> class PQ_LeftHeap:public PQ<T>,public BinTree<T>{
    public:
        void insert(T);
        T getMax(){
            return root->data;
        }
        T delMax();
    }
    

    在满足左倾性的情况下,我们给出一种递归的合并算法的实现,代码如下。

    template <typename T> static BinNodePosi(T) merge(BinNodePosi(T) a,BinNodePosi(T) b){
        if(!a) 
            return b;
        if(!b) 
            return a;
        if(a->data<=b->data)
            swap(b,a) //因为需要让b作为a的子树
        a->rc=merge(a->rc,b);//将a的右子堆与b合并
        a->rc->parent=a;
        if(!a->lc||a->lc->npl<a->rc->npl)
            swap(a->lc,a->rc);
        a->npl=a->rc?a->rc->npl+1:1;
        return a;
    }
    

    先介绍到这儿吧,有机会我在详细展开,读者可以自己画一些图帮助理解一下。

  • 相关阅读:
    codeforces C. No to Palindromes!
    codeforces D. Pashmak and Parmida's problem
    codeforces C. Little Pony and Expected Maximum
    codeforces D. Count Good Substrings
    codeforces C. Jzzhu and Chocolate
    codeforces C. DZY Loves Sequences
    codeforces D. Multiplication Table
    codeforces C. Painting Fence
    hdu 5067 Harry And Dig Machine
    POJ 1159 Palindrome
  • 原文地址:https://www.cnblogs.com/miachel-zheng/p/6977827.html
Copyright © 2011-2022 走看看