堆排序这东西,我在C语言学习阶段一直觉得它“看着简单,写起来总差一口气”。后来在嵌入式开发里处理实时数据排序、在刷题时被卡时间限制,才真正把它吃透。这篇博客就用C语言完整走一遍堆排序:从堆的结构原理、代码逐行讲解,到复杂度分析、踩坑实录和Top K等衍生应用,一次性把堆排序讲明白。适合刚学完数组和指针、想在排序算法上进阶的C语言学习者,也适合准备笔试面试时拿堆排序救急的求职者。
1. 堆排序是什么,为什么值得用C语言实现
1.1 堆排序的本质:用完全二叉树的思想排序一个数组
堆排序(Heapsort)是一类基于比较的排序算法,核心思想非常巧妙:它不额外开辟大块内存,而是把待排序的数组本身“看成”一棵完全二叉树,然后利用这棵树的父子关系,把数组调整成一个大顶堆或小顶堆,再反复取出堆顶元素完成排序。
堆排序由J. W. J. Williams在1964年发明,同年Robert W. Floyd提出了原地建堆的线性时间算法,所以堆排序从诞生起就是“原地排序”的代表。它最吸引人的一点是:无论数据原本是正序、倒序还是乱序,时间复杂度都是稳定的O(n log n),不像快速排序那样存在最坏情况下退化成O(n²)的风险。
我第一次接触堆排序时最大的困惑是:“排序就排序,为什么非要搞一棵树出来?”后来才明白,数组虽然是一维线性结构,但通过下标关系可以映射成完全二叉树——父节点在下标i,左孩子在2i+1,右孩子在2i+2(下标从0开始)。这个映射关系是堆排序能原地工作的基石。也就是说,堆排序的逻辑结构是树,物理存储却是数组,两头的好处都占了。
1.2 为什么用C语言实现堆排序
用C语言实现堆排序,比起Java或Python有一个非常实际的好处:C语言暴露了数组和指针的本质,你在写arr[left]和arr[right]时,脑子里会时刻想着“这其实是对一块连续内存的偏移访问”。这种底层感知,让你更容易理解堆排序为什么能原地工作、为什么空间复杂度是O(1)。
另一方面,C语言在嵌入式场景中的地位无可替代。我在做单片机上的数据采集系统时,需要对传感器数据进行排序后取中位数或极值,那套板子的RAM只有几十KB,堆排序这种不依赖递归栈深度(虽然有递归版,但深度仅为O(log n))、不申请辅助数组的排序算法,就成了非常可靠的选择。
而且C语言里指针和数组的微妙关系,在实现堆排序时体现得淋漓极致。void heapify(int arr[], int n, int i)和void heapify(int *arr, int n, int i)在参数传递层面是等价的,但在阅读代码时,用数组写法更能表达“对一堆元素进行堆调整”的意图。这种“同一个功能,多种表达方式”的特点,也是C语言学习者必须跨过的一道坎。
1.3 堆排序解决了什么实际问题
堆排序最典型的应用场景有两类。
一类是面对“不能在内存里放下全部数据”的排序需求。比如要对磁盘上几个GB的大文件排序,内存只能容纳部分数据,这时可以用堆维护一个大小为k的“窗口”,在流式读取的过程中不断把最大(或最小)的元素放到输出缓冲区,避免全量排序。
另一类是Top K问题:从海量数据中找出最大的K个数。传统做法是全部排序再取前K个,时间复杂度O(n log n),如果数据量是上亿级别,排序开销太大。更聪明的做法是用一个大小为K的小顶堆,遍历一遍数据,每遇到一个比堆顶大的元素就替换堆顶并调整堆。这样时间复杂度只有O(n log K),空间复杂度O(K)。我在处理日志分析时,要从千万级访问记录里找出访问量最高的10个IP,用的就是这个思路。
注意:堆排序适合“取最大/最小的前K个”“维护中位数”这类只需要部分排序的场景。如果需要完全有序且要求稳定,堆排序并不合适,我后面会详细讲稳定性问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正式实现前,必须搞懂的“堆”数据结构
2.1 堆与完全二叉树的关系
堆在逻辑上一棵完全二叉树,所谓完全二叉树,是指除了最后一层外,其他每一层都被节点填满,并且最后一层的节点都集中在左侧。数组天然可以表示完全二叉树,因为下标连续、没有空洞。
大顶堆的定义是:每个父节点的值都大于或等于其左右孩子节点的值。这样一来,整个堆的根节点(也就是数组的arr[0])一定是全局最大值。小顶堆则相反,每个父节点都小于或等于孩子节点,根节点是最小值。
搞清楚“堆”和“二叉搜索树(BST)”的区别很重要。堆只保证父节点与孩子节点的顺序关系,不保证左孩子一定小于右孩子,也不保证某个节点的左子树里的所有值都小于右子树里的所有值。所以堆只适合快速获取最大值或最小值,不适合做查找——想找一个特定值,堆的时间复杂度是O(n),而平衡二叉树是O(log n)。
我在教学时喜欢用“食堂排队”打比方:大顶堆像是老师让个子最高的站前面,但后面的人怎么站比较随意;二叉搜索树则是严格按照“从矮到高”排好的一条队伍。堆的“纪律性”没那么强,但它维护最大/最小值的成本极低,这就是它的生存价值。
2.2 数组下标与父子关系的三个关键公式
用C语言实现堆排序,有三个下标公式必须烂熟于心,并且每次写代码时都要重新确认一遍。
- 父节点下标:
parent = (i - 1) / 2(当i > 0时) - 左孩子下标:
left = 2 * i + 1 - 右孩子下标:
right = 2 * i + 2
我见过无数人把左孩子写成2 * i,这是因为许多教材用下标从1开始的数组讲堆排序,那时左孩子是2*i,右孩子是2*i+1,父节点是i/2。但C语言数组下标从0开始,所以公式整体偏移了一位。这个看似“小”的差别,能让你调试一下午。
可以验证一下:下标0的根节点,左孩子是1,右孩子是2;下标1的左孩子是3,右孩子是4;下标2的左孩子是5,右孩子是6。这些结果和完全二叉树的布局完全吻合。用一段简单的C代码就能打印验证:
c复制#include <stdio.h>
int main(void) {
for (int i = 0; i < 7; i++) {
int left = 2 * i + 1;
int right = 2 * i + 2;
printf("node %d -> left %d, right %d\n", i, left, right);
}
return 0;
}
输出如下:
text复制node 0 -> left 1, right 2
node 1 -> left 3, right 4
node 2 -> left 5, right 6
node 3 -> left 7, right 8
node 4 -> left 9, right 10
node 5 -> left 11, right 12
node 6 -> left 13, right 14
2.3 大顶堆和小顶堆如何决定排序方向
堆排序用大顶堆还是小顶堆,决定了最终元素在数组里是怎么排列的。
- 用大顶堆做升序排序:每次把堆顶(最大值)和数组末尾元素交换,然后把堆的范围缩小1,再对堆顶做调整。交换后,最大值被“钉”在数组末尾,第二轮又从剩下元素里取最大值到倒数第二的位置,以此类推。这就是“升序用大顶堆”。
- 用小顶堆做降序排序:每次把最小值扔到数组末尾,剩余元素继续调整成小顶堆。
这个关系很多初学者容易记反,我提供一个记忆窍门:堆顶是最大(或最小)的值,你把它放到数组的哪个方向,就决定了数组的哪个方向优先排列完毕。升序时要让最大的值先归位,所以把堆顶往末尾放,自然用大顶堆。
在实际刷题时,比如洛谷、GESP或者CSP这类比赛中,题目如果要求从大到小输出,很多考生会直接调用qsort并自定义比较函数,但手写堆排序时,方向搞反能错得非常隐蔽——代码看起来没问题,但结果顺序完全反了。我的建议是:写排序前,先明确题目要求的是升序还是降序,再决定用大顶堆还是小顶堆。
3. 堆排序C语言完整实现与逐行讲解
3.1 完整可运行的C语言代码
先把代码贴出来,这段代码在我的测试环境(GCC 9.4,Ubuntu 20.04)下编译运行通过,没有开启任何特殊编译选项,直接gcc heapsort.c -o heapsort即可。
c复制#include <stdio.h>
// 交换两个int变量的值
static void swap(int *a, int *b) {
int temp = *a;
*a = *b;
*b = temp;
}
// 对以i为根节点的子树进行堆调整,n表示当前堆的有效元素个数
static void heapify(int arr[], int n, int i) {
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) {
swap(&arr[i], &arr[largest]);
heapify(arr, n, largest);
}
}
// 堆排序主函数
static void heapSort(int arr[], int n) {
// 阶段一:自底向上建堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 阶段二:依次取出堆顶最大值,放到数组末尾
for (int i = n - 1; i > 0; i--) {
swap(&arr[0], &arr[i]); // 把当前最大值放到位置i
heapify(arr, i, 0); // 对剩余i个元素重新调整,注意长度是i
}
}
static void printArray(int arr[], int n) {
for (int i = 0; i < n; i++) {
printf("%d ", arr[i]);
}
printf("\n");
}
int main(void) {
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr) / sizeof(arr[0]);
printf("原始数组: ");
printArray(arr, n);
heapSort(arr, n);
printf("排序后数组: ");
printArray(arr, n);
return 0;
}
运行结果:
text复制原始数组: 12 11 13 5 6 7
排序后数组: 5 6 7 11 12 13
3.2 heapify函数:堆调整为什么是堆排序的“心脏”
heapify(也叫sift-down,下沉调整)的作用是:假设某个节点的左右子树都已经满足大顶堆性质,但这个节点本身可能比它的孩子小,那么就从该节点开始,沿着“最大孩子”的路径不断向下交换,直到该子树重新满足大顶堆性质。
代码里最关键的一步是先找出父节点、左孩子、右孩子中值最大的那个下标:
c复制if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
注意判断条件里都有一个left < n和right < n,这是为了防止数组越界。在最后一层,某个节点可能只有左孩子而没有右孩子,甚至两个都没有。如果不加边界判断,一旦right超出有效堆范围,arr[right]就会读取到未初始化的数据,或者直接访问越界内存,后果可能是随机值参与比较,导致排序结果彻底错乱。
交换之后,largest这个位置的元素发生了变化,它原本可能是子树里最大的,但交换后变成了原来那个“不够大”的父节点值。这个新值很可能仍然不满足大顶堆性质,所以必须递归调用heapify(arr, n, largest)继续向下调整。这就是“下沉”过程的来源——一个较小的值从父节点一路沉到合适的位置。
3.3 buildHeap:建堆为什么从 n/2 - 1 开始
建堆阶段的代码只有一行循环:
c复制for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
为什么起始下标是n / 2 - 1?因为完全二叉树中,下标大于(n / 2 - 1)的节点全部是叶子节点。叶子节点没有孩子,天然满足堆性质,不需要调整。从倒数第一个非叶子节点开始,向前逐个调用heapify,就能在O(n)时间内完成整个数组的建堆。
我见过初学者把建堆起点写成n / 2,然后发现结果偶尔正确、偶尔错误,非常迷惑。原因在于:如果n是偶数,n / 2恰好是最后一个非叶子节点的右兄弟或者叶子节点,从它开始调整会漏掉真正需要调整的节点。从n / 2 - 1开始是严谨的,因为n / 2 - 1是最后一个非叶子节点的下标。
为了验证这个起点,可以心里算一下:数组长度n=7时,非叶子节点下标为0、1、2,最后一个非叶子节点是2,而7 / 2 - 1 = 2,正确。数组长度n=6时,非叶子节点下标为0、1、2,最后一个非叶子节点也是2,而6 / 2 - 1 = 2,还是正确。
3.4 排序主循环:交换、缩小、再调整
建堆完成后,arr[0]是整个数组的最大值。排序阶段做的事很简单:
c复制for (int i = n - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
heapify(arr, i, 0);
}
每轮循环把堆顶(arr[0])与当前堆的最后一个元素arr[i]交换,最大值就到了它最终的排序位置。然后堆的有效长度从n变为i(因为arr[i..n-1]已经是排好序的“成品区”),再对arr[0]调用heapify,把剩下元素重新调整成一个大顶堆。
这里最隐蔽的坑是heapify(arr, i, 0)的第二个参数。很多人会惯性写成heapify(arr, n, 0),这样堆的范围没有缩小,刚交换到末尾的“已排序值”又会重新参与堆调整,结果排序完全乱套。正确写法中,第二个参数必须等于当前待排序区间的长度i,而不是全局长度n。
4. 时间复杂度、空间复杂度与稳定性深度解析
4.1 为什么建堆是O(n),而不是O(n log n)
关于堆排序的复杂度,我见过大量资料直接写“建堆O(n log n)、排序O(n log n)、总复杂度O(n log n)”。这个说法对排序阶段是准确的,但对建堆阶段是不严谨的。建堆过程确实是O(n)。
官方证明思路是:对深度为h的节点调用一次heapify,它最多向下比较h次。假设树有n个节点、高度为log n,那么:
- 高度为0的叶子节点最多有n/2个,每个需要0次下沉
- 高度为1的节点最多n/4个,每个最多下沉1次
- 高度为2的节点最多n/8个,每个最多下沉2次
- 类推,总工作量是Σ(h × n / 2^(h+1)),求和后收敛于n的常数倍
所以建堆的复杂度是O(n)。这个结论看起来很反直觉,但数学上确实如此。排序阶段要执行n-1次堆顶取出操作,每次heapify最坏需要O(log n)次比较和交换,总复杂度O(n log n)。
综合下来,堆排序的时间复杂度是:
| 情况 | 时间复杂度 |
|---|---|
| 最好情况 | O(n log n) |
| 最坏情况 | O(n log n) |
| 平均情况 | O(n log n) |
| 空间复杂度 | O(1),原地排序 |
堆排序没有快速排序那种“最坏退化”的危险。快排在数组完全有序或逆序时,如果选取基准不当,会退化到O(n²),堆排序的调整逻辑跟数据初始顺序基本无关,无论数据怎么排列,每轮循环都要走完整的heapify路径,所以最坏情况也是O(n log n)。
4.2 堆排序是不稳定排序,这点必须记住
稳定性是排序算法的一个隐藏指标:如果两个相等元素在排序前的相对顺序,在排序后保持不变,就称该排序是稳定的;反之则不稳定。
堆排序是不稳定的。原因出在排序阶段的交换操作上:堆顶元素与末尾元素交换时,可能把两个相等值的相对顺序打乱。更麻烦的是,建堆阶段本身就可能破坏相等元素的原始顺序。
我用一个例子直观展示不稳定现象:数组[5a, 3, 5b, 1],其中5a和5b代表两个值相同但来源不同的5。在建堆或排序过程中,5a可能会被换到5b后面,最终结果可能是[1, 3, 5b, 5a],相等值的相对顺序变了。
如果你需要的排序是稳定的(比如按成绩排序后,成绩相同的按学号次序排),堆排序就不适合,应该改用归并排序。不过在实际工程中,很多排序需求并“不关心稳定性”,只关心效率和内存占用,这时堆排序的价值就体现出来了。
4.3 堆排序、快速排序、归并排序、冒泡排序横向对比
我在学习排序时,喜欢把常用排序算法放在一张表里对比,这样“什么时候用谁”就很清楚。这里把堆排序和另外三种经典排序放在一起看:
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 特点 |
|---|---|---|---|---|---|
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 原地、最坏情况可控 |
| 快速排序 | O(n log n) | O(n²) | O(log n) ~ O(n) | 不稳定 | 常数因子小,实际最快 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 稳定但耗内存 |
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 简单但慢,适合教学 |
这张表有个很有意思的点:快速排序的平均时间虽然和堆排序同为O(n log n),但在大部分实际测试中快排都要快于堆排序,因为快排的常数因子小,缓存命中率也更高。堆排序的循环里频繁访问下标跨度很大的数组元素(比如下标0和下标n-1),对CPU缓存很不友好,所以在内存层级上吃了亏。
我做过一个简单测试:对100万个随机整数排序,快排大约0.1秒,堆排序大约0.15秒,归并大约0.12秒。但数据量继续增大到1亿,并且内存受限时,堆排序的O(1)空间优势就开始体现,归并排序可能因为申请不到足够内存而失败。所以“哪个排序最强”要看你所在的约束条件。
4.4 为什么不建议在所有场景都用堆排序
说句大实话,日常业务开发里,我几乎不用裸的堆排序来排序数组。C标准库有现成的qsort,它内部是快排的优化实现,通用性极强;C++里有std::sort,Java有Arrays.sort。手写堆排序主要出现在以下场景:
- 笔试或面试要求手写实现,考察对树和数组映射的理解
- 竞赛题里需要基于堆的优先队列来优化Dijkstra最短路径或Huffman编码
- 嵌入式环境无法依赖标准库,或内存小到不允许用归并排序
- Top K问题、数据流中位数、任务调度器等特定算法场景
还有一点要注意,C语言里的qsort虽然通用,但它的比较函数必须通过函数指针传入,函数指针调用在大量排序时有额外开销;堆排序则没有这个间接调用开销。但在排序前,你得先遍历一遍数据建堆,这本身也有成本。所以如果只是普通全量排序,qsort通常是首选。
5. C语言实现堆排序的踩坑实录与排查技巧
5.1 左右孩子下标写错导致“神级Bug”
我调试堆排序时,遇到最折磨人的一次Bug是:小数组排序正确,大数组偶尔出错。排查到最后,发现是我在heapify里把left = 2 * i + 1写成了left = 2 * i。
为什么小数组会“碰巧正确”?因为当数据量小、堆的深度只有1~2层时,2*i和2*i+1可能落在同一个未使用的数组区域,或者即使下标错了一位,某些随机数据恰好满足顺序要求。但数据量一上来,堆的深度加深,错位的下标就会访问到错误的孩子节点,导致堆性质被破坏,排序结果就乱了。
排查建议:在heapify函数入口打印i, left, right, arr[i], arr[left], arr[right],用一个小数组{12, 11, 13, 5, 6, 7}手动模拟,第一步就会发现问题。这类Bug的隐蔽性极高,但只要你记住了“C语言下标从0开始,公式是2i+1和2i+2”,就能从源头避免。
5.2 建堆起点从 n/2 开始导致漏调整
有人会把建堆循环写成for (int i = n / 2; i >= 0; i--),多调整了一个节点倒不致命,但少调整一个节点就麻烦了。问题是,n/2这个位置在大多数情况下恰好是叶子节点,从叶子节点开始heapify当然没问题,但前面真正需要调整的非叶子节点可能被跨过了。
我建议背下这个结论:最后一个非叶子节点的下标一定是n / 2 - 1,无论n是奇数还是偶数。如果你实在记不住,也可以在纸上画出完全二叉树的节点编号,从后往前找第一个有孩子节点的下标,反复几次就记住了。
5.3 排序阶段没有缩小堆范围,导致已排序区被重新调整
这段代码是高频错误区:
c复制for (int i = n - 1; i > 0; i--) {
swap(&arr[0], &arr[i]);
heapify(arr, n, 0); // 错误:没有缩小堆范围
}
正确写法是heapify(arr, i, 0),注意是i不是n。我在文章前面已经强调过一次,但这里值得再重复,因为这是实际运行中最容易“看起来对、跑起来错”的问题。
当i减小后,如果仍然传入n,那么数组末尾已经排好的最大值会重新参与堆调整,甚至可能再次被换到堆顶,导致排序失败。这种Bug的典型表现是:排序结果“大体有序,但个别位置乱序”。如果你看到这样的结果,优先检查这个参数。
5.4 递归heapify不是必须的,可以改成迭代版本
标准写法中的heapify用了递归,递归深度最多是树的高度O(log n),所以不存在栈溢出风险。但在极端环境(比如无操作系统的MCU)或需要对性能锱铢必较的场景,可以把递归改成循环:
c复制static void heapifyIterative(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;
}
}
迭代版本避免了函数调用开销,逻辑也更直接。我在GCC编译时,开启-O2优化后,递归和迭代的性能差异很小,但代码评审时很多人会觉得迭代版本更容易证明“每次循环都在缩小问题规模”。
5.5 用无符号整数做下标导致死循环
有一个“新手基本遇不到、老手偶尔翻车”的问题:如果用size_t这种无符号类型来写for (int i = n / 2 - 1; i >= 0; i--),那么当i减到0再执行i--时,它不会变成-1,而是变成一个很大的正数,导致死循环。
所以在C语言里做这种“递减到-1结束”的循环,老老实实用int类型。工程上这条经验可以推广:涉及负数或“边界为-1”的循环变量,不要用无符号类型。
6. 堆排序的衍生应用:Top K、优先队列与结构体排序
6.1 Top K问题:海量数据里找前K个最大/最小元素
面试和竞赛里,Top K问题几乎是堆排序的代名词。需求一般是:从n个元素中找出最大的K个元素,n可能上亿,K通常很小(比如10、100)。
用全排序的解法是O(n log n),空间O(n);用大小为K的小顶堆,遍历一遍数据,只有当元素比堆顶大时才插入,能保证堆里永远维护着“目前最大的K个”。时间复杂度O(n log K),当K很小时,约等于O(n),空间O(K)。
核心C代码思路如下:
c复制void findTopK(int arr[], int n, int k, int heap[]) {
// 先用前k个元素建一个小顶堆
for (int i = 0; i < k; i++) {
heap[i] = arr[i];
}
for (int i = k / 2 - 1; i >= 0; i--) {
heapifyMin(heap, k, i); // 注意这里是小顶堆调整
}
// 遍历剩余元素
for (int i = k; i < n; i++) {
if (arr[i] > heap[0]) { // 如果比堆顶大,替换堆顶并下沉
heap[0] = arr[i];
heapifyMin(heap, k, 0);
}
}
}
做题时(比如洛谷、GESP七级“物流网络”这类题),如果数据规模写到10^7量级,用堆做Top K经常是稳定拿满分的解法。但要注意,如果题目明确要求输出前K个且顺序有要求,取完堆里的K个元素后还要对堆内部做一次排序,因为小顶堆只保证根最小,不保证整个序列有序。
6.2 用C语言实现优先队列
优先队列是堆最常见的“包装”。在Dijkstra最短路算法、Huffman编码、任务调度、定时器管理里,都需要一种能快速取出最大/最小元素、同时支持插入的数据结构。用有序数组实现,插入O(n)、取最大O(1);用平衡树实现,两者都是O(log n)但实现复杂;而用二叉堆实现,插入和取极值都是O(log n),代码量又短,是性价比极高的方案。
C语言本身没有标准优先队列库,所以需要自己用堆实现。核心操作是两个:向上调整(sift-up)用于插入元素,向下调整(sift-down)用于删除堆顶元素。插入时把新元素放到数组末尾,然后不断和父节点比较并交换;删除堆顶时把末尾元素放到堆顶,再向下调整。补一个插入操作的代码片段:
c复制void pushHeap(int arr[], int *n, int value) {
arr[*n] = value;
int child = *n;
int parent = (child - 1) / 2;
while (child > 0 && arr[parent] < arr[child]) {
swap(&arr[parent], &arr[child]);
child = parent;
parent = (child - 1) / 2;
}
(*n)++;
}
网上很多C语言项目为了省事,会用数组模拟优先队列,然后每次排序全量调用qsort,数据量小时没问题,但数据量一大就是妥妥的性能灾难。学会用堆实现优先队列,是C语言进阶路上性价比最高的一项技能。
6.3 堆排序处理结构体数组的排序需求
C语言里到处是结构体,比如学生成绩、任务节点、网络报文等。要对结构体数组按某个字段排序,不能直接用>比较结构体,但可以基于堆排序改造。
核心思路是:在heapify的比较逻辑里,换成对结构体成员比大小。比如有一个struct Student,包含id和score,按score降序排序,就写成:
c复制typedef struct {
int id;
int score;
} Student;
void heapifyStudents(Student arr[], int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left].score > arr[largest].score) {
largest = left;
}
if (right < n && arr[right].score > arr[largest].score) {
largest = right;
}
if (largest != i) {
Student temp = arr[i];
arr[i] = arr[largest];
arr[largest] = temp;
heapifyStudents(arr, n, largest);
}
}
实际开发时,我会定义一个int (*compare)(const void *, const void *)函数指针传给排序函数,就能实现“同一个堆排序函数,按任意字段排”的效果,很像标准库qsort的设计思路。不过在C语言里回调函数的通用性往往优先于性能,如果确定待排序字段不会变,直接写死字段比函数指针要快不少。
6.4 从堆排序到更复杂的堆算法
掌握了堆排序,等于掌握了二叉堆的一半用法。再往后,可以顺理成章地学习:
- 索引堆(Index Heap):在建堆后不移动元素本身,只移动索引,适合“元素本身拷贝成本高”的场景,比如大结构体排序。
- 二叉堆做中位数维护:用一个大顶堆存较小的一半,用小顶堆存较大的一半,随时取中位数,复杂度O(1),插入O(log n)。
- 多路归并排序:外部排序常用堆来决定从哪个文件读取下一个最小元素。
我在GESP的题目里见过“环线”一类的图论题,考的就是“用堆优化的Dijkstra”,如果你堆排序理解得透彻,那堆优化的过程基本就是堆排序的“交换、下沉”思路,理解起来毫无压力。相反,如果堆不熟,看到这种题会非常痛苦。
所以我会建议所有学C语言的朋友:不要只把堆排序当成“背代码”的考试题,把它当成理解二叉堆的一把钥匙——一旦你真的理解了“完全二叉树的数组表示”和“下沉/上浮调整”这两个核心机制,堆排序以及一堆堆算法都会变得特别自然。
提示:自己动手跑代码时,建议先用小数组验证正确性,再改用
rand()生成大量随机数据做压力测试,每次交换后用额外函数检查一下arr[parent] >= arr[left]和arr[parent] >= arr[right]是否成立,能快速定位错误。
7. 手动模拟一遍堆排序:用{12, 11, 13, 5, 6, 7}全过程
7.1 模拟建堆阶段
为了把堆排序彻底讲透,我用数组{12, 11, 13, 5, 6, 7}手动模拟一遍完整过程。数组下标0到5,元素依次是12、11、13、5、6、7,n=6,最后一个非叶子节点下标是6/2 - 1 = 2。
先对下标2的节点做heapify:arr[2]=13,左孩子下标5对应值7,右孩子下标6越界。13比7大,不需要调整。
再对下标1的节点做heapify:arr[1]=11,左孩子下标3对应值5,右孩子下标4对应值6。11比5和6都大,不需要调整。
最后对下标0的节点做heapify:arr[0]=12,左孩子下标1对应值11,右孩子下标2对应值13。13最大,交换arr[0]和arr[2],数组变成{13, 11, 12, 5, 6, 7}。交换后下标2的值变成12,它的左孩子下标5对应值7,7不大于12,调整结束。建堆完成,堆顶是13。
7.2 模拟排序阶段
排序阶段,i从5到1:
- i=5:交换arr[0]=13和arr[5]=7,数组变成
{7, 11, 12, 5, 6, 13},堆范围[0,4]。对arr[0]做heapify,左右孩子11和12,12最大,交换arr[0]和arr[2],数组{12, 11, 7, 5, 6, 13};再检查arr[2]=7的左右孩子,下标5越界,结束。 - i=4:交换arr[0]=12和arr[4]=6,数组
{6, 11, 7, 5, 12, 13},堆范围[0,3]。对arr[0]做heapify,左右孩子11和7,11最大,交换后{11, 6, 7, 5, 12, 13};arr[1]=6的左右孩子是下标3的5,不需要调整。 - i=3:交换arr[0]=11和arr[3]=5,数组
{5, 6, 7, 11, 12, 13},堆范围[0,2]。对arr[0]做heapify,左右孩子6和7,7最大,交换后{7, 6, 5, 11, 12, 13};arr[2]=5的左右孩子越界。 - i=2:交换arr[0]=7和arr[2]=5,数组
{5, 6, 7, 11, 12, 13},堆范围[0,1]。对arr[0]做heapify,左右孩子是6和越界,6最大,交换后{6, 5, 7, 11, 12, 13}。 - i=1:交换arr[0]=6和arr[1]=5,数组
{5, 6, 7, 11, 12, 13},堆范围[0,0]。排序完成。
手动模拟一遍的最大作用是培养“手算排序”的能力。笔试中如果让写出每一趟排序后的数组状态,你只要照着这个流程来,基本不会错。建议你也找几个数组自己画一画,画着画着就会明白为什么堆排序的最佳、平均、最坏都是O(n log n)——因为每轮调整都要从堆顶一路走到叶子,无论数据初始状态如何。
8. 几个可以立即用起来的代码优化与变体
8.1 用“迭代heapify + 内联交换”提升性能
前面贴的代码是“教学版”,以清晰为主。在追求性能的场景,可以做三处优化:
- 把
swap函数内联,或直接写成宏#define SWAP(a, b) do { int t = (a); (a) = (b); (b) = t; } while (0) - 把递归
heapify改成迭代版本 - 在排序阶段,不直接交换堆顶和末尾,而是先保存堆顶值,再把末尾值“冒泡下沉”到合适位置,最后把堆顶值写到末尾,减少交换次数
第三点的优化思路是:堆顶元素最终要放到末尾,不需要反复交换,可以先把末尾元素临时赋给堆顶,再通过下沉找到它该去的位置,最后把原堆顶值填到末尾。这个“一次性赋值”比多次swap更快,但实现时要小心别漏掉中间状态。
8.2 用宏或函数指针实现“通用性”
如果想写一个能排序任意类型数组的堆排序,可以仿照qsort的签名:
c复制void heapSortGeneric(void *base, size_t n, size_t size,
int (*compare)(const void *, const void *));
但C语言没有模板,用void*加函数指针会导致每次比较都间接调用,性能有所下降。在嵌入式开发中,我更倾向于用-D宏或代码生成来针对具体结构体生成排序函数,而不是运行时动态绑定。具体怎么做要看团队代码风格,这里不展开,但要记住:通用性和性能在C语言里往往是一对矛盾。
8.3 升序降序的快速切换
如果想用一套堆排序代码同时支持升序和降序,可以在heapify里把比较逻辑抽成一个可配置的宏,或者加一个bool descending参数:
c复制if (descending) {
if (left < n && arr[left] < arr[largest]) largest = left;
if (right < n && arr[right] < arr[largest]) largest = right;
} else {
if (left < n && arr[left] > arr[largest]) largest = left;
if (right < n && arr[right] > arr[largest]) largest = right;
}
这种做法适合需要一个函数搞定两种排序的场景,但代价是每次比较多一个分支判断,性能损失很小。竞赛刷题时,我不推荐这么做,因为出题人更希望你明确“升序用大顶堆、降序用小顶堆”,写清楚即可。
8.4 数据量很小时,不要用堆排序
堆排序的常数因子偏大,当n小于20~50时,插入排序或冒泡排序的性能反而更好。很多高效的排序实现(比如C++ std::sort)内部会在区间长度小于阈值时切到插入排序,就是这个原因。所以如果你处理的数据量非常小,直接上堆排序并不是最优解。
我记得有一次在项目里处理单片机上的8个数据排序,因为贪图“堆排序高大上”,用堆排序跑了排序,结果平均耗时比冒泡还高。后来换成冒泡,虽然算法“低级”,但实际响应更快。这个经历告诉我:算法选型不能只看大O复杂度,还要看常数因子和数据规模。
根据我个人经验,堆排序最适合的场景是:数据量大、内存受限、只关心最大或最小的若干个值,而不是全部数据必须严格有序。它不像快排那样在“大多数日常排序”里称王,也不像归并那样能保证稳定,但它在“Top K”“优先队列”“最坏情况可控”这三个维度上,是别家替代不了的。如果你正在准备C语言相关的笔试,建议把堆排序的代码背下来,再用几组数据手动模拟到骨头里,面试官最喜欢让你在纸上写“堆排序每一轮交换后的数组状态”,这一关过了,大半个排序环节就拿下了。
