【数据结构】由完全二叉树引申出的堆的实现
- 一、什么是堆
- 二、目标
- 三、实现
- 1、初始化工作
- 2、堆的插入(堆的创建)
- 2.1、向上调整建堆
- 2.1.1、向上调整算法原理解析
- 2.1.2、代码实现
- 2.2、向下调整建堆
- 2.2.1、向下调整算法原理解析
- 2.2.2、代码实现
- 2.3、“向上”和“向下”复杂度的差异
- 3、堆的删除
- 3.1、原理解析
- 3.2、代码实现
- 4、返回堆顶数据
- 5、判断堆是否为空
- 6、返回堆中数据个数
- 7、堆的销毁
一、什么是堆
关于“堆”,百度百科上是这么说的:
——————————引自百度百科
由上面可知,我们可以将堆理解成一个数组,也可以理解成一个完全二叉树。
其实由于完全二叉树的特殊性,其本身就可以使用一个数组来存储。
在之前的二叉树的实现中,我们已经知道完全二叉树的最后一层的叶子节点一定是连续的:
而之所以完全二叉树能用数组存储,是因为完全二叉树的以下这些性质:
对于具有n个结点的完全二叉树,如果按照从上至下从左至右的数组顺序对所有节点从0开始编号,则对
于序号为i的结点有:
- 若i>0,i位置节点的双亲序号:(i-1)/2; i=0,i为根节点编号,无双亲节点
- 若2i+1<n,左孩子序号:2i+1,2i+1>=n否则无左孩子
- 若2i+2<n,右孩子序号:2i+2,2i+2>=n否则无右孩子
我们可以对上面那可完全二叉树进行编号试试:
大家可以自行验证一下,就会发现是完全符合上面的性质的。
而我们把这些序号理解成下标,我们就会发现堆其实就可以存储在数组中:
(这里假设数数组的下标从1开始)
其实堆在计算机界有着相当广泛的应用,比较容易理解的就是Topk问题和堆排序,Topk就是一个求排名前几的问题,堆排序就是对一个序列进行排序。
而其他更高深的应用得以后更深入的学习时才能有所体会,我们现在得先把原理搞明白。
好,那就让我们进入堆的实现吧。
二、目标
今天要实现的堆主要有以下接口:
// 堆的初始化
void HeapInit(Heap* php);
// 堆的插入(堆的创建)
void HeapPush(Heap* php, HPDataType x);
// 堆的删除,删除堆顶数据
void HeapPop(Heap* php);
// 返回堆顶数据
HPDataType HeapTop(Heap* php);
// 判断堆是否为空
bool HeapEmpty(Heap* php);
// 返回堆的数据个数
int HeapSize(Heap* php);
// 堆的销毁
void DestroyHeap(Heap* php);
接口不是很多,但是有一两个接口并不是那么好实现的……
三、实现
1、初始化工作
首先我们来定义堆的结构,有上面的介绍我们可知堆的存储结构其实就是一个数组,是数组就要进行扩容,所以在我们的结构定义中就应该有capacity和size:
typedef int HPDataType;
typedef struct Heap {
HPDataType* data;
int size;
int capacity;
} Heap;
然后我们还需要一个接口,对堆进行初始化:
void HeapInit(Heap* php) {
assert(php);
php->data = NULL;
php->size = 0;
php->capacity = 0;
}
其实就是对各个成员赋初始值而已。
2、堆的插入(堆的创建)
其实将数据插入堆中,就是创建堆的过程。
而建堆也分为两种方案,分别是向上调整建堆和向下调整建堆,其核心主要涉及到两个算法,即向上调整算法和向下调整算法。
2.1、向上调整建堆
2.1.1、向上调整算法原理解析
其实向上调整建堆就是通过向上调整算法,将每次新加入的数据调整到它该去的位置,即经过调整后整个数组还符合堆的结构。
例如我们现在要建一个小堆,在已有的小堆基础上,再添加入一个新的数据:
如上图标红色的那个节点就是我们新加入的节点,而我们知道小堆要符合的条件是任意一个父节点都要比它的左右孩子都要小。如果如上面这样就结束了,明显就不符合小堆的要求了。
所以我们需要通过向上调整算法将新加入的节点移动到正确地位置,方法就是,如果新加入的节点不满足其节点小于它,就一直和父节点进行交换,过程如下:
补充一个想上调整算法的前提条件,那就是在新节点的上面一定要是一个堆,这有这样才能保证,向上调整算法执行完后整个堆依然符合堆的结构。
因为是向上调整,所以算法开始时的定位就是新加入的节点,然后我们再通过上面提到的公式:
来计算出新节点的双亲节点的下标,我们定义新节点的下标为child,那其双亲节点的下标就为 parent == (child - 1) / 2
如果parent的值不小于child的值,我们就将两者交换。然后将两者迭代,即再执行child = parent ,parent = (child - 1) / 2,再次进行判断:
如此反复,直到parent小于child或者child不再大于0就结束,因为我们最多就调整到根节点:
2.1.2、代码实现
有了以上思路,那我们写起代码来也就水到渠成了:
void AdjustUp(Heap* php, int child) {
assert(php);
int parent = (child - 1) / 2;
while (child > 0) {
if (php->data[child] < php->data[parent]) {
Swap(&php->data[child], &php->data[parent]);
child = parent;
parent = (child - 1) / 2;
}
else {
break;
}
}
}
有了向上调整算法,那我们完成向上调整建堆也就很简单了,只需要对每次加入的新节点执行一次向上调整算法即可:
void HeapPush(Heap* php, HPDataType x) {
assert(php);
// 先检查是否需要增容
if (php->size == php->capacity) {
int newCapacity = php->capacity == 0 ? 10 : 2 * php->capacity;
HPDataType* temp = (HPDataType*)realloc(php->data, newCapacity * sizeof(HPDataType));
if (NULL == temp) {
perror("realloc fail!\n ");
exit(-1);
}
php->data = temp;
}
// 向上调整插入
php->data[php->size] = x;
AdjustUp(php, php->size);
php->size++;
}
只不过在正式插入之前还要检查一下是否需要增容,不过我觉得这已经是基本操作了。
2.2、向下调整建堆
建堆的另个方式就是向下调整建堆。
经过上面的线上调整建堆的讲解,可能大家已经明白,如果插入的新节点不能保证堆还符合堆的规定,那一定是要将新节点往上移动的。
其实向下调整建堆的本质也是将新节点向上移动,只不过实现的原理不同了。
2.2.1、向下调整算法原理解析
向下调整算法的使用前提是要保证被调整的节点的下面(子树)是堆。
我们先来说说向下调整算法的思路,向下调整算法和向上调整刚好是反过来。向上调整算法是通过迭代的方法持续将孩子节点移动到上边,而向下调整算法是通过迭代的方法持续将双亲节点向下移动。
例如我们现在将堆顶的数据改成一个更大的数:
这很明显就不符合小堆的规定了,所以我们要使用向下调整算法来将堆顶数据调到下方。
和向上调整算法不同,这里的向下调整算法还涉及到一个“二选一”的操作,因为小堆的规定时间双亲节点一定要小于左右孩子,所以这里实际是要将左右孩子中较小的那个节点和双亲节点交换:
然后就是如向上调整算法一样,一直迭代parent和child,直到跳到最底层或者满足双亲小于孩子,例如这里的18最终调到的位置如下:
但问题来了,我们这里是每次在数组末尾,也就是数的底层加入数据,我们这里是“向下”调整算法,那该怎么把底层的节点调到上面呢?
其实我们这里要使用到一个“反向”的思维,就是将新节点的双亲节点向下调以达到新节点向上调的目的,例如这里我们就可以对0节点的双亲节点4执行向上调整,调整完之后就是这样:
而这里只是因为到了底层所以只调整了一次,但这显然还没完全调整完毕。所以我们还要继续调整。
而后我们在对parent进行自减1,因为堆本身就是使用数组来存储的,所以减了一个1也就到了相邻的左边的一个节点:
再继续执行向下调整算法,很明显这里是不需要调整的,而当parent自减到节点3时,就需要调整了,对接点3指向完向下调整算结果如下:
如此反复,直到调整到根节点或者parent小于左右孩子:
2.2.2、代码实现
有了以上思路,那我们写起代码来也就水到渠成了:
先实现向下调整算法:
void AdjustDown(Heap* php, int parent) {
assert(php);
int child = 2 * parent + 1; // 默认左孩子为较小的那个
while (child < php->size) {
if (child + 1 < php->size && php->data[child + 1] < php->data[child]) {
child++;
}
if (php->data[child] < php->data[parent]) {
Swap(&php->data[child], &php->data[parent]);
parent = child;
child = 2 * parent + 1;
}
else {
break;
}
}
}
然后就可以实现向下调整版的建堆了:
void HeapPush(Heap* php, HPDataType x) {
assert(php);
// 先检查是否需要增容
if (php->size == php->capacity) {
int newCapacity = php->capacity == 0 ? 10 : 2 * php->capacity;
HPDataType* temp = (HPDataType*)realloc(php->data, newCapacity * sizeof(HPDataType));
if (NULL == temp) {
perror("realloc fail!\n ");
exit(-1);
}
php->data = temp;
}
// 向下调整插入
php->data[php->size] = x;
php->size++;
int parent = (php->size - 1 - 1) / 2;
while (parent >= 0) {
AdjustDown(php, parent);
parent--;
}
}
2.3、“向上”和“向下”复杂度的差异
那我们有了向上和向下两种建堆方式,我们应该选用哪一种更优呢?
这就得比较一下向上和向下两种建堆方式的复杂度了。
我们先来分析一下向上调整建堆的复杂度:
要计算调整算法的复杂度其实和完全二叉树的每层节点数和高度有关,所以我们首先得知道关于二叉树的三个性质:
而我们知道,满二叉树其实是一个特殊的完全二叉树,完全二叉树相比于与同高度的满二叉树也只是最后一层少了若干个节点。
而时间复杂度的计算计算的是一个估值,所以我们完全可以将完全二叉树的复杂度计算近似成满二叉树的计算,也就是忽略最后一层缺少的节点不计。
假设我们现在已经有了一个满二叉树,他已经是一个通过向上调整算法建出来的堆:
在最坏的情况下,对于每一个新插入的节点我们都要将它调整到根节点的位置(也就是调整高度减1次),所以对于每一层的调整次数我们就可以用刚才的公式来算了:
所以总共的移动次数算起来就是:
这其实就是一个典型的等差乘等比的数列求和,可能大家看到这个式子很快就会从美好的记忆中想起来这个式子应该用“错位相减法”来解决。
置于错位相减法,我这里不不具体展开计算了,毕竟这不是数学课,我就直接给出计算好的结果了:
因为时间复杂度算的是估值,所以我们可以再进一步忽略常数,则式子就变成了h * 2^h,而我们再把h的计算方法:
代入公式,并整理,忽略常数就可以算出最终的时间复杂度的量级为nlogn。
故对于有n个节点的堆来说,使用向上调整算法建堆的时间复杂度为O(nlogn)。
然后我们再来看看向下调整算法的时间复杂度分析:
也是类似的分析方式,但因为向下调整的对象只能是双亲节点,所以我们只需算到第h - 1层即可,因为最后一层都是叶子结点,不需要调整。
最坏情况下,还是每一个节点都需要调整:
所以总计的次数是:
同样用错位相减法得出来的结果是:
再代入计算h的公式最后的出结果为n - log(n + 1),再进一步省略掉常数和对数,最后的时间复杂度近似为n。
故向下调整建堆的时间复杂度为O(n)。
通过上面的比较,我们可以得出结论:向下调整建堆的时间复杂度要比向上调整的更优,所以我们以后要建堆的话就优先选择向下调整建堆。
3、堆的删除
就像栈和队列有它们的规定的删除和插入一样,堆也有自己的规定,那就是插入数据只能在最后面插入,而删除数据只能在堆顶删除。
3.1、原理解析
因为对实际上使用数组存储的,有的朋友可能就认为删除就很简单,不就是删除第一个元素然后再把后面的元素再往前移动一位吗:
其实这样就大错特错了,光光这样挪动数据是不能保证挪动后的数据还是一个堆的,上面的这个例子在挪动后还是堆值是一个特例,我再举一个例子就能证明了:
很明显,挪动后面的数据覆盖了第一个0之后,再将数组化成堆,就不符合堆得规定了。
所以我们正确地做法应该是像下面这样:
第一步: 先将第一个数据和最后一个数据交换:
第二步: size–(相当于删除)
第三步: 通过向下调整算法将堆顶数据移动到正确的位置:
这样做的好处是,既不破坏大部分的堆结构,而且向下调整的复杂度也较低。
3.2、代码实现
有了以上思路,那我们写起代码来也就水到渠成了:
void HeapPop(Heap* php) {
assert(php);
// 先交换堆顶和最后一个叶子结点
Swap(&php->data[0], &php->data[php->size - 1]);
php->size--;
// 再向下调整
AdjustDown(php, 0);
}
4、返回堆顶数据
其实堆最难实现的就是上面的这三个接口,剩下的这些接口基本就不用动脑子了。
返回堆顶数据,堆顶数据其实就是数组的首元素,所以我们返回首元素即可:
HPDataType HeapTop(Heap* php) {
assert(php);
assert(!HeapEmpty(php));
return php->data[0];
}
5、判断堆是否为空
我们直接返回size是否等于0的结果即可:
bool HeapEmpty(Heap* php) {
assert(php);
return php->size == 0;
}
6、返回堆中数据个数
其实就是直接返回size:
int HeapSize(Heap* php) {
assert(php);
return php->size;
}
7、堆的销毁
void DestroyHeap(Heap* php) {
assert(php);
free(php->data);
php->data = NULL;
php->size = 0;
php->capacity = 0;
}
其实在我个人看来,堆的实现是要比链式二叉树要难得,因为链式二叉树的实现只需要死死抓住递归这个特性即可。而堆,虽然接口不多,但是可能光是理解两个向上向下调整算法就能让很多朋友抓耳挠腮。而且向上和向下的实现思路也是有点变形的,这对于基础比较薄弱的朋友来说可能是比较难的。
所以还是建议大家:一定要自己多实现几遍!自己多实现几遍!自己多实现几遍!
重要的事情要说三遍。