前阵子一个学弟拿着“堆排序(C语言)”的课堂作业来找我,说网上的代码能跑,但解释看不懂。他原话是:“为什么建堆要从 n/2-1 开始?为什么排序的时候把堆顶换到末尾再重新调整?”这两个问题其实问到了堆排序最核心的地方,也说明他是真想把原理弄懂,而不是背代码。我答应他写一篇尽量讲透的笔记,下面就是整理后的版本。
堆排序本质上不是一种“新排序思路”,而是把“堆”这种数据结构拿来当工具用:先建堆,再反复从堆顶取走最大元素。它能在 O(n log n) 时间内完成排序,而且是原地排序,不需要额外开数组。如果你正在学 C 语言、准备啃数据结构,或者想在机试里快速手写一个稳定的排序算法,这篇笔记应该能帮上忙。我会从堆的数组模型讲起,然后给出完整 C 代码,最后聊几个常见的坑和应用场景。
1. 为什么堆排序值得用C语言单独写一遍
1.1 它不是一个孤立的知识点
很多人在学排序算法的时候,是把冒泡、选择、插入、快排、归并、堆排这几个算法当成“六个要背的模板”来处理。但堆排序和前面几个有本质区别:冒泡、选择、插入是纯数组操作,快排是分治思想,归并是分治加合并,而堆排序的前提是先有一个“堆”。
“堆”本身是一种数据结构,它既可以用在排序里,也可以独立用在很多场景中,比如优先队列、任务调度、Top-K 问题、Dijkstra 最短路优化。所以学堆排序的时候,你其实是在同时学两件事:第一,堆是怎么维护的;第二,怎么把堆顶一个个拔出来形成有序序列。前一件事才是真正通用的,后一件事只是堆的一个应用。
用 C 语言实现堆排序,比用其他语言更“透明”。你直接用数组下标访问左右孩子,直接就看见了父子关系;你直接操作指针做交换,能看到数据是怎么移动的。我见过不少用 Python 或 Java 写堆排序的同学,代码逻辑没问题,但问一句“为什么数组从 0 开始下标公式会是 2*i+1”,就答不上来。因为很多高级语言把细节藏掉了,而 C 语言逼着你面对这些细节,这反而是优势。
1.2 从数组到树的思维转变
堆排序的难点在于:你的大脑同时需要维护两套视图——底层的数组和逻辑上的完全二叉树。
一开始会觉得别扭:数组明明是线性的,怎么脑子里要浮现出一棵树?其实完全二叉树有一个非常好的性质:只要树的最后一层节点尽量靠左排列,那么整棵树可以用一个数组无缝隙地存下来,不需要任何指针。父子关系完全由下标决定。C 语言数组天然适合干这个。
一旦你习惯了这种映射,就会意识到堆排序的最关键操作——下沉和上浮——其实只是“沿着数组下标在树上往下爬或往上爬”。这种思维转变对后面学习线段树、二叉堆、优先队列都有帮助。所以就算你以后不做底层开发,我也建议用它来训练自己“用数组模拟结构”的能力。
1.3 这篇笔记适合谁
- 正在学 C 语言,刚学到数组、函数、指针,想找个综合练手项目的人。
- 准备数据结构和算法考试,需要理解堆排序而不想死记硬背的人。
- 准备机试或算法竞赛,需要快速手写堆排序和优先队列的人。GESP 七级、洛谷一些数据结构题,经常会出现“维护一个最值集合”的需求,堆就是标准答案。
下面我从最底层开始拆。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数组里藏着的完全二叉树:堆的物理结构与四个下标关系
2.1 逻辑上是树,物理上是数组
堆的定义是:一个完全二叉树,并且每个父节点的值都不小于(最大堆)或不大于(最小堆)它的孩子节点的值。
“完全二叉树”的意思稍有点绕,我用一句话解释:除了最后一层,上面每一层都是满的;最后一层的节点全部靠左排列,中间不留空位。正因为有这个“紧凑排列”的性质,才能用数组连续存储。
比如数组 {9, 5, 8, 4, 3, 6},它在逻辑上长这样:
code复制 9
/ \
5 8
/ \ /
4 3 6
数组下标 0 到 5 依次对应树的第一层、第二层、第三层。左边那个 6 是索引 2(值 8)的左孩子,索引 2 没有右孩子,因为数组刚好到头了,这完全符合完全二叉树的定义。
2.2 C语言数组从0开始,所以公式要小心
如果数组下标从 1 开始,父节点的左孩子是 2*i,右孩子是 2*i+1。但 C 语言数组下标从 0 开始,所以公式整体左移一位:
- 左孩子下标:
2 * i + 1 - 右孩子下标:
2 * i + 2 - 父节点下标:
(i - 1) / 2
很多新手在这里栽跟头,是因为课本上常用从 1 开始的伪代码,自己实现的时候忘了把公式改成从 0 开始。网上有些帖子也直接用 2*i 和 2*i+1,在 C 语言里会出现下标越界,或者访问到错误元素。
举个具体例子,数组 {9, 5, 8, 4, 3, 6},索引为 1 的节点值是 5:
- 左孩子:
2*1+1 = 3,值为 4,对应树中 5 的左下孩子。 - 右孩子:
2*1+2 = 4,值为 3,对应树中 5 的右下孩子。 - 反过来,索引 4 的父节点:
(4-1)/2 = 1,整型除法得到 1,也就是 5 所在的节点。
2.3 叶子节点:最后一个非叶子节点的计算
这是堆排序实现里最容易出现错误的地方。
数组长度为 n 时,最后一个节点的下标是 n-1。根据父节点公式,它的父节点是 (n-1-1)/2 = n/2 - 1。而这个节点就是整棵树中最后一个非叶子节点。
为什么重要?因为建堆的时候,我们只需要从最后一个非叶子节点开始往前逐层下沉,不需要从最后一个元素开始。叶子节点没有孩子,它自身已经满足“没有子树需要调整”的条件。
以 n=6 为例,n/2-1 = 2,最后一个非叶子节点是索引 2,也就是值为 8 的那个节点。它的孩子是索引 5(值 6),做完下沉之后,再处理索引 1、索引 0。
2.4 最大堆与最小堆
堆排序一般用最大堆,这样堆顶就是全局最大值。排序时反复把堆顶换到数组末尾,数组从后往前依次确定最大值、次大值,最终变成升序。
最小堆则是堆顶最小,常用于构造优先队列,或者求动态数据流中的最小值。它们的核心操作完全一样,只是比较符号反一下。
记住一个关键性质:堆只保证父节点和子节点之间的大小关系,不保证兄弟节点之间、更不保证同一层节点之间的顺序。所以堆不是“近似有序”,它只是“父节点压着子节点”。这也是后面要讲的“堆排序不稳定”的根源之一。
3. 下沉和上浮:两个方向相反但同样重要的堆维护操作
3.1 下沉:自顶向下调整
下沉(sift down)是堆排序里最重要的函数,它的任务是:假设某个节点 i 的左右子树都已经满足堆性质,但节点 i 本身可能小于它的孩子,那么把它一路往下换,直到它不小于任何孩子,或者到达叶子。
用 C 语言实现,我推荐循环版本,不推荐递归。理由很简单:递归每次都要压栈,堆排序又是递归频繁调用的场景,循环版本更稳,也更容易控制边界。而且堆的高度只有 log2(n),递归栈溢出风险不大,但循环代码更直观。
c复制void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
// 对数组 arr 中下标为 i 的节点执行下沉
// n 是当前堆的有效大小
void sift_down(int arr[], int n, int i)
{
while (1) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest == i)
break; // 父节点已经不小于所有孩子
swap(&arr[i], &arr[largest]);
i = largest; // 继续沿着被交换下去的位置调整
}
}
这里有一个非常隐蔽的细节:arr[left] > arr[largest],不是 arr[left] > arr[i]。因为每次交换后 i 更新为 largest,但初始比较时 i 和 largest 是同一个节点,后续如果先比较了左孩子,再比较右孩子时,largest 可能已经变成左孩子的下标了。如果你写成 arr[left] > arr[i],在第三次下沉时就会出错,因为此时 arr[i] 仍然是原来的父节点值,而不是当前要比较的位置。
3.2 上浮:自底向上调整
上浮(sift up)是插入操作的核心。新元素总是先放在数组末尾,然后沿着父节点往上比较,如果比父节点大就交换,直到满足堆性质。
c复制void sift_up(int arr[], int i)
{
while (i > 0) {
int parent = (i - 1) / 2;
if (arr[i] <= arr[parent])
break;
swap(&arr[i], &arr[parent]);
i = parent;
}
}
上浮只会破坏当前节点到根这一条路径的性质,所以我只需要往上看父节点,不需要比较孩子。而下沉需要考虑两个孩子,因为父节点可能比其中一个孩子小,也可能比两个都小,必须挑最大的那个交换,否则交换后子堆性质可能仍然被破坏。
用一个不算太严谨但很形象的类比:下沉像把一个不合格的“领导”往下撤职,撤到它管不了比他强的人为止;上浮像一个有本事的新人一路晋升,超过一个领导就往上一级。
3.3 为什么堆排序阶段只需要下沉
这是学弟问的第二个问题的核心。
建堆完成后,堆顶是最大值。排序时:
- 把堆顶和当前堆的最后一个元素交换。
- 堆的有效大小减一,这样刚换到末尾的最大值就“脱离”堆了。
- 对新的堆顶执行下沉,恢复堆性质。
整个过程中,只有堆顶一个节点违反了堆性质,其他所有节点仍然满足条件。所以不需要上浮,也不需要重建整个堆,只需要对堆顶做一次下沉。这就是堆排序每轮 O(log n) 的原因,如果每轮都重新建堆,那就变成 O(n log n) 的建堆次数乘以 n 轮,复杂度完全不对了。
那上浮在哪里用?在“边读取数据边建堆”的场景里,比如动态插入元素构造优先队列,每插入一个元素就上浮一次。如果给你一个无序数组一次性建堆,用下沉从底向上调整更高效,这就是下一章要说的建堆过程。
4. 一份可直接跑的C语言实现:建堆、排序、边界处理逐行拆解
4.1 完整代码
直接贴一份能编译、能跑的完整 C 程序,我加了比较详细的注释。
c复制#include <stdio.h>
void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
// 下沉调整,n 表示当前堆的节点个数,i 表示待调整节点的下标
void sift_down(int arr[], int n, int i)
{
while (1) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest])
largest = left;
if (right < n && arr[right] > arr[largest])
largest = right;
if (largest == i)
break;
swap(&arr[i], &arr[largest]);
i = largest;
}
}
void heap_sort(int arr[], int n)
{
// 第一步:建堆
// 从最后一个非叶子节点开始,自底向上下沉
for (int i = n / 2 - 1; i >= 0; i--)
sift_down(arr, n, i);
// 第二步:排序
// 把堆顶的最大值换到当前堆末尾,缩小堆,再下沉堆顶
for (int i = n - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
sift_down(arr, i, 0); // 注意这里堆大小是 i,不是 n
}
}
int main(void)
{
int arr[] = {3, 7, 2, 9, 1, 8, 5, 6, 4, 0};
int n = (int)(sizeof(arr) / sizeof(arr[0]));
heap_sort(arr, n);
for (int i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
return 0;
}
运行结果是 0 1 2 3 4 5 6 7 8 9。
4.2 建堆起点为什么是 n/2-1
前面已经推导过了,n/2-1 是最后一个非叶子节点。假设数组长度为 10,那么最后一个非叶子节点下标是 10/2-1 = 4,对应值 7。它有两个孩子:左孩子 9,右孩子 1。先调整它,让 7 和 9 交换,再往上走调整下标 3、2、1、0。
如果你从 n-1 开始倒退着调,也不是不行,只是白白处理了大量叶子节点。叶子节点没有孩子,下沉一次什么事都不会发生。虽然不影响正确性,但会浪费判断开销。
如果你从 0 开始正着调,那就错了。想象下标 0 调整后把一个大值换到了子树深处,可是这个子树的内部还没有调整过,很可能仍然不满足堆性质。所以建堆只能是“自底向上”的。
4.3 交换与下沉的顺序
排序阶段最容易写错的地方是 sift_down(arr, i, 0) 里的第二个参数。
在 for (int i = n - 1; i > 0; i--) 这个循环里,第一次 i = n-1,我们将堆顶和最后一个元素交换,此时新堆的有效大小是 n-1,所以要把 i 传给下沉函数。如果你传 n,下沉时可能又把刚换到末尾的最大元素翻上来了,直接错乱。
我当年第一次写堆排序时就在这里翻过车,检查了半天才意识到:堆的有效大小不是固定的数组长度,而是每轮交换后减一。
另一个边界是右孩子下标。判断 right < n 是必须的,因为最后一个非叶子节点可能只有左孩子、没有右孩子。漏掉这个判断,要么越界访问,要么读到一个堆外的元素,结果无法预测。
4.4 指针与数组传参的说明
代码里 swap 用了指针,这正好呼应 C 语言里“函数传数组其实就是传指针”的知识点。
数组作为参数传递时,退化成指向首元素的指针,所以在 sift_down 里通过 arr 修改元素,修改的是原数组的内容。而 swap 接收两个 int *,在函数内部解引用交换,效果也作用到原数组上。这些操作本质上都没有拷贝整个数组,只在函数栈上传递了几个地址,这也是堆排序能实现原地排序的底层原因之一。
4.5 测试用例与常见输入
我建议拿到代码后至少跑这几组输入:
- 乱序数组:
{3, 7, 2, 9, 1, 8, 5, 6, 4, 0} - 逆序数组:
{9, 8, 7, 6, 5, 4, 3, 2, 1, 0} - 已经有序的数组:
{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} - 全部相等的数组:
{5, 5, 5, 5, 5} - 单元素数组:
{1} - 空数组或长度为 0 的数组
空数组需要单独注意:n = 0 时,n/2-1 = -1,循环条件 i >= 0 直接不成立,排序循环 i > 0 也不成立,代码能安全返回。
这些测试不仅能帮你验证代码正确性,也能帮你观察堆排序在不同数据分布下的行为模式。
5. 复杂度结论背后:建堆为什么是O(n),排序为什么是O(n log n)
5.1 不要满足于背结论
很多人知道“堆排序时间复杂度 O(n log n)”,但细问一下:这个 O(n log n) 是整个算法的,还是只是排序阶段的?严格来说,建堆是 O(n),排序阶段是 O(n log n),所以总复杂度是 O(n log n)。如果你把建堆也算成 O(n log n),那整个算法就是 O(n log n) + O(n log n),数量级不变,但对理解有偏差。
5.2 建堆 O(n) 的直觉
有人会疑惑:建堆不是要对 n 个节点做下沉吗?每次下沉最多 O(log n),为什么不是 O(n log n)?
关键在“每次下沉最多”这几个字。绝大多数节点根本没有 log n 层可以下沉。叶子节点不需要下沉,倒数第二层最多下沉 1 层,倒数第三层最多下沉 2 层,依此类推。越靠近根部的节点越少。
假设 n 个节点的树高约 log2(n):
- 倒数第二层大约有 n/4 个节点,每个最多下沉 1 层,总工作量约 n/4。
- 倒数第三层大约有 n/8 个节点,每个最多下沉 2 层,总工作量约 n/4。
- 倒数第四层大约有 n/16 个节点,每个最多下沉 3 层,总工作量约 3n/16。
把所有层加起来,形成的是一个类似 n/4 + 2n/8 + 3n/16 + ... 的级数,尽管每一项分子在变大,但每层的节点数按 2 的幂递减,这个级数收敛到一个常数倍的 n。所以建堆总工作量是 O(n),而不是 O(n log n)。
这个结论想直接证明也不难,只需要做一次简单的代数求和,网上有很多帖子都推导过。关键是建立“大多数节点都很矮”的直觉。
5.3 排序阶段的 O(n log n)
排序阶段是另一个数字:n-1 轮交换,每轮交换后都要对堆顶执行一次下沉。堆顶在最顶层,它下沉的最大步数等于当前堆的高度。虽然堆的大小在逐轮缩小,但高度基本还是在 log n 这个数量级附近。所以排序阶段的总工作量是 O(n log n)。
这也是堆排序想快起来不容易的原因:它每轮都必须要走完 log n 层,不像快排的递归有时候会提前触发“小区间用插入排序”之类的优化。堆排序的复杂度非常稳定,最坏、平均、最好都是 O(n log n),它没有快排那种“最坏情况退化成 O(n^2)”的风险,但常数因子比快排大,后面会展开讲。
5.4 空间复杂度 O(1)
堆排序是原地算法,只需要常数个临时变量。这一点在内存受限的嵌入式环境里非常有价值。比如一个只有几十 KB 内存的 MCU 上要排序上千个采样数据,用快排的递归栈可能比较危险,用归并要额外开一块一模一样的数组,而堆排序只需要一个交换用的临时变量。
但你要知道,C 语言里的递归调用也会消耗栈空间,所以如果你的运行环境栈比较小,堆排序的非递归实现会是更安全的选择。
6. 不稳定不是玄学:一个例子看清堆排序的相对顺序问题
6.1 什么是稳定排序
稳定性这个概念,指的是值相等的元素在排序后能不能保持它们在原始数组里的相对顺序。能保持,就称为稳定排序;不能,就称为不稳定排序。
典型场景是学生成绩排序:先按成绩排出一份名单,然后再按班级排。如果排序算法不稳定,第二次按班级排的时候,同班同学内部的成绩顺序就乱了。如果稳定,第二次排序后,同班里仍然保持第一次的成绩相对顺序。
6.2 堆排序不稳定的例子
我直接给一个非常小的例子,用 7a 和 7b 表示两个相同值但不同语义的元素。原始数组是 [5, 7a, 7b, 2],目标是升序排列。
建最大堆的过程:
- 最后一个非叶子节点是下标 1,值 7a,它的孩子是下标 3(值 2),不需要调整。
- 下标 0,值 5,它的孩子是下标 1(值 7a)和下标 2(值 7b)。7a 比 5 大,交换 5 和 7a,得到
[7a, 5, 7b, 2]。 - 继续下沉 5,它的孩子是下标 3(值 2),不需要调整。建堆完成。
排序阶段:
- 交换堆顶 7a 和末尾 2,得到
[2, 5, 7b, 7a]。堆的有效大小变成 3。 - 下沉堆顶 2。它的孩子是 5 和 7b,7b 最大,交换 2 和 7b,得到
[7b, 5, 2, 7a]。 - 交换堆顶 7b 和当前堆末尾 2,得到
[2, 5, 7b, 7a]。堆的有效大小变成 2。 - 下沉堆顶 2。它的孩子是 5,交换 2 和 5,得到
[5, 2, 7b, 7a]。 - 交换堆顶 5 和当前堆末尾 2,得到
[2, 5, 7b, 7a]。排序完成。
最终结果是 [2, 5, 7b, 7a]。原始顺序是 7a 在 7b 前面,排序后变成 7b 在 7a 前面,相对顺序被打破了。所以堆排序不稳定。
问题出在哪?堆顶和末尾元素交换的那一步,可能把两个相同值的元素一个越过另一个;下沉过程中的多次交换,也可能改变相同元素的位置。
6.3 几大排序算法稳定性对比
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 简单选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
注意:简单选择排序也不稳定,因为它每次选择最小值可能跨越其他相同值直接交换。快排的稳定性取决于具体实现,标准实现通常不稳定。
6.4 什么时候不能选堆排序
如果你的数据结构需要“按多个字段依次排序”,而且要求低层字段的相对顺序在高层排序后仍然保留,那就要用稳定排序。比如先按姓名字典序排,再按年级排,如果采用不稳定排序,第二次排序后同年级内部的名字顺序可能被打乱,需要额外处理。
但如果只是给整数数组排序,稳定性完全无所谓。堆排序代码可以放心用。
7. 堆排序在真实场景里的位置:优先队列、Top-K和几处容易误用的地方
7.1 真正的价值是堆结构本身
堆排序确实不是一个“打遍天下无敌手”的排序算法。C 标准库的 qsort 底层通常是对快排的优化实现,常数因子比手写堆排序小得多,在大多数排序场景里都比堆排序快。那堆还有没有存在价值?有,而且非常大。
价值在于“动态维护最值”的能力。排一次序,只需要在某个时间点算一次结果;但真实系统里,很多数据是不断变化的,你随时要获取当前的最大值或最小值,还要随时插入新数据。此时堆的时间复杂度非常舒服:插入 O(log n),取最值 O(1),删除堆顶 O(log n)。
7.2 Top-K:用最小堆找最大的 K 个
海量数据里找最大的 K 个,这是堆的经典应用。如果数据总量很大,比如 100 亿个数,你没法全部读进内存排序,那就维护一个大小为 K 的最小堆:
- 堆里存的是当前遇到的 K 个最大元素。
- 新元素如果大于堆顶(堆里最小的那个),就把堆顶换掉,然后下沉。
- 遍历完所有数据后,堆里就是最大的 K 个。
这个算法的时间复杂度是 O(n log K),空间复杂度 O(K)。相比全量排序的 O(n log n),在 K 远小于 n 时优势巨大。比如 K=100,n=10 亿,log K 只有 7,log n 接近 30,差距非常明显。
7.3 优先队列:系统里的隐形英雄
操作系统进程调度、网络包优先级处理、定时器管理,这些场景都离不开优先队列。C 标准库没有现成的优先队列,但库里 C++ 的 std::priority_queue 就是堆实现的,Java 的 PriorityQueue 也是堆实现的。
如果你用 C 语言做嵌入式或者写底层服务,手写一个最小堆作为定时器队列是非常标准的做法。每个定时器节点存一个超时时间,每次从堆顶取出最早超时的定时器,插入新定时器时执行上浮。用数组实现,不依赖动态内存分配,这在资源受限的环境里非常实用。
7.4 为什么大多数通用排序还是用快排
既然堆排序没有最坏情况退化,为什么不都用堆排序?
因为复杂度是个渐进概念,O(n log n) 的常数因子不同。快排的核心操作是“选一个基准值分区”,每轮比较的次数相对少,而且现代 CPU 对顺序访问数组非常友好。堆排序下沉过程每次都要跳转到孩子的下标,不是连续访问,缓存命中率低,常数因子大。数据量越大,这个差距越明显。
所以工程结论比较一致:通用数组排序选快排或其变体;需要稳定性选归并;内存紧张且要求稳定复杂度序列选堆排序;动态取最值必须用堆这种数据结构。
7.5 手写堆的几个常见坑
根据我自己的经验,手写堆排序最容易踩这么几个坑,列出来给大家排雷:
- 将堆排序阶段的
sift_down传参写成n而不是i,导致刚排好的最大值又参与后续调整。 - 下沉时比较孩子没有先判断下标越界,尤其右孩子可能不存在。
- 实现最小堆时只把比较符号改了,忘记把
left、right的下标运算同步改对。 - 交换堆顶和末尾后忘记先缩小堆大小,再调用下沉。
- 把“堆”和“有序数组”混为一谈。建堆完成后,数组中元素并不是升序或降序,必须走完交换-下沉的排序流程才是有序的。
- 在递归版本里每次递归都重新计算堆大小,导致逻辑越来越乱。尽量用循环版本,把堆大小作为参数传入。
调试时我有个习惯:先写一个 print_heap 函数,把数组按树的层级打印出来,肉眼确认每次下沉后父节点是否都不小于孩子。堆的问题是局部的,看局部比看整段逻辑快得多。
另外,如果你在 OJ 上做题,注意 C 语言没有内置的堆结构,很多题目需要自己维护。可以把 sift_down 和 sift_up 写成通用函数,后面遇到优先队列相关的题直接复用。我自己就是先写好一套标准的堆操作代码,然后根据题目改比较符号和数据类型,效率会高很多。
堆排序本身代码量不大,几十行而已。但真正理解了数组下标和树节点之间的映射,理解了“只有堆顶违规所以只需要下沉一次”这个逻辑,你就不会再怕这类题目。后面再看到优先队列、Top-K、定时器这些应用,你会觉得非常自然。
