
作者:一位曾把“小顶堆”写成“大顶堆”、调了两小时才意识到的计算机学生 目标读者:刚搞懂树基本概念、准备深入二叉树的大一/大二同学 一句话预告:为什么完全二叉树能用数组存?堆排序为什么快?Top-K 问题为何反直觉地用小堆?
上一篇,我们用文件系统理解了“树”的本质:层次 + 分支。 但如果你真用链表把每个文件夹、文件都连起来,内存开销大不说,找一个文件可能得遍历半天。
有没有一种结构规整、存储紧凑、操作高效的树?
有!它就是——完全二叉树,而它的明星应用,叫做 堆(Heap)。
今天,我们就揭开堆的神秘面纱:
别怕,我们一步步来,用生活例子 + C 代码 + 手动模拟,让你彻底搞懂!
先快速回顾:二叉树 ≠ 任意树。
它的两大特点:
✅ 举个反例: 如果你把“左子树”和“右子树”位置随便换,那前序/中序遍历结果就变了! 所以——二叉树的结构信息藏在“左右顺序”里。
定义:每一层的结点数都达到最大值。 深度为 h 的满二叉树,总共有 2h−1 个结点。
比如深度为 3 的满二叉树:

📌 特点:完美对称,没有“缺胳膊少腿”。
定义: 深度为 h 的树,前 h-1 层是满的,第 h 层结点“靠左连续排列”,中间不能有空缺。
✅ 正确例子(完全二叉树):

❌ 非完全二叉树:

💡 关键理解: “完全” = 尽可能满,且最后一层靠左。 → 满二叉树是特殊的完全二叉树。
因为它可以用数组高效存储!而普通二叉树用数组会浪费大量空间。
假设我们用数组从 0 开始编号,存储一棵完全二叉树:
i | (i-1)/2 | 2*i + 1 | 2*i + 2 |
🔍 推导逻辑(理解比死记更重要):
(0-1)/2 是负数,忽略)✅ 例子验证: 数组:[A, B, C, D, E, F]
对应树:
A(0) / \ B(1) C(2) / \ / D(3) E(4) F(5)
完全匹配!
📌 重要结论: 只有完全二叉树才能用数组无浪费存储父子关系。 普通树用数组会有大量“空洞”,比如空指针占位。
堆是一种特殊的完全二叉树,满足:
✅ 大根堆例子(数组表示):
数组:[90, 80, 70, 60, 50, 40] 树形: 90 / \ 80 70 / \ / 60 50 40
堆的插入/删除,本质是破坏堆性质 → 调整恢复。
场景:在数组末尾插入一个新元素(比如插入 95 到大根堆)
步骤:
[90,80,70,60,50,40,95][90,80,95,60,50,40,70][95,80,90,60,50,40,70]✅ C 代码实现:
void AdjustUp(HPDataType* a, int child) {
int parent = (child - 1) / 2;
while (child > 0) {
if (a[child] > a[parent]) { // 大根堆:孩子 > 父?
Swap(&a[child], &a[parent]);
child = parent;
parent = (parent - 1) / 2;
} else {
break; // 已满足堆性质
}
}
}
void HPPush(HP* php, HPDataType x) {
// 数组扩容(略)
php->a[php->size] = x;
php->size++;
AdjustUp(php->a, php->size - 1); // 从最后一个元素向上调整
}📌 时间复杂度:O(log n) —— 最多从叶子走到根,走树高。
前提:左右子树已经是堆(插入不满足,但删除时满足!)
场景:删除堆顶(90),常规做法:
[40,80,70,60,50][80,40,70,60,50][80,60,70,40,50]✅ C 代码实现:
void AdjustDown(HPDataType* a, int n, int parent) {
int child = parent * 2 + 1; // 先假设左孩子
while (child < n) {
// 选左右孩子中更大的(大根堆)
if (child + 1 < n && a[child + 1] > a[child]) {
child++; // 右孩子更大
}
if (a[child] > a[parent]) {
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
} else {
break;
}
}
}
void HPPop(HP* php) {
Swap(&php->a[0], &php->a[php->size - 1]);
php->size--;
AdjustDown(php->a, php->size, 0); // 从根向下调整
}两种思路:
逐个插入(向上调整) | 从空堆开始,一个个 HPPush | O(n log n) |
整体调整(向下调整) | 从最后一个非叶子结点开始,倒着 AdjustDown | O(n) |
✅ 为什么向下调整建堆更快?
💡 代码实现(向下建堆):
// 从最后一个非叶子结点开始调整
// 最后一个结点索引 = n-1 → 父 = (n-2)/2 = (n-1-1)/2
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(a, n, i);
}📌 踩坑经历: 我第一次写堆排序,用的是“逐个插入建堆”,数据一多直接超时。 后来换成“向下调整建堆”,瞬间快了 10 倍! 记住:建堆用向下调整,别用插入!
✅ 原地排序:不需要额外数据结构,直接在原数组操作!
void HeapSort(int* a, int n) {
// 建大堆(O(n))
for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
AdjustDown(a, n, i);
}
// 排序(O(n log n))
int end = n - 1;
while (end > 0) {
Swap(&a[0], &a[end]); // 最大值放到末尾
AdjustDown(a, end, 0); // 对 [0, end) 重新堆化
end--;
}
}✅ 时间复杂度:O(n) + O(n log2 n) = O(n log2 n) ✅ 空间复杂度:O(1) —— 原地排序!
从 100 万个数中,找出最大的前 10 个数。
❌ 暴力法:排序 → O(n log n),而且可能内存装不下!
✅ 堆解法:建小堆,存前 K 大!
核心思想: 我们只关心“是不是比当前第 K 大的数更大”。 小堆的堆顶是最小值,正好代表“当前第 K 大的门槛”。
✅ 优势:
void TopK(int* data, int n, int k) {
int* minheap = (int*)malloc(sizeof(int) * k);
// 1. 取前k个,建小堆
for (int i = 0; i < k; i++) minheap[i] = data[i];
for (int i = (k - 2) / 2; i >= 0; i--)
AdjustDownMin(minheap, k, i); // 注意:这里是小堆版 AdjustDown
// 2. 遍历剩余元素
for (int i = k; i < n; i++) {
if (data[i] > minheap[0]) {
minheap[0] = data[i];
AdjustDownMin(minheap, k, 0);
}
}
// 输出结果
for (int i = 0; i < k; i++) printf("%d ", minheap[i]);
free(minheap);
}💡 小堆 AdjustDown 需要改比较方向(孩子 < 父才交换)
完全二叉树 | 最后一层靠左连续,可用数组无浪费存储 |
父子索引公式 | 父=(i-1)/2,左=2i+1,右=2i+2 |
堆 | 完全二叉树 + 堆性质(大根/小根) |
向上调整 | 插入用,O(log n) |
向下调整 | 删除 & 建堆用,建堆总体 O(n) |
堆排序 | 建堆 + 首尾交换 + 向下调整 |
Top-K | 前 K 大 → 小堆,内存省、效率高 |
[10, 20, 15, 30, 40] 画出对应的完全二叉树,并标出每个结点的父子关系。[3,1,4,1,5,9,2] 执行“向下调整建大堆”,写出每一步调整后的数组。AdjustDown 有什么问题?if (child + 1 < n && a[child + 1] > a[child]) // 小堆用 >我们将深入:
🌟 终极目标:让你看到树,就想到递归;写递归,就想到树!
📣 小寄语: 堆不是“堆内存”,而是一种精巧的结构设计。 它告诉我们:规则带来效率,结构决定性能。 下次你在游戏里看到“战力排行榜前 10”, 就知道——背后很可能是一个小堆在默默工作!
欢迎在评论区晒出你的思考题答案,或分享你在堆排序/Top-K 上的踩坑经历! 👉 点赞 + 收藏 + 关注,三连不迷路!
感谢各位的观看