本篇目标:
一.核心概念与结构
1.什么是堆
2.堆的存储
3.堆的访问
二.实现核心操作(重点)
三.空间复杂度与时间复杂度
四.解决Topk问题
一.核心概念与结构
1.什么是堆
概念:是一种特殊的完全二叉树(满二叉树),又分为大堆与小堆。
大堆要满足父亲节点>=孩子节点,堆顶为最大值。
如图所示:

小堆要满足父亲节点>=孩子节点,堆顶为最小值。
如图所示:

注意:同一节点的大小比较是不确定的
2.堆的存储
我们已经知道堆是一种特殊的二叉树,那么应该如何存储呢?
答案是数组 。
原因如下 :1.空间效率高,只存储数据,无额外指针开销,空间浪费少
2.访问速度快,支持随机访问
3.连续内存布局,CPU缓存利用率高
3.堆的访问
我们已经知道堆可以通过数组来实现,那么我们就可以通过下标来访问父亲节点与孩子节点了。
假设父亲节点在数组中下标为:i,
那么左孩子节点在数组中下标为:2*i+1,
右孩子节点在数组中下标为 :2*i+2,
假设孩子节点在数组中下标为:i,
那么左孩子节点在数组中下标为:(i-)/2。
例如:
二.实现核心操作
为了避免在同一文件中代码过长,所以我要在不同文件中实现堆
1.核心代码
cpp
#define _CRT_SECURE_NO_WARNINGS 1
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <time.h>
typedef int HeapDataType;
typedef struct
{
HeapDataType* a;
int size;
int capacity;
} Heap;
//交换操作
void Swap(int* x, int* y);
// 向上调整算法
void AdjustUp(HeapDataType* a, int child);
// 向下调整算法
void AdjustDown(HeapDataType* a, int n, int parent);
// 默认初始化堆
void HeapInit(Heap* hp);
// 堆的销毁
void HeapDestroy(Heap* hp);
// 堆的插入
void HeapInitArray(Heap* hp, HeapDataType* a, int n);
void HeapPush(Heap* hp, HeapDataType x);
// 堆的删除,删除堆顶数据
void HeapPop(Heap* hp);
// 获取堆顶数据
HeapDataType HeapTop(Heap* hp);
// 判空
int HeapEmpty(Heap* hp);
// 获取堆的数据个数
int HeapSize(Heap* hp);
//打印堆
void PrintfHeap(Heap* hp);
//堆的排序
void HeapSort(int* a, int n);
我们已经知道堆可以通过数组来实现,但如果只是静态数组 ,就存在空间不足 或者空间浪费过大 ,因此我们需要通过malloc/realloc来创建动态数组。
来实现堆,而代码中的size 则表示指向最后数组元素的下一个位址 ,capacity则表示实际的数组空间。
2.堆的基本操作
2.1.堆的初始化
这个比较简单,就不多说了。
cpp
void HeapInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->size = hp->capacity = 0;
}
我们可以开始的时候开辟a的空间,当然也可以在插入的时候开辟空间
2.2.堆的插入
这个操作很重要,但是也比较难理解,首先我们在插入的时候,要先判断这个堆的容量是否为满,
为满的话就要用realloc扩容,然后在堆的尾部插入数据,如代码:
cpp
//堆的插入,在堆末尾插入数据,然后向上调整
void HeapPush(Heap* hp, HeapDataType x)
{
assert(hp);
if (hp->capacity == hp->size)
{
int newCapacity = hp->capacity == 0 ? 4 : hp->capacity * 2;
HeapDataType* tmp = (HeapDataType*)realloc(hp->a, sizeof(HeapDataType) * newCapacity);
if (tmp == NULL)
{
printf("扩容失败\n");
exit(-1);
}
hp->a = tmp;
hp->capacity = newCapacity;
}
hp->a[hp->size++] = x;
//然后向上调整
AdjustUp(hp->a, hp->size-1);
}
例如我们有这么个小堆,如图:

插入数值 8 后:

可以看出,插入数据后,堆结构已经被破坏了 ,所以我们需要在插入数据后,使用⼀个向上调整算法来调整堆 ,从插⼊数据这个位置开始沿着祖先路径向上调整 ,最坏情况下要调整到根。当然如果调整到祖先路径的中间位置就满⾜堆也可以停止,甚⾄插入后,直接就满足堆,就不需要调整。
分析过程:
<1>.在数组尾部插入 8
cpp
11
/ \
21 35
/ \ / \
24 65 66 36
/ \
81 8
数组:[11, 21, 35, 24, 65, 66, 36, 81, 8]
child = 8
parent = (child - 1) / 2 = 3
比较:
cpp
8 < 24,需要交换
交换后:
cpp
11
/ \
21 35
/ \ / \
8 65 66 36
/ \
81 24
数组:[11, 21, 35, 8, 65, 66, 36, 81, 24]
<2>. 继续向上调整
更新下标:
cpp
child = 3
parent = (child - 1) / 2 = 1
比较:
cpp
8 < 21,需要交换
交换后:
cpp
11
/ \
8 35
/ \ / \
21 65 66 36
/ \
81 24
数组:[11, 8, 35, 21, 65, 66, 36, 81, 24]
<3>. 再次向上调整
更新下标:
cpp
child = 1
parent = (child - 1) / 2 = 0
比较:
cpp
8 < 11,需要交换
最终结果:
cpp
8
/ \
11 35
/ \ / \
21 65 66 36
/ \
81 24
数组:[8, 11, 35, 21, 65, 66, 36, 81, 24]
此时 child = 0,新插入的结点已经移动到根结点,向上调整结束。
最终的小堆:
cpp
8
/ \
11 35
/ \ / \
21 65 66 36
/ \
81 24
流程图:


向下调整法的代码:
cpp
void Swap(int* x, int* y)
{
assert(x != NULL && y != NULL);
int tmp = *x;
*x = *y;
*y = tmp;
}
// 向上调整算法,先建个小堆吧
void AdjustUp(HeapDataType* a, int child)
{
assert(a);
int parent = (child - 1) / 2; //父节点
while(child>0)
{
//a[parent] > a[child]代表建立的是小根堆
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
child = parent;
parent = (child - 1) / 2;
}
else
{
break;
}
}
}
2.3.堆的删除
堆的删除并不是简单的删除堆尾的数据 ,因为那样没有什么价值。堆的删除是删除堆顶的数据 ,删除后使用向下调整算法 ,这样可以保持它继续是堆,同时把次大或次小的数据选出来放到了堆顶位置。
删除堆顶数据,并不是把后面的数据往前挪动覆盖 ,原因有二:
a .后面数据往前挪动覆盖效率低 ,时间复杂度为 O(n)
b .往前挪动覆盖,堆结构就被破坏了,要把它重新调整为堆,成本很大。
这里举个例子来说明**为什么不可以直接删除堆顶?**然后后面的数据往前移动,如图:


真正的方法 :删除堆顶数据的⾼效方法是把堆尾的数据和堆顶的数据交换 ,删除堆尾数据,这样左⼦树和右子树结构不变,依旧保持是堆的结构,使用向下调整算法就可以把它高效的调整成堆。
代码:
cpp
// 堆的删除,删除堆顶数据
void HeapPop(Heap* hp)
{
assert(hp&&hp->size>0);
Swap(&hp->a[0], &hp->a[hp->size - 1]);
hp->size--;
//向下调整法
AdjustDown(hp->a, hp->size, 0);
}
向下调整法的分析思路:
<1>. 将堆顶元素与最后一个元素交换
正确做法是先交换堆顶元素 11 和最后一个元素 81:
cpp
81
/ \
21 15
/ \ / \
24 65 66 16
/
11
数组:
cpp
[81, 21, 15, 24, 65, 66, 16, 11]
然后删除数组尾部的 11:
cpp
81
/ \
21 15
/ \ / \
24 65 66 16
数组:
cpp
[81, 21, 15, 24, 65, 66, 16]
此时堆顶元素 81 可能不满足小堆的要求,需要进行向下调整。
<2>. 第一次向下调整
当前双亲结点下标:
cpp
parent = 0
左右孩子下标:
cpp
leftChild = parent * 2 + 1 = 1
rightChild = parent * 2 + 2 = 2
左右孩子分别为:
cpp
nums[1] = 21
nums[2] = 15
在小堆中,需要选出两个孩子中较小的一个:
cpp
15 < 21
因此选择右孩子 15 与双亲结点 81 比较:
cpp
15 < 81,需要交换
交换后:
cpp
15
/ \
21 81
/ \ / \
24 65 66 16
数组:
cpp
[15, 21, 81, 24, 65, 66, 16]
更新双亲结点下标:
cpp
parent = 2
<3>. 继续向下调整
当前双亲结点为 81,下标为:
cpp
parent = 2
左右孩子下标:
cpp
leftChild = parent * 2 + 1 = 5
rightChild = parent * 2 + 2 = 6
左右孩子分别为:
cpp
nums[5] = 66
nums[6] = 16
选择两个孩子中较小的 16:
cpp
16 < 66
然后将 16 与双亲结点 81 比较:
cpp
16 < 81,需要交换
交换后:
cpp
15
/ \
21 16
/ \ / \
24 65 66 81
数组:
cpp
[15, 21, 16, 24, 65, 66, 81]
更新双亲结点下标:
cpp
parent = 6
<4>. 向下调整结束
结点 81 的下标为 6。
它的左孩子下标为:
cpp
leftChild = 6 * 2 + 1 = 13
但是数组中只有 7 个元素,下标范围为 [0, 6],因此结点 81 已经没有孩子,向下调整结束。
最终的小堆:
cpp
15
/ \
21 16
/ \ / \
24 65 66 81
最终数组:
cpp
[15, 21, 16, 24, 65, 66, 81]
注意:上面的操作插入之前是一个小堆,插入后也要调整为小堆。
流程图:

注意:删除末尾数据时,仅需将size--即可。

向下调整法的代码:
cpp
// 向下调整算法,先建立一个小根堆吧
void AdjustDown(HeapDataType* a, int n, int parent)
{
assert(a&&n>=0);
int child = 2*parent+1; //孩子节点
while (child<n)
{
//a[child + 1] < a[child]说明a[child+1]的值更小
if (child + 1 < n && a[child + 1] < a[child])
{
child++;
}
//a[parent] > a[child]建立的是小根堆
if (a[parent] > a[child])
{
Swap(&a[parent], &a[child]);
parent = child;
child = 2 * parent + 1;
}
else
{
break;
}
}
}
注意:向下调整算法的时间复杂度为O(log(N)),完全⼆叉树的高度是log(N)+1, 向下调整算法最坏情况的调整次数是高度次。
2.4.其他操作
cpp
// 获取堆顶数据
HeapDataType HeapTop(Heap* hp)
{
assert(hp && hp->size > 0);
return hp->a[0];
}
// 判空
int HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
// 获取堆的数据个数
int HeapSize(Heap* hp)
{
assert(hp);
return hp->size;
}
//打印
void PrintfHeap(Heap* hp)
{
assert(hp);
for (int i = 0; i < hp->size; i++)
{
printf("%d ", hp->a[i]);
}
printf("\n");
}
// 堆的销毁
void HeapDestroy(Heap* hp)
{
assert(hp);
hp->capacity = hp->size = 0;
free(hp->a);
hp->a = NULL;
}
2.5.测试代码
cpp
#include"Heap.h"
void TestHeap1()
{
int a[] = { 4,2,8,1,5,6,9,7,3,2,789,564,8,97,56,46,5,6,4,5};
int size = sizeof(a) / sizeof(int);
Heap h;
HeapInit(&h); //初始化
//HeapSort(a, size); //排序
//for (int i = 0; i < size; i++)
//{
//printf("%d ", a[i]); //排序后的打印数组,检查是否排序正确
//}
//printf("\n");
PrintfHeap(&h);
for (int i = 0; i < sizeof(a) / sizeof(int); i++)
{
HeapPush(&h, a[i]); //先建立一个小根堆
}
PrintfHeap(&h); //打印小根堆
//堆并不代表有序,但是持续出堆顶的数据,就打印出有序序列
int i = 0;
int k;
scanf("%d", &k); //取前k个数,进行排序
while (!HeapEmpty(&h) && k--)
{
printf("%d ", HeapTop(&h));
HeapPop(&h);
}
HeapDestroy(&h); //销毁
}
int main()
{
TestHeap1();
return 0;
}
3.建堆法
有时我们需要把⼀个数组直接构建成⼀个大堆或者小堆, 建堆有两种方法。
3.1.向上建堆法
第⼀种方法:类似堆插入的过程,先把第0个数看做堆,然后依次把1⾄n-1位置的数据使用向上调整算法插入堆。
流程图:





代码:
cpp
void TestHeap2()
{
int a[] = { 4,2,8,1,5,6,9,7,3,2 };
int size = sizeof(a) / sizeof(int);
// a构建成一个堆
for (int i = 0; i < size; i++)
{
//向上调整法
AdjustUp(a, i);
}
}
时间复杂度:


3.2.向下建堆法
第⼆种⽅法:把所有的叶子结点都看做⼀个个堆,从倒数第二层的最后⼀个非叶子结点(最后⼀个叶子的双亲)开始进行向下调整算法建堆,直到调整到根。
流程图:





代码:
cpp
void TestHeap2()
{
int a[] = { 4,2,8,1,5,6,9,7,3,2 };
int size = sizeof(a) / sizeof(int);
// a构建成一个堆
for (int i = (size - 1 - 1) / 2; i >= 0; i--)
{
AdjustDown(a, size, i);
}
}
时间复杂度:

4.堆排序
堆排序是堆的⼀个重要应用场景之⼀,堆的本质是可以快速选出最大数或最小数 ,删除堆顶后可以 快速选出次大或次小数据。那我们想要排序就是不断的选出剩余数据中最大值或最小值完成排序, 堆排序的思想上是⼀种选择排序。
n个数要排升序,那么注意我们不能建小堆 ,因为如果建小堆,会导致第0个数排好了,但是要选出 次小的数以及后面的树,剩下的数堆结构全乱了,需要重新建堆,代价太⼤。
例如:


注意:图片中所说的删除堆顶元素也不是真正的删除数据,以及下面的删除堆顶数据。
n个数要排升序的逻辑:
a. n个建大堆,选出最大的数,
b. 然后把堆顶的数据跟堆尾数据交换,最大的数排到最后⼀个位置了,
c. 然后控制堆数据个数为n-1,逻辑上最后⼀个数不是堆的 ,然后根位置向下调整选出次⼤数,
d. 然后把次大的数交换到倒数第二个位置,以此类推,重复b、c、d步骤的逻辑,直到数组有序,
e. 排降序就是反过来,建小堆,选出最小数,换到最后。本质此逻辑类似堆删除的逻辑。
流程图:





堆排序代码:
cpp
// 堆排序
void HeapSort(int* a, int n)
{
// 升序,建大堆,用向下建堆法,因为时间复杂度更好
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
//向下调整法
AdjustDown(a, n, i);
}
int end = n-1;
while (end > 0)
{
//大堆中的最大值去到了数组的末尾
Swap(&a[end], &a[0]);
//向下调整继续建立大堆
AdjustDown(a, end, 0);
end--;
}
}
5.Top-k问题
概念:从海量数据(有时甚至大到无法⼀次性全部装入内存)中,可以快速、高效地找出最大 (或最小)的前k个元素,或出现频率最高的K个元素等。
例如:在抖⾳每天的带货排行榜前100,或者在⼀个金融交易系统中,需要实时监控交易金额最大的前k笔交易,以识别潜在的异常行为等等。
我们 要找前k大的数据,主要有以下三种思路(前k小的反过来就行):
思路1 :排降序,数组前k个位置就是前k大的。用堆排/快排等最优秀的排序。时间复杂度O(nlogn) ,但是如果数据量非常大 ,都无法一次性装入内存 ,则这个思路就没办法使用了,因为排序基本都需要在内存中完成。
思路2 :将整个数组建成⼀个大堆,取堆顶数据,再删除堆顶的数据k-1次,就可以取到前k大的 。 时间复杂度为O(n+(k−1)logn) ,⼀般情况下k远小于n,是⼀个常数,则时间复杂度为O(nlogn) , 最坏情况下k接近n时,则时间复杂度为。但是如果数据量非常大,都⽆法⼀次性装⼊内 存,则这个思路也没办法使⽤,因为堆只能建在内存中。
思路3:⽤数组的前k个数建小堆,剩下的数据n-k依次跟堆顶的数据⽐较,如果比堆顶的数据大, 则替换堆顶的数据,再向下调整维护堆。结束后,堆里面剩下的数据就是前k大的数据,因为是⼩ 堆,所以堆顶的数据就是第k大的 。时间复杂度为 O(k +(n−k)∗logk) ,⼀般情况下k远小于n 时,时间复杂度为O(n) ;当k=n/2时,时间复杂度是O(nlogn);本思路是最适合解决现实中的 top-k 问题,因为数据量非常大⽆法放⼊内存时,他也能解决,且效率很高。
如图所示:

代码演示:
cpp
// data.txt的统一路径
const char* dataFile =
"F:/cpp_node/vs2022.c++文件夹/code.c/git_code/"
"data-structure-learning/堆/数据结构之堆/data.txt";
// 生成随机数文件
void CreateNDate()
{
// 随机数的数量
int n = 100000;
// 设置随机数种子
srand((unsigned int)time(NULL));
// 以写入方式打开文件
FILE* fout = fopen(dataFile, "w");
if (fout == NULL)
{
perror("fopen error");
return;
}
// 生成100000个随机数并写入文件
for (int i = 0; i < n; ++i)
{
int x = (rand() + i) % 1000000;
// 每行写入一个随机数
fprintf(fout, "%d\n", x);
}
// 关闭文件,保证数据全部写入磁盘
fclose(fout);
printf("随机数文件生成成功\n");
}
void topk()
{
CreateNDate();
int k = 0;
printf("请输入k:>");
scanf("%d", &k);
FILE* fin = fopen(dataFile, "r");
if (fin == NULL)
{
perror("fopen error");
return;
}
int* minheap = (int*)malloc(sizeof(int) * k);
if (minheap == NULL)
{
fclose(fin);
return;
}
// 读取前k个数
for (int i = 0; i < k; i++)
{
if (fscanf(fin, "%d", &minheap[i]) != 1)
{
free(minheap);
fclose(fin);
return;
}
}
// 建立小堆
for (int i = (k - 1 - 1) / 2; i >= 0; i--)
{
//向下调整法
AdjustDown(a, n, i);
}
// 读取剩余数据
int x = 0;
while (fscanf(fin, "%d", &x) == 1)
{
if (x > minheap[0])
{
minheap[0] = x;
AdjustDown(minheap, k, 0);
}
}
// 输出最大的前k个数
for (int i = 0; i < k; i++)
{
printf("%d ", minheap[i]);
}
printf("\n");
free(minheap);
fclose(fin);
}
总结:
本篇主要介绍了堆的概念、存储方式以及基本操作。堆本质上是一棵使用数组存储的完全二叉树,其中大堆的堆顶是最大值,小堆的堆顶是最小值。堆的插入需要先在数组末尾添加数据,再进行向上调整;删除堆顶时,需要交换堆顶与堆尾、缩小有效数据范围,再进行向下调整。
建堆可以采用向上调整法和向下调整法,其中向上建堆的时间复杂度为 O(n log n),向下建堆的时间复杂度为 O(n),因此实际建堆通常优先使用向下调整法。利用堆可以实现时间复杂度为 O(n log n) 的堆排序,也可以通过维护大小为 k 的小堆,高效解决海量数据中的 Top-K 问题。
总的来说,堆的核心就是向上调整和向下调整。理解这两种调整算法以后,堆的插入、删除、建堆、堆排序和 Top-K 问题就比较容易理解了。
