在实际开发里,能把快速排序写明白的人,往往不是靠背代码,而是真正理解了“分治”和“分区”这两个动作。而和快速排序同源的快速选择排序,又是一个容易被忽略、但解决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]。
