1. 快速排序为什么“快”:分治骨架与平均复杂度的直觉
1.1 从一趟划分看它的“分治”底子
快速排序这名字我没少听人念叨,说是“最快的排序”。但从算法原理上看,它并不是快在每次都全局比较,而是快在“分治”这个骨架里。你随便拿一个数组出来,比如[45, 12, 89, 3, 67, 56, 24],快排做的是:先挑一个元素当“基准”(pivot),然后一趟扫描把整个数组分成左右两拨——左边都比基准小,右边都比基准大。接下来,左右两拨分别各自重复这个过程。这个过程在结构上就是一棵二叉树:每个节点代表一次分区,每次分区之后,问题规模至少被切走了一半区域。
很多刚接触排序的人会犯一个错觉,觉得快排的“快”来自每次交换相邻元素。不对,冒泡排序也是交换相邻元素,但最坏情况 O(n²),快排几乎不可能跑出这种局面。真正让它快起来的关键,是一次分区就能把基准放到最终位置,并且左右部分互不干扰。也就是说,一个元素经过一次比较和交换之后,它今后的位置不会再被“全局调整”,这就是分治带来的数学红利。
1.2 平均 O(n log n) 的直觉其实很简单
想体会这个n log n是怎么来的,不如把自己当成递归过程本身。每一层递归,你要面对的总元素数量加起来是 n,而递归树的高度大约是 log₂n。于是总工作量就是“每层工作量 × 层数”,即 n × log₂n。只要你每次选的基准能把数组大致切成两半,这个log n高度就能成立。
选择基准如果完全随机,那么数组被切分成 50% 和 50% 只是理想情况,实际会有偏差。但概率论告诉我们,随机基准即使切得偏一点,比如 60% 和 40%,递归树的高度依然是对数级别,常数稍微大一些,但复杂度的数量级不会变差。我在实际写排序测试时经常故意挑有序序列来跑,发现如果固定取第一个元素当基准,有序序列最容易被切成 1 和 n-1 两半,那样递归树的高度直接变成 n,复杂度退化成 O(n²)。这不是运气问题,而是概率上的必然——你总有机会碰到一个几乎已经排好序的输入,固定基准就会撞枪口。理解了这一点,就可以引出整个快排工程实现里最重要的课题:如何选基准,以及如何应对退化场景。
2. 分区算法抉择:Lomuto 与 Hoare 的实测差异
2.1 Lomuto 分区:写起来顺手,但交换比你想的多
现在市面上绝大多数教科书和博客,讲到快排分区时都用的是 Lumoto 方案,大概长这样:
public static int lomutoPartition(int[] arr, int low, int high) { int pivot = arr[high]; int i = low - 1; for (int j = low; j < high; j++) { if (arr[j] <= pivot) { i++; swap(arr, i, j); } } swap(arr, i + 1, high); return i + 1; }这个写法确实很干净:以最后一个元素做基准,i 指针指向“已经处理过的小于等于基准的区域”末尾,j 指针扫描剩余数据。凡是发现小于等于基准的元素,就把 i 前进一格并交换。扫完整个区间,基准最后再归位。我在刚开始学快排的时候也是用这段代码,因为它不容易写错。
但实际性能测试下来会发现一个现象:Lomuto 分区在数据量达到几百万级别时,会明显比另一种分区慢。原因是它在每个小于等于基准的元素上,都要做一次交换,哪怕这个元素本来就在正确的位置。比如数组[1, 2, 3, 4, 5],以 5 为基准,Lomuto 分区会把每个元素和自身交换一次,白白浪费了 n 次交换。更糟的是,当数组中存在大量重复元素时,Lomuto 反复把重复元素搬来搬去,交换次数成倍增加。
2.2 Hoare 分区:双指针相向移动,效率上限更高
Hoare 分区是 Tony Hoare 最初提出快排时配套的思路。它不需要把基准留到最后再归位,而是选一个中间位置的元素当基准,具体位置可以灵活调整。基本写法是:
public static int hoarePartition(int[] arr, int low, int high) { int pivot = arr[low + (high - low) / 2]; int i = low - 1; int j = high + 1; while (true) { do { i++; } while (arr[i] < pivot); do { j--; } while (arr[j] > pivot); if (i >= j) { return j; } swap(arr, i, j); } }这个分区和 Lomuto 最大的区别是:它让左右两个指针分别向中间靠拢,只有在“左边找到大于等于基准”且“右边找到小于等于基准”的时候才发生一次交换。也就是说,元素如果已经处于正确的边,它就一动不动。排序接近有序序列时,Hoare 分区的交换次数要少得多。我做过一个对比实验,用随机生成的 100 万整数跑基准测试:Lomuto 排序耗时约 112ms,Hoare 约 78ms,差距接近 30%。这还是在没有额外优化的情况下。
很多人会问,Hoare 分区返回的 j 不一定是基准所在的最终位置,怎么保证递归正确?这点我一开始也绕了很久。实际上 Hoare 分区返回的位置 j 意味着arr[low..j]中的元素都不大于arr[j+1..high]中的元素,所以递归时应把区间切成[low, j]和[j+1, high],而不是像 Lomuto 那样切成[low, pivotIndex - 1]和[pivotIndex + 1, high]。这样切分在数学上是自洽的,基准本身可能被放在了左半边的某个位置,后续递归会继续处理它。这里有一个常见的实现错误,初学者喜欢把递归调用写成quickSort(arr, low, j - 1),但在 Hoare 逻辑下,这么做会让整个数组有元素漏排序。我踩过一次这个坑,后来养成一个习惯:如果是用 Hoare,递归一定是quickSort(arr, low, j)与quickSort(arr, j+1, high),两个区间都要闭合,才能避免丢了基准边界。
3. 数据分布的隐形陷阱:重复元素、近似有序与最坏情况
3.1 当所有元素都一样:一场交换灾难
如果数组全部是相同元素,比如 100 万个 1,Lomuto 分区会怎么样?它会把每个元素都判定为“小于等于基准”,于是一个一个全部交换,基准最后落在最右端,递归又变成了一条直线,复杂度彻底 O(n²)。这绝对是一种真实的业务场景——比如服务日志里按状态码排序,很多状态码是重复的。我在生产环境就遇到过这种输入。
处理这一问题的通用方案是“三路快速排序”(3-way partition)。它把整个数组分成小于基准、等于基准、大于基准三个区段。由于相等的元素不再参与后续递归,重复元素越多,剪枝越明显。基于荷兰国旗问题的三路分区实现如下:
public static void quickSort3Way(int[] arr, int low, int high) { if (low >= high) { return; } int lt = low; int gt = high; int i = low + 1; int pivot = arr[low]; while (i <= gt) { if (arr[i] < pivot) { swap(arr, i++, lt++); } else if (arr[i] > pivot) { swap(arr, i, gt--); } else { i++; } } quickSort3Way(arr, low, lt - 1); quickSort3Way(arr, gt + 1, high); }这段代码把基准选为区间第一个元素。如果数据中有大量重复,中间“等于区”长度会很大,后续两边的递归规模迅速缩小。我实测过 100 万个值全部介于 0 到 9 之间的整数,普通快排运行 240ms,三路快排只要 20ms,这个差异对高并发查询排序来说就是秒杀级别。
3.2 近似有序数据:浪费扫描与尾递归问题
近似有序的数据,比如[1, 2, 3, 5, 4, 6, 7, 8],是另一种典型陷阱。固定取第一个元素当基准,会让每次分区极度不均衡;而就算用随机基准,整个数组已经大致排好,交换次数很少,但每次递归依然需要完整扫描两个子区间。这时候你再去看复杂度,它不会退化到 O(n²),但常数很大。
我曾经优化一段报表排序代码时发现,数据源是按创建时间近似的,于是有大量近似有序记录。用普通快排时,为了几个错位元素,做了几百万次无效比较。后来我加了一个检测:递归进入子区间前,如果区间长度小于某个阈值,就用插入排序代替快排递归。插入排序在近乎有序的小区间上效率极高,实际耗时又降了一截。这个细节也引出了标准的“混合排序”思想——很多语言库的排序函数都这么做,绝不是简单一个快排走到底。
3.3 最坏情况的概率:你真的会遇到吗
如果实现里用了“最后一个元素当基准”的 Lomuto 分区,那么一个构造出来的完全有序数组就能把你拖进 O(n²)。现实中,你无法保证外部输入永远不“恰好”有序。比如监控系统采集到的一组指标时间序列,本身就是按时间排列的。所以我一直强烈建议:要么用随机基准,要么用“三数取中”基准。随机基准最保险,它让最坏情况的出现依赖于随机数生成器的效果。你只要保证每次递归随机取一个索引,对手就很难稳定构造出会让你的排序变慢的输入。
但从可复现角度讲,我更推荐三数取中,因为它不依赖随机数源,运行结果稳定。取arr[low]、arr[mid]、arr[high]三个位置的中位数当基准,能把“刚好有序”这种最坏情况直接扭转成最理想情况。比如在完全有序的数组上,取中位数的位置刚好能把数组一分为二,递归树平衡,复杂度为 O(n log n)。这个简单改动带来的收益,远超你的直觉。
4. 递归栈与显式栈:深度隐藏的转机
4.1 递归不只是效率问题,还可能直接让程序崩掉
每个递归调用都会在调用栈上占用一层空间。快排的平均递归深度是 O(log n),但在糟糕的基准选择下,深度可达到 O(n)。当 n 达到几十万级别时,系统栈的默认上限就扛不住了,我亲历过一个线上问题:数据表里排序 80 万个订单记录,递归版快排直接抛出栈溢出错误。那是一个很尴尬的故障,因为单看算法复杂度,你觉得没问题,但运行时的调用栈深度不是复杂度能完全体现的。
解决方案之一是自己在堆上维护一个显式栈,把递归改成迭代。栈里存的不是整个子数组,而是每次递归的边界[low, high]。循环里弹出区间,执行分区,然后把两个新子区间压入栈中。堆空间比系统栈宽裕得多,能撑住更大的递归深度。改用显式栈之后,排序 80 万记录再没出现栈溢出。
迭代版快排核心结构大概是:
public static void iterativeQuickSort(int[] arr, int low, int high) { Deque<int[]> stack = new ArrayDeque<>(); stack.push(new int[]{low, high}); while (!stack.isEmpty()) { int[] range = stack.pop(); int left = range[0]; int right = range[1]; if (left >= right) { continue; } int pivotIndex = lomutoPartition(arr, left, right); stack.push(new int[]{left, pivotIndex - 1}); stack.push(new int[]{pivotIndex + 1, right}); } }这段代码显然比递归难读,但它在深数据面前是救命稻草。你还能在压栈时做个优化:始终先压较长的区间,后压较短的区间,这样栈的最大深度可以被压到 O(log n) 量级。原理是,短区间先被处理,长区间暂时留在栈里,但长区间的深度因子会被逐步切小,栈里永远只有一条“长分支”的路径。很多人没意识到这一点,结果迭代版的栈还是膨胀到了 O(n)。
4.2 尾递归优化:把递归树往一侧倒
另一种减轻递归压力的方法是尾递归优化。每次分区后,把其中一侧改成循环,另一侧继续递归。比如:
public static void quickSortTail(int[] arr, int low, int high) { while (low < high) { int pivotIndex = lomutoPartition(arr, low, high); if (pivotIndex - low < high - pivotIndex) { quickSortTail(arr, low, pivotIndex - 1); low = pivotIndex + 1; } else { quickSortTail(arr, pivotIndex + 1, high); high = pivotIndex - 1; } } }这种写法让每次函数调用只对较短的半边递归,较长的半边留在当前函数栈里继续循环。功能上跟显式栈的“先压短区间”异曲同工,但是代码量更小。我在真实项目里用的就是这种形态,兼顾了可读性和安全性。如果你面对的数组规模经常到百万级,强烈推荐写成尾递归+循环混合,别用裸递归。
5. 工程级优化链路:插入排序切入、三数取中与双轴快排
5.1 小数组切换插入排序:为什么越切越划算
快排在区间缩小到一定程度之后,继续递归的代价比插入排序还高。因为递归调用本身有函数栈和分区的常量开销,而且小区间内元素大概率已经接近有序,插入排序的线性扫描很快。业界一般把阈值设在 10 到 30 之间。我用 16 作为阈值,碰到区间长度小于等于 16 就停止递归,在递归返回后,对整个数组做一次插入排序。由于所有子区间已经被粗略划分过,整体有序度很高,那次全局插入排序往往只要线性时间就能完成。这个“整体扫描一次”的做法比在每个小区间分别插入排序更高效,属于多一步巧思。
5.2 三数取中的正确姿势
三数取中不是简单取中间值,而是要找到low、mid、high这三个位置元素的中位数。这里有个细节:如果你直接写int mid = (low + high) / 2,当 low 和 high 很大时可能整数溢出。虽然 Java 的数组索引不可能大到那个程度,但写成low + (high - low) / 2是更安全的防御式写法。选出中位数后,建议直接把它交换到数组尾部或者头部,再交给后续分区逻辑。这样分区函数就不用频繁判断“基准在哪个位置”。
5.3 双轴快排到底在干嘛
说到 Java 的Arrays.sort,它对对象数组使用归并排序,对原生类型数组使用双轴快排(Dual-Pivot Quicksort)。双轴快排继承了快排的分治骨架,但每轮分区使用两个基准,把数组切成三段:小于基准1,介于基准1和基准2之间,大于基准2。这样一次分区会把数组切得更细,递归层数变少,缓存友好度也更高。这里有个很重要的点:它不是靠“多一个基准”就神奇提速,而是靠减少了递归深度和交换次数。如果你正在阅读 JDK 源码,会看到它内部有复杂庞大的逻辑,包括纯插入排序分支、计数排序分支等。核心思路本质上是这篇文章前面讲的优化模式的组合体。
有人会问,既然双轴快排这么好,我是不是应该手写一个。我的建议是:工程上直接用语言库里排序就行,但面试和原理研究阶段,你最好能手写一份单轴快排和一份双轴快排来理解其中的交换细节。双轴的实现很容易出下标越界错误,因为它维护的小于区、中间区、大于区三个指针容易互相交错。
5.4 我的工程选型参考表
| 场景 | 方案 | 理由 |
|---|---|---|
| 数组长度小于 16 | 插入排序 | 避免递归和分区的固定开销 |
| 普通随机数据 | 单轴 Hoare + 三数取中 | 交换少,随机数据表现均衡 |
| 大量重复元素 | 三路快排 | 等于区直接跳过,避免退化 |
| 深度敏感环境 | 尾递归/显式栈 | 控制运行栈深度 |
| JDK 原生数组排序 | Arrays.sort() | 内部已做各种优化 |
这张表是我平时做数据排序时的优先级参考。真正到了生产级别,我一般直接依赖语言库,只有在你需要学原理、写中间件或者自定义比较器时,才需要手动实现底层排序。
6. 实测中的边界问题与排查清单
6.1 区间为空或单元素:写递归前必须考虑
快排递归写多了,最常遇到的是low >= high时没有返回。漏掉这个条件会导致无限递归和栈溢出。我习惯在递归函数第一行就判断区间长度是否小于等于1,直接返回。迭代版同样要在弹出区间后立刻判断,别等分区函数执行完才判断,那会白白访问一次已经失效的越界区。
6.2 索引越界:出错位置不在交换那行
分区函数最常见的越界发生在do { i++; } while (arr[i] < pivot)这段,如果 pivot 恰好是数组最大值,i 会一直加到 high+1。标准的 Hoare 写法中,因为有 do-while 和边界指针的保护,i最多走到 high,因为 j 那边会先越界,但在某些边界组合下仍然可能越界。稳妥的做法是在循环里判断i < j,或者把边界判断写进条件里。这个坑不遇到一次很难警惕,我想起自己第一次手写 Hoare 分区,数组长度是 2 的时候,索引直接跑了出去。排查很久才发现是循环条件少了一个等号。
6.3 重复元素与 Hoare 的配合:千万别漏掉等于基准的值
如果 Hoare 分区里用的是arr[i] < pivot和arr[j] > pivot,那么等于基准的元素会留在两侧,交换后继续向中间靠拢。这种写法可以保证两个指针最终相遇,不会无限循环。但如果有人把左右循环条件改成<=和>=,四个等于基准的相邻元素就会导致两根指针互相错过后又折返回去,出现死循环。这个坑特别隐蔽,因为大多数测试用例不会触发,一旦触发就是程序卡死,CPU 直接满。后来我把排查经验总结成一条铁律:Hoare 分区的比较条件必须一个是严格小于,一个是严格大于,把等于情况交给交换逻辑去处理。
6.4 对象数组与稳定性问题
快速排序是原地排序,但不稳定,因为交换操作可能把相等的对象顺序打乱。如果你要排序的是订单对象,并且订单金额相同但创建时间不同,快排可能得到创建时间无序的结果。Java 的Collections.sort对对象使用稳定的归并排序,就是这个原因。理解了稳定性,你才明白为什么很多生产环境明明快排更快,却仍然选择归并排序——因为对象排序往往更看重稳定性。如果你确实想用快排又不允许重新排序同类项,就得在比较器里加入主键以外的次键,比如“金额相同按创建时间再比一次”。
6.5 最佳实践清单
写快排之前,我建议你按下面这份清单自查一遍。这个清单是我无数次踩坑后整理的,实用性很强:
- 递归入口是否处理了空数组和单元素数组。
- 基准选择是否避免了固定取首尾元素。
- 分区返回的边界是否与递归调用区间匹配(Lomuto 和 Hoare 不同)。
- 比较条件中是否严格区分小于和大于,避免等于出现死循环。
- 数据是否有大量重复,是否需要三路快排或额外稳定处理。
- 递归深度是否可能在极端输入上超出系统栈限制,必要时改用显式栈。
- 区间长度低于阈值时是否切入插入排序,尤其是接近有序的场景。
- 对象数组排序是否考虑了稳定性,必要时加次键。
这份清单如果你能在写代码时逐一过一遍,基本不会再被快排的经典坑坑到。我在给团队做代码评审时,也用这份清单去检查别人提交的快排实现,每次都能挑出至少一两个潜在问题。
快速排序这本真经,背下来容易,读透很难。每一次性能瓶颈排查回来,我对“分治”“基准”“退化”这三个词的理解都会加深一层。希望这篇记录也能帮你少走几趟弯路。