在实际开发里,能把快速排序写明白的人,往往不是靠背代码,而是真正理解了“分治”和“分区”这两个动作。而和快速排序同源的快速选择排序,又是一个容易被忽略、但解决TopK问题极其趁手的算法。很多Java面试者在准备算法时,会把这两种排序分开记,结果面试官一追问“为什么快排平均能到O(n log n)”“能不能用快排的思路找第K大的数”就卡住了。
这篇文章我就用Java把快速排序和快速选择排序放在一起讲透,包含Lomuto分区、Hoare分区、三路快排、随机化轴点、迭代写法,以及从快速排序推导出快速选择的过程。无论你是准备Java面试,还是写业务代码时偶尔需要手写排序或TopK,这篇文章都值得收藏起来慢慢看。
1. 把“分区”想明白,快速排序就学会了一半
很多人看快排代码第一眼是懵的:为什么一个排序算法里要塞一个循环,循环里还嵌套着交换?问题的关键不在循环,而在“分区”。分区做的事情很简单:选一个基准值pivot,把数组调整成“左边都小于等于pivot,右边都大于等于pivot”的样子,然后返回pivot最终所在的下标。只要这步搞清楚了,快排整体结构就是在递归地对左右两段继续做同样的事。
1.1 Lomuto分区:最容易写对的写法
我自己早期学快排,最常用的就是Lomuto分区方案。它的逻辑非常直白:取最右边的元素当pivot,用两个指针i和j把数组扫一遍。j负责探路,i指向“下一个可以放小元素的位置”。一旦发现nums[j]比pivot小,就把nums[j]和nums[i]交换,i向右移动一格。扫描结束后,把pivot从最右边换到i的位置,此时i就是pivot的最终位置。
java复制public class QuickSort {
public static void quickSort(int[] nums) {
if (nums == null || nums.length < 2) {
return;
}
quickSort(nums, 0, nums.length - 1);
}
private static void quickSort(int[] nums, int lo, int hi) {
if (lo >= hi) {
return;
}
int pos = partition(nums, lo, hi);
quickSort(nums, lo, pos - 1);
quickSort(nums, pos + 1, hi);
}
private static int partition(int[] nums, int lo, int hi) {
int pivot = nums[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
if (nums[j] <= pivot) {
swap(nums, i, j);
i++;
}
}
swap(nums, i, hi);
return i;
}
private static void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
注意这段代码里的边界条件:外层quickSort里,lo >= hi 直接返回。很多人会在这里纠结要不要用 ==,其实用 >= 更安全,防止递归调用时出现 lo > hi 的情况。partition内部的循环条件 j < hi,因为hi位置是pivot,不能把pivot自己也参与比较,否则会破坏分区语义。
Lomuto分区最大的优点是简单,不容易写错,所以在面试手写快排时,这是最推荐的方案。缺点也有:如果数组里有很多和pivot相等的元素,Lomuto会把它们一部分放在左边、一部分放在右边,导致重复元素多的时候性能下降。这个问题后面讲三路快排时会专门处理。
1.2 Hoare分区:更快的双端扫描
Hoare分区是快排祖师爷Tony Hoare提出的原始方案,思路稍微绕一点:pivot不一定要最右边,可以从中间取。两个指针分别从数组两端向中间移动,左边找大于等于pivot的元素,右边找小于等于pivot的元素,找到后交换,直到两个指针相遇。
java复制private static int partitionHoare(int[] nums, int lo, int hi) {
int pivot = nums[lo + (hi - lo) / 2];
int i = lo - 1;
int j = hi + 1;
while (true) {
do {
i++;
} while (nums[i] < pivot);
do {
j--;
} while (nums[j] > pivot);
if (i >= j) {
return j;
}
swap(nums, i, j);
}
}
Hoare分区有个容易踩坑的地方:它返回的j并不一定是pivot的最终下标,而是“左半区和右半区的分界点”。所以对应的递归边界不是 [lo, pos-1] 和 [pos+1, hi],而是必须写 [lo, pos] 和 [pos+1, hi]。如果照着Lomuto的递归方式写,就会在特定数据下产生死循环或漏排元素。
霍尔的交换次数通常比Lomuto少,因为每个元素真正跨过pivot才交换一次,而不是一碰到小于pivot的元素就交换。实际性能上,Hoare在大量随机数据下更快,但代码的边界条件确实对新手不友好。我的建议是:面试手写用Lomuto保命,平时写工具代码想追求性能再上Hoare,并且要配单元测试把边界条件测清楚。
1.3 分区方法怎么选:我的实战建议
分区分好,快排的框架就只剩递归了。但选用哪种分区,还是看场景。如果只是刷题、面试、写一次性脚本,Lomuto足够;如果你在维护一个数据量很大的中间件模块,希望排序尽可能减少元素交换,Hoare更合适。
这里我还要提一个细节:Lomuto分区为什么要用 <= 而不是 <?如果写 <,那等于pivot的元素全部会滑到右侧,遇到大量重复元素时分区会严重失衡。用 <= 保证等于pivot的元素也会有一部分跑到左侧,两侧相对均衡。同理,Hoare分区两侧的判断符号是 < 和 >,左右相等的元素遇到就交换,这也是一种保持均衡的手段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 递归边界与退化防护:快排的完整骨架和价值所在
有了partition函数,一个基础快排就算完成了。但“能排序”和“可靠地排序”是两回事。真正有经验的人,会在递归边界、轴点选取、数据分布三个维度上做防护,让快排在各种输入下都不至于退化到O(n²)。
2.1 递归到底该在哪一层停
快排的递归基首先就是 lo >= hi。但实际工程里,很多人并不等到区间长度为1才停,而是当 hi - lo + 1 小于某个阈值(比如16)时,直接改用插入排序收尾。原因在于:元素少的时候,递归调用的开销占比太大,插入排序的常数极小,性能反而更好。这个阈值不是玄学,现代JDK的排序实现里就干过类似的事。
如果你不想在快排函数里再引入一个插入排序函数,也可以直接在递归开头写:
java复制if (hi - lo < 16) {
insertionSort(nums, lo, hi);
return;
}
当区间长度小于16,直接插入排序。很多人问为什么不是8或者32?这个数值需要实测,不同机器略有差异。我自己的测试经验是16左右在通用场景下比较稳,但也不是绝对标准。面试时能说出“小数组切换插入排序是为了减少递归开销”这个理由,就已经比单纯背代码的候选者强不少。
2.2 为什么有序数组会让普通快排变“慢排”
一个最坏情况是:数组已经有序,且每次取最右边的元素当pivot。此时每次分区都极不平衡,左边有n-1个元素,右边0个,递归深度达到O(n),时间复杂度退化成O(n²)。
用生活类比解释:一个班级按身高已经排好了队,你每次拉队伍最右边的人当“分界线”,结果每次分完只能排除一个最矮的,剩下的人继续重复这个过程,这不叫分治,这叫“每次踢出去一个人”。快排之所以快,核心在于每次分区后数据规模被真正切小。
所以工程上绝不能写固定取最左或最右的pivot,否则你写的快排和冒泡排序在最坏情况下没什么区别。解决思路有三种:
- 随机选pivot:期望上避免最坏情况。
- 三数取中:取首、中、尾三个元素的中位数当pivot,对付部分有序数据很有效。
- 三路快排:把所有等于pivot的元素集中到中间,避免重复元素导致失衡。
2.3 随机化轴点与三数取中的Java实现
随机化轴点最简单的做法是,在partition之前随机选一个下标,和最右边的元素交换,然后继续用Lomuto。代码改动极小:
java复制Random random = new Random();
private static void shufflePivot(int[] nums, int lo, int hi) {
int idx = lo + random.nextInt(hi - lo + 1);
swap(nums, idx, hi);
}
这样即使输入是最坏情况,因为pivot是随机落点,退化到O(n²)的概率也极低。那三数取中又怎么写?
java复制private static void medianOfThree(int[] nums, int lo, int hi) {
int mid = lo + ((hi - lo) >> 1);
if (nums[lo] > nums[mid]) {
swap(nums, lo, mid);
}
if (nums[lo] > nums[hi]) {
swap(nums, lo, hi);
}
if (nums[mid] > nums[hi]) {
swap(nums, mid, hi);
}
swap(nums, mid, hi);
}
这个函数的作用是把首、中、尾三个元素排序,然后把中位数放到hi位置,后续partition直接用hi的元素当pivot。三数取中对付有序数组、逆序数组特别有效,因为它保证pivot大致在中间区域。不过三数取中也不能完全杜绝最坏情况,只是把出现概率降得很低,工程实现里通常会叠加随机化一起用,形成双重保险。
2.4 借助Arrays.sort看工业级优化思路
如果我们打开JDK里Arrays.sort(int[])的源码,会发现它的设计远比基础快排复杂:处理小数组时使用插入排序;数据规模稍大时用双轴快排(Dual-Pivot QuickSort),选择两个pivot,把区间拆成三段;数据非常大时还会检查数组是否近乎有序,再决定采用哪种排序策略。
这里我不建议死记JDK内部实现,但可以从中学到三条工程经验:
- 小数组和小开销:插入排序的常数优势在n小于几十时很大。
- 按数据分布选择算法:纯快排不是银弹,要配合其他策略。
- 减少递归深度:双轴在多数情况下能更均匀地划分区间,虽然最坏复杂度理论仍在O(n²),但实际表现稳健。
这也是为什么我在写自己的排序工具时,即使不引用JDK源码,也会在一开始就把随机化pivot和阈值降级写上,这是工业界验证了无数次的组合思路。
3. 快速选择排序:用同一个partition解决TopK问题
快速排序的partition函数有个附带价值:它能告诉你某个元素在排序后最终待的位置。这个信息非常关键。比如你只想找数组里的第3小元素,并不需要把所有元素都排好,只需要让某个元素落在下标2的位置,那么它左边都小于等于它,右边都大于等于它。这就是快速选择排序(QuickSelect)的基本原理。
3.1 从“全排”到“只排一半”
我们回忆快速排序的递归过程:partition返回pos之后,左右两侧都要继续递归排序。但快速选择的思路完全不同:如果我们要找下标k的元素,partition返回pos之后,只需要比较k和pos的关系:
- 如果
k == pos,恭喜,pivot本身就是要找的第k小元素,直接返回。 - 如果
k < pos,说明目标在左半区,右半区完全不用管。 - 如果
k > pos,说明目标在右半区,左半区完全不用管。
这个“只处理一侧”的剪枝,让快速选择的期望复杂度从O(n log n)降到O(n)。如果只是想求第K大,只需把问题转换成第 n - K 小,因为第K大等价于“升序排列后下标为n-K的那个数”。
3.2 快速选择完整Java实现
沿用之前的Lomuto分区,直接复用partition方法即可:
java复制public class QuickSelect {
public static int quickSelect(int[] nums, int k) {
if (nums == null || k < 0 || k >= nums.length) {
throw new IllegalArgumentException("参数不合法");
}
int lo = 0;
int hi = nums.length - 1;
while (lo <= hi) {
int pos = partition(nums, lo, hi);
if (pos == k) {
return nums[pos];
} else if (pos < k) {
lo = pos + 1;
} else {
hi = pos - 1;
}
}
throw new IllegalArgumentException("k越界");
}
private static int partition(int[] nums, int lo, int hi) {
int pivot = nums[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
if (nums[j] <= pivot) {
swap(nums, i, j);
i++;
}
}
swap(nums, i, hi);
return i;
}
private static void swap(int[] nums, int i, int j) {
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
这里有个重要细节:快速选择处理的是第k小的问题,k的语义是下标,第1小对应k=0,第2小对应k=1。如果调用方说的是“求第3大的元素”,正确的转换方式是 k = nums.length - 3,因为第3大是升序排列后倒数第3个位置。
另一个容易漏的地方是:快速选择会修改传入数组的元素顺序。它会像快排一样发生swap,导致数组不再是原始顺序。业务代码里如果要保留原数组,必须提前拷贝一份。这是实际使用中经常踩到的坑,我在给一个推荐系统写“TopK热门商品查询”时就因为这个没注意,导致后续统计逻辑拿到的数组顺序变了,排查了半天。
3.3 为什么快速选择能做到平均O(n)
这个问题的直觉论证很经典:每次partition之后,我们只需要处理一侧。假设每次pivot大致落在区间中间位置,那被处理的子问题规模大约减半。第一次耗时约cn,第二次约cn/2,第三次约c*n/4……把所有时间加起来:
T(n) = c*n + c*n/2 + c*n/4 + ... <= 2*c*n = O(n)
这个等比级数求和是理解快速选择的关键。对比一下快速排序:排序要处理两侧,所以每次递归总和是T(n) = 2*T(n/2) + O(n),由主定理得到O(n log n)。差别不在于partition本身,而在于递归之后你要不要继续处理另一半。这也是为什么快速选择比“先完整排序再取中间值”快得多的根本原因。
当然,快速选择在最坏情况和快排一样,也会退化成O(n²),比如数组有序且pivot固定取最右。因此和快排一样,最好也用随机化pivot保护一下。如果面试官再追问“有没有最坏也能O(n)的方法”,可以提BFPRT算法,也就是中位数的中位数算法,它通过精心挑选pivot来保证每次至少排除固定比例的元素。不过BFPRT常数较大,工程上极少使用,面试能说出原理即可。
3.4 快速选择与堆排解决TopK的取舍
很多人会问:求TopK,为什么不用堆?这里我可以给一个实践参照。
如果只是求第K大或第K小,快速选择期望O(n),空间O(1),还能原地改数组,性能非常优秀。如果数据会动态增加、随时都要返回当前TopK,那堆更合适,因为堆的插入和删除都是O(log K),不需要每次对全量数据重算。
举个例子:某个订单系统需要实时维护“当前销售额最高的10个商品”,订单不断涌入,这时候用固定容量大小为10的小顶堆更自然。但如果给你一个已经存在的百万量级数组,只求一次第500大的值,快速选择明显更优,因为你不必维护一个容量500的堆反复调整,堆方案时间达到O(n log K),而快速选择期望O(n)。
4. 快速排序的坑:稳定性、重复元素与递归深度
作为用Java写业务的开发者,不能只知道快排怎么写,还得知道它为什么不适合某些场景。下面三个点,是我在实际项目里真实遇到过、或者帮别人review代码时见到过的高频问题。
4.1 不稳定的排序:哪些场景不能用
快速排序是不稳定排序。什么叫不稳定?举个例子,假设有一组对象,先按id排好序,然后需要按分数再排一次。稳定排序会保留“分数相同时id的先后顺序”,而不稳定排序可能打乱顺序。
Java中Arrays.sort()对基本类型数组和对象数组的处理策略就体现了这个差别:基础类型数组不需要保持相对顺序,所以JDK可以使用快排类算法提升性能;而对象数组排序需要保持稳定性,JDK使用TimSort(一种稳定的归并排序改进算法)。这个设计思路也提醒我们:如果你给一个对象列表做多关键字排序,不要贸然用快排方案,尤其是依赖“上一轮排序结果作为下一轮并列项次序”的场景,必须使用稳定排序。
还有更隐蔽的场景:数据分析里经常需要“按用户先分组、组内按时间排序”。如果第一步分组处理使用了不稳定排序,就不能保证组内时间顺序的稳定性,后续计算就全乱了。实际开发中,我遇到这种需求会优先用List.sort配合Comparator,让稳定排序替我们处理这些细节。
4.2 重复元素很多时的三路快排
假设数组里90%的元素都是同一个值,普通Lomuto分区会怎样?每次选到的pivot如果是大量重复值之一,partition之后虽然pivot在正确的位置,但和pivot相等的其他元素散落在左右两侧,递归还得一遍一遍处理这些相等的值,白白浪费时间。
大牛Dijkstra提出过三路快排(3-Way QuickSort),基本思路是把数组分成三段:小于pivot、等于pivot、大于pivot。递归时只需处理小于和大于这两段,中间所有相等元素直接跳过。
java复制private static void quickSort3Way(int[] nums, int lo, int hi) {
if (lo >= hi) {
return;
}
int lt = lo;
int i = lo + 1;
int gt = hi;
int pivot = nums[lo];
while (i <= gt) {
if (nums[i] < pivot) {
swap(nums, lt++, i++);
} else if (nums[i] > pivot) {
swap(nums, i, gt--);
} else {
i++;
}
}
quickSort3Way(nums, lo, lt - 1);
quickSort3Way(nums, gt + 1, hi);
}
三路快排本质上把“大量重复元素”从递归任务中摘出去了,实际运行时间甚至能到O(n)。比如对一个只有0和1两种值的数组排序,一趟三路快排结束,中间全是0或全是1,不需要再递归。
我在处理一些业务上的枚举分类数据时,对这种算法印象很深。当你数据里有明显的“热点值”(比如某个状态码占比极高),普通快排会浪费大量时间在重复比较上。切换到三路快排后,性能改善非常明显。
4.3 递归太深:从递归改成显式栈
快排本质上是递归,递归深度在平均情况下为O(log n),最坏情况下可能到O(n)。Java虚拟机默认栈大小有限,如果处理一个几百万级且组织不好的数组,递归写法的快排可能触发StackOverflowError。
一种常见方案是递归中先处理较小的一侧,再把较大的一侧压入递归调用。这样可以保证递归深度O(log n),因为较小的子区间长度最多为n/2,每次递归深度对数增长,较大区间通过循环继续处理。另一种更通用的工程方案,是把递归改成显式栈:
java复制public static void quickSortIterative(int[] nums, int lo, int hi) {
Deque<int[]> stack = new ArrayDeque<>();
stack.push(new int[]{lo, hi});
while (!stack.isEmpty()) {
int[] range = stack.pop();
int l = range[0];
int r = range[1];
if (l >= r) {
continue;
}
int p = partition(nums, l, r);
stack.push(new int[]{l, p - 1});
stack.push(new int[]{p + 1, r});
}
}
这里的任务栈保存的是“还没有处理的区间”。每次从栈里取出一个区间,分区完成后把左右两个子区间压回栈。压栈顺序不影响结果,因为后进先出只是处理顺序不同。实际使用中,也可以用LinkedList当栈,但ArrayDeque在Java里整体性能更好。
需要注意:显式栈版本空间复杂度依旧是O(log n)到O(n),和递归版本的栈空间本质相同,只是不会受JVM方法调用栈限制,更适合在超大数组场景下使用。
5. 从JDK源码反推Java排序工程的取舍
学习快排绕不开JDK,因为java.util.Arrays是几乎所有Java程序员日常都会接触的排序入口。理解JDK为什么在不同数据上“偷偷切换”算法,对我们自己设计算法策略非常有启发。
5.1 基本类型数组为何选了双轴快排思路
Java对int[]、long[]这类基础类型数组排序时,使用了一种被称为双轴快排的算法思路。它的核心是选出两个轴点pivot1、pivot2,且保证pivot1 <= pivot2,一趟遍历把数组分成三段:小于pivot1、介于pivot1和pivot2之间、大于pivot2。之后三段递归排序。
理论上说,三段划分比两段划分能更早地“消灭”更多的元素,特别是减少了中间那段元素的重复比较次数。虽然双轴快排的最坏复杂度下界没有改变,但实际常数更低,对CPU缓存也更友好。JDK里还做了很多启发式保护:比如当递归深度超过一定阈值,改用堆排序打底,防止最坏情况拖垮性能;当数组长度小于47时,直接使用插入排序。
这些设计不是炫技,而是对“不同数据规模、不同有序程度”做了响应式选择。对我们普通程序员来说,最重要的一条经验是:不要默认所有数据都是一张白纸,排序实现里一定要对“小数据”和“接近有序的数据”做特殊处理。
5.2 对象数组排序的稳定性需求才是藏起来的重点
前面提到过,对象数组排序用的是TimSort而不是快排,最核心原因是稳定性。那么为什么稳定性这么重要?当你的数据不是单关键字,而是多关键字时,稳定排序可以“叠加”多次排序来达到复合排序的效果。
比如一个购物网站想按“销量优先、销量相同时价格低者靠前”排序,你可以先用价格排序,再用稳定排序按销量排。第一次价格排序后,相同销量的商品仍然保持价格从低到高,第二次稳定排序不会破坏这层顺序。如果第二次用的是不稳定快排,价格次序就全乱了。
TimSort的巧妙之处还在于它吸收了归并排序的稳定性和快排的局部性:它会把数组切分成一段段已经天然有序的run,再用归并把这些run合并。对于现实中大量“部分有序”的数据,TimSort不需要做太多比较和交换。这也是为什么Java的Collections.sort和List.sort默认都用TimSort而不是快排。我自己在做对账系统时,对一个“时间相近但偶有乱序”的几百万数据排序,TimSort的实测速度就比普通归并排序快不少,实际跑出来的结果非常惊喜。
5.3 从JDK设计里能直接抄的作业
把JDK的思路落到自己的代码里,我总结了可落地的作业清单:
- 排序前判断数据长度,过短直接插入排序。
- 选择pivot前先检测数组是否基本有序,有序时减少递归损耗。
- 对递归深度做保护,超过阈值切换为堆排序。
- 对多关键字排序需求,选择稳定性有保证的排序实现。
如果只是自己写算法练习,不必完全照搬JDK。但理解这些逻辑能让你在面对“为什么Java排序这么快”的面试题时,能说出工程层面的思考,而不是只背一句“底层是双轴快排”。
6. 面试与笔试里的快速排序高频变体
我在帮别人做Java面试辅导时发现,快速排序是算法面试的“必考题库”。但面试官早就听腻了标准代码,更喜欢用各种变形题考察候选人的迁移能力。
6.1 常见题目类型与答题思路
一类是直接手写快排,但要求“原地排序、空间复杂度O(1)”。只需注意:递归空间不算额外的辅助数组空间,所以能用递归写;但如果你写了一个新数组去收集左右元素,再合并回原数组,那就不叫原地了。
另一类是“求第K大元素”或“求最小的K个数”。对应策略就是快速选择。这里我建议先问清楚:K从1开始计还是从0开始计?求的是第K大还是第K小?如果是从1开始计的第K大,换算成下标就是 len - K,如果题目又要求返回最大的K个数,那通常要返回的是数组而不是单个值,要注意返回集合的元素不是局部有序没关系,但它们都必须比数组里剩下的元素大。
还有一类是“数组中有大量重复元素,请在O(n)时间按某种规则重排”,比如荷兰国旗问题。三路快排的实现本身就是经典解法:维护lt、i、gt三个指针,把小于pivot、等于pivot、大于pivot三个区域稳定地划分出来。理解了三路快排,这类变体题也就不难了。
最后还有一类是“快排非递归实现”。面试官考这个,其实是想确认你有没有考虑过栈溢出风险和递归深度的问题。用显式栈把递归改成循环即可,我在上一章节给的代码可以直接套用。
6.2 手写时容易被扣分的边界点
我在面试别人时发现,手写快排最容易扣分的位置,反而不是主逻辑,而是一些细节:
partition中返回值语义不统一。到底是返回pivot下标,还是返回分界位置?两个语义会影响递归边界写法。swap写成数组翻转里的对称交换,这种是无效操作。快排里交换的是i和j、i和hi,不是nums[lo]和nums[hi]反复横跳。- 忘记处理
nums == null或长度小于2的边界。 - 使用递归时没有对左、右区间的范围做校验,导致栈溢出。
- 快速选择里把k误用为“从1开始”的序号,而不是下标。
- 选了最右元素为pivot,但循环里又让j遍历到hi,导致pivot被自身交换。
这些小问题在IDE里不一定跑挂,但只要你测试用例覆盖到“长度为1”“全相等”“已经升序”“已经降序”这四种极端输入,问题就会立刻暴露。所以每次写完快排或快选,我都建议至少用上面四组用例自测一遍,其中会有一些让你觉得很挫败但很涨经验的结果。
再补充一个大多数教程不会提的点:快速排序的partition里,nums[j] <= pivot这个判断如果是用对象比较器,可能隐含一个复杂的compareTo调用,数据量大时这个开销不可忽视。实际业务中如果对对象排序,而且比较器里又做了远程调用或复杂计算,那快排再怎么优化都救不了你。这种情况下应该先提取排序键,再做排序,比较操作只针对int/long/String等基础类型。这种细节,面试官不问,但真正的线上环境一定会用得到。
