← 返回首页

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

2026-09-12·2 次浏览
排序算法入门(二):快速排序 —— 分区、基准与 O(n log n) 的平均性能

快排快,是因为每一次分区都顺手把一个元素钉死在最终位置上,整个过程只做原地交换、不开额外数组;它容易被最坏情况打败,同样是因为分区:切得均匀,每次大致二分,n 个元素只走 log n 层;切得难看,每次只削掉一个元素,n 层递归叠起来就是 O(n²),而且这时递归栈也会跟着长成 n 那么深。

分治与分区

快排的骨架只有三步:挑一个基准(pivot),按基准把区间分成“比它小”和“比它大”两半,再对两半递归。分区结束的那一刻,基准落到的位置就是它在最终有序数组里的位置,之后不需要再碰它。

这一点是它和归并排序最大的差别:归并的划分是机械对半砍,力气全花在合并上;快排把力气全花在分区上,分完就不需要合并。代价是边界容错空间很小——把基准本身也放进递归区间,分区点就会原地打转,几万个元素足够把调用栈撑爆。

Lomuto 分区

Lomuto 用一个指针 i 标记“小于区”的下一个空位,另一个指针 j 从左往右扫描,碰到小于基准的元素就和 i 交换。写法短,一眼能看懂,缺点是每次遇到小于基准的元素都要换一次,交换次数偏多;更麻烦的是它处理重复元素很差,如果整个区间元素全部相等,a[j] < pivot 永远不成立,分区点会固定在区间端点上,递归深度直接变成 n。

java Copy
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 Copy
    /** 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 Copy
    // 用到 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 Copy
    /** 先递归较短的一边,较长的一边用循环摊平:栈深度锁死在 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 边界上。

── 完 ──

评论

还没有评论,来说点什么
发表评论
0 / 1000