设计动机以及基本框架
在现实应用中,我们有这样一种需求,就是选取出当前队列中优先级最高的元素,比如操作系统中的线程调度,当前线程时间片用完的时候,需要从就绪队列中选出优先级最高的线程,对于一个无序队列,我们需要遍历所有的元素,那么时间复杂度就是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的缩写,它是指对应节点到外部节点的最近距离,这里引入两条规则:
- npl(NULL)=0;
- 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;
}
先介绍到这儿吧,有机会我在详细展开,读者可以自己画一些图帮助理解一下。