跳转到内容

Lesson 41: 快速排序

练习任务

难度:中-难 【重点】

实现快速排序,包含两个核心函数:

  1. partition(arr, lo, hi) — Lomuto 分区:以 arr[hi] 为 pivot,重排数组使 pivot 归位,返回 pivot 索引
  2. quicksort(arr, lo, hi) — 递归快排:分区后对左右子数组递归
c
int partition(int arr[], int lo, int hi);
void quicksort(int arr[], int lo, int hi);

swap 函数已提供。验证用例:

输入 "5 3 8 1 4 2\n" 1 2 3 4 5 8
输入 "9 1 5 3 7\n" 1 3 5 7 9

快速排序是工程中最常用的原地排序算法,也是 C 标准库 qsort 的底层基础。Tony Hoare 于 1960 年发明它时年仅 26 岁,2011 年获颁 IEEE 冯·诺依曼奖。理解快排的分治思想与分区操作,是掌握算法设计与分析的关键一步。

提示:快排与归并排序同为分治算法,但有一个本质区别——归并排序是"先递归,后合并"(需要额外空间),快排是"先划分,后递归"(原地完成,无需合并)。理解这一点,你就抓住了快排的核心设计思想。


核心知识点

  • 分治三步骤:划分(partition)→递归左右→无需合并。与归并排序"先递归再合并"形成对比,快排是原地排序的核心优势
  • Lomuto 分区i = lo - 1 作为 ≤pivot 区的右边界,jlo 扫描到 hi-1,遇到 ≤ pivoti++ 并交换,最后 swap(arr[i+1], arr[hi]) 使 pivot 归位
  • Pivot 落最终位不变式:分区完成后,pivot 处于最终排序位置——左侧全 ≤ pivot,右侧全 ≥ pivot。后续递归永远不会再触碰该位置
  • 有序输入 + 末位 pivot → 最坏 O(n²):每次递归只减少 1 个元素,递归树退化为单链,总比较次数 ≈ n(n-1)/2
  • 随机化 pivot / 三数取中 → 期望 O(n log n):通过打乱 pivot 选择消除最坏情况,使分区在期望下近乎平衡
  • Lomuto vs Hoare 划分:pivot 选择位置(hi vs lo)、交换次数(较多 vs 较少)、返回值语义(pivot 位置 vs 分界点)全方位对比
  • 快排 vs 归并 vs 堆排:三者在稳定性、原地性、空间复杂度、缓存局部性上的差异——快排常数小、缓存友好、原地,是"实际最快"
  • 三路划分(荷兰国旗问题):处理大量重复元素时,将数组分成 < pivot / == pivot / > pivot 三段,避免冗余比较
  • Introsort:快排 + 堆排混合策略,递归深度超过 2log₂n 时切换堆排,兼顾平均性能和最坏保证。std::sort 和 glibc qsort 的内核

代码框架

41_quick_sort.c
c
/* 41_quick_sort.c — 快速排序
 *
 * 任务:实现快速排序
 *       1. partition(arr, lo, hi) — Lomuto 分区
 *          选 arr[hi] 为 pivot,扫描 [lo, hi) 区间,小的放左边
 *          返回 pivot 最终位置
 *       2. quicksort(arr, lo, hi) — 递归快排
 *          分区 → 递归排左半 → 递归排右半
 *
 * 知识点:分治、Lomuto 分区、pivot 选择、原地排序
 *
 * 验证:
 *   stdin: "5 3 8 1 4 2\n" → 1 2 3 4 5 8
 *   stdin: "9 1 5 3 7\n"   → 1 3 5 7 9
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

/* Lomuto 分区:
 * - 选 arr[hi] 作为 pivot
 * - i 指向"小于 pivot 的区域"的最后一个位置
 * - j 扫描 [lo, hi),遇到 ≤ pivot 的元素就交换到左边
 * - 最后把 pivot 放到正确位置 (i+1) */
int partition(int arr[], int lo, int hi) {
    // 在这里实现: 选 pivot = arr[hi]; i = lo - 1
    // for j = lo; j < hi; j++
    //   如果 arr[j] <= pivot: i++; swap(&arr[i], &arr[j])
    // 最后: swap(&arr[i+1], &arr[hi]); return i+1;
}

/* 递归快排 */
void quicksort(int arr[], int lo, int hi) {
    // 在这里实现: 终止条件 lo >= hi → return
    // 分区: p = partition(arr, lo, hi)
    // 递归左: quicksort(arr, lo, p-1)
    // 递归右: quicksort(arr, p+1, hi)
}

int main(void) {
    char line[1024];
    fgets(line, sizeof(line), stdin);

    int arr[1024], n = 0;
    char *tok = strtok(line, " \n");
    while (tok) {
        arr[n++] = atoi(tok);
        tok = strtok(NULL, " \n");
    }

    quicksort(arr, 0, n - 1);

    for (int i = 0; i < n; i++) {
        if (i > 0) printf(" ");
        printf("%d", arr[i]);
    }
    printf("\n");
    return 0;
}

阅读骨架后,尝试自己填充 // 在这里... 标记的部分。核心挑战在于:i 的初始值为什么是 lo - 1 而不是 loj 的扫描范围为什么是 lohi-1(不含 pivot)?pivot 归位为什么是 swap(arr[i+1], arr[hi]) 而不是 swap(arr[i], arr[hi])

TIP

先不要往下翻看参考解答。尝试在纸上画出 ij 的运动轨迹——i 永远指向 ≤pivot 区域的最后一个元素,j 是探索指针。当 j 发现一个 ≤pivot 的元素时,"≤区"向右扩展一位,把该元素交换进去。最后 pivot 自己还没有进 ≤区,所以放在 i+1 处。


深度讲解

4.1 分治:划分 → 递归 → 无需合并

分治(Divide and Conquer)是将问题分解为子问题、递归求解、再合并的通用策略。快排和归并排序都遵循分治框架,但对三步的映射完全不同:

        归并排序(Merge Sort)                快速排序(Quick Sort)
        ════════════════════════              ══════════════════════

Divide:  对半分(无实际工作)               Partition:将数组按 pivot 重排
                                        [≤pivot] [pivot] [≥pivot]


Conquer:  递归排序左半                           递归排序左半
          递归排序右半                           递归排序右半


Combine:  Merge 合并两个有序子数组 无需合并!pivot 已归位,左右独立排序后整体有序
          需要 O(n) 额外空间              原地完成,无额外数组

关键洞察:归并排序的工作量集中在"合并"阶段(需要遍历两个子数组,复制到临时空间),而快排的工作量集中在"划分"阶段(重排数组使 pivot 归位)。因为划分操作直接在原地完成,且 pivot 被放到最终正确位置后永不再移动,所以递归完成后无需合并

这一设计差异带来了实际性能的重大区别:

归并排序快速排序
主要工作量合并阶段划分阶段
是否原地否(需临时数组)(原地分区)
空间复杂度O(n)O(log n) (仅递归栈)
缓存局部性一般(合并时跨区域读写)极好(顺序扫描数组)

NOTE

快排的"无需合并"只有在前一个条件——pivot 被放到最终正确位置——成立时才成立。这正是 Lomuto 分区(及任何正确分区)必须保证的不变式,也是下一节的核心。


4.2 Lomuto 分区:全程逐轮跟踪

Lomuto 分区以 arr[hi] 为 pivot,维护两个指针:

  • i:≤pivot 区域的右边界(初始 lo - 1,表示 ≤区为空)
  • j:扫描指针,从 lo 遍历到 hi - 1(不含 pivot 自身)

算法流程

1. pivot = arr[hi]
2. i = lo - 1
3. for j = lo; j < hi; j++:
     if arr[j] <= pivot:
        i = i + 1
        swap(arr[i], arr[j])
4. swap(arr[i + 1], arr[hi])   // pivot 归位
5. return i + 1                // 返回 pivot 的最终位置

[5, 3, 8, 1, 4, 2] 为例,pivot = arr[hi] = 2,全程逐轮跟踪

═══════════════════════════════════════════════════════════════════
初始状态:
  arr = [5, 3, 8, 1, 4, 2]

         j              pivot
  pivot = 2,  i = -1 (lo - 1 = 0 - 1)

说明: j 从索引 0 开始扫描,i 指向 ≤2 区的末尾(当前为空,所以是 -1)
═══════════════════════════════════════════════════════════════════

j = 0:  arr[0] = 5
        5 > 2 不满足 pivot 条件
        不做任何操作,i 保持 -1
        arr = [5, 3, 8, 1, 4, 2]   (不变)
                    i=-1
              j

j = 1:  arr[1] = 3
        3 > 2 不满足条件
        arr = [5, 3, 8, 1, 4, 2]   (不变)
                 i=-1
                 j

j = 2:  arr[2] = 8
        8 > 2 不满足条件
        arr = [5, 3, 8, 1, 4, 2]   (不变)
              i=-1
                    j

j = 3:  arr[3] = 1
        1 2 满足条件!
        (1) i = i + 1 = 0 i -1 变成 0
        (2) swap(arr[0], arr[3])   → 5 和 1 交换位置
        arr = [1, 3, 8, 5, 4, 2]

               i        j
        解读: ≤2 区现在包含 arr[0]=1,i=0 标记了它的右边界

j = 4:  arr[4] = 4
        4 > 2 不满足条件
        arr = [1, 3, 8, 5, 4, 2]
     i=0
               i           j

═══════════════════════════════════════════════════════════════════
扫描完成 (j 遍历了 lo..hi-1 0..4)
═══════════════════════════════════════════════════════════════════

pivot 归位:
  swap(arr[i + 1], arr[hi])
  = swap(arr[0 + 1], arr[5])
  = swap(arr[1], arr[5])
 交换 arr[1]=3 arr[5]=2

  arr = [1, 2, 8, 5, 4, 3]
         ←≤2→ ←──≥2──→
         i=0  p=1  余下

  return i + 1 = 1   (pivot 的最终位置)

结果验证:
  arr[0..0] = [1]    全部 ≤ 2 ✓
  arr[1]     = 2     pivot 归位
  arr[2..5] = [8,5,4,3]  全部 ≥ 2 ✓

三个变量的含义总结

i:   ≤pivot 区域的右边界(闭区间 [0, i] pivot)
     初始 lo-1 = 空区间
     每次 arr[j] pivot i 右移一位

j:   扫描指针(闭区间 [i+1, j-1] 是已扫描的 >pivot 区域)
     每次迭代 j 右移一位

pivot: 基准值 arr[hi],本身不在 j 的扫描范围内

IMPORTANT

为什么 i 初始是 lo - 1 而不是 lo 因为 ≤pivot 区初始为空,用 i = lo - 1 表示"右边界在左边界之前"。如果初始设为 lo,则第一个 ≤pivot 元素被交换时 swap(arr[lo], arr[j]) 会丢掉 arr[lo]


4.3 Pivot 落在最终位置:不变式的力量

Lomuto 分区完成后,以下不变式同时成立:

arr[lo  .. p-1]  arr[p]  arr[p+1 .. hi]
 全部 ≤pivot   pivot 全部 ≥pivot

这个不变式不是巧合,而是算法每一步都精心维护的结果。证明思路:

循环不变式(for 循环的每次迭代前):

  • arr[lo..i] 全部 ≤ pivot
  • arr[i+1..j-1] 全部 > pivot
  • arr[j..hi-1] 尚未扫描

初始(i = lo - 1, j = lo):三个区间均为空,平凡成立。

保持(每次迭代):

  • arr[j] ≤ pivoti++swap(arr[i], arr[j]),即两个边界都向右移动,≤区扩大一位,arr[j](原 > 区边界元素)被交换到 > 区首部。不变式保持。
  • arr[j] > pivot:仅 j++,> 区自然扩大一位。不变式保持。

终止(j = hi):

  • arr[lo..i] 全部 ≤ pivot
  • arr[i+1..hi-1] 全部 > pivot
  • arr[hi] 是 pivot 自己

最后 swap(arr[i+1], arr[hi]) 将 pivot 插入 ≤区和 >区之间,恰好满足最终不变式。

NOTE

不变式的精妙之处在于:pivot 一旦归位,后续递归永远不会再触碰它。左递归处理 lo..p-1,右递归处理 p+1..hi——pivot 索引 p 被完美跳过。这就是为什么快排可以"原地"工作:每一轮都确定了一个元素的最终位置,总共 n 轮即可完成整数组的排序。


4.4 有序输入 + 末位 pivot → 最坏 O(n²):逐轮跟踪证明

为什么最坏情况是 O(n²)? 如果每次选取的 pivot 都是当前子数组的最值(最小或最大),则分区极不平衡——一边为空,另一边只减少 1 个元素。

典型触发场景:已排序数组 [1, 2, 3, 4, 5],始终选 arr[hi](最后一个元素)为 pivot。

═══════════════════════════════════════════════════════════════════
 1 轮: quicksort(arr, 0, 4)
  arr = [1, 2, 3, 4, 5], pivot = arr[4] = 5
  ═══════════════════════════════════════
  j=0: 1 5 i++, swap(arr[0], arr[0])   (自交换,可优化跳过)
  j=1: 2 5 i++, swap(arr[1], arr[1])
  j=2: 3 5 i++, swap(arr[2], arr[2])
  j=3: 4 5 i++, swap(arr[3], arr[3])
  ═══════════════════════════════════════
  迭代次数: 4 (j  0 3)
  swap(arr[4], arr[4]) → 自交换,p=4
  结果: [1, 2, 3, 4] [5]    ← 右子数组空,左子数组仅减少 1
 left 4个  ↑pivot

 2 轮: quicksort(arr, 0, 3)
  arr = [1, 2, 3, 4], pivot = arr[3] = 4
  j=0,1,2: 全部 ≤4,各交换一次
  迭代次数: 3
  p=3, 结果: [1, 2, 3] [4]

 3 轮: quicksort(arr, 0, 2)
  arr = [1, 2, 3], pivot = arr[2] = 3
  j=0,1: 全部 ≤3
  迭代次数: 2
  p=2, 结果: [1, 2] [3]

 4 轮: quicksort(arr, 0, 1)
  arr = [1, 2], pivot = arr[1] = 2
  j=0: 1 2
  迭代次数: 1
  p=1, 结果: [1] [2]

终止: quicksort(arr, 0, 0)  lo hi,返回
═══════════════════════════════════════════════════════════════════

总迭代次数 (即比较次数): 4 + 3 + 2 + 1 = 10
通项公式: (n-1) + (n-2) + ... + 1 = n(n-1)/2 = O()

递归树的可视化

正常快排(平衡分区):              最坏快排(退化分区):
        [5,3,8,1,4,2]                   [1,2,3,4,5]
       /              \                 /            \
  [1,2]               [8,5,4]       [1,2,3,4]       [ ]
  /    \              /     \       /        \
[1]   [2]          [4,5]   [8]  [1,2,3]     [ ]
                   /   \        /      \
                 [4]   [5]   [1,2]    [ ]
                             /   \
                           [1]   [2]

深度: O(log n)                    深度: O(n)
每层工作量: O(n)                   每层工作量: O(n - depth)
总计: O(n log n)                   总计: O()

树形: 接近完全二叉树               树形: 退化为单链 (链表)

WARNING

在真实工程中,已排序数据是最坏输入的情况非常常见(例如从数据库读取已按时间排序的记录)。如果始终选末尾元素为 pivot,每次快速排序都会退化到 O(n²)。对于 n = 10⁵,n² = 10¹⁰ 与 n log₂n ≈ 1.7×10⁶ 差了近 6000 倍——这足以让一个"正常工作"的程序在面对已排序输入时直接卡死。


4.5 随机化 pivot 与三数取中:修复最坏情况

4.5.1 随机化 pivot

在分区前将 pivot 与随机位置交换,再正常执行 Lomuto 分区:

c
// 随机化版本
#include <stdlib.h>
#include <time.h>

int randomized_partition(int arr[], int lo, int hi) {
    int rand_idx = lo + rand() % (hi - lo + 1);
    swap(&arr[rand_idx], &arr[hi]);  // pivot 换到末尾
    return partition(arr, lo, hi);   // 照常分区
}

概率分析:随机化后,pivot 会等概率地落在排序后任意位置。则:

  • pivot 落在中间 50% 区域(即分区比 ≥ 1:3)的概率为 1/2
  • 即便最坏 pivot 选择发生,每次二选一,连续 k 次选到最坏的概率为 (1/2)^k,指数衰减

因此期望时间复杂度为 O(n log n)。严格的期望分析与递归树展开有关——即使存在低概率的坏分区,它们在期望值中被更多的好分区平均掉了。

TIP

调用 srand(time(NULL)) 初始化随机种子。但如果需要可复现的调试行为,用固定种子 srand(42)——这样每次运行时 pivot 选择序列相同。

4.5.2 三数取中(Median of Three)

arr[lo]arr[mid]arr[hi] 三者的中位数作为 pivot:

c
int median_of_three(int arr[], int lo, int hi) {
    int mid = lo + (hi - lo) / 2;
    // 三数排序: 将中位数放到 arr[hi]
    if (arr[lo] > arr[mid]) swap(&arr[lo], &arr[mid]);
    if (arr[lo] > arr[hi])  swap(&arr[lo], &arr[hi]);
    if (arr[mid] > arr[hi]) swap(&arr[mid], &arr[hi]);
    // 此时 arr[mid] 是三个数的中位数
    swap(&arr[mid], &arr[hi]);  // 将中位数作为 pivot 放到末尾
    return partition(arr, lo, hi);
}
三数取中示意:

  arr[lo]          arr[mid]         arr[hi]
    5                2                8

    └──────────┬─────┘

         (5 > 2 swap)              


     arr[2]=mid arr[5]=lo    arr[8]=hi  (不变)
     
  三数: lo=5, mid=2, hi=8 中位数 = 5 选为 pivot

结果分析

  • 已排序数组 [1,2,3,4,5]:三数取 lo=1, mid=3, hi=5 的中位数 3,分区为 [1,2] [3] [4,5],完美二分 ✓
  • 逆序数组 [5,4,3,2,1]:三数取 5,3,1 的中位数 3,同样完成二分 ✓
  • 三数取中的开销是常数级(3 次比较 + 若干 swap),而避免最坏 O(n²) 的收益是渐近级差异

NOTE

三数取中同时解决了已排序输入逆序输入两种最坏情况,且不带随机性(结果可复现),因此在工业实现中(如 std::sort)被广泛使用。相比之下,随机化 pivot 提供了对所有输入模式的期望保障,但引入了非确定性。


4.6 Lomuto vs Hoare 划分:完整对比

Tony Hoare 在 1960 年提出的原始快排使用了一种不同的分区方案(后来以他命名)。两种方案的核心差异在于扫描方向和 pivot 位置

Hoare 分区伪代码

c
int hoare_partition(int arr[], int lo, int hi) {
    int pivot = arr[lo];           // ← 选 arr[lo] 为 pivot
    int i = lo - 1, j = hi + 1;
    while (1) {
        do { i++; } while (arr[i] < pivot);   // 左指针找 ≥pivot
        do { j--; } while (arr[j] > pivot);   // 右指针找 ≤pivot
        if (i >= j) return j;                  // 返回分界点
        swap(&arr[i], &arr[j]);                // 交换错位对
    }
}
Hoare 分区执行示意 (pivot = arr[lo] = 5):

  arr = [5, 3, 8, 1, 4, 2]

        i=lo          j=hi

  第1轮: i找到 5≥5, j找到 2≤5 swap(5,2)
         [2, 3, 8, 1, 4, 5]

  第2轮: i找到 8≥5, j找到 4≤5 swap(8,4)
         [2, 3, 4, 1, 8, 5]

  第3轮: i找到 8≥5, j找到 1≤5 i≥j, 返回 j
         结果: lo..j ≤5, j+1..hi ≥5

  return j (不是 pivot 位置!pivot=5 可能在左侧任何位置)

完整对比表

维度Lomuto 分区Hoare 分区
pivot 选择arr[hi](末尾)arr[lo](开头,或中间)
扫描方向单向扫描(从左到右)双向扫描(两端向中间)
指针数量2 个(i, j2 个(i 从左,j 从右)
交换次数较多(每个 ≤pivot 的元素都交换)较少(仅交换错位对)
返回值语义pivot 的最终位置分界点 j,保证 lo..j ≤ pivot ≤ j+1..hi
递归调用quicksort(lo, p-1); quicksort(p+1, hi)quicksort(lo, j); quicksort(j+1, hi)
是否有元素留在原地pivot 确定归位pivot 不一定归位
重复元素处理 判断使相等元素归左侧相等元素可能分布两侧
教学友好度直观清晰,不变式容易理解边界条件较多,指针交叉逻辑需小心
性能(常数因子)偏慢(更多 swap)偏快(更少 swap,约 3 倍)

NOTE

核心差异:Lomuto 返回 pivot 的最终位置——递归时跳过 p。Hoare 返回 分界点 j——递归时左区间包含 j(因为 pivot 可能还在左边)。这导致递归调用方式不同:Lomuto 用 p-1 / p+1;Hoare 用 j / j+1

为什么本题选 Lomuto 而非 Hoare?

  1. 教学价值:Lomuto 的循环不变式直观(≤区边界清晰),返回值语义明确(pivot 的位置),适合第一次理解分区操作
  2. 错误风险低:Hoare 分区的指针交叉和边界条件容易出错(如 do-while 越界需要哨兵),不适合作为入门方案
  3. 后续可扩展:理解 Lomuto 的 ≤区边界后,三路划分(荷兰国旗)的 lt/gt 双边界设计自然延伸

4.7 快排 vs 归并 vs 堆排:三大 O(n log n) 排序全方位对比

                        快排            归并            堆排
                        (Quick Sort)    (Merge Sort)    (Heap Sort)
                        ════════════    ═══════════    ═══════════
平均时间复杂度          O(n log n)      O(n log n)      O(n log n)
最坏时间复杂度          O()           O(n log n)      O(n log n)
最好时间复杂度          O(n log n)      O(n log n)      O(n log n)

额外空间                O(log n)        O(n)            O(1)
                        (递归栈)      (临时数组)    (原地)

稳定性 不稳定 稳定 不稳定
(相等元素相对顺序)    (分区破坏顺序)(合并保持顺序)(堆调整破坏顺序)

原地排序

缓存局部性 极好         🟡 一般
(连续内存访问模式)    (顺序扫描)    (跨区域归并)  (跳跃式父子访问)

常数因子                最小 (~1.4)     中等 (~2.0)     较大 (~3.0)
(log n 前的系数)

递归 / 迭代             递归            递归            迭代

适用场景                通用排序首选    外排序、链表    优先队列、Top-K
                        稳定排序需求    嵌入式(空间有限)

缓存局部性详解

这也许是三者在实践中最被忽略但最重要的差异:

快排的扫描模式(对 L1/L2 缓存极其友好):
  顺序扫描 [lo..hi],相邻元素地址连续:
  ┌─┬─┬─┬─┬─┬─┬─┬─┐
  │5│3│8│1│4│2│ cache line 一次性加载,后续命中率极高
  └─┴─┴─┴─┴─┴─┴─┴─┘

归并排序的合并模式(需要来回访问两个数组):
  读:  [1,3,5]    写: 临时数组
  读:  [2,4,6]    写: 临时数组
  跨区域跳跃,cache miss 较多

堆排序的父子访问模式(索引跳跃 n n/2):
  parent(i) = (i-1)/2
  children(i) = 2i+1, 2i+2
  每次 sift-down 跳跃访问,cache miss 最多

IMPORTANT

快排为什么是"实际最快"? 不是因为渐近复杂度更低(三者都是 O(n log n)),而是因为常数因子最小(每个元素平均参与约 1.4 次比较),并且顺序扫描模式使 CPU 缓存命中率极高。在现代 CPU 上,主存访问比缓存访问慢约 100 倍——缓存局部性的优势在 n 较大时可转化为数倍甚至十数倍的性能差距。


4.8 三路划分:荷兰国旗问题

4.8.1 需求场景

当数组中存在大量重复元素时,标准的 Lomuto/Hoare 二分法会做无用功——相等的元素被反复划分和递归。例如排序全是 0 和 1 的数组:

标准快排: [0,1,0,1,0,1] → partition → [0,0,0,1,1,1]
各层仍然处理含 1 的中间区域,冗余比较很多

三路快排: 将数组一次性分为 [0,0,0] [ ] [1,1,1]
         pivot=1 <1 区为全0,=1 区为全1>1 区为空
         一轮完成!无需递归

荷兰国旗问题*Dijkstra 1976):将一个红白蓝三色旗重新排列为单色块,恰好对应将数组分为 < pivot== pivot> pivot 三段。

4.8.2 算法

维护三个指针:

  lo                             hi

  [  ? ? ? ? ? ? ? ? ? ? ? ?  ]

  lt        i           gt

  区间含义:
    [lo,   lt-1]    < pivot  (左侧区)
    [lt,   i-1 ]    == pivot (中间区)
    [i,    gt  ]    未处理区
    [gt+1, hi  ]    > pivot  (右侧区)
c
void three_way_qsort(int arr[], int lo, int hi) {
    if (lo >= hi) return;

    // 三数取中选择 pivot
    int mid = lo + (hi - lo) / 2;
    if (arr[lo] > arr[mid]) swap(&arr[lo], &arr[mid]);
    if (arr[lo] > arr[hi])  swap(&arr[lo], &arr[hi]);
    if (arr[mid] > arr[hi]) swap(&arr[mid], &arr[hi]);
    int pivot = arr[mid];

    int lt = lo, i = lo, gt = hi;
    while (i <= gt) {
        if (arr[i] < pivot) {
            swap(&arr[lt], &arr[i]);
            lt++; i++;               // <区右扩,扫描指针前进
        } else if (arr[i] > pivot) {
            swap(&arr[i], &arr[gt]);
            gt--;                    // >区右扩,扫描指针不动(换来的元素未检查)
        } else {
            i++;                     // ==区自然扩大
        }
    }
    // 分区完成: [lo..lt-1] < pivot, [lt..gt] == pivot, [gt+1..hi] > pivot

    three_way_qsort(arr, lo, lt - 1);   // 只递归 < 和 > 两段
    three_way_qsort(arr, gt + 1, hi);   // == 段已就位,跳过
}

4.8.3 逐步演示

[4, 2, 4, 2, 4, 1] 为例,pivot = 4:

初始:  lt=0  i=0  gt=5
       [4, 2, 4, 2, 4, 1]

      lt,i              gt

i=0: arr[0]=4 == pivot i++
     [4, 2, 4, 2, 4, 1]   lt=0, i=1, gt=5

     lt  i

i=1: arr[1]=2 < pivot → swap(lt,i), lt++, i++
     [2, 4, 4, 2, 4, 1]   lt=1, i=2, gt=5

        lt  i

i=2: arr[2]=4 == pivot i++
     [2, 4, 4, 2, 4, 1]   lt=1, i=3, gt=5

        lt     i

i=3: arr[3]=2 < pivot → swap(lt,i), lt++, i++
     [2, 2, 4, 4, 4, 1]   lt=2, i=4, gt=5

           lt     i

i=4: arr[4]=4 == pivot i++
     [2, 2, 4, 4, 4, 1]   lt=2, i=5, gt=5

           lt       i,gt

i=5: arr[5]=1 < pivot → swap(lt,i), lt++, i++
     [2, 2, 1, 4, 4, 4]   lt=3, i=6, gt=5

              lt  gt    i

i=6 > gt 退出循环

结果:
  < 4:  [2, 2, 1]    (lo..lt-1 = 0..2)
  == 4: [4, 4, 4]    (lt..gt = 3..5)  ← 已就位,跳过递归
  > 4:  [ ]           (gt+1..hi = 6..5,)

TIP

关键技巧:遇到 arr[i] > pivot 时,swap(arr[i], arr[gt])i 不动——因为从 gt 换来的元素尚未检查。遇到 arr[i] < pivot 时,swap(arr[lt], arr[i])i++——因为从 lt 换来的元素已被 i 检查过(就是 ==pivot 区的第一个元素,在 i 扫描到它之前已经被确认等于 pivot)。

三路划分的优势

  • 大量重复元素时远快于标准快排:时间复杂度从 O(n²/D) 降到 O(n)(其中 D 是不同值的数量)
  • Java 的 Arrays.sort() 对基本类型使用 Dual-Pivot Quicksort(三路划分的扩展)
  • 对仅有少量不同值的数组接近线性时间

4.9 Introsort:快排 + 堆排的混合策略

4.9.1 动机

纯快排的问题:最坏 O(n²) 总存在理论可能。纯堆排的问题:常数因子大,缓存不友好,平均比快排慢 2-3 倍。

能不能鱼与熊掌兼得? —— David Musser 在 1997 年提出 Introsort(Introspective Sort):大多数情况用快排(取其速度优势),递归过深时切换堆排(取其最坏保证)。

4.9.2 深度阈值的计算

为什么阈值是 2·log₂ n?

平衡快排的递归深度:   ~ log₂ n
                    (每次分区接近二分)

退化快排的递归深度:   ~ n
                    (每次分区只减少 1 个元素)

阈值设为 2·log₂ n 的理由:
  - 如果深度 > 2·log₂ n,分区严重不平衡
  - 继续快排可能进入 O() 区域
  - 此时切换到堆排,用 O(n log n) 完成剩余工作
  - 2 倍系数给"正常波动"留出余量,不因偶然稍深的分区就误切换
递归深度示意:

深度 0:  ┌──────────────────┐
     n elements
深度 1:  ┌──────┐  ┌────┬──────┐
 n/2   n/2     正常: 平衡分区
深度 2:  ┌──┐┌──┐┌──┐┌──────┐
  ││  ││  ││
         ...
深度 log₂n:  叶子层 排序完成

如果某分支深度 > 2·log₂ n 检测到退化,切换堆排

4.9.3 Introsort 伪代码

c
#define DEPTH_LIMIT(n) (2 * ((int)(log2(n))))

void introsort(int arr[], int lo, int hi, int depth_limit) {
    if (lo >= hi) return;

    // 小数组直接用插入排序(常数因子更优)
    if (hi - lo < 16) {
        insertion_sort(arr, lo, hi);
        return;
    }

    // 递归过深 → 切换堆排,保证最坏 O(n log n)
    if (depth_limit == 0) {
        heapsort_range(arr, lo, hi);
        return;
    }

    // 正常快排:三数取中 + Hoare 分区
    int p = hoare_partition(arr, lo, hi);
    introsort(arr, lo, p,     depth_limit - 1);
    introsort(arr, p + 1, hi, depth_limit - 1);
}

// 入口
void sort(int arr[], int n) {
    introsort(arr, 0, n - 1, DEPTH_LIMIT(n));
}

IMPORTANT

Introsort 是真实世界的标准答案

  • std::sort(C++ 标准库):introsort 核心 + 插入排序优化小数组
  • glibc qsort(C 标准库):introsort 核心(自 glibc 2.38 起)
  • .NET Array.Sort:introsort 变体
  • Go sort.Sort:pdqsort(pattern-defeating quicksort,introsort 的进化版)

三层优化总结

层次策略解决的问题
第 1 层三数取中 pivot防御已排序 / 逆序输入
第 2 层深度检测 + 堆排兜底防御任意模式的最坏输入
第 3 层小数组切换插入排序利用插入排序对短数组的常数优势(通常 < 16 个元素)

参考解答

参考解答(Lomuto 快排)
solution_41_quick_sort.c
c
/* solution_41_quick_sort.c — 快速排序参考解答
 *
 * 实现: Lomuto 分区 + 递归快排
 * 时间复杂度: 平均 O(n log n), 最坏 O(n²)
 * 空间复杂度: O(log n) 递归栈
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

/* Lomuto 分区:选 arr[hi] 为 pivot,将所有 ≤pivot 的元素移动到左边
 * 返回值:pivot 的最终位置 */
int partition(int arr[], int lo, int hi) {
    int pivot = arr[hi];
    int i = lo - 1;                     /* ≤区右边界,初始为空 */

    for (int j = lo; j < hi; j++) {     /* j 扫描 lo..hi-1 */
        if (arr[j] <= pivot) {
            i++;
            swap(&arr[i], &arr[j]);     /* 将小元素收入 ≤区 */
        }
    }
    swap(&arr[i + 1], &arr[hi]);        /* pivot 归位到 ≤区之后 */
    return i + 1;                       /* pivot 的最终位置 */
}

/* 递归快排 */
void quicksort(int arr[], int lo, int hi) {
    if (lo >= hi) return;               /* 终止条件:空或单元素 */
    int p = partition(arr, lo, hi);     /* 分区,pivot 归位 */
    quicksort(arr, lo, p - 1);          /* 递归排序左半 */
    quicksort(arr, p + 1, hi);          /* 递归排序右半 */
}

int main(void) {
    char line[1024];
    fgets(line, sizeof(line), stdin);

    int arr[1024], n = 0;
    char *tok = strtok(line, " \n");
    while (tok) {
        arr[n++] = atoi(tok);
        tok = strtok(NULL, " \n");
    }

    quicksort(arr, 0, n - 1);

    for (int i = 0; i < n; i++) {
        if (i > 0) printf(" ");
        printf("%d", arr[i]);
    }
    printf("\n");
    return 0;
}
参考解答(随机化快排 + 尾递归优化)
solution_41_quick_sort_optimized.c
c
/* solution_41_quick_sort_optimized.c — 快速排序优化版
 *
 * 优化:
 *   1. 随机化 pivot 避免最坏 O(n²)
 *   2. 尾递归优化:先排小区间,用迭代替代大区间递归
 *
 * 编译: gcc -Wall -Wextra -o quick_opt solution_41_quick_sort_optimized.c
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
}

int partition(int arr[], int lo, int hi) {
    int pivot = arr[hi];
    int i = lo - 1;
    for (int j = lo; j < hi; j++) {
        if (arr[j] <= pivot) {
            i++;
            swap(&arr[i], &arr[j]);
        }
    }
    swap(&arr[i + 1], &arr[hi]);
    return i + 1;
}

/* 随机化分区 */
int randomized_partition(int arr[], int lo, int hi) {
    int r = lo + rand() % (hi - lo + 1);
    swap(&arr[r], &arr[hi]);
    return partition(arr, lo, hi);
}

/* 优化版快排:随机 pivot + 尾递归优化 */
void quicksort(int arr[], int lo, int hi) {
    while (lo < hi) {
        int p = randomized_partition(arr, lo, hi);
        /* 先递归处理较小的子数组,用迭代处理大的 */
        if (p - lo < hi - p) {
            quicksort(arr, lo, p - 1);
            lo = p + 1;               /* 右半通过 while 迭代处理 */
        } else {
            quicksort(arr, p + 1, hi);
            hi = p - 1;               /* 左半通过 while 迭代处理 */
        }
    }
}

int main(void) {
    srand((unsigned int)time(NULL));  /* 初始化随机种子 */

    char line[1024];
    fgets(line, sizeof(line), stdin);

    int arr[1024], n = 0;
    char *tok = strtok(line, " \n");
    while (tok) {
        arr[n++] = atoi(tok);
        tok = strtok(NULL, " \n");
    }

    quicksort(arr, 0, n - 1);

    for (int i = 0; i < n; i++) {
        if (i > 0) printf(" ");
        printf("%d", arr[i]);
    }
    printf("\n");
    return 0;
}

课堂讨论

讨论答案

Q1: 快排为什么在有序输入下退化?如何用随机化避免?

有序输入 [1,2,3,4,5] 选末位 pivot 时,每次 pivot 都是最大值,分区极不平衡(一边 n-1,一边 0),递归树退化为深度 n 的单链,每层 partition 扫描递减的 n 个元素,总和 ~ n(n-1)/2 = O(n²)。

随机化:分区前 swap(arr[random], arr[hi]),pivot 不再由数据排列决定,期望下每次分区接近二分。虽然仍可能运气差选到最值,但概率极低——连续 k 次选到最坏 pivot 的概率为 (2/n)^k。

三数取中:对已排序和逆序输入,中位数恰好是中间元素,完美二分。

Q2: Lomuto 和 Hoare 分区,谁的交换次数更少?为什么?

Hoare 更少(平均约 1/3 的交换次数)。原因:

  • Lomuto:每个 ≤pivot 的元素都触发一次 swap(arr[i], arr[j])——即使该元素原本就在正确位置。例如已排序数组,每个元素都自交换一次。
  • Hoare:仅在两端各找到一个"错位"元素时才交换——只交换真正需要跨过分界线的元素对。

但 Hoare 每次交换可能动两个"都错位"的元素,效率更高。代价是返回值语义更复杂(返回分界点而非 pivot 位置)。

Q3: 三路划分(荷兰国旗问题)在什么场景下比标准快排快?快多少?

场景:大量重复元素的数组(如只有 0/1/2 三种值)。

加速原理:标准快排遇到相等元素时仍会分区和递归;三路快排将 ==pivot 段一次性排除,跳过所有相等元素的递归。极端情况下(如全相同数组),三路快排一次调用完成排序 = O(n),而标准快排 O(n log n)。

参考数据:100 万个元素、10 个唯一值 → 三路快排约比标准 Lomuto 快 5-10 倍。

Q4: Introsort 为什么选堆排兜底,而不用归并?

原因有三:

  1. 堆排是原地的(O(1) 额外空间),归并需要 O(n) 临时数组——introsort 的承诺是整个排序 O(log n) 空间,归并会破坏这个保证
  2. 堆排切换零开销:对子数组直接调 heapsort_range(arr, lo, hi),无需分配/释放内存,切换成本极低
  3. 最坏 O(n log n):堆排无论什么输入都是 O(n log n),适合作为"保险丝"

归并虽然在缓存局部性上优于堆排,但原地性是 introsort 作为通用排序算法的硬需求。

Q5: 快排、归并、堆排——如果你的数据已经存在链表中,选哪个?为什么?

归并排序。原因:

  • 链表的归并不需要 O(n) 额外空间——合并两个有序链表只需要修改指针,零额外分配
  • 链表的快排需要随机访问(partition 需要以 pivot 划分元素),链表不支持 O(1) 随机访问
  • 堆排通常依赖数组的索引计算(parent = i/2),不适合链表

因此链表排序的标准答案就是归并排序。这也解释了为什么 std::list::sort 实现为归并排序。

Q6: 写一个 partition 的递归实现(不用循环)可行吗?为什么通常不这样做?

理论上可行,但很丑且没必要:

c
// 递归版 partition(仅供思考,不建议使用)
int partition_rec(int arr[], int lo, int hi, int i, int j) {
    if (j >= hi) { /* 扫描完成 */ swap(&arr[i+1], &arr[hi]); return i+1; }
    if (arr[j] <= arr[hi]) { i++; swap(&arr[i], &arr[j]); }
    return partition_rec(arr, lo, hi, i, j + 1);
}

不推荐理由

  1. partition 是 O(n) 操作——用递归会溢出栈(n 大时递归深度 = n)
  2. 循环版代码更短、更清晰、更安全
  3. 递归版失去了循环的局部性优势(每次递归调用都有函数调用开销)

循环是 partition 的自然选择,递归是 quicksort 的自然选择——各用所长。


课后练习

练习 1:三数取中快排

实现使用三数取中法选择 pivot 的快速排序 median_quicksort。验证它对已排序数组 [1,2,3,4,5,6,7,8] 的 partition 调用次数显著少于标准 Lomuto 版本。

知识点提示:三数取中、最坏情况防御、partition 调用计数

参考解答
c
#include <stdio.h>

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

int partition(int arr[], int lo, int hi) {
    int pivot = arr[hi];
    int i = lo - 1;
    for (int j = lo; j < hi; j++)
        if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); }
    swap(&arr[i + 1], &arr[hi]);
    return i + 1;
}

void median_quicksort(int arr[], int lo, int hi) {
    if (lo >= hi) return;
    // 三数取中
    int mid = lo + (hi - lo) / 2;
    if (arr[lo] > arr[mid]) swap(&arr[lo], &arr[mid]);
    if (arr[lo] > arr[hi])  swap(&arr[lo], &arr[hi]);
    if (arr[mid] > arr[hi]) swap(&arr[mid], &arr[hi]);
    swap(&arr[mid], &arr[hi]);  // 中位数放到末尾
    int p = partition(arr, lo, hi);
    median_quicksort(arr, lo, p - 1);
    median_quicksort(arr, p + 1, hi);
}

int main(void) {
    int arr[] = {1, 2, 3, 4, 5, 6, 7, 8};
    int n = sizeof(arr) / sizeof(arr[0]);
    median_quicksort(arr, 0, n - 1);
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    return 0;
}

对已排序的 8 个元素:三数取中 pivot 始终是中间元素,递归树完美二分,partition 调用 7 次(接近最优)。标准 Lomuto 每次只减少 1 个元素,partition 也调用 7 次但每层比较次数为 n-1, n-2, ...(更多比较)。

练习 2:实现三路快排

实现荷兰国旗三路快排 three_way_qsort,并排序含大量重复元素的数组 [3,1,4,1,5,9,2,6,5,3,5,8,9,7,9,3,2,3,8,4],验证 == pivot 段被正确跳过。

知识点提示:三路划分、lt/i/gt 三指针、arr[i] > pivot 时 i 不前进

参考解答
c
#include <stdio.h>

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

void three_way_qsort(int arr[], int lo, int hi) {
    if (lo >= hi) return;
    // 选 arr[lo] 为 pivot(简化)
    int pivot = arr[lo];
    int lt = lo, i = lo, gt = hi;
    while (i <= gt) {
        if (arr[i] < pivot) {
            swap(&arr[lt], &arr[i]); lt++; i++;
        } else if (arr[i] > pivot) {
            swap(&arr[i], &arr[gt]); gt--;
        } else {
            i++;
        }
    }
    three_way_qsort(arr, lo, lt - 1);
    three_way_qsort(arr, gt + 1, hi);
}

int main(void) {
    int arr[] = {3,1,4,1,5,9,2,6,5,3,5,8,9,7,9,3,2,3,8,4};
    int n = sizeof(arr) / sizeof(arr[0]);
    three_way_qsort(arr, 0, n - 1);
    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    return 0;
}

练习 3:编写 partition 调用计数器

修改标准快排,在 partition 函数中用静态变量统计调用次数。分别对随机数组和有序数组测试,观察两者的 partition 调用次数差异,验证最坏 O(n²) 的空间栈深度(递归调用次数)为 n-1 而非 ~log₂ n。

知识点提示:递归深度分析、partition 调用关系、有序 vs 随机输入对比

参考解答
c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int partition_calls = 0;
int max_recursion_depth = 0;

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

int partition(int arr[], int lo, int hi) {
    partition_calls++;
    int pivot = arr[hi];
    int i = lo - 1;
    for (int j = lo; j < hi; j++)
        if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); }
    swap(&arr[i + 1], &arr[hi]);
    return i + 1;
}

void quicksort(int arr[], int lo, int hi, int depth) {
    if (depth > max_recursion_depth) max_recursion_depth = depth;
    if (lo >= hi) return;
    int p = partition(arr, lo, hi);
    quicksort(arr, lo, p - 1, depth + 1);
    quicksort(arr, p + 1, hi, depth + 1);
}

void test(char *label, int arr[], int n) {
    partition_calls = 0;
    max_recursion_depth = 0;
    quicksort(arr, 0, n - 1, 1);
    printf("%s: partition调用=%d, 最大深度=%d\n",
           label, partition_calls, max_recursion_depth);
}

int main(void) {
    srand((unsigned int)time(NULL));
    int n = 1000;

    int *sorted = malloc(n * sizeof(int));
    int *random = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        sorted[i] = i + 1;             /* 有序 */
        random[i] = rand() % 10000;    /* 随机 */
    }

    test("有序数组 (最坏)", sorted, n);
    test("随机数组 (平均)", random, n);

    free(sorted);
    free(random);
    return 0;
}

运行示例:

有序数组 (最坏): partition调用=999, 最大深度=999
随机数组 (平均): partition调用=~700, 最大深度=~30

有序数组的深度 999 ≈ n,随机数组深度 ~30 ≈ log₂(1000)。partition 调用次数和递归深度都反映了分区平衡度。

练习 4:实现 Hoare 分区快排

用 Hoare 分区重写快排,注意递归边界的差异(Hoare 返回分界点 j,左递归到 j 而非 j-1)。对比 Lomuto 和 Hoare 在对随机 10000 个元素排序时的交换次数。

知识点提示:Hoare 分区、双向指针扫描、返回语义差异、递归边界 quicksort(lo, j) vs quicksort(lo, p-1)

参考解答
c
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int swap_count = 0;

void swap(int *a, int *b) {
    int t = *a; *a = *b; *b = t;
    swap_count++;
}

int hoare_partition(int arr[], int lo, int hi) {
    int pivot = arr[lo];
    int i = lo - 1, j = hi + 1;
    while (1) {
        do { i++; } while (arr[i] < pivot);
        do { j--; } while (arr[j] > pivot);
        if (i >= j) return j;
        swap(&arr[i], &arr[j]);
    }
}

void hoare_quicksort(int arr[], int lo, int hi) {
    if (lo >= hi) return;
    int j = hoare_partition(arr, lo, hi);
    hoare_quicksort(arr, lo, j);
    hoare_quicksort(arr, j + 1, hi);
}

int main(void) {
    srand(42);
    int n = 10000;
    int *arr = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) arr[i] = rand();

    swap_count = 0;
    hoare_quicksort(arr, 0, n - 1);
    printf("Hoare 交换次数: %d\n", swap_count);

    /* 重新随机填充,用 Lomuto 测试 */
    for (int i = 0; i < n; i++) arr[i] = rand();

    /* Lomuto 部分省略... 预期 Hoare 交换次数约 Lomuto 的 1/3 */

    free(arr);
    return 0;
}

关键差异:Hoare return j 后,递归调用 quicksort(lo, j)包含 j 而非 j-1),因为 pivot 可能留在左区间任何位置。Lomuto return i+1 后递归 quicksort(lo, p-1)排除 p),因为 pivot 已确定归位。

练习 5:模拟 Introsort 深度切换

实现一个简化版 introsort:以标准快排为基础,添加 depth_limit 参数(初始值为 2 * log₂(n),整数部分即可),当 depth_limit == 0 时切换到冒泡排序(替代堆排以简化实现)。测试构造的最坏输入,验证切换机制被触发。

知识点提示:introsort 原理、递归深度监控、混合排序策略、depth_limit 计算和递减

参考解答
c
#include <stdio.h>
#include <math.h>

void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }

/* 简化堆排: 使用冒泡排序作为兜底(教学简化,实际用堆排) */
void bubble_sort(int arr[], int lo, int hi) {
    for (int i = lo; i <= hi; i++)
        for (int j = lo; j < hi - (i - lo); j++)
            if (arr[j] > arr[j + 1])
                swap(&arr[j], &arr[j + 1]);
}

int partition(int arr[], int lo, int hi) {
    int pivot = arr[hi];
    int i = lo - 1;
    for (int j = lo; j < hi; j++)
        if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); }
    swap(&arr[i + 1], &arr[hi]);
    return i + 1;
}

void introsort(int arr[], int lo, int hi, int depth_limit) {
    if (lo >= hi) return;

    if (depth_limit == 0) {
        bubble_sort(arr, lo, hi);
        printf("  [切换兜底] depth_limit=0, 区间 [%d, %d], 用冒泡排序\n", lo, hi);
        return;
    }

    int p = partition(arr, lo, hi);
    introsort(arr, lo, p - 1, depth_limit - 1);
    introsort(arr, p + 1, hi, depth_limit - 1);
}

int main(void) {
    // 构造最坏输入:有序数组 + 末位 pivot = O(n²)
    int arr[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
    int n = sizeof(arr) / sizeof(arr[0]);
    int depth_limit = 2 * (int)(log2(n));  /* 2 * log₂(16) = 8 */

    printf("n=%d, depth_limit=%d\n", n, depth_limit);
    introsort(arr, 0, n - 1, depth_limit);

    for (int i = 0; i < n; i++) printf("%d ", arr[i]);
    printf("\n");
    return 0;
}

运行结果分析:n=16,depth_limit=8。对有序数组当递归深度超过 8 时,剩余子数组(从深度 9 开始)将全部由冒泡排序(实际中用堆排)完成。若无深度保护,快排在最坏输入下会继续 O(n²) 分区到底。


参考资料

  • 《算法导论》(CLRS)第 7 章 快速排序 — 最权威的快排教材,包含严格的期望时间复杂度证明和 Hoare 分区分析
  • Hoare, C.A.R. "Quicksort", The Computer Journal, 5(1):10–16, 1962 — Tony Hoare 的原始论文,提出快速排序算法(现已全文开放获取)
  • Musser, David R. "Introspective Sorting and Selection Algorithms", Software: Practice and Experience, 27(8):983–993, 1997 — Introsort 的提出论文,阐述递归深度阈值的设计原理
  • Dijkstra, E.W. "A Discipline of Programming", Chapter 14, Prentice-Hall, 1976 — 荷兰国旗问题的原始出处,Dijkstra 用"红白蓝旗"比喻三路划分
  • Bentley, J. & McIlroy, M. "Engineering a Sort Function", Software: Practice and Experience, 23(11):1249–1265, 1993 — 描述实际系统中实现高效排序函数的工程经验(三数取中、小数组优化、三路划分)
  • Sedgewick, R. "Implementing Quicksort Programs", Communications of the ACM, 21(10):847–857, 1978 — 对快排实现细节的全面分析,包括小数组的 cut-off 策略和 pivot 选择方案对比
  • Jon Bentley, Programming Pearls, 第 11 章 (Sorting) — 用易懂的方式讲解快排工程优化,包括插入排序对小数组的性能优势

Released under the MIT License.