写在前面
上一篇 数据结构-二叉树(一):树的基础概念与二叉树核心结构-CSDN博客 ,我们学习了树、二叉树以及完全二叉树,并得到数组存储下父子下标公式:
左孩子:
left = parent * 2 + 1右孩子:
right = parent * 2 + 2父节点:
parent = (child - 1) / 2
堆就是建立在完全二叉树之上的数据结构,它把树形逻辑映射到连续数组,高效维护集合的最大值/最小值。堆不只是课堂考点,工程中大量使用:堆排序、Top‑K问题、优先级队列、操作系统任务调度、Dijkstra最短路径算法都离不开它。
本文基于C语言完整实现0下标二叉堆,讲解:
- Heap结构体设计,为什么传结构体指针;
- 向上调整、向下调整核心逻辑与易错坑;
- 向上建堆 vs 向下建堆,时间复杂度对比;
- 大小根堆切换方法;
- 堆排序原理,搞懂「降序建小堆,升序建大堆」;
- 排序算法横向对比;
- 多组测试用例解读与常见踩坑。
代码仓库
数据结构/Heap · Luminous/Code_2026 - 码云 - 开源中国
一、什么是堆(Heap)
堆是特殊的完全二叉树,只约束父子节点的大小关系,整棵树并不全局有序,这点是绝大多数初学者的误区。
小根堆
任意父节点的值 ≤ 子节点的值;堆顶(根)一定是整个集合最小值。
7
/ \
12 45
/ \
89 23
底层数组:{7,12,45,89,23}
大根堆
任意父节点的值 ≥ 子节点的值;堆顶(根)一定是整个集合最大值。
89
/ \
45 56
/
23
特别注意:堆不是有序数组。
示例:
1
/ \
5 3
/
8
满足小根堆性质,但数组 {1,5,3,8} 并不是升序。堆只保证父子关系,不保证左右子树、兄弟节点之间有序。堆的优势:(O(1))拿到极值;想要整体有序,需要堆排序。
二、堆的存储与结构体设计
逻辑上是完全二叉树,但不使用链式TreeNode节点。
完全二叉树几乎没有空间浪费,直接用一段连续动态数组存储,效率更高。
typedef int HPDataType;
typedef struct Heap
{
HPDataType* a; // 动态数组,存放堆元素
int size; // 当前有效元素个数
int capacity; // 数组总容量
}Heap;
a:动态开辟的数组,也可以直接挂载外部已有数组,实现原地建堆;size:堆的有效元素,[0,size‑1]为有效区间;capacity:数组总容量,用于判断是否需要realloc扩容。
内存示意:
capacity=8,size=5
[7][12][45][89][23][ ][ ][ ]
三、接口为什么传Heap*指针,不传Heap值拷贝
函数原型对比:
// 传值,操作副本,外面堆不会变化
void HeapPush(Heap hp, int x);
// 传指针,操作原堆对象
void HeapPush(Heap* hp, int x);
三点设计原因:
- 修改原堆对象 插入、删除会修改
size、a指针。传值会生成结构体副本,函数内部修改全部作用在副本上,外部堆完全不受影响,逻辑失效。 - 减少拷贝开销 虽然结构体本身不大,但传值会完整复制结构体;传指针只传递地址,开销恒定。
- 核心:支持外部数组复用,原地建堆 这是
HeapCreate接口设计的关键。可以直接把外部普通数组挂载到hp->a,不需要malloc新空间、不需要拷贝数据。
int arr[] = {12,45,7,89,23,56};
HeapCreate(&hp, arr, 6); // hp->a直接指向arr,原地建堆
省去内存分配与拷贝,也是堆排序可以做到(O(1))额外空间的底层思想。
四、堆调整核心思想:为什么需要向上和向下调整
堆的本质要求:
任意节点都必须满足父节点和孩子节点之间的大小关系。
以小根堆为例:
父节点
↓
父 ≤ 子
7
/ \
12 45
但是在实际操作过程中:
- 插入元素只能破坏从新增节点到根节点这一条路径
- 删除堆顶只能破坏从根节点到叶子节点这一条路径
因此没有必要重新遍历整棵树,只需要沿着一条路径调整即可。
这就是堆高效的原因:
利用完全二叉树高度为 log N 的特点,只需要调整一条路径。
4.1 向上调整:新元素插入后的维护
使用场景:HeapPush 插入元素
例如当前小根堆:
10
/ \
20 30
/
40
数组:[10,20,30,40]
现在插入新元素 5。 按照完全二叉树规则,新元素只能接在末尾:
10
/ \
20 30
/ \
40 5
对应数组:[10,20,30,40,5]
此时发现 5 < 20,破坏小根堆性质:
10
/ \
20 30
/
40
/
5
所以需要让新元素不断向上移动。
向上调整过程
第一次交换:
10
/ \
5 30
/
40
/
20
继续比较,5 < 10,继续交换:
5
/ \
10 30
/
40
/
20
最终恢复小根堆:
5
/ \
10 30
/
40
/
20
向上调整代码对应逻辑
while(child > 0)
{
parent=(child-1)/2;
if(a[child] < a[parent])
{
Swap(&a[child],&a[parent]);
child=parent;
}
else
{
break;
}
}
核心逻辑:
孩子违反规则
↓
和父节点交换
↓
继续向上检查
插入只能影响新增节点到根节点路径,因此使用向上调整。
时间复杂度: 树高度 h= log N,最坏一路交换到根,单次调整 O(log N)。
4.2 向下调整:删除堆顶后的维护
使用场景:HeapPop 删除堆顶
例如小根堆:
5
/ \
10 20
/ \
30 40
数组:[5,10,20,30,40]
如果直接删除堆顶元素5,树形结构直接残缺:
?
/ \
10 20
/
30
完全二叉树结构被破坏,不能直接删除根。
正确操作分为两步:
第一步:交换堆顶和最后元素
交换:5 ↔ 40
40
/ \
10 20
/
30
数组变为:[40,10,20,30]
此时树形结构完整,但堆性质被破坏。
第二步:让40向下移动
左右孩子比较,10 < 20,选择更小的孩子10。 交换父与子:
10
/ \
40 20
/
30
继续比较,40 > 30,再次交换:
10
/ \
30 20
/
40
堆性质恢复。
向下调整代码思想
child = parent*2+1;
while(child<size)
{
//找到更优孩子
//父子比较
//交换
parent=child;
}
核心逻辑:
父节点违反规则
↓
选择更小/更大的孩子
↓
交换
↓
继续向下
删除堆顶只影响根节点到叶子的路径,所以使用向下调整。
时间复杂度:O(log N)
五、堆基础接口实现
初始化、销毁
void HeapInit(Heap* hp)
{
assert(hp);
hp->a = NULL;
hp->size = 0;
hp->capacity = 0;
}
void HeapDestory(Heap* hp)
{
assert(hp);
free(hp->a);
hp->a = NULL;
hp->capacity = hp->size = 0;
}
特别注意:
HeapCreate挂载栈数组时,栈内存不能free,销毁前务必手动置空hp->a = NULL,否则程序崩溃。
堆插入 HeapPush
尾插元素,容量不足则二倍realloc扩容,新元素做向上调整。单次插入(O(\log N))。
void HeapPush(Heap* hp, HPDataType x)
{
if (hp->capacity == hp->size)
{
int num = hp->capacity == 0 ? 4 : hp->capacity * 2;
HPDataType* tmp = (HPDataType*)realloc(hp->a, sizeof(HPDataType) * num);
if (tmp == NULL)
{
perror("realloc fail");
exit(-1);
}
hp->a = tmp;
hp->capacity = num;
}
hp->a[hp->size] = x;
hp->size++;
HeapAdjustUp(hp->a, hp->size-1);
}
删除堆顶 HeapPop
不能直接删除数组下标0,会破坏完全二叉树结构。
正确流程:堆顶与末尾元素交换 → size‑1逻辑删除末尾 → 对根做向下调整。单次删除(O(log N))。
void HeapPop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
Swap(&hp->a[0], &hp->a[hp->size-1]);
hp->size--;
HeapAdjustDown(hp->a, hp->size, 0);
}
取堆顶、判空
HPDataType HeapTop(Heap* hp)
{
assert(hp);
assert(hp->size > 0);
return hp->a[0];
}
bool HeapEmpty(Heap* hp)
{
assert(hp);
return hp->size == 0;
}
六、两种建堆方式详细对比
如果现在给你一个无序数组:
int a[]={12,45,7,89,23,56};
如何把它构建成堆?存在两种实现思路。
6.1 方法一:向上调整建堆
思想:假设前面的元素已经是堆,不断插入新元素。
推演过程:
初始:
12
加入45:
12
\
45
满足堆性质。
加入7,数组[12,45,7]
12
/ \
45 7
7 < 12,违反小根堆,进行交换:
7
/ \
45 12
继续加入89:
7
/ \
45 12
/
89
继续加入23:
7
/ \
23 12
/ \
89 45
最终形成小根堆:
7
/ \
23 12
/ \
89 45
对应代码:
for(int i=1;i<n;i++)
{
HeapAdjustUp(a,i);
}
每新增一个元素就做一次向上调整,单次最多 log N。 总时间复杂度:O(N logN)。
6.2 方法二:向下调整建堆(推荐)
思想:叶子节点天然满足堆,只需要调整非叶子节点。
数组:12 45 7 89 23 56 对应完全二叉树:
12
/ \
45 7
/ \ /
89 23 56
叶子节点:89、23、56,叶子不需要调整。
从最后一个非叶子节点向前处理。 最后非叶子节点下标:(n‑2)/2,本例:(6‑2)/2 = 2。
1)处理下标2节点:
7
/
56
满足堆,无需交换。
2)处理下标1节点:
45
/ \
89 23
孩子23更小,执行交换:
23
/ \
89 45
3)处理下标0节点:
12
/ \
23 7
/ \ /
89 45 56
孩子7最小,执行交换:
7
/ \
23 12
/ \ /
89 45 56
小根堆建立完成。
为什么向下建堆是 (O(N))?
很多人直觉认为每个节点调整代价 log N,总复杂度就是 O(N log N),这个理解是错误的。
原因在于不同层节点数量、调整次数不一样:
- 底层节点数量最多,但高度为0,几乎不用向下调整;
- 越靠近上层,节点数量越少,但向下调整步数变多。
求和:

拿到完整数组时,优先选择向下调整建堆。
建堆方式最终总结
| 方式 | 核心思想 | 复杂度 | 适用场景 |
|---|---|---|---|
| 向上建堆 | 模拟不断Push插入 | O(N log N) | 动态逐个插入元素 |
| 向下建堆 | 从最后非叶子节点向前调整 | (O(N)) | 已有完整数组,原地建堆 |
七、堆排序深度解析
重点区分:建堆O(N),完整堆排序整体(O(N logN))。
堆排序分为两步:
- 将无序数组建堆;
- 不断把堆顶极值交换到数组尾部,尾部区间逐步有序,前面区间继续向下调整维护堆。
void HeapSort(int* a, int n)
{
// 降序,建小堆
// 升序,建大堆
//for (int i = 1; i < n; i++)//向上调整 O(N*logN)
//{
// HeapAdjustUp(a, i);
//}
//向下调整建堆 O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; i--)
{
HeapAdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[0], &a[end]);
HeapAdjustDown(a, end, 0);
--end;
}
}
核心口诀:降序建小堆,升序建大堆
很多同学在这里记反,推导逻辑: 堆顶永远是当前区间的极值;每次把堆顶交换到数组末尾,末尾位置就排好序,不再参与后续堆调整。
- 建小根堆:堆顶是最小值。最小值不断放到数组末尾,尾部依次存放最小、次小……最终整体数组降序。
- 建大根堆:堆顶是最大值。最大值不断放到数组末尾,尾部依次存放最大、次大……最终整体数组升序。
复杂度拆解:
- 建堆阶段:O(N)
- while循环执行n‑1次,每次向下调整(O(log N)):(O(N log N))
总复杂度 T=O(N)+O(N log N)=O(N log N);大O表示法保留高阶项。
- 空间复杂度:(O(1)) 原地排序;稳定性:不稳定排序。
八、排序算法横向对比
| 算法 | 平均时间 | 最坏时间 | 额外空间 | 稳定性 | 评价 |
|---|---|---|---|---|---|
| 冒泡排序 | O(N^2) | O(N^2) | (O(1)) | 稳定 | 仅教学,大数据量性能极差 |
| 堆排序 | O(N log N) | O(N log N) | (O(1)) | 不稳定 | 最坏情况性能稳定,内存友好,缓存局部性较差 |
| 快速排序 | O(N log N) | O(N^2) | O(log N)递归栈 | 不稳定 | 实际运行最快,语言内置排序核心,常数因子小 |
堆排序最坏时间不会退化,但实际跑的速度一般弱于快排。堆真正强项不是完整排序,而是Top‑K、优先级队列场景。
Top‑K简单理解
海量数据求前K大元素,不需要全部排序。维护大小为K的堆,复杂度O(N log K);K远小于N的时候,比全局排序O(N log N)效率高很多。
九、项目代码结构与test.c测试解读
项目三层文件拆分:
Heap.h:结构体、函数声明;Swap为内部辅助函数,不对外暴露;Heap.c:全部接口实现,默认小根堆;test.c:三组测试,同一时间只启用一组main,其余注释。
测试1:裸数组原地堆排序,求前K大元素(当前启用)
直接操作普通数组,不使用Heap结构体,原地完成堆排序,排序完成数组降序,数组前k个即为前k大元素。注释保留向上调整建堆代码用于对比学习。
测试2:堆接口Push/Pop整套功能测试
完整验证初始化、插入、弹出、堆顶、判空、销毁。当前是小根堆,弹出顺序从小到大;切换大根堆后输出会改变。
测试3:拷贝堆数组,不破坏原堆求前k大
malloc复制一份堆内存,在副本上执行Pop取TopK,原始堆数据不受影响。
注意:
tmpHp只是借用malloc出来的内存,不要调用HeapDestory(&tmpHp),手动free拷贝内存即可,避免二次释放。
代码仓库
已经上传本篇及以往博客代码,可以拉取代码学习使用。
数据结构/Heap · Luminous/Code_2026 - 码云 - 开源中国
十、常见坑点汇总
- 0下标堆父子公式不要和1下标混用,混用直接逻辑错乱;
- 大小根堆三处比较符号必须同步修改,否则堆性质失效;
HeapCreate挂载栈数组,销毁前hp->a = NULL,禁止free栈内存;- Pop不能直接删除下标0,必须交换堆顶与末尾元素再向下调整;
- 已有完整数组建堆,优先向下调整建堆O(N),不要向上调整;
- 区分概念:建堆O(N),堆排序整体(O(N logN)),不要混淆两个复杂度。
写在最后
从树形结构到数组存储,从堆调整到堆排序,二叉堆看似只是完全二叉树的一种特殊形式,但背后体现的是数据结构中非常重要的思想:利用结构特点降低操作成本。
本篇重点掌握了几个核心:
- 堆本质是完全二叉树,通过数组实现,避免了链式结构的额外空间开销;
- 向上调整解决「插入后维护堆性质」的问题,向下调整解决「删除堆顶以及建堆」的问题;
- 已有完整数组时,优先使用向下调整建堆,时间复杂度从 (O(N logN)) 优化到 (O(N));
- 堆排序虽然实际效率通常不如快速排序,但它拥有稳定的最坏情况复杂度和 (O(1)) 空间优势;
- 堆真正强大的地方并不是排序,而是在动态维护极值,例如 Top-K、优先级队列、任务调度等场景。
学习数据结构时,不应该只停留在记忆代码和复杂度,更重要的是理解每个设计背后的原因:
- 为什么完全二叉树适合数组存储?
- 为什么插入使用向上调整,删除使用向下调整?
- 为什么建堆推荐从最后一个非叶子节点开始?
- 为什么堆排序选择大根堆或小根堆会影响最终排序方向?
当这些问题真正理解后,堆就不再是一段需要背诵的代码,而会成为解决实际问题的一种工具。
下一篇将继续深入二叉树专题,学习二叉树的遍历、递归思想以及更多经典应用。从基础结构出发,逐步构建数据结构与算法能力体系。
:二叉堆从零实现|Heap结构设计 + 建堆优化 + 堆排序深度解析&spm=1001.2101.3001.5002&articleId=164004497&d=1&t=3&u=e83890d9f46249778161ff12d2b8710f)
2518

被折叠的 条评论
为什么被折叠?



