前言
大顶堆是一种常见的可排序数据结构,可视化出来的样子就是一颗完全二叉树,如下图:
其特点是根节点的值一定大于其左右子节点,因此树根元素是整棵树中的最大值。如此每次取出堆顶元素,就是取出了一组数中最大值,有排序的作用。
对于大顶堆有两个基本操作:入堆、出堆;这两个操作中都要保证在操作完成时仍要保持大顶堆的基本特点,即根节点的值一定大于其左右子节点。
在python中可以十分方便地使用heapq库中的函数来实现大顶堆及小顶堆。但在C语言中就需要自己去构造堆,并实现入堆、出堆操作。本文就是针对C语言的大顶堆实现作出讲解。(参考:一篇知乎文章及一个leetcode题解)
一、存储结构
1.一维数组存储
大顶堆直观来看是一颗完全二叉树,但是在实际使用时我们不必真的构造一棵树,只需要一个一维数组来存储每个树节点的元素就可以。
为什么一维数组就可以反映树的父子节点间的关系呢?
这里最主要的原因是大顶堆是一棵完全二叉树,也就是叶子节点一定是从左向右排满的,中间不会缺少节点。
-
假设一个节点在一维数组中的位置下标是 i i i,那么其父节点的位置下标一定是 i / / 2 i//2 i//2(//表示下取整);
-
或者说,假设一个节点在一维数组中的位置下标是 j j j,那么其左子节点的下标是 2 ∗ j 2*j 2∗j,右子节点的下标是 2 ∗ j + 1 2*j+1 2∗j+1
不过一定要注意,树根节点在一维数组中的位置下标是 1 1 1而不是 0 0 0(把数组的0位置空出来),大家可以参考下图试验证一下上述说法的正确性:
(还是前面那张图 = ̄ω ̄=)
所以,我们用一个一维数组存放大顶堆,根节点从下标为 1 1 1的位置开始存放!
2.堆的初始化
S i z e Size Size表示预设的大顶堆节点总数量, h e a p S i z e heapSize heapSize表示当前堆的长度,初始时堆中无元素,故长度为0。
int heap[Size + 1], heapSize = 0;
二、入堆操作
1.入堆算法(上浮)
所谓入堆是指我们要将一个新获得的数字加入到已经存在的大顶堆中,加入新元素后仍要保证大顶堆满足根节点值大于其子节点值。
一般的入堆算法可以描述为:
- 在往已有的大顶堆中添加元素时,将新元素作为树的最后的一个节点;
- 比较新节点与其父节点:如果新节点的值大于父节点,那么交换父节点和新节点的位置(其实就是交换两个元素的值);
- 重复上述步骤,直到新节点比其父节点小,或者当前新节点的位置已经是根节点了,那么停止上述循环即可,此时大顶堆更新完毕
直观图示:
上浮:将大值上浮
2.代码实现
下面是一个入堆的函数,输入参数包括要插入的堆 ∗ h e a p * heap ∗heap,当前堆的最后一个元素的位置下标 ∗ h e a p S i z e * heapSize ∗heapSize,以及要插入的元素值 x x x。
void swap(int *a, int *b) { // 交换函数
int tmp = *a;
*a = *b, *b = tmp;
}
void push(int *heap, int *heapSize, int x) {
heap[++(*heapSize)] = x;
for (int i = (*heapSize); i > 1 && heap[i] > heap[i >> 1]; i >>= 1) {
swap(&heap[i], &heap[i >> 1]);
}
}
按照上面的算法进行操作,在比较父子节点时,用 h e a p [ i ] heap[i] heap[i]表示当前节点值, h e a p [ i > > 1 ] heap[i>>1] heap[i>>1]表示当前节点的父节点值,当父节点值小于子节点时,不满足大顶堆特点,故交换两者的值。
再使父节点成为当前节点,继续向树根方向比较!
i > 1 && heap[i] > heap[i >> 1]
即为循环的条件。
二、出堆操作
1.出堆算法(下沉)
出堆操作是指我们需要取出这一组数据中的最大值,即弹出堆顶元素,同时要保证弹出后剩下的元素仍然满足大顶堆的要求,可能需要重新调整节点位置。
一般的出堆算法可以描述为:
- 将堆的最后一个叶子节点移到根节点的位置;
- 从根节点开始,比较根节点和其左右子节点的元素大小,若根节点不是都比子节点大,那么根节点与其较大的一个子节点进行交换;
- 只要存在子节点,那么继续比较父节点和左右子节点的大小,直到当前节点已经是叶子节点或者它比它的左右子节点取值都大,那么停止循环,最大堆已经更新完毕。
直观图示:
下沉:将小值下沉
2.代码实现
下面是一个出堆的函数,输入参数包括要操作的堆 ∗ h e a p * heap ∗heap,当前堆的最后一个元素的位置下标 ∗ h e a p S i z e * heapSize ∗heapSize,也即堆的长度
void pop(int *heap, int *heapSize) {
int tmp = heap[1] = heap[(*heapSize)--];
int i = 1, j = 2;
while (j <= (*heapSize)) {
if (j != (*heapSize) && heap[j + 1] > heap[j]) ++j;
if (heap[j] > tmp) {
heap[i] = heap[j];
i = j;
j = i << 1;
} else {
break;
}
}
heap[i] = tmp;
}
(1) 按照前面的算法描述,我们先将堆的最后一个元素插入堆顶:
int tmp = heap[1] = heap[(*heapSize)--];
再从堆顶开始,向下进行父子节点值的比较。若父节点值小于子节点值,就交换两者位置。
(2) 因此初始化要进行比较的父子节点:
int i = 1, j = 2;
i
=
1
i=1
i=1表示初始父节点,即堆顶;
j
=
2
j=2
j=2表示堆顶的左子节点。
注意:我们在比较时,要选择左右子节点中值较大的那个子节点同父节点进行比较:
if (j != (*heapSize) && heap[j + 1] > heap[j]) ++j;
(代码中的
j
j
j一直都是左子节点的下标)
这样,当父节点值小于子节点时,交换完父子节点后,可以保证交换后的父节点的值大于左右子节点!
(3) 综上,出堆过程的实现就是通过代码中的这个循环完成的:
while (j <= (*heapSize)) { // 当子节点存在时,进行循环
if (j != (*heapSize) && heap[j + 1] > heap[j]) ++j;
if (heap[j] > tmp) {
heap[i] = heap[j]; // 用子节点的值直接覆盖父节点,不需要交换两者值。
i = j;
j = i << 1;
} else { // 当父节点的值大于子节点时,跳出循环
break;
}
}
上面代码中有一处需要注意:heap[i] = heap[j];
我们没有交换父子节点的值,而是直接覆盖,这是因为此时父节点的值就是tmp存储的值,只需要最后将它赋到合适的位置即可,不用担心其丢失。
三、获得堆顶值
堆顶元素就是一维数组中下标为1的元素,直接返回就好,输入参数是要操作的堆 ∗ h e a p * heap ∗heap:
int top(int *heap) {
return heap[1];
}
总结
入堆和出堆是堆的两个基本操作,在建堆时可以对每个元素采用入堆操作(小顶堆的实现是类似的)。掌握这两个操作后,可以灵活地将堆用在不同场景中,大家可以做leetcode1046. 最后一块石头的重量这道简单题尝试。