1. 先从“选择”这件事说起
很多人在学习排序算法的时候,会把选择排序当成入门级的“开胃菜”,觉得它太简单了,看一眼就会。但真正让我对排序算法产生系统认知的,恰恰是把这个“简单”的选择排序和堆排序放到一起去理解的那一刻。你要知道,堆排序本质上就是选择排序的进阶形态,它们的底层逻辑是一脉相承的:都是“不断从未排序部分选出最大(或最小)元素,放到已排序部分的末尾”,只不过选择排序靠线性扫描来找极值,堆排序靠堆这种数据结构来高效找极值。这个区别非常关键,理解了它,你就同时掌握了两个算法,而且能更深入理解“数据结构如何优化算法复杂度”这个核心命题。
这篇文章我会把选择排序和堆排序拆开来,从原理到代码,从复杂度到面试常考的循环不变量证明,再到实际工程中怎么选型,系统地讲一遍。适合正在准备面试的开发者、刚入门数据结构与算法的学生,以及写业务代码但想补一补基本功的朋友。我会用C语言作为实现语言,跟你平时接触到的教材风格保持一致,看完你就能手写这两种排序,并且能够回答关于它们的绝大多数追问。
2. 选择排序:看似简单,坑其实不少
2.1 核心逻辑与直观理解
选择排序的基本思路就是一句话:每一轮从未排序区间中找出最小值,把它放到已排序区间的末尾。
举个具体例子,假设有一个数组[64, 25, 12, 22, 11],第一轮扫描整个数组,找到最小值11,和第一个位置的64交换,结果是[11, 25, 12, 22, 64]。此时11已经位于正确位置,我们把它看作已排序部分。第二轮扫描剩余部分[25, 12, 22, 64],找到最小值12,和第二个位置(也就是索引1)的25交换,结果为[11, 12, 25, 22, 64]。以此类推,每一轮都让一个元素归位,直到整个数组有序。
这个算法好理解到什么程度?你甚至可以不用写代码,拿一副扑克牌就能模拟:把牌摊开,每次选出最小的一张放到最前面,重复操作。它的思路直白到几乎不会有人理解错。
C语言实现如下:
void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; } } if (min_idx != i) { int temp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = temp; } } }注意外层循环的条件是i < n - 1,不是i < n。当n - 1个元素都放到了正确位置,剩下的最后一个元素自然是有序的,不需要再处理。这是一个很细节但值得注意的点,很多初学的人会在这里多算了一轮,虽然不影响正确性,但会多做无用功。
2.2 为什么选择排序有两层循环
很多人问过,冒泡排序、插入排序也都是两重循环,为什么选择排序的时间复杂度一定是O(n^2),而且无法通过提前退出机制优化到更低的复杂度?
关键在于选择排序的“扫描”是无条件的。冒泡排序如果在某一轮没有发生任何交换,可以提前终止;插入排序如果当前元素比前一个元素大,可以提前结束本轮。但选择排序不一样,即使数组已经有序,你仍然需要扫描完整轮来“确认”当前区间的最小值就是第一个元素。换句话说,选择排序的最优时间复杂度、平均时间复杂度和最坏时间复杂度全部都是O(n^2)。
比较次数方面,选择排序是固定的n(n-1)/2次比较。交换次数最多是n-1次,这也是选择排序的两个优点之一:
- 交换次数少,对交换成本高的场景(比如元素是很大的结构体)有优势;
- 思路简单,实现无脑,不容易出错。
缺点是无论数据怎么样,比较次数都固定,不像插入排序那样在近乎有序的数据上能跑到O(n)。
2.3 正确性证明:循环不变量怎么用
CLRS(《算法导论》)里讲选择排序的时候会用到循环不变量,很多自学的人在这里容易卡住。我用自己的话给你讲明白。
循环不变量是一个在循环每次迭代前后都保持成立的性质。对于选择排序,我们建立这样的不变量:
在处理第
i轮循环之前,数组的前i个位置(索引0到i-1)已经包含了整个数组中最小的i个元素,并且它们已经有序。
用这个不变量来证明算法正确性分三步:
初始化:当i = 0,前0个位置自然是“包含最小的 0 个元素”,不变量成立。
保持:假设在第i轮开始时,前i个元素已经是全局最小的i个元素且有序。内层循环从i+1到n-1扫描,找到当前未排序部分的最小值。这个最小值一定是整个剩余数组中的最小元素,因此它大于等于前i个元素中的任意一个。把它的索引记为min_idx,与arr[i]交换后,索引0到i这前i+1个位置就是全局最小的i+1个元素,而且依然有序。下一轮i增加为i+1,不变量继续成立。
终止:当循环结束时,i = n-1,前n-1个位置包含了全局最小的n-1个元素且有序,剩下的最后一个元素只能是最大的那个,所以整个数组有序。证明完毕。
这段证明在面试中出现的频率很高,尤其是“选择排序的循环不变量证明”这个问题,很多人知道答案但说不清楚。关键在于说透两点:一是“未排序部分扫描出的最小值必定是全局剩余最小”,二是“前 i 个元素的有序性在交换后依然保持”。抓住这两点,面试官就会认可你对算法的理解不是背书,而是真的懂了。
3. 选择排序的进阶:从O(n^2)到O(n log n)
3.1 核心瓶颈在哪
选择排序慢,慢在“找最小值”这一步上。每一轮要扫描整个未排序区间才能确定一个最小值的位置,扫描一遍是O(n),要做n轮,所以总共是O(n^2)。
那么问题来了:能不能用一种数据结构来维护未排序区间的最小值,使得每一轮查询最小值的时间复杂度降到O(log n)?如果可以,总复杂度就能变成O(n log n)。
答案就是堆。堆是一种特殊的完全二叉树,分为大顶堆和小顶堆。大顶堆的特点是每个节点的值都大于等于它的左右子节点的值,所以堆顶元素一定是整个堆中的最大值。小顶堆反之,堆顶是最小值。
如果我们维护一个小顶堆,每次从堆顶取出的就是当前最小值,取出并调整堆的时间复杂度是O(log n),总共执行n次,就是O(n log n)。这就是堆排序的底层逻辑,一句话概括:用堆来加速选择排序的“选择”过程。
这个思维跃迁非常重要。你写代码的时候可能不觉得数据结构有多重要,但当你看到同样的选择逻辑,仅仅因为换了一个“查找工具”,就把复杂度从O(n^2)拉到了O(n log n),你对“算法 = 逻辑 + 数据结构”这句话就会有切肤之感。
3.2 堆的基础操作:上浮与下沉
用 C 语言实现堆,我们通常会用一个数组来模拟完全二叉树。对于索引从0开始的数组,某个节点i的:
- 左孩子索引是
2 * i + 1 - 右孩子索引是
2 * i + 2 - 父节点索引是
(i - 1) / 2
堆的核心操作有两个:堆化(heapify)和建堆(build heap)。
堆化的场景是:某个节点的左右子树都已经满足堆的性质,但这个节点本身不满足。我们需要通过“下沉”操作让整个子树重新满足堆的性质。
以大顶堆为例,下沉操作的 C 代码:
void heapify(int arr[], int n, int i) { int largest = i; int l = 2 * i + 1; int r = 2 * i + 2; if (l < n && arr[l] > arr[largest]) { largest = l; } if (r < n && arr[r] > arr[largest]) { largest = r; } if (largest != i) { int temp = arr[i]; arr[i] = arr[largest]; arr[largest] = temp; heapify(arr, n, largest); } }这段代码的含义是:找到节点i、左孩子、右孩子三者中的最大值下标,如果最大值不是当前节点,就交换它们,然后递归地对被交换下去的子树继续堆化。
这里有一个新手常踩的坑:堆化操作没有显式的“层数限制”,它是靠递归自己终止的。如果传入的n不对,或者下标计算边界没处理好,很容易数组越界。所以每次递归前都要检查l < n和r < n,这是硬条件,少了任何一个,程序都会出问题。
上浮操作对应的是插入场景,从小顶堆中插入元素时,把新元素放到数组末尾,然后不断和父节点比较,如果比父节点小就交换,直到满足堆性质。堆排序其实用不到上浮操作,但理解上浮有助于你理解优先队列的实现。很多人在学堆排序时感觉吃力,就是因为对这两种操作的区别没有形成清晰的认知:建堆用下沉,插入用上浮,方向相反,但都用“交换 + 递归/循环”的方式修正结构。
3.3 建堆:为什么从n/2-1开始
拿到一个无序数组,我们怎么把它变成一个合法的堆?答案是自底向上地调用heapify。
代码是这样:
void build_heap(int arr[], int n) { for (int i = n / 2 - 1; i >= 0; i--) { heapify(arr, n, i); } }为什么从n/2 - 1开始往前遍历,而不是从0开始,也不是从n-1开始?这里要理解完全二叉树的性质:所有叶子节点本身就已经是满足堆性质的单个节点。对于索引i的节点,它的左孩子2*i+1和右孩子2*i+2有可能越界。当i >= n/2时,它的左孩子索引2*i+1 >= n,说明它没有孩子,是叶子节点。所以第一个非叶子节点的索引是n/2 - 1。
从最后一个非叶子节点开始,从右往左、从下往上依次 heapify,就能保证在处理某个节点时,它的左右子树已经是合法的堆了。这正好满足heapify的前提条件。
举个例子,数组[4, 10, 3, 5, 1],n = 5,第一个非叶子节点索引是5/2 - 1 = 1,也就是值为10的节点。先对索引1做 heapify,它的孩子是索引3(值5)和索引4(值1),最大值是10本身,无需交换。然后处理索引0(值4),它的左孩子索引1(值10)、右孩子索引2(值3),最大值是10,交换后数组变为[10, 4, 3, 5, 1],接着递归地对索引1再做 heapify,此时4的孩子是5和1,最大的是5,交换后变为[10, 5, 3, 4, 1]。此时堆化完成,堆顶是最大值10。
建堆的时间复杂度不是O(n log n),而是O(n)。这个结论看起来很反直觉,因为从代码上看,你要对大约n/2个节点执行heapify,而每次heapify是O(log n),相乘不就是O(n log n)吗?问题的关键在于:并不是所有节点的 heapify 都是 O(log n) 的。绝大部分节点位于树的底部,它们下沉的高度很小。精确计算后会发现,总的操作次数可以被归约为一个等比数列求和,结果是O(n)。这个结论在面试中经常被当作追问考点,你能答出“建堆是 O(n)”并不稀奇,但能解释清楚为什么才是真正的加分项。
3.4 堆排序的完整流程
堆排序的思路也很简单直接:
- 把无序数组建成一个大顶堆,此时堆顶是最大值。
- 把堆顶元素和堆的最后一个元素交换,这样最大值就到了数组末尾的正确位置。
- 堆的大小减一,对新的堆顶执行一次
heapify,重新调整为大顶堆。 - 重复第 2 步和第 3 步,直到堆中只剩一个元素。
C 语言实现:
void heap_sort(int arr[], int n) { build_heap(arr, n); for (int i = n - 1; i > 0; i--) { int temp = arr[0]; arr[0] = arr[i]; arr[i] = temp; heapify(arr, i, 0); } }执行过程我用一个具体案例走一遍。假设堆排序开始前的大顶堆是[10, 5, 3, 4, 1],数组长度为5。
第一轮:交换arr[0]和arr[4],数组变为[1, 5, 3, 4, 10],此时10已经归位。对前 4 个元素做 heapify,arr[0] = 1下沉,变成[5, 4, 3, 1, 10]。
第二轮:交换arr[0]和arr[3],数组变为[1, 4, 3, 5, 10],5归位。对前 3 个元素 heapify,1下沉,变成[4, 1, 3, 5, 10]。
第三轮:交换arr[0]和arr[2],数组变为[3, 1, 4, 5, 10],4归位。对前 2 个元素 heapify,3下沉,变成[3, 1, 4, 5, 10]。
第四轮:交换arr[0]和arr[1],数组变为[1, 3, 4, 5, 10],3归位。堆中只剩一个元素,排序结束。
最终结果是[1, 3, 4, 5, 10],排序正确。
这样走一遍之后你会发现,堆排序的核心代码其实非常短,难的不是写出来,而是理解每一步之后堆的结构发生了怎样的变化。建议你手头有纸的话,自己画一棵树,跟着每一轮交换后的数组状态重新画一个对应的完全二叉树,亲眼看到最大值逐个“浮”到堆顶、然后被“扔”到数组末尾的过程。这个可视化过程一旦建立,堆排序就永远不会忘了。
4. 两种排序的复杂度对比和工程选型
4.1 时间复杂度、空间复杂度和稳定性对照
为了让你一屏看清楚,我把这两种排序的核心指标整理成一个表:
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 |
接着解释一下两个容易困惑的点。
为什么选择排序不稳定?不稳定是因为选择排序使用了“交换”而不是“移动”。举个例子,数组[5a, 5b, 1],其中5a和5b是两个值相等的元素。第一轮扫描找到最小值1,和第一个位置的5a交换,结果为[1, 5b, 5a]。你看,两个5的相对位置从原来的5a在前变成了5b在前,相等元素的顺序被破坏了,所以不稳定。
为什么堆排序不稳定?堆排序不稳定有两个原因。一是建堆过程中会破坏原有的相对顺序,二是每次把堆顶和末尾元素交换时,跨越了很多位置,容易把相同元素的相对顺序打乱。最典型的例子是[5, 5, 1],建堆后可能变成[5, 5, 1]或[5, 1, 5],取决于子节点的大小关系;排序过程中,交换位置可能跨越多个元素,导致相同值的两个5相对顺序改变。所以堆排序天然不稳定,这是它的内在属性,没有任何实现技巧能改变这一点。
4.2 数据规模与场景适配分析
实际工程中该怎么选?我的建议如下。
数据量小(比如 n < 1000):选择排序和堆排序都没有优势。这个量级下,插入排序反而是更优的选择,因为它实现简单、近乎有序时效率高、且稳定。选择排序唯一的优势在于交换次数少,如果你的数据是大型结构体,交换开销远大于比较开销,那么选择排序可以考虑,但这种情况场景比较罕见。
数据量大且需要原地排序:堆排序是很稳的选择。它不需要额外空间,最坏情况也能保证O(n log n)。相比之下,快速排序虽然平均性能也很强,但有O(n^2)的最坏情况,而且实现不当容易递归过深。如果你的系统对最坏情况时间有硬性要求,堆排序比快速排序更让人安心。
需要稳定性:这两种都不能用,直接考虑归并排序或插入排序。
实时系统或嵌入式环境:堆排序的O(1)空间复杂度非常友好。但要注意,它的访问模式是跳跃式的(父节点和孩子节点跨度大),在缓存友好的场景下表现不如快速排序。这属于“理论上最优但不一定是实际最快”的典型例子。如果你不是在做操作系统内核或者特殊嵌入式任务,日常业务开发里,库函数内置的qsort或std::sort基本就够了,不必自己手写堆排序。
4.3 一个实际案例:优先队列中的应用
堆排序在日常开发里最广为人知的应用其实是优先队列。很多场景下我们并不需要对整个数组排序,只是反复需要“当前最大(或最小)的元素”,比如操作系统的任务调度、Dijkstra 最短路径算法、Top K 问题。这些场景如果用“每次全量排序”的思路做,效率不堪设想;用“维护一个堆,只取堆顶”的思路做,就能把每次取值的开销压缩到O(log n)。
我曾经在项目中遇到一个实时推荐场景,需要从几十万个候选物品中不停选出当前得分最高的物品,但候选物品的得分会动态更新。这时候用堆排序的变体——索引堆或带位置映射的堆——就能高效处理更新操作。当然,这是后话了,但如果你能把堆排序的原理吃透,在这个基础上去理解优先队列和索引堆,会顺畅很多。
5. 常见问题排查与实操心得
5.1 写选择排序和堆排序时最容易犯的错
问题一:选择排序的比较条件写反了。很多人会写成if (arr[j] > arr[min_idx]),这样一来找的就不是最小值而是最大值了,排序结果方向直接反。排查办法很简单:打印出每一轮的min_idx和交换后的数组,核对是否符合预期。
问题二:堆排序的 heapify 边界条件缺失。经常有人把if (l < n)漏掉,或者把n传成整个数组长度而不是当前堆的大小。堆排序里堆的规模是动态缩小的,如果你在每一轮都传入原始n,那已经被交换到数组末尾的“乱序元素”就会再次参与堆化,导致排序出错。这是堆排序最隐蔽的 bug,肉眼很难看出来。建议你在写完后专门用逆序数组测试一下,如果结果顺序不对,优先检查这一步。
问题三:建堆起始索引不对。有些人会从n-1开始向前遍历,这样做也能得到正确的堆,但效率低了下,因为你对所有叶子节点都做了一次无用的 heapify。问题倒是不致命,但会被面试官追问“为什么从 n/2 - 1 开始”,回答不上来就容易减分。
问题四:数组索引从 0 还是从 1 开始。很多教材为了公式简洁,习惯用从 1 开始的索引描述堆,父节点是i/2,孩子是2i和2i+1;但 C 语言实际实现是 0 基索引,左右孩子是2i+1和2i+2。如果你照着伪代码抄,很容易算错下标。我用过最笨也最有效的方法:在自己代码里注释几行“索引 0 的左右孩子是 1 和 2,索引 1 的孩子是 3 和 4”,防止自己每次都要重新推导。
5.2 面试和考试中的追问点总结
从热词“clrs 选择排序循环不变量证明”也能看出来,很多人是被教材里的严谨证明折磨过的。我建议你掌握下面这组常见追问,基本能覆盖大多数面试场景:
- 选择排序是稳定的吗?为什么?举一个反例。
- 选择排序的最好时间复杂度和最坏时间复杂度是多少?为什么无法优化?
- 堆排序为什么不稳定?
- 建堆的时间复杂度是多少?推导一下。
- heapify 的时间复杂度是多少?什么时候调用?
- 堆排序和选择排序的关系是什么?
- 如果要找第 k 大的元素,应该怎么做更高效?
对最后一道题的提示:完整排序是O(n log n),但如果只需找出第k大的元素,维护一个大小为k的小顶堆,遍历一遍数组,比当前堆顶大就替换并堆化,时间复杂度是O(n log k)。这个思路就是堆排序思想在实际问题中的延伸,能答出来会让面试官觉得你真的理解了堆的精髓。
5.3 我的实操建议
学排序算法,我一直推荐的方法是在纸上手推一遍完整流程,再在电脑上实现一遍,再故意改错几个地方看结果会变成什么样子。手推能建立直觉,实现能检验理解,故意改错能帮你排查边界问题。这个方法适用于所有算法,但对选择排序和堆排序尤其有效,因为这两个算法的数据流比较直观,出错时的现象也比较明确。
另外,我发现在写堆排序代码的时候,统一使用“下沉”函数名(sift_down)比使用“堆化”(heapify)更容易减少误解,因为堆化的含义在不同教材里略有差异。墙裂建议你在自己的代码模板里固定一个命名习惯,这会让你在快速编码时减少认知负担。
这两类排序虽然基础,但它们是理解许多高级数据结构(优先队列、索引堆、Dijkstra 优化等)的地基。把地基打牢,后面再往上盖楼,你会走得更稳。