我们这里所描述的堆并不是“垃圾收集存储机制“,而是堆数据结构。
(二叉)堆是一个数组,它可以被看成一个近似的完全二叉树。树上的每个节点对应数组中的一个元素,除了最底层外,该树是完全充满的,而且是从左向右填充。下文如果不加限定的话,所提到的堆均指二叉堆。常见的堆还有斐波那契堆。
下面我们给出一个最大堆的二叉树和数组形式的图示:
如图所示,节点上方的数字是该元素在数组中的下标,这样给定一个节点的下标i,我们很容易得出它的父节点,左孩子和右孩子的下标:
int parent(int i)
{
return i / 2;
}
int left(int i)
{
return 2 * i;
}
int right(int i)
{
return 2 * i + 1;
}
二叉堆可以分为两种形式:最大堆(大根堆)和最小堆(小根堆)。
最大堆是指除了根节点以外的其他节点i都要满足:A[parent(i)] >= A[i]。也就是说某个节点的值至多与其父节点一样大
最小堆是指除了根节点以外的其他节点i都要满足:A[parent(i)] <= A[i]
由于最大堆和最小堆非常相似,所以我们下面只讨论一下最大堆。并且为了方便起见,下面给出的C代码中数组下标均从1开始。要做到这一点其实是很容易的,只需为数组多开辟一个元素的空间,然后令下标从1开始,不使用A[0]即可。
首先我们定义一个全局变量
/*
* 表示有多少个堆元素存储在该数组中,也就是堆中的元素个数。
*/
static int heapsize;
在下面的代码中,我们略去了一些错误检查,比如在实际应用中,你必须保证heapsize的值是有效的,即heapsize不能大于数组的长度。
维护堆的性质
/* max_heap函数:维护最大堆的性质 */
void max_heap(int *a, int i)
{
int l, r, largest;
l = left(i);
r = right(i);
/* 找出a[i]、a[l]、a[r]中的最大值 */
if (l <= heapsize && a[l] > a[i])
largest = l;
else
largest = i;
if (r <= heapsize && a[r] > a[largest])
largest = r;
/* 如果a[i]是最大的,那么以该节点为根的子树已满足最大堆的性质,函数结束;
* 否则,最大元素是其某个孩子节点,我们交换a[i]和a[largest],从而使a[i]
* 及其孩子都满足最大堆的性质
*/
if (largest != i) {
swap(a, i, largest);
/* 当a[largest]成为a[i]之后,以该节点为根的子树可能会违反最大堆的性质
* 所以要对该子树递归调用max_heap
*/
max_heap(a, largest, heapsize);
}
}
/* swap函数:交换两个数 */
void swap(int *a, int i, int j)
{
int tmp = a[i];
a[i] = a[j];
a[j] = tmp;
}
代码中注释的已经非常清楚了,我们通过让a[i]在最大堆中“逐级下降“,从而使得以a[i]为根节点的子树重新遵循最大堆的性质。
下面我们举例说明一下,下图是max_heap(a, 2),heapsize = 10的执行过程:
a[2]违背了最大堆的性质,因为它不大于它的孩子,所以要将它和它的孩子中最大的进行交换,即swap(a[2], a[4])。递归调用max_heap(a, 4, 10),发现a[4]同样不满足最大堆的性质,所以要swap(a[4], a[9])。再递归调用max_heap(a, 9, 10),发现以a[9]为根的子树满足最大堆的性质。
维护最小堆的性质与之类似,我们只需将不满足最小堆性质的节点与其孩子中最小的一个进行交换,然后递归下降即可。直到遇见满足最小堆性质的节点。
建堆
/* build_max_heap函数:构建一个最大堆 */
void build_max_heap(int *a, int n)
{
/* a[n / 2]..a[n]中的元素都是树的叶节点,所以
* 我们对树中的其他节点都调用一次max_heap即可
*/
heapsize = n;
for (int i = n / 2; i >= 1; i--)
max_heap(a, i);
}
构建一个最大堆就是对于以a[n / 2] .. a[n]为根节点的每个子树,使它们分别都满足最大堆的性质,也就是对它们的根节点分别都执行一次max_heap即可。这样整棵树都满足了最大堆的性质,即构建成了一个最大堆。因为a[1] .. a[n / 2]都是树叶,显然满足最大堆的性质。
如上图所示,要将该数组构建成为一个最大堆,只需对红色区域的节点各执行一次max_heap即可。
堆的应用
堆主要有两个应用:堆排序和优先队列。
堆排序
堆排序先利用build_max_heap将整个数组构建成为一个最大堆。因为数组中的最大元素总存放在根节点a[1]中,通过把它与a[n]交换,我们能将它放到正确的位置上。然后我们从堆中去掉该节点,可以通过减少heapsize的值来实现。新的根节点可能会违背最大堆的性质,所以我们对该节点执行max_heap以维护最大堆的性质,从而在a[1] .. a[n - 1]上构建出一个新的最大堆。堆排序会不断重复这一过程,直到堆的大小下降为2。
/* heap_sort函数:堆排序 */
void heap_sort(int *a, int n)
{
build_max_heap(a, n); /* 将数组构建为一个最大堆 */
for (int i = n; i >= 2; i--) {
swap(a, 1, i); /* 将最大堆元素与堆中的最后一个元素交换 */
heapsize--; /* 从堆中去掉a[i],即最大堆元素 */
max_heap(a, 1); /* 每次在剩余节点上构建一个新的最大堆 */
}
}
我们这里举一个例子(i = 10)来说明一下排序过程。
我们先将a[1]与a[10]交换,然后从堆中去掉a[10],a[1]并不满足最大堆的性质,所以我们对它调用一次max_heap,沿红色路径进行调整,使之重新满足最大堆的性质。调整后的结果为:
堆排序过程:
优先队列
和堆一样,优先队列也有两种形式:最大优先队列和最小优先队列。这里我们只讨论最大优先队列的有关操作。显而易见,在这里它就是基于最大堆来实现的。
最大优先队列的应用有很多,其中有一个就是作业调度问题。最大优先队列记录将要执行的各个作业以及它们之间的相对优先级。当一个作业完成或者被中断后,调度器从所有等待的作业中选出具有最高优先级的作业来执行。在任何时候,调度器可以把一个新作业加入到队列中。
返回最大堆元素
/* topheap函数:返回最大堆元素 */
int topheap(int *a)
{
return a[1];
}
扩充堆
扩充堆就是将一个新的元素插入到堆中。具体方法就是:我们先对堆进行扩充,可以通过增加heapsize的值来实现。然后将待插入的元素key放到扩充后的位置上去,即a[heapsize] = key。接下来对于节点key,我们沿树向上进行调整,如果它的值大于它的父节点的值,我们就将它与其父节点进行交换,… 直到它的值不大于它的父节点的值,这时就满足了最大堆的性质,即将key插入到了堆中正确的位置上。
/* addheap函数:在堆中插入一个元素key */
void addheap(int *a, int key)
{
int i, j;
i = ++heapsize;
a[i] = key;
j = parent(i);
while (i > 1 && a[i] > a[j]) {
swap(a, i, j);
i = j; /* 更新i的值 */
j = parent(i);
}
}
假如我们要把15插入到下列最大堆中
它的过程如下:即将新插入的节点15沿红色路径向上进行调整,这个过程我们上面已经详细地描述过了。
最后的结果为:
缩减堆
缩减堆就是去掉堆中的最大元素。具体思想和堆排序的思想很相似。就是把堆的最后一个元素a[heapsize]赋给最大堆元素a[1],然后缩减堆。对于新的a[1],调用max_heap使之满足最大堆的性质即可。
/* delheap函数:去掉并返回最大堆元素 */
int delheap(int *a, int n)
{
int max;
if (heapsize < 1)
printf("heap underflow\n");
max = a[1];
a[1] = a[heapsize--];
/* 将堆的最后一个元素赋给最大堆元素,然后缩减堆,即去除堆的最后一个元素 */
max_heap(a, 1);
return max;
}
缩减堆的过程可以参照堆排序的过程去理解。