1. 快速排序的核心原理与思路拆解
1.1 快排的分治骨架与分区过程
很多人第一次接触快速排序,都会觉得它比冒泡、选择这类基础排序难理解。原因在于快速排序不是靠简单的相邻比较一步步把大数“沉”到末尾,而是用了一种叫“分治”的策略:选中一个基准元素,把数组分成两半——左边都比基准小,右边都比基准大,然后对左右两半各自重复这个过程,直到区间缩小到不能再分。
这个“分半”动作,行话叫 partition(分区)。最朴素也最容易写对的实现是 Lomuto 分区:用一个游标 j 从头到尾扫描,另一个游标 i 记录“最后一个小于基准的位置”,遇到比基准小的元素就把 i 后移一位并交换,扫描结束后把基准换到 i+1 的位置上。整个过程就像在做“原地筛选”,不需要额外开数组,空间复杂度是 O(1) 的辅助空间。
分区动作做完,基准元素就待在它最终应该在的位置上了——这一点非常关键。它左侧的元素都比它小,右侧都比它大,但左右两侧内部乱不乱暂时不管。接下来只需要对左侧区间和右侧区间分别递归调用同样的过程。这是一种典型的“先处理后组合”的分治套路,和归并排序“先切两半、排序后再合并”的思路正好反过来。
伪代码层面可以这样理解:
function quickSort(arr, left, right) { if (left >= right) return; const pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); }递归的终止条件就是区间里只剩下一个元素或者没有元素,一个元素天然有序,不需要再分。
1.2 基准选择为什么决定生死
如果你只记住了分区过程就以为掌握了快排,那你会踩一个大坑:基准(pivot)怎么选,直接决定算法是“超神”还是“超鬼”。
最糟糕的情况是每次选的基准恰好是当前区间的最大值或最小值。比如数组本身已经有序,你还傻乎乎地固定取第一个元素当基准,那么第一次分区只划分出一个空区间和一个 n-1 长度的区间,第二次又一样……递归深度会变成 n,时间复杂度退化成 O(n²)。这也是很多人写快排后面试被追问“最坏情况是什么”时最容易翻车的地方。
工程上常见的解法有三个:
- 随机选基准:从当前区间随机挑一个下标作为基准,从概率上避免“最坏输入”稳定触发。
- 三数取中(median-of-three):取区间首、中、尾三个元素,选它们的中位数当基准。这个策略对“基本有序”的输入尤其有效,直接把最坏情况干掉了大半。
- 双轴快排(Dual-Pivot Quicksort):JDK 的
Arrays.sort对基本类型数组用的就是这种变体,选两个基准,一次性把区间分成三段,减少递归层数。
我自己在写通用排序工具时,默认用“随机基准 + 三数取中”的组合,杀鸡用牛刀虽然浪费了一点点随机数开销,但换来的稳定性(指性能稳定,不是排序稳定性)非常值。
2. 快速排序的代码实线与优化细节
2.1 Java 与 C 语言的标准实现
先给一份 Java 实现,用的是最常见的 Lomuto 分区加随机基准,代码量最少,适合新手啃:
public class QuickSort { public static void quickSort(int[] arr, int left, int right) { if (left >= right) return; int pivotIndex = partition(arr, left, right); quickSort(arr, left, pivotIndex - 1); quickSort(arr, pivotIndex + 1, right); } private static int partition(int[] arr, int left, int right) { int randomIndex = left + (int)(Math.random() * (right - left + 1)); swap(arr, left, randomIndex); int pivot = arr[left]; int i = left; for (int j = left + 1; j <= right; j++) { if (arr[j] < pivot) { i++; swap(arr, i, j); } } swap(arr, left, i); return i; } private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } }C 语言版本更常写 Hoare 分区,它的交换次数更少,但边界条件更难调:
void quickSort(int arr[], int left, int right) { if (left >= right) return; int pivot = arr[(left + right) / 2]; int i = left, j = right; while (i <= j) { while (arr[i] < pivot) i++; while (arr[j] > pivot) j--; if (i <= j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; i++; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); }注意 Hoare 版本里递归边界是[left, j]和[i, right],不是围绕基准下标切分,因为基准可能已经被换到中间任意位置了。这个细节写错会直接死循环或栈溢出。
2.2 非递归实现:手写栈代替递归栈
有些场景下递归不安全——待排序数组特别大且数据分布极端时,递归深度可能接近 n。Java 虚拟机默认栈深度一般也就几千到上万层,而快排最坏情况的递归深度就是 n,一百万条数据的逆序输入,递归版直接 StackOverflowError。这时候就需要非递归版本。
思路是把“待处理的左右边界”存到显式栈里,先压右边界再压左边界,循环弹出一个区间就分区一次,然后继续压入新的区间:
public static void quickSortNonRecursive(int[] arr) { Deque<int[]> stack = new ArrayDeque<>(); stack.push(new int[]{0, arr.length - 1}); while (!stack.isEmpty()) { int[] range = stack.pop(); int left = range[0], right = range[1]; if (left >= right) continue; int pivotIndex = partition(arr, left, right); // 大区间先压栈,小区间后压,可以控制栈的增长速度 stack.push(new int[]{pivotIndex + 1, right}); stack.push(new int[]{left, pivotIndex - 1}); } }还有一个细节:压栈顺序会影响空间占用。如果你总是把区间小的一侧后处理,栈的最大深度能维持在 O(log n) 级别,这也是很多教科书里“尾递归优化”想达到的效果。手写栈版可以精确控制这一点。
2.3 快排优化的三个实用招数
第一招:小区间切换插入排序。当区间长度小于某个阈值(常见的经验值是 10 到 20),递归调用的开销已经大于插入排序的开销了。Arrays.sort内部也是这么干的,阈值设在 47 左右。改成插入排序后,整体性能能提升 10% 到 20%,数据量越大越明显。
第二招:三路快排(3-way partition)。经典快排遇到大量重复元素时会非常吃亏:分区扫一遍,等于值的区域反复被比较。三路快排把区间分成“小于基准 / 等于基准 / 大于基准”三段,等于基准的区间直接跳过不参与递归。遇到全是相同元素的数组,三路快排的时间复杂度直接降到 O(n),这是普通快排做不到的。
第三招:与 Introsort 结合。C++ STL 的std::sort用的是 Introsort:默认快排,如果发现递归深度超过 log n 的某个倍数,就切换成堆排。因为快排最坏情况是 O(n²),而堆排最坏是 O(n log n),两者结合能保证任何输入都不会退化到平方级。这个思路比单纯优化基准选择更克制,面试和工程里都很加分。
3. 快排思路在真实业务场景里的延伸
3.1 大数据生态里的 MapReduce 排序与分组排序
搞后端和大数据的同学,大概率都遇过头歌平台或者实际生产环境里的 MapReduce 排序题目。MapReduce 框架本身在 shuffle 阶段就会对 key 做一次排序,但用户想控制排序规则、分组边界时,需要自己实现排序逻辑。这里其实处处都是快排思想:MR 内部的辅助排序(secondary sort)、分区器(partitioner)、分组比较器(grouping comparator)都是在“排序结果之上再做一次关键提取”。
热搜词里反复出现“分组排序”,典型需求是:数据按部门分组,每个组内再按工资从高到低排。用 MapReduce 做,核心不是自己写快排,而是实现一个自定义WritableComparable,让 key 同时包含部门 ID 和工资两个字段,先按部门排序,再按工资排序;然后通过GroupingComparator指定“只要部门相同就视为同一组”,这样 reduce 阶段拿到的就是整个组的有序列表。组内第一条数据就是工资最高的,组内排序直接用context.write的顺序保证。
这个场景告诉我们一个道理:快排作为一种通用排序内核,通常不会直接暴露给你,但它的排序语义(比较规则、稳定性、内存占用)渗透在每一层框架里。理解快排,其实是在理解“任何排序系统都需要回答三个问题:比什么、怎么分、怎么保证边界”。
3.2 JavaScript 数组排序与多字段排序
前端同学最常见的排序需求是:“点击表头排序”“对象数组按某个字段排序”。JavaScript 的Array.prototype.sort在不同引擎里实现不一样——V8 早期用快排变体,后来为了稳定改为 TimSort。对开发者来说,真正要掌握的是比较函数的写法:
// 单字段 arr.sort((a, b) => a.age - b.age); // 多字段:先按年龄升序,年龄相同按姓名拼音降序 arr.sort((a, b) => { if (a.age !== b.age) return a.age - b.age; return b.name.localeCompare(a.name, 'zh-Hans-CN'); });字符串排序必须用localeCompare,直接减字符串是拿不到中文拼音顺序的。字母数字组合的场景,比如“A-1”“A-2”“B-3”这种编号,最好先拆出数字部分做整型比较,否则“A-10”会排在“A-2”前面,这就是字典序 vs 自然序的经典陷阱。
从技术视角看,JavaScript sort 是“稳定排序”,稳定意味着相等元素的原始相对位置被保留。快排本身是不稳定的,但 TimSort 稳定,所以现代 JS 引擎选它是有理由的。这也是为什么算法选型不能只看平均复杂度。
3.3 数据库排序:MySQL 与 SQL Server 的组内编号
数据库里的排序需求也绕不开一个“组内编号排序”:比如 SQL Server 里想给每个班级的学生按成绩排名,可以用ROW_NUMBER() OVER (PARTITION BY 班级 ORDER BY 成绩 DESC)。这里 PARTITION BY 相当于把数据分组,ORDER BY 负责组内排序,生成 1、2、3 这种组内序号。
MySQL 8 以前没有窗口函数,只能靠变量模拟:
SET @group_id := NULL, @rank := 0; SELECT class_id, student_name, score, @rank := IF(@group_id = class_id, @rank + 1, 1) AS group_rank, @group_id := class_id FROM students ORDER BY class_id, score DESC;这个技巧的原理是:先保证全局有序(按班级和分数排序),然后逐行判断当前行的班级是否和上一行相同,相同则序号累加,不同则重置。本质上就是把“排序”和“组内排名”拆开做,和 MapReduce 的 GroupingComparator 是一个思路。
MySQL 的排序还会涉及 filesort 和索引排序。如果 ORDER BY 字段上有索引,MySQL 直接走索引序输出,不需要额外排序;没有索引就只能把结果集全部读出来做排序,数据量大时性能惨不忍睹。这也是为什么“大表排序慢”的排查方向永远是先看 EXPLAIN 里的 Using filesort 标记。
4. 排序算法全谱系与实战选型参考
4.1 八大排序算法对比表
很多人在“数据结构排序算法”这个热搜词上花了很多时间,却不知道怎么用。我的建议是先把八大排序的核心指标做成一张表,背下来不如理解透:
| 算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(1) | 稳定 | 几乎只在教学中出现 |
| 选择排序 | O(n²) | O(n²) | O(1) | 不稳定 | 数据量极小且写交换次数少 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 基本有序的小数组,快排的补充 |
| 希尔排序 | O(n^1.3) | O(n²) | O(1) | 不稳定 | 中等规模数据,嵌入式环境 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 外部排序、链表排序 |
| 快速排序 | O(n log n) | O(n²) | O(log n) | 不稳定 | 通用排序,数据量大且内存敏感 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 需要最坏复杂度保证的场景 |
| 计数/桶排序 | O(n+k) | O(n+k) | O(k) | 稳定 | 整数、密集分布数据 |
快排和归并的对比最有意思:归并稳定但要额外 O(n) 空间,快排原地操作但牺牲稳定性。工程上选哪个,本质上是在“内存带宽”和“稳定性要求”之间做权衡。Java 的Collections.sort对对象排序用归并变体(保证稳定),Arrays.sort对基本类型用双轴快排(性能优先),就是这种权衡的官方示范。
4.2 从循环不变量证明到排序正确性
热搜词里有“CLRS 选择排序循环不变量证明”,这说明很多人在啃算法导论时卡在了证明环节。循环不变量的思路并不神秘:它要求在每次循环开始前、循环过程中、循环结束后,某个条件始终为真,称为“不变量”。比如选择排序的不变量是:处理完前 i 个位置后,这 i 个位置已经是整个数组最小的 i 个元素且有序。
快排也可以用同样的方式证明正确性:分区操作结束后,基准左侧所有元素 ≤ 基准 ≤ 基准右侧所有元素。这个断言就是分区后不变量。递归调用快排时,因为左右区间都被限制在基准两侧,不会跨区间比较,所以只要子区间排序正确,整体就一定有序。
我诚实地说:工作中没人会手写循环不变量证明,但理解这套逻辑能帮你 debug。快排出 bug 最常见的症状是“大多时候对、偶尔乱序”,这种问题靠肉眼根本看不出来,只能靠写一条断言检查“分区后左侧所有元素 ≤ 右侧所有元素”来定位。把不变量写成assert代码,比一遍遍打印日志高效得多。
4.3 三值排序这类“偏门题”到底考什么
USACO 的“三值排序”和 LeetCode 的“颜色分类”(Dutch national flag problem)本质是同一个问题:数组里只可能有三种值,如何用一趟扫描把它排好。解法就是三指针:左指针放最小值区和中值区的边界,右指针放中值区和最大值区的边界,当前指针负责遍历。发现当前值是最小值就扔到左边,是最大值就扔到右边。
这个题目与其说考排序,不如说考“分区思想”的变体。快速排序的 partition 也是在做同样的事:把一个值域不确定的数组按基准分成“小于 / 大于”两个阵营。当你学会从分区视角看排序,再看各种变种题就一通百通了。这也是为什么我强烈建议先吃透快排的 partition,再去看其他算法。
5. 常见问题与排查技巧实录
5.1 快速排序实战中踩过的坑
第一坑:递归深度爆栈。数据量到几十万级别、而且输入接近有序时,固定选第一个元素当基准的递归快排非常容易 StackOverflowError。排查方法很简单:看函数调用栈里是不是一层套一层全是 quickSort。解决手段我用过三种,按推荐程度排序:三数取中、随机基准、非递归实现。
第二坑:分区边界写错导致死循环。Hoare 分区里如果 while 条件没有加i <= j的保护,两个指针可能交叉后继续走,最后递归区间没缩小,程序直接卡死。这个问题隐蔽在“看起来逻辑正确”的代码里,建议多写几个测试样例:空数组、单元素、两个相同元素、全相同元素、逆序数组、随机大数组。
第三坑:误用Math.random()产生性能瓶颈。在千万级数据的排序中,每个分区都调一次Math.random()的开销其实不小。更好的做法是只做一次三数取中,或者用更轻量的伪随机方式。对性能极致敏感的场景,我甚至见过直接取区间中点做基准,配合 Introsort 兜底,实测速度反而更快。
5.2 排序结果不对的排查思路
排序结果不对,先别急着怀疑算法,按这个顺序排查:
- 比较器写反了:升序、降序搞混是最常见原因。Java 里
a - b是升序,b - a是降序;SQL 里ASC和DESC写错位置。建议统一封装命名清晰的比较器,不要裸写箭头函数。 - 数据类型不一致:字符串和数字混排,“10”会被排在“2”前面。转成同一类型再比较。
- 稳定性依赖:业务要求相等数据保持原序,结果用的却是不稳定排序,秩序乱了。解决方案要么换稳定排序(归并、TimSort),要么给对象加一个序号字段作为次级排序键。
- 浮点数精度:
0.1 + 0.2不等于0.3这种问题会导致比较结果自相矛盾,破坏排序算法内部假设。用Double.compare或 BigDecimal 解决。
5.3 一个亲测有效的“快排性能验证清单”
我给团队做代码评审时,会把下面几条当成硬性检查项:
- 用“基本有序的大数组”压测,观察是否退化到平方级耗时。
- 用“全相同元素数组”压测,确认三路快排或等价优化是否生效。
- 用“百万级随机数组”压测,对比递归版和非递归版的耗时与栈深度。
- 用
assert验证分区不变量,跑 100 轮随机测试。 - 与系统自带的
Arrays.sort做对比,如果自定义快排明显更慢,优先怀疑分区实现不够 cache friendly(比如访问内存跳跃太大)。
我自己的项目中就遇到过:手写快排比 Java 自带排序慢 30%,后来发现是 Lomuto 分区对基本有序数组不友好,换成 Hoare 分区后反超 15%。这说明算法书上给的复杂度分析只能帮你预测大概,真正的性能还是要在具体数据分布上实测。
6. 个人实操总结与建议
如果让我只保留一条关于排序的建议,那就是:绝大多数业务代码里,直接用系统自带的高质量排序,不要重复造轮子;但你依然要理解快排内部的基准选择、分区过程和退化条件,只有这样,当数据规模上去、性能瓶颈出现时,你才知道该去哪里优化。
快排的“快”是有前提的:数据分布足够随机、基准选择得当、递归深度可控。用快排处理一个已经排好的大数组,体验和用插入排序差不多,甚至更差。这也是我每带一个新人,都要求他写一遍三数取中快排和非递归快排的原因——写完之后,他对“为什么工程里用 Introsort 而不用纯快排”的体会,比看十遍文档都深刻。
最后分享一个我在实际项目中经常用的习惯:把排序需求拆成两层。第一层是“排序键提取”,搞清楚客户端想按什么字段、什么规则排;第二层才是“排序执行”,选算法、定比较器、考虑稳定性。大部分排序问题都不是算法太复杂,而是需求描述没想清楚——你到底想排“谁”和“按什么排”,这两个问题回答清楚了,代码自然就出来了。