排序算法入门(二):快速排序 —— 分区、基准与 O(n log n) 的平均性能

快排快,是因为每一次分区都顺手把一个元素钉死在最终位置上,整个过程只做原地交换、不开额外数组;它容易被最坏情况打败,同样是因为分区:切得均匀,每次大致二分,n 个元素只走 log n 层;切得难看,每次只削掉一个元素,n 层递归叠起来就是 O(n²),而且这时递归栈也会跟着长成 n 那么深。
分治与分区
快排的骨架只有三步:挑一个基准(pivot),按基准把区间分成“比它小”和“比它大”两半,再对两半递归。分区结束的那一刻,基准落到的位置就是它在最终有序数组里的位置,之后不需要再碰它。
这一点是它和归并排序最大的差别:归并的划分是机械对半砍,力气全花在合并上;快排把力气全花在分区上,分完就不需要合并。代价是边界容错空间很小——把基准本身也放进递归区间,分区点就会原地打转,几万个元素足够把调用栈撑爆。
Lomuto 分区
Lomuto 用一个指针 i 标记“小于区”的下一个空位,另一个指针 j 从左往右扫描,碰到小于基准的元素就和 i 交换。写法短,一眼能看懂,缺点是每次遇到小于基准的元素都要换一次,交换次数偏多;更麻烦的是它处理重复元素很差,如果整个区间元素全部相等,a[j] < pivot 永远不成立,分区点会固定在区间端点上,递归深度直接变成 n。
java
import java.util.Arrays;
public class QuickSort {
public static void sort(int[] a) {
if (a == null || a.length < 2) {
return;
}
quickSort(a, 0, a.length - 1);
}
private static void quickSort(int[] a, int lo, int hi) {
if (lo >= hi) {
return; // 区间里只剩 0 或 1 个元素,天然有序
}
int p = partition(a, lo, hi);
quickSort(a, lo, p - 1); // 基准已经归位,递归时必须把它排除在外,
quickSort(a, p + 1, hi); // 否则每次分区都切出 n-1 个元素,区间不收敛
}
/** Lomuto 分区:把 a[hi] 当基准,返回基准归位后的下标 */
private static int partition(int[] a, int lo, int hi) {
int pivot = a[hi];
int i = lo; // i 是"小于基准区"的下一个空位
for (int j = lo; j < hi; j++) {
if (a[j] < pivot) { // 严格小于:等于基准的元素留在右半区,
swap(a, i, j); // 它们本来也不用挪,交换次数因此少一半
i++;
}
}
swap(a, i, hi); // 基准换到小于区和大于区之间,这里就是它的最终位置
return i;
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
public static void main(String[] args) {
int[] a = {5, 1, 4, 2, 8, 3};
sort(a);
System.out.println(Arrays.toString(a)); // [1, 2, 3, 4, 5, 8]
}
}
Hoare 分区
Hoare 是快排原始论文里的写法,两个指针从两端往中间走,各自停在“站错了边”的元素上,交换之后继续前进。
java
/** Hoare 分区:以区间中间元素为基准,返回左右两半的分界点 */
private static int hoarePartition(int[] a, int lo, int hi) {
int pivot = a[lo + ((hi - lo) >>> 1)]; // 先把基准的值取出来,后面的交换动不了它
int i = lo - 1;
int j = hi + 1;
while (true) {
do { i++; } while (a[i] < pivot); // 停在第一个不小于基准的元素上
do { j--; } while (a[j] > pivot); // 停在第一个不大于基准的元素上
if (i >= j) {
return j; // 指针交叉,本轮分区结束
}
swap(a, i, j);
}
}
/** 配套递归:分界点 j 不是基准的位置,所以右半边从 j+1 开始 */
private static void hoareSort(int[] a, int lo, int hi) {
if (lo >= hi) {
return;
}
int j = hoarePartition(a, lo, hi);
hoareSort(a, lo, j); // 写成 (lo, j-1) 会漏元素,甚至让区间不再缩小而死循环
hoareSort(a, j + 1, hi);
}
它的返回值 j 是左右两半的分界点,不是基准的最终位置,基准这时可能还留在左半区里,所以递归必须写成 (lo, j) 和 (j+1, hi)。两个 do-while 不会越界:基准值本身就在数组里,指针遇到它就会停下,这也是基准不必挪到区间端点的原因。
两个指针都会主动前进,碰上一堆重复元素时也能把区间稳定地劈成两半,不会像 Lomuto 那样退化,所以手写实现和面试里更常见的是它。
基准怎么选
把基准固定成区间首元素,遇到已经排好序的数据会退化成 O(n²):我在一次导出日志的排序里真的踩到过——日志本来就是按时间递增落库的,用固定首元素的做法再过一遍,十万行直接卡住十几秒,把基准换掉就回到毫秒级。原因不难推:有序输入下每次分区都是左边 0 个、右边 n-1 个,n 层递归乘以每层 O(n) 的扫描,正好是 n²。
java
// 用到 ThreadLocalRandom,需要 import java.util.concurrent.ThreadLocalRandom;
/** 三数取中:把 lo、mid、hi 三个位置排好序,再取中间那个当基准 */
private static void medianOfThreeToEnd(int[] a, int lo, int hi) {
int mid = lo + ((hi - lo) >>> 1); // 不用 (lo + hi) / 2,避免大下标相加溢出
if (a[lo] > a[mid]) swap(a, lo, mid);
if (a[lo] > a[hi]) swap(a, lo, hi);
if (a[mid] > a[hi]) swap(a, mid, hi);
swap(a, mid, hi); // 三次交换后 a[mid] 是中位数,换到右端给 Lomuto 用
}
/** 随机基准:让最坏情况的输入无法被提前构造出来 */
private static void randomPivotToEnd(int[] a, int lo, int hi) {
if (hi - lo < 2) {
return; // 两三个元素的区间上,随机数生成比比较本身还贵
}
int r = lo + ThreadLocalRandom.current().nextInt(hi - lo + 1);
swap(a, r, hi);
}
三数取中治的是有序或接近有序的输入,治不了大量重复元素,那种情况要靠双路或三路分区。随机基准的意义不是减少比较次数,而是让最坏情况不再由输入决定,别人没法构造出一组必然退化的数据。
递归深度与栈
复杂度分析里快排的空间是 O(log n),指的是递归栈深度;原地指的是不用额外数组,不等于不用栈。最坏情况下递归链有 n 层,每层压一个栈帧,数据量一上来就是 StackOverflowError。
java
/** 先递归较短的一边,较长的一边用循环摊平:栈深度锁死在 O(log n) */
private static void quickSortBounded(int[] a, int lo, int hi) {
while (lo < hi) {
int p = partition(a, lo, hi);
if (p - lo < hi - p) { // 左半更短:左半真正递归,右半留到下一轮循环
quickSortBounded(a, lo, p - 1);
lo = p + 1;
} else { // 右半更短,反过来处理
quickSortBounded(a, p + 1, hi);
hi = p - 1;
}
}
}
这样改完,每层真正入栈的区间规模至少减半,栈深度稳定在 O(log n),被循环接手的区间不再吃栈,额外成本只有一次比较,值得无条件写进实现。
小数组与插入排序的配合
区间小到十几个元素时,继续分区已经不划算了:递归调用、基准选取、指针移动都要花时间;而插入排序在短区间上的常数极小,内层就是一次比较加一次写入,近乎有序时几乎不用挪动。常见做法是设一个阈值,区间长度小于它就切插入排序,JDK 里这个阈值是 47。
工程里 JDK 用的是双轴快排
Arrays.sort(int[]) 的实现叫 DualPivotQuicksort:一次挑两个基准,把区间切成三段而不是两段,比较次数和交换次数都比经典单轴快排少,大规模基本类型数组上更快。它同时带两条兜底:数组很短时切换成插入排序;检测到分区效果太差、递归太深时切换成堆排序,把最坏情况从 O(n²) 拉回 O(n log n)。
而 Arrays.sort(Object[]) 走的是 TimSort,归并系,不是快排。原因在稳定性:对象数组里两个“相等”的元素往往是两个不同的对象,业务上通常还要求它们保持原来的先后顺序,归并排序天然稳定,快排则做不到既原地又稳定。基本类型没有身份,两个 3 交换先后没有任何可观察的差别,稳定性就无所谓,这时快排的原地和缓存友好才是更值钱的性质。
三种 O(n log n) 排序怎么选
| 算法 | 平均 | 最坏 | 空间 | 稳定性 | 是否原地 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n²) | O(log n) 递归栈 | 不稳定 | 是 |
| 归并排序 | O(n log n) | O(n log n) | O(n) 辅助数组 | 稳定 | 否 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 是 |
选择好判断:基本类型、内存紧、要原地,选快排;对象数组、要保序,选归并系,也就是 Arrays.sort(Object[]);只关心“前 K 大”,或者对最坏情况的延迟有硬要求,堆排更稳。
小结
快排的三件事连着看最清楚:每层 O(n) 的分区把区间切小,切得均匀才有 n log n 的平均性能;最坏情况来自基准选得差,随机化或者三数取中是成本最低的保险;O(log n) 的空间说的是递归栈,要把它压到 log 层,靠的是“先递归短边、长边走循环”。具体到实现:Lomuto 适合讲清思路,Hoare 适合跑生产数据,但它仍然不稳定——跨区间的长距离交换会打乱相等元素的次序;小数组切插入排序、递归过深切堆排序,这几条凑齐才是一份能上线的快排。下一篇写归并排序,重点会放在那个多数人第一遍写不对的 merge 边界上。
评论