堆排序
优先队列(priority queue)是按照某种优先级进行排列的队列,优先级越高的元素出队越早,优先级相同者按照先进先出的原则进行处理。优先队列的基本算法可以在普通队列的基础上修改而成。例如,入队时将元素插入到队尾,出队时找出优先级最高的元素出队;或者入队时将元素按照优先级插入到合适的位置,出队时将队头元素出队。这两种实现方法,入队或出队总有一个时间复杂度为 O ( n ) {O(n)} O(n)。而采用堆来实现优先队列,入队和出队的时间复杂度则均为 O ( l o g 2 n ) {O(log_{2}n)} O(log2n)。
堆的定义
堆(heap)可以定义为一棵二叉树,树的节点中包含键(每个节点一个键),并且满足下面两个条件:
(1)树的形状(shape property)要求,这棵二叉树是基本完备(essentially complete)的(或者简称为完全二叉树),这意味着,树的每一层都是满的,除了最后一层最右边的元素有可能缺位。
(2)父母优势(parental dominance)要求,又称为堆特性(heap property),每一个节点的键都要大于或等于它子女的键(大根堆),或者每一个节点的键都要小于或等于它子女的键(小根堆)(对于任何叶子我们认为这个条件都是自动满足的)。
如果将堆按层序从0开始编号至n结束,则结点之间满足以下关系:
从堆的定义可以看出,一个完全二叉树如果是堆,则根节点(称为堆顶)一定是当前堆中所有节点的最大者(大根堆)或最小者(小根堆),如下图所示。用小根堆实现的优先队列称为极小队列,用大根堆实现的优先队列称为极大队列。
堆的构造
堆的逻辑结构为完全二叉树,而堆的存储结构一般用数组进行存储。以大根堆为例,可以把堆定义为一个数组 H [ 0.. n ] H[0..n] H[0..n],其中,数组前半部分中,每个位置i上的元素总是大于等于位置2i+1和2i+2中的元素,也就是存在如下关系:
H [ i ] ≥ m a x { H [ 2 i + 1 ] , H [ 2 i + 2 ] } i = 0... ⌊ ( n + 1 ) / 2 − 1 ⌋ H[i] \ge max\{H[2i+1],H[2i+2]\} \\ i=0...\lfloor{(n+1)/2-1}\rfloor H[i]≥max{
H[2i+1],H[2i+2]}i=0...⌊(n+1)/2−1⌋
根据这一特性,构造一个堆的方法主要有两种:
- 自底向上堆构造
- 自顶向下堆构造
自底向上堆构造
在初始化一棵包含n个节点的完全二叉树时,我们按照给定的顺序来放置键,然后按照下面的方法对树进行“堆化”。从最后的父母节点开始,到根为止,该算法检查这些节点的键是否满足父母优势要求。如果该节点不满足,该算法把节点的键K和它子女的最大键(最小键)进行交换,然后再检查在新位置上,K是不是满足父母优势要求。这个过程一直继续到对K的父母优势要求满足为止(最终它必须满足,因为对于每个叶子中的键来说,这个条件是自动满足的)。对于以当前父母节点为根的子树,在完成它的“堆化”以后,该算法对于该节点的直接前趋进行同样的操作。在对树的根完成这种操作以后,该算法就停止了。
// 堆构造
template<typename T>
void HeapBottomUp(vector<T>& H){
int n = H.size();
for(int i=n/2-1; i>=0; i--){
int k=i;
T v = H[k];
bool heap = false;
while(!heap && 2*k+1<n){
int j=2*k+1;
if(j+1<n && H[j] < H[j+1]){// 存在两个子女
j = j+1;
}
if(v>=H[j]){
heap=true;
}else{
H[k]=H[j];
k=j;
}
}
H[k]=v;
}
}
自顶向下堆构造
自顶向下算法(效率较低)通过把新的键连续插入预先构造好的堆,来构造一个新堆。首先,把一个包含键K的新节点附加在当前堆的最后一个叶子后面。然后按照下面的方法把K筛选到它的适当位置。拿K和它父母的键做比较:如果后者大于等于K,算法停止(该结构已经是一个堆了);否则,交换这两个键并把K和它的新父母做比较。这种交换一直持续到K不大于它的最后一个父母,或者是达到了树的根为止。
堆顶的删除
从一个堆中删除根的键。要删除的键和最后的键做交换,然后,我们这样来“堆化”(堆调整)这棵较小的树:根中的新键和它子女中较大的键做交换,直到满足父母优势要求,具体过程如下图所示。
// 堆调整
template<typename T>
void adjustHeap(vector<T>& H, int k, int len){
T temp = H[k];
for(int i=2*k+1; i<len; i=2*i+1){
if(i+1<len && H[i] < H[i+1]){
i++;
}
if(H[i] > temp){
H[k] = H[i];
k = i;
}else{
break;
}
}
H[k] = temp;
}
堆排序
堆排序(heapsort)是威廉姆斯(J.W.J. Williams)发明的一种重要的排序算法,这种两阶段算法的实现过程分为两个部分:
第一步:构造堆,即为一个给定的数组构造一个堆。
第二步:删除最大键,之后进行堆调整,并循环执行第二步直至删除所有元素。
最终结果是按照降序删除了该数组的元素。但是对于堆的数组实现来说,一个正在被删除的元素是位于最后的,所以结果数组将恰好是按照升序排列的原始数组。
图中第一步构造堆的过程使用了自底向上的构造方式,将数组构造成大根堆!
图中第二步中,每一次都将堆顶与最后一个元素交换(删除最大键),并对剩下的部分(前面未被删除的元素)进行堆调整,如此循环多个回合后,得到从小到大顺序排列的数组。
实际上,无论是最差情况还是平均情况,堆排序的时间效率都属于 O ( n l o g n ) O(nlogn) O(nlogn)。因此,堆排序的时间效率和合并排序的时间效率属于同一类。而且,与后者不同,堆排序是在位的,也就是说,它并不需要任何额外的存储空间。针对随机文件的计时实验指出,堆排序比快速排序运行得慢,但和合并排序相比还是有竞争力的。
代码实现
template<typename T>
void HeapSort(vector<T>& H){
// 第一步:建堆
HeapBottomUp(H);
// 第二步:删除最大键,并进行堆调整
for(int i=H.size()-1; i>0; i--){
swap(H[0], H[i]); // C++中提供了swap函数
adjustHeap(H, 0, i);
}
}