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

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

  • 相关阅读:
    破解Excel写密码保护方法
    【收藏推荐】JavaScript 秘密花园
    Flask框架第六篇.Flask中的蓝图和特殊装饰器
    Flask框架第四篇.Flask 中的 请求Request和session
    Flask框架第三篇.Flask 中的 Response
    Flask框架第八篇.请求上下文
    Flask框架第一篇.Flask框架基础
    Flask框架第二篇.Flask中的配置
    Flask框架第五篇.Flask 中的路由
    Flask框架第七篇.CBV和Flasksession
  • 原文地址:https://www.cnblogs.com/miachel-zheng/p/6977827.html
Copyright © 2011-2022 走看看