堆是一棵特殊的完全二叉树,由于这个特性,它的底层采用数组储存数据,这样,我们通过一个数组以及一组
heap
算法就可以轻易地实现一个堆。
- 完整实现代码
概述
根据数组的特点,我们使用一个技巧。如果将 #i 处的元素看作根,则它的左右子树分别为 #(2*i+1) 和 #(2*i+2),这样堆就可定义如下:
- 堆总是一棵完全二叉树;
- 对于大堆,树中每个节点的值总是大于其左右孩子节点的值,对于小堆,树中每个节点的值总是小于其左右孩子节点的值;
- 根节点最大的堆称为大堆,根节点最小的堆称为小堆;
下图形象的展示了小堆:
有了以上定义,我们就能通过一个数组和一组堆算法(用来建堆、插入、删除、取堆顶、调整堆)轻易地实现一个堆了。
普通数组无法动态改变大小,而堆则需要这项功能,故采用 vector 代替数组 是堆更好的选择。
堆算法
根据元素排列方式,堆有大小堆之分,我们在接下来的讨论中皆以小堆为例,大堆相关和小堆如出一辙。
push_heap()
入堆操作必须保证两点:
- 新加元素后不破坏完全二叉树的性质;
- 新加元素后不破坏孩子节点和父亲节点的大小关系;
对于第一点,我们很容易做到,只需调用 vector
的 尾插算法即可完成;
对于第二点,通过一个向上调整算法来实现。
下图演示的是 调整算法的实际操演情况:
插入之前,堆满足其性质,在插入一个节点 (25) 之后, 显然堆的性质被破坏,故需要调整;
如果将插入的节点作为 child
节点, 则父亲节点为 parent
, 下标为:(#child-1)/2;
当插入 child
时,由于它比根 parent
小, 故破坏了以 parent
为根节点的那棵树的(堆)性质,为满足小堆性质,将 child
和 parent
交换,此时以 parent
为根的这棵树已经满足堆的性质,但这种操作又破坏了以 parent
的父节点为根的那棵树的堆性质,故一直上溯,直到不需要交换或者到达根节点为止。
下面是该算法的实现细节:
void PushHeap(T value)
{
_a.push_back(value);//_a为vector的对象
_AdjustUp(_a.size() - 1);
}
//该函数进行向上调整细节
void _AdjustUp(size_t child)
{
size_t parent = (child - 1) / 2;
while (0 != child){
if (Com()(_a[child], _a[parent])){//仿函数实现比较
swap(_a[child], _a[parent]);
child = parent;//进行上溯
parent = (child - 1) / 2;
}
else//说明已经不需要交换,则可以终止循环了
break;
}
}
pop_heap()
出堆也必须保证和入堆同样的两点;
出堆操作总是要取走根节点,因为最大值(最小值)总是在根节点处。为了要保证第一点:完全二叉树的性质,我们不能直接拿走根节点,因为拿走根节点所导致的调整工作的开销实在是太大了。我们采用一个投机取巧的办法:将根节点元素和最后一个叶子节点元素交换,然后将最后一个叶子节点出堆,这样对于底层容器 vector
来说负担不至于太大, 也不会导致我们后面的调整算法太复杂。
至于第二步的调整,通过向下调整算法实现:
下图演示了出堆操作的整个步骤:
结合这图来看,我们将向下调整算法描述为以下:
从根节点开始,如果某个孩子节点的值比根节点的小,则将该孩子与跟交换,然后让该被交换的节点作为新的根节点,继续执行上述步骤,直至不需要交换或者孩子孩子节点不存在(孩子节点下标超出 vector
的 size 范围)。
下面是 pop_heap()
算法的实现细节:
void Pop()
{
assert(!_a.empty());
swap(_a[0], _a[_a.size() - 1]);//交换
_a.pop_back();//删掉最后一个元素
_AdjustDown(0);
}
//向下调整细节
void _AdjustDown(int parent)
{
int child = parent * 2 + 1;
while (child < _a.size()){
if (child + 1 < _a.size() && Com()(_a[child + 1], _a[child]))
++child;
if (Com()(_a[child], _a[parent]))
swap(_a[child], _a[parent]);
parent = child;
child = parent * 2 + 1;
}
}
make_heap()
‘make_heap()’算法用来调整一段现有的数据,使之满足堆的性质。其主要依据二叉树的数组表示法以及上文提到的向下调整算法。
如果把堆看作由“左子树”、“右子树”、“根”组成的话,仔细观察上文堆的结构,不难发现,每棵子树都满足根的性质。也就是说,如果我们能调整一棵树的结构,使它的左子树,右子树满足堆的性质,并且它本身也满足堆的性质的话,那么这可树整体就满足堆的性质。
上文中的向下调整算法可以帮助我们做到这一点。具体做法是,先找到这棵树的最后一个孩子节点不为空(至少有一个孩子节点)的节点,以它作为根,使用向下调整算法调整,再找前一棵子树,继续调整,直至调整到根节点。
下图演示了这一过程(数据过小,演示的可能不全面):
heap_sort()
既然每次的 pop_heap()'
都可以得到数组中的最小的元素,如果持续对堆进行 pop_heap()
操作,并且每将讲范围缩小一个(因为要将出堆的那个元素置于尾端),那么当整个程序执行完毕时,我们就得到了一个有序序列。
用这种方法,我们可以对一组数据进行排序:
- 升序:大堆
- 降序:小堆
如上图所示,我们通过堆的调整算法和出堆算法就对一组数列排好了序。
下面是它的逻辑顺序:
- 建堆
- 交换堆顶和堆尾的元素
- 缩小堆的范围
- 重新调整
- 跳到第二步
下面给出完整实现代码:
//向下调整
//a为数据的数组, n为调整的范围, p为调整的起始位置
void AdjustDown(int *a, size_t n, size_t p)
{
assert(a);
int parent = p;
int child = p * 2 + 1;
while (child <= n)
{
if (child + 1 < n && a[child] < a[child + 1])
++child;
if (a[parent] < a[child]){
swap(a[parent], a[child]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(int *a, int size)
{
for (int i = size / 2 - 1; i >= 0; --i)
AdjustDown(a, size, i);
int end = size - 1;
while(end > 0) {
swap(a[0], a[end]);
--end;
AdjustDown(a, end, 0);
}
}
排序测试:
参考资料
- github完整代码
- STL源码下载
- 堆的相关算法可以在 STL 源码的头文件
stl_heap.h
中找到。
【作者:果冻 http://blog.csdn.net/jelly_9】
——谢谢!