写在前面
二叉堆是堆的一种常见实现。从形态上看,二叉堆是一棵完全二叉树,但不是一棵二叉搜索树。本节总结和实现二叉堆的基本操作。注意体会:
定义向上调整和向下调整公共操作带来的好处
从顶到底和从底到顶两种从数组构建堆的方法
将堆作为优先级队列
堆排序
二叉堆(Binary Heap): 二叉堆是堆的一种常见实现。二叉堆可以看做是有两个约束的二叉树(可参见维基百科binary heap):
(1)形态约束
二叉堆是完全二叉树,树的所有层中,除了最后一层外,其余所有层都被填满,如果最后一层没有填满,则节点必定是从左至右排列的。
(2) 数据约束
每个节点都大于等于(大顶堆)或者小于等于(小顶堆)它的每个子节点的值。
堆可以由数组实现。形态上是一棵完全二叉树,但是与二叉搜索树不同的是,中序遍历时元素并不一定保持有序。n个元素的序列k1,k2,⋯,kn,当且仅当满足下列关系时称为堆:
ki≤k2i 并且 ki≤k2i+1 (小顶堆 min heap)
或者:
ki≥k2i 并且 ki≥k2i+1 (大顶堆 max heap)
注意: 堆中的元素并没有正确排序。例如大顶堆,我们只知道最大的元素在根节点中,对于每个节点而言,其所有的后继结点都小于或者等于该节点。但是,同辈之间、叔侄之间的关系并未确定。
大顶堆例如:
这里我们看出同辈节点(19和36,17和3),叔侄节点(19和25,36和3)之间大小关系未确定。
以下讨论我们以大顶堆进行,小顶堆是可以类似操作。下文的堆的约束指的也就是大顶堆中,节点元素必须大于等于它的每个孩子节点。
删除大顶堆时,总是从堆顶取出元素,然后进行调整。具体算法思想为:
(1) 取出堆顶元素
(2) 将最后一个叶子节点的元素放在根节点中,删除最右一个叶子节点
(3) 自顶向下调整根节点元素,直到到达叶子或者不在违反堆的约束为止。在调整过程中,节点如果违反堆的约束,总是与较大的孩子交换。
例如在下图的堆中删除堆顶元素对应的过程(截取自参考资料:《Data Structures and Algorithms in C++》 Adam Drozdek Fourth Edition):
步骤3可以作为一个公共操作来实现,每当节点违背堆的性质时,可以进行这种自顶向下的调整。
在调整的过程中,如何检测一个节点是否为叶子节点呢?
在二叉堆中,一个节点如果没有左孩子,必定不会有右孩子;如果没有右孩子,它可能有左孩子(在倒数第二层)。因此,当一个节点没有左孩子时,它就是叶子节点。
二叉堆维护的数据成员包括:
/** * 二叉堆-大顶堆 * Binary Heap * 约定根节点编号为0,后续结点依次增加 */
template<typename T,int initCapacity = 32>
class BiHeap {
private:
T * base; // 存储数据的数组指针
int size; // 实际元素个数
int capacity; // 已经分配的容量
};
删除操作和自顶向下调整操作实现如下所示:
/** * 取出顶元素 * 算法思想: * 1)如果堆中没有元素,则返回false * 2)如果堆中只有一个元素,则将e设置为根元素,堆大小置为0,返回true * 3)否则,将最后叶子节点赋值给根,并删除叶子节点 * 然后自顶向下调整根元素直到不违反堆性质,返回true */
template<typename T,int initCapacity>
bool BiHeap<T,initCapacity>::pop(T& e) {
if(size == 0)
return false;
e = base[0];
if(size == 1)
size = 0;
else {
base[0] = base[--size];
moveDown(base,0,size);
}
return true;
}
/** * 自顶向下调整curIndex所指元素 直到不违反堆的性质或者curIndex已经为叶子节点时停止 * curIndex为当前节点索引 * endIndex为超出末端索引 */
template<typename T,int initCapacity>
void BiHeap<T,initCapacity>::moveDown(T*data, int curIndex, int endIndex){
int left = 2*curIndex+1;
int right = 2*curIndex+2;
int maxIndex = curIndex;
while( left < endIndex) {
if(data[left] > data[maxIndex])
maxIndex = left;
if(right < endIndex && data[right] > data[maxIndex])
maxIndex = right;
if(maxIndex == curIndex) // 不需要再继续交换结点
break;
std::swap(data[maxIndex],data[curIndex]);
curIndex = maxIndex;
left = 2*curIndex+1;
right = 2*curIndex+2;
}
}
上述代码中,已知当前元素索引为curIndex,则它的两个孩子节点索引分别计算为 left=2*curIndex+1,right=2*curIndex+2。
插入元素后,元素需要与它的父元素比较,如果比父元素大,则违法堆的约束,必须与父元素交换。持续这一过程,直到没有违反堆的约束,或者直到调整为根时停止。
例如在下图所示的堆中插入元素15的过程(截取自参考资料:《Data Structures and Algorithms in C++》 Adam Drozdek Fourth Edition):
这是一个自底向上的调整过程,抽取这一操作为公共操作,实现为:
/** * 自底向上调整curIndex所指元素 直到不违反堆的性质或者curIndex已经为根结点为止 */
template<typename T,int initCapacity>
void BiHeap<T,initCapacity>::moveUp(T* data,int curIndex){
int pIndex = ( curIndex-1) / 2;
while ( curIndex != 0 && data[curIndex] > data[pIndex]) {
std::swap(data[curIndex], data[pIndex]);
curIndex = pIndex;
pIndex = (curIndex-1) / 2;
}
}
上述代码中,已知当前节点索引为curIndex,则它的父节点索引为 pIndex = (curIndex-1) / 2 。
则插入元素实现为:
/** * 插入元素e * 插入失败返回false,成功返回true */
template<typename T,int initCapacity>
bool BiHeap<T,initCapacity>::push(const T& e) {
if(size == capacity) { // 重新分配内存 并拷贝内容
capacity += capacity / 2;
T* tmp = new T[capacity]; // 分配新的空间
if(base == 0) {
std::cerr<<"insert error:outof memory"<<std::endl;
return false;
}
std::copy(base, base+size,tmp); // 拷贝元素
delete[] base; // 释放原空间
base = tmp;
}
base[size] = e;
moveUp(base,size);
++size;
return true;
}
从数组构建二叉堆分为两个方法,一种是自顶到底的方法,一种是自底到顶的方法。这两种算法在平均情况下,效率处于同一水平。
所谓自顶到底,就是按照插入元素的方式,依次将数组中元素加入到堆中,调整的方法采用插入的方式即moveUp调整方法进行调整。这种方法最终能建立一个堆,因为是从一个空节点开始,一点点往下增长的,因此可以看成是“自顶到底”。这种方法是由John Williams提出的,在平均情况下比较次数介于1.75n和2.76n之间,其中n是数组的大小。
具体实现为:
/** * 从顶到底的插入范围指针内的元素,构造一个大顶堆 * John Williams 算法 * 算法思想: * 逐个加入范围指针内的元素 * 每个新加入的元素都按照insert方法类似的从叶子调整至根的方法调整 */
template<typename T,int initCapacity>
void BiHeap<T,initCapacity>::buildTopDown(const T* pbegin,const T* pend){
if(pbegin == pend) return;
capacity = (pend-pbegin)+initCapacity;
base = new T[capacity];
while(pbegin != pend)
push(*pbegin++);
}
所谓自底到顶,就是将给定的数组看做是一个不合定义的堆,然后从最后一个非叶子节点开始调整,直到根节点调整完毕,就形成了一个符合定义的堆。在调整的过程中,非叶子节点,与它的孩子节点比较,如果小于孩子,则进行交换。注意的是,非叶子节点不仅与孩子比较,还与孙子节点、曾孙节点比较直到找到合适的位置,这是一个自顶向下的调整过程,可以使用moveDown操作完成。由于在调整的过程中,是从树的底部开始逐步到根节点的调整的,因此可以看成是“自底到顶”的方式。这种方法石油Robert Floyd提出的,平均需要1.88n次比较。
具体实现为:
/** * 从底到顶的方法将指针范围内的元素转换为大顶堆 * Robert Floyd 算法 * 算法思想: * 1)将指针范围内元素复制到data内 * 2)从第一个非叶子节点 data[size/2 -1]开始按照pop方法类似的自顶向下的方法调整 * 3)重复2直到根节点调整完毕为止 */
template<typename T,int initCapacity>
void BiHeap<T,initCapacity>::buildBottomUp(const T* pbegin,const T* pend) {
if(pbegin == pend)
return;
capacity = (pend-pbegin)+initCapacity;
base = new T[capacity];
std::copy(pbegin,pend,base);
size = pend-pbegin;
int curIndex = size/2-1;
while(curIndex >= 0)
moveDown(base,curIndex--,size);
}
堆可以用来实现优先级队列,以前面介绍的插入和删除元素为入队和出队操作即可。
给定顺序表,对其中的元素进行堆排序的基本思想:
(1)由顺序表构建一个大顶堆
(2)对n个元素的堆,将堆顶元素与最后一个元素交换,然后对剩下的n-1个元素重新调整成为大顶堆。
(3)重复2过程n-1次。
实际上我们直到,如果每次都输出堆顶元素,然后把结果输出到另一个有序表中缓存结果也可以,可是会导致额外开销,步骤2中将堆顶元素与最后一个元素交换,实际上是把堆顶元素交换到最后一个位置上,这样就节省了空间开销。这是一个改善的技巧。
堆排序具体实现为静态方法:
/** * 对范围指针内的内容进行堆排序 升序排列 * 算法思想: * 1)构建大顶堆,共n个元素 * 2)每次将根元素与最后一个元素i交换,然后对根元素调整为大顶堆 * 3)重复2过程n-1次 */
template<typename T,int initCapacity>
void BiHeap<T,initCapacity>::sort(T* pbegin,T* pend) {
// 构建初始大顶堆
int count = pend-pbegin;
int curIndex = count/2-1;
while(curIndex >= 0)
moveDown(pbegin,curIndex--,count);
// n-1次交换
for(int n = count-1; n > 0 ;--n) {
std::swap(pbegin[n],pbegin[0]);
moveDown(pbegin,0,n);
}
}
例如给定测试用例:
// 堆排序测试 升序
void heapTest6() {
int keys[] = {2,8,6,1,10,15,3,12,11,49,38,65,97,76,13,27};
int count = sizeof(keys) / sizeof(int);
BiHeap<int>::sort(keys, keys+count);
std::cout << "sorted result: "<<std::endl;
std::ostream_iterator<int> out_it (std::cout,"\t");
std::copy ( keys, keys+count, out_it );
std::cout << std::endl;
}
运行上述测试,输出结果为:
sorted result:
1 2 3 6 8 10 11 12 13 15 27 38 49 65 76 97
可见数据经过排序变成了升序的。实际上初始建立的堆为:
97 49 76 27 38 65 13 12 11 10 8 6 15 3 2 1
经过n-1次调整后,数据才变成有序的。
堆排序运行耗费的时间主要在初始建堆和对堆进行重新调整时的反复筛选上。重建堆操作共执行n-1次。堆排序平均时间复杂度和最坏的情况下时间复杂度,都为O(nlogn)。