排序的本质是搜索,优化的本质是利用信息来缩小搜索空间。n 个元素的全排列有 n! 种可能,排序的目标就是从这浩如烟海的可能性中,找出唯一有序的那一个。信息论告诉我们,基于比较的排序,其复杂度下限是 log₂(n!) ≈ n log n 次比较——因为每次比较最多获取 1 bit 信息,而我们需要从 n! 种可能性中锁定答案。
这篇文章的核心目标非常明确:把快速排序、归并排序、堆排序这“三巨头”的核心差异讲透,然后顺带介绍计数、桶、基数等非比较排序,以及由排序思想衍生出的几道经典面试题。
我们不追求逐行手写代码,你需要的是深刻理解每种排序“在做什么”以及“适合什么场景”,并最终达到对模板代码的肌肉记忆。
一、比较排序的三巨头
1.1 快速排序:分治 + 分区
核心思想:选择一个基准元素(pivot),将数组划分为“所有小于等于 pivot 的元素”和“所有大于 pivot 的元素”两部分,然后对这两部分递归地进行排序。
为什么会更快:每次分区(Partition)操作将问题规模大致减半,平均情况下每层递归处理 n 个元素,递归深度为 log n,因此总时间复杂度为 O(n log n)。
关键细节:
- 分区的本质:用两个指针从两端向中间扫描,将元素按照与 pivot 的大小关系,交换到正确的一侧。这是一个
O(n) 时间复杂度的原地(in-place)操作。
- Pivot 的选择决定性能:如果每次都固定选择第一个或最后一个元素作为 pivot,那么在遇到完全有序的数组时,算法会退化为
O(n²)。常用的优化方案是随机选择 pivot 或“三数取中法”。
- 三路快排:当数组中存在大量重复元素时,普通快排也会出现性能退化。三路快排将数组划分成
[< pivot] [= pivot] [> pivot] 三个区间,等于 pivot 的元素被直接确定位置,无需参与后续递归。
适用场景:大多数情况下的通用排序首选。
面试关注点:
- 必须能手写出 Partition 的双指针扫描逻辑。这段代码虽然容易写错,但它是基础,写不出来基本会被认为基础不扎实。
- 为什么平均复杂度是
O(n log n),最坏是 O(n²)。
- 如何通过随机化来避免最坏情况的发生。
- 快速选择(QuickSelect)算法:它利用 Partition 的思想,能在
O(n) 的平均时间复杂度内找到数组中第 K 大的元素。
1.2 归并排序:分治 + 合并
核心思想:先递归地将数组对半拆分,直到每个子数组只有一个元素(天然有序),然后再用双指针法,以 O(n) 的时间将两个有序子数组合并成一个大的有序数组。
为什么快:它和快排一样基于分治思想,但归并排序的划分是“无脑”对半分,不依赖 pivot,因此总能保证划分的均匀性。其核心操作是“合并(merge)”。因为划分始终均匀,即便在最坏情况下,它的时间复杂度也稳定在 O(n log n)。
关键细节:
- 稳定性:归并排序是稳定排序,即相等元素的原始相对顺序在排序后保持不变。而快排和堆排序都不是稳定的。这一点在面试中经常被追问。
- 代价:合并过程需要一块额外的内存空间来存放临时数据,空间复杂度为
O(n)。这是它的主要劣势。
- 自底向上版本:可以使用迭代而非递归来实现,即先两两合并,再四四合并……以此类推,能避免递归带来的栈空间开销。
适用场景:当你需要稳定排序时(例如数据库操作中);处理无法完全加载到内存的外部排序(External Sorting)问题时;或是对链表进行排序(链表上的归并排序无需额外空间)。
面试关注点:
- merge 函数的手写能力。
- 稳定与不稳定的区别及其背后的工程意义。
- 归并思想的衍生问题,如逆序对计数、合并 K 个有序链表等。
1.3 堆排序:借助数据结构
核心思想:将数组原地构建成一个最大堆(Max Heap),然后反复将堆顶元素(当前最大值)取出,与数组末尾元素交换,并从堆顶向下调整维持堆的性质。
为什么快:建堆操作可以优化至 O(n)。之后进行 n 次取堆顶和向下调整的操作,每次调整都是 O(log n),因此总复杂度为 O(n log n)。
关键细节:
- 原地 + O(1) 额外空间:堆排序不需要像归并那样申请额外数组,也不依赖递归(快排则需要
O(log n) 的递归栈空间),它是真正意义上的原地排序算法。
- 不稳定:堆的调整操作会破坏相等元素原始的相对顺序。
- 缓存(Cache)不友好:堆的父子节点在数组中索引是跳跃的(父节点
i,子节点 2i 和 2i+1),这导致程序访问数据时的空间局部性极差。这正是堆排序在实际运行中慢于快排的根本原因。
适用场景:在内存极度受限,需要严格 O(1) 额外空间且 O(n log n) 时间复杂度的场景下,作为快排的兜底方案。不过,Top-K 问题才是堆在面试求职中的主战场,通过维护一个大小为 K 的堆,可以将时间复杂度控制在 O(n log K)。
面试关注点:
- 建堆为什么是
O(n) 而不是 O(n log n)?这个问题能答上来是很大的加分项。
- 堆排序与快排的实际性能差异,重点在于理解缓存局部性(Cache Locality)。
- 堆的两个核心操作:向上调整(up)和向下调整(down)。
1.4 三巨头对比
| 维度 |
快排 |
归并 |
堆排序 |
| 平均时间 |
O(n log n) |
O(n log n) |
O(n log n) |
| 最坏时间 |
O(n²) |
O(n log n) |
O(n log n) |
| 额外空间 |
O(log n) 栈 |
O(n) |
O(1) |
| 稳定性 |
不稳定 |
稳定 |
不稳定 |
| Cache 友好 |
好(顺序访问) |
一般 |
差(跳跃访问) |
| 实际速度 |
最快 |
中等 |
最慢 |
| 典型用途 |
通用排序 |
稳定排序/外部排序 |
空间受限/Top-K |
面试“万金油”回答:为什么很多标准库的内部排序实现用快排而不是堆排序?——虽然堆排序能在最坏情况下也保证 O(n log n),但快排拥有极佳的缓存局部性(Cache Locality),能高效利用 CPU 缓存,实际运行速度往往是堆排序的 2 到 3 倍。
二、非比较排序:突破 O(n log n) 下界
比较排序的 O(n log n) 下界,是在“仅通过元素间两两比较来获取信息”的模型下成立的。如果我们能直接利用元素本身的数值信息(比如知道所有数据的范围),就能打破这个限制。
2.1 计数排序
思想:已知元素范围在 [0, K] 之间,创建一个长度为 K+1 的计数数组,扫描一遍原数组统计每个值出现的次数,最后按顺序依次输出即可。
时间复杂度:O(n+K),空间复杂度 O(K)。当 K 远大于 n 时,会浪费大量空间,就不划算了。
稳定性技巧:对计数数组求前缀和,然后倒序遍历原数组,根据前缀和确定每个元素的最终位置。这个“前缀和+倒序”的技巧保证了稳定性,面试可能问到。
2.2 桶排序
思想:将元素分散放入若干个“桶”中,每个桶内部采用其他排序算法(如插入排序)排好,最后将所有桶按顺序拼接。
适用场景:当输入数据分布均匀时,效率极高,平均时间复杂度可达 O(n)。
经典面试题——最大间隙问题:给定 n 个数,要求找出它们在排序后相邻两数的最大差值,时间 O(n)。核心思路是利用桶排序思想,将 [min, max] 区间划分为 n-1 个等宽桶。根据鸽巢原理,最大差值一定出现在某个桶的最小值与它前一个非空桶的最大值之间。这样每个桶只需记录最大值和最小值,无需进行完整排序。
2.3 基数排序
思想:从最低有效位(LSD)到最高有效位,对每一位数字使用稳定的排序算法(如计数排序)进行一轮排序。
时间复杂度:O(d·(n+K)),d 是数字的位数,K 是每位的取值范围(如十进制是 10)。
适用场景:非常适合整数、等长字符串的排序。在实际工程中,Redis 的 SORT 命令底层就可能用到基数排序。
三、排序思想的衍生问题
纯粹的排序算法在面试中越来越少直接考察,更多是考察对排序思想的灵活运用。
3.1 逆序对计数(归并思想)
问题:计算数组中前面数字大于后面数字的对数。
归并解法:利用归并排序的合并过程。当左半边 arr[i] > arr[j] 时,因为左右两半各自有序,所以左半边从 i 到 mid 的所有元素都大于 arr[j],一次性累加 mid - i + 1 即可。一次 merge 操作就能批量统计,将时间复杂度从暴力的 O(n²) 降至 O(n log n)。
3.2 合并 K 个有序链表/数组(归并 + 堆)
问题:合并 K 个已排序的链表。
- 分治归并:类似归并排序,两两合并,进行 log K 轮,总时间
O(n log K)。
- 最小堆:维护一个大小为 K 的最小堆,每次取堆顶(全局最小节点),并将其下一个节点入堆,总时间
O(n log K)。堆方法更适用于数据流式到达的在线场景。
3.3 颜色分类 / 荷兰旗问题(Partition 思想)
问题:一个数组只包含 0、1、2,要求原地排序。
本质:它就是三路快排中“等于区域”的特化版本。
思路:维护三个指针 s0(0的右边界)、cur(当前元素)、s2(2的左边界)。cur 遍历数组:
arr[cur] = 0:与 s0 交换,s0++,cur++
arr[cur] = 1:cur++
arr[cur] = 2:与 s2 交换,s2--(注意 cur 不动,因为换过来的元素还没检查)
3.4 快速选择:O(n) 找第 K 大
问题:在无序数组中寻找第 K 大的元素。
思路:使用快排的 Partition。如果 pivot 的位置恰好是 K,则找到;如果大于 K,只需递归左边;否则递归右边。它每次只递归一侧,平均时间复杂度为 O(n)。
二者的区别:找“第K大”平均 O(n) 时间就够了,而“前K大”需要完全排序或全扫描,代价更高。进阶还可以了解最坏 O(n) 的 BFPRT 算法,了解其存在即可。
四、面试实战要点
4.1 排序相关的高频面试追问
- Q:为什么实际应用中快排通常比堆排序快?
A:关键在 CPU 缓存局部性。快排是顺序访问内存,而堆排序父子节点间跳跃访问,L1/L2 缓存命中率非常低。
- Q:什么时候宁愿选归并排序也不用快排?
A:当你需要稳定性时;或处理无法全量载入内存的数据(外部排序);或对链表进行排序时。
- Q:手写快排时,如何防止递归栈溢出?
A:采用尾递归优化,先递归处理较短的那一半,再用循环处理长的一半,可以将栈深度从 O(n) 降到 O(log n)。
- Q:存在
O(n) 时间复杂度的排序吗?
A:基于比较的排序不可能。但如果数据的值域范围有限,可以使用计数、桶、基数等非比较排序。
4.2 n 的范围与排序算法选择
| 数据规模(n) |
排序策略 |
说明 |
| n ≤ 50 |
插入排序 |
很多标准库在小数据量时切换到此,常数时间极快 |
| n ≤ 10⁵ |
快排/归并 |
通用场景下的选择 |
| n ≤ 10⁶ 且值域小 |
计数/桶排序 |
O(n) 线性时间,突破比较排序下界 |
| 数据在磁盘/外部 |
外部归并排序 |
分块排序后再进行多路归并 |
| 只需第 K 大 |
快速选择 |
平均 O(n),无需全排序 |
| 动态 Top-K |
维护大小为 K 的堆 |
O(n log K),适合数据流 |
五、本篇模板题
按照“每个核心算法精练一道模板题”的原则,以下是本篇对应的关键题目:
| Pattern |
模板题 |
关键思路 |
| 快排 Partition |
第 K 大元素(LC 215) |
QuickSelect,只递归一侧 |
| 归并合并 |
逆序对计数(剑指 51) |
merge 时 cnt += mid - i + 1 |
| 三路 Partition |
颜色分类(LC 75) |
三指针 s0/cur/s2 |
| 桶排序思想 |
最大间隙(LC 164) |
n-1 桶 + 鸽巢原理 |
这四道题,练到闭上眼都能流畅写出来,排序这一关就算是稳了。让模板代码形成肌肉记忆,是应对面试最有效的策略。
结尾
排序是算法与数据结构世界里的基础设施。它的价值不只在于“把数据排好序”,更在于其核心思想——分治、分区、合并、堆维护——会反复出现在后续更复杂的题目中。
回顾本篇核心:
- 快排、归并、堆排序各有优劣,选择取决于具体场景:是否需要稳定性?内存是否受限?数据量级多大?是否只需部分排序?
- 非比较排序能突破
n log n 的理论下界,但前提是能有效利用数据本身的分布特征。
- 排序思想的衍生问题(如逆序对、合并K路、颜色分类、第K大)在面试中甚至比排序算法本身更常考,唯有深刻理解,方能灵活变通。
希望这份总结能帮你建立起完整的排序知识体系,从容应对各种面试场景。在云栈社区,我们相信,体系化的知识硬实力,是职业发展最可靠的基石。