第 7 章 快速排序 (Quicksort)

      +

      核心结论

      • 快速排序 (Quicksort):分治排序算法——选 pivot,把数组分为 ≤ pivot 和 > pivot 两部分,递归排序。最坏 Θ(n²),期望 Θ(n lg n),原地排序。

      • PARTITION 子程序 (Lomuto 版):以 A[r] 为 pivot,把 A[p..r] 划分为 ≤ x 和 > x 两部分;Θ(n) 时间。

      • 性能分析:最坏情况(每次划分极不平衡,如已排序输入)= Θ(n²);最佳情况(每次均分)= Θ(n lg n);平均 = Θ(n lg n)。

      • 随机化版本 (RANDOMIZED-QUICKSORT):在划分前先随机选 pivot;期望 Θ(n lg n) 对所有输入成立——任何输入都不会引发最坏情况。

      • 指示随机变量分析 (引理 7.1):期望比较次数 E[X] = Σ 2/(j-i+1) = O(n lg n);两个元素被比较 ⟺ 它们所在区间中第一个被选为 pivot。

      • 常数因子小:实践中常数因子比堆排序小约 2 倍——多数排序库默认选择快速排序。

      本章主旨

      本章是「分治思想 + 概率分析」的联合展示。快速排序以简洁著称(伪代码仅 7 行),但其性能分析比归并排序复杂——最坏 Θ(n²) 与期望 Θ(n lg n) 的巨大差异要求严格的概率分析。引入随机化后,最坏情况仅以「期望」出现(而非确定性的最坏)。本章也是「指示随机变量」技术的精彩应用。

      一、核心概念

      本章围绕 5 个核心概念展开:先描述快速排序与 PARTITION 子程序,再分析其最坏 / 最佳 / 平衡情况,然后引入随机化版本,最后用指示随机变量严格分析期望运行时间。

      概念 定义 + 重要性 实现提示

      快速排序 (Quicksort) 算法

      分治:以 A[r] 为 pivot,划分 A[p..r] 为 ≤ pivot 与 > pivot 两部分,递归排序。

      §7.1;QUICKSORT(A, p, r);原地排序。

      PARTITION (Lomuto 版)

      Θ(n) 子程序;以 A[r] 为 pivot;维护循环不变量「A[p..i] ≤ x,A[i+1..j-1] > x」。

      §7.1;i = p-1 起始;j 从 p 到 r-1;最后交换 A[i+1] ↔ A[r]。

      最坏情况分析

      每次划分极不平衡(如 pivot 是最大/最小)→ 递归式 T(n) = T(n-1) + Θ(n) → Θ(n²)。

      §7.4.1;输入已排序 / 逆序;T(n) = max_q (T(q) + T(n-q-1)) + Θ(n)。

      随机化版本 (RANDOMIZED-QUICKSORT)

      PARTITION 前先 RANDOM(p, r) 选 pivot;期望 Θ(n lg n) 对任何输入成立。

      §7.3;RANDOMIZED-PARTITION = 随机选 pivot + PARTITION。

      期望分析 (引理 7.1)

      用指示随机变量 X_{ij} = I{z_i 与 z_j 比较};E[X_{ij}] = 2/(j-i+1);总 E[X] = O(n lg n)。

      §7.4.2;两个元素被比较 ⟺ 它们所在区间中第一个被选为 pivot(指示变量法的精妙应用)。

      二、详细笔记

      7.1 快速排序与 PARTITION

      What:QUICKSORT(A, p, r) 是分治排序:

      1. Divide:调用 PARTITION(A, p, r) 得 q,使 A[p..q-1] ≤ A[q] ≤ A[q+1..r]。

      2. Conquer:递归 QUICKSORT(A, p, q-1) 和 QUICKSORT(A, q+1, r)。

      3. Combine:无需——原地排序。

      PARTITION (Lomuto, §7.1):

      \[\begin{array}{rl} 1 & x = A[r] \\ 2 & i = p - 1 \\ 3 & \textbf{for } j = p \textbf{ to } r - 1 \\ 4 & \quad \textbf{if } A[j] \leq x \\ 5 & \quad\quad i = i + 1 \\ 6 & \quad\quad \text{exchange } A[i] \leftrightarrow A[j] \\ 7 & \text{exchange } A[i+1] \leftrightarrow A[r] \\ 8 & \textbf{return } i + 1 \end{array}\]

      循环不变量:每次 j 迭代开始时:

      1. A[p..i] ≤ x

      2. A[i+1..j-1] > x

      3. A[r] = x(pivot 未被移动)

      Why:快速排序是「分治 + 原地 + 期望 O(n lg n)」的典范——实践中常数因子比堆排序小,比归并排序省空间(无需合并数组)。PARTITION 的设计巧妙:所有工作通过「单次扫描 + i 边界推进」完成。

      How:正确性用循环不变量三步证明:

      1. 初始化:i = p-1, j = p → 两个区域都为空 → 不变量成立。

      2. 保持:分两种情况:A[j] > x(仅 j)或 A[j] ≤ x(i、交换 A[i] ↔ A[j]、j++)——两种情况都不变量都保持。

      3. 终止:j = r → 不变量覆盖整个数组,最后把 pivot 放到 i+1 位置。

      When:实践中最常用的排序算法——C/C++ qsort、Java Arrays.sort(基本类型)、Python list.sort()、Unix sort 核心都基于快速排序(含各种优化)。

      Example:图 7.1 —— PARTITION 在 A = ⟨2, 8, 7, 1, 3, 5, 6, 4⟩ 上:逐步把 ≤ 4 的元素移到左边,> 4 的元素移到右边,最后把 pivot 4 放到中间。

      7.2 性能分析(最坏 / 最佳 / 平衡)

      What:QUICKSORT 的性能取决于划分的「平衡程度」:

      1. 最坏划分:q = 0 或 q = n-1(pivot 是最大/最小)→ T(n) = T(n-1) + Θ(n) → Θ(n²)。

      2. 最佳划分:q = ⌊n/2⌋ → T(n) = 2T(n/2) + Θ(n) → Θ(n lg n)(主定理情形 2)。

      3. 常数比例划分(如 9:1)→ T(n) = T(9n/10) + T(n/10) + Θ(n) → 仍是 O(n lg n)。

      Why:理解平衡对性能的影响——「常数比例的划分」(哪怕是 99:1)已足以保证 Θ(n lg n);只有「极端不平衡」(每次都 0:n-1)才退化到 Θ(n²)。

      How:图 7.4 —— 9:1 划分的递归树:每层总代价 cn,树高 ≈ log_{10/9} n = Θ(lg n);总代价 O(n lg n)。

      最坏情况的直觉(§7.2):每次划分产生 0 元素和 n-1 元素子问题——递归式 T(n) = T(n-1) + Θ(n) 求和得 Θ(n²)。已排序输入触发最坏情况——实际中可在划分前「洗牌」或随机选 pivot 避免。

      直觉:图 7.5 —— 即使「坏划分」紧跟「好划分」,总代价仍是 Θ(n)(因为好划分的 Θ(n) 吸收了坏划分的 Θ(n-1))。

      When

      1. 关心最坏 → 用 RANDOMIZED-QUICKSORT 或尾部递归优化。

      2. 关心期望 → 任何 QUICKSORT 变体的期望都是 Θ(n lg n)。

      3. 实际中已排序 / 逆序输入常见——必须用 RANDOMIZED-QUICKSORT 或在划分前随机打乱。

      Example

      1. A = ⟨1, 2, 3, 4, 5, 6, 7, 8⟩(已排序):QUICKSORT 用 A[r] = 8 作 pivot → 划分 0:7 → 递归式 T(n) = T(n-1) + Θ(n) → Θ(n²)。

      2. A = ⟨4, 1, 3, 2, 6, 5, 7, 8⟩:第一次划分 q ≈ 4 → 两半 ≈ 平衡 → Θ(n lg n)。

      7.3 随机化版本 (RANDOMIZED-QUICKSORT)

      What:RANDOMIZED-PARTITION 在 PARTITION 前先随机选 pivot;RANDOMIZED-QUICKSORT 用 RANDOMIZED-PARTITION 代替 PARTITION。

      Why:保证「任何输入」都不会引发最坏情况——最坏情况仅以「概率 2^(-n)」出现(来自连续选最差 pivot 的罕见事件)。这是概率算法设计中「让输入无关」的经典案例。

      How:RANDOMIZED-PARTITION(§7.3):

      \[\begin{array}{rl} 1 & i = \text{RANDOM}(p, r) \\ 2 & \text{exchange } A[r] \leftrightarrow A[i] \\ 3 & \textbf{return } \text{PARTITION}(A, p, r) \end{array}\]

      关键:随机选 pivot 等价于「在划分前把数组随机打乱」——确保 pivot 均匀随机。

      When

      1. 任何「不能信任输入分布」的场景——通用排序库默认使用。

      2. 「最坏情况性能至关重要」的场景——如实时系统、安全相关代码(避免被精心构造的输入攻击,McIlroy 1999 证明非随机化快速排序可被 O(n²) 攻击)。

      Example:RANDOMIZED-QUICKSORT 对已排序输入:第一次 pivot = 随机元素(期望中位数)→ 平衡划分 → 期望 Θ(n lg n)。McIlroy 的「杀手对手」可针对具体非随机化实现构造 O(n²) 输入——随机化版本免疫。

      7.4 期望分析(引理 7.1 与式 7.4)

      What:用指示随机变量严格证明 RANDOMIZED-QUICKSORT 期望比较次数 = O(n lg n)。

      Why:引理 7.1 是「概率 + 算法分析」的精妙应用——把「比较次数」分解为「每对元素是否被比较」的指示变量之和,再用「区间中第一个被选为 pivot」的概率求期望。

      How:分析步骤(§7.4.2):

      1. 设排序元素为 z_1 < z_2 < …​ < z_n,定义 Z_{ij} = {z_i, …​, z_j}。

      2. 关键观察:每对元素最多比较一次;z_i 与 z_j 被比较 ⟺ Z_{ij} 中第一个被选为 pivot 的是 z_i 或 z_j。

      3. Z_{ij} 中每个元素等概率成为第一个 pivot:概率 1/(j-i+1)。

      4. 因此:

      \[\Pr\{z_i \text{ 与 } z_j \text{ 比较}\} = \frac{1}{j-i+1} + \frac{1}{j-i+1} = \frac{2}{j-i+1} \quad \text{(式 7.3)}\]
      1. 设 X = 总比较次数,X_{ij} = I{z_i 与 z_j 比较},则 X = Σ X_{ij},由线性期望:

      \[E[X] = \sum_{i<j} E[X_{ij}] = \sum_{i=1}^{n-1} \sum_{j=i+1}^{n} \frac{2}{j-i+1}\]
      1. 令 k = j-i:

      \[E[X] = \sum_{i=1}^{n-1} \sum_{k=1}^{n-i} \frac{2}{k+1} \leq \sum_{i=1}^{n-1} \sum_{k=1}^{n} \frac{2}{k} = (n-1) \cdot O(\lg n) = O(n \lg n) \quad \text{(式 7.4)}\]
      1. 加上 O(n) 的 PARTITION 调用 → 期望运行时间 O(n lg n)。

      When:当需要「严格证明随机算法的期望性能」时;展示指示变量方法的威力——避免直接枚举概率分布。

      Example:z_3 与 z_7(i=3, j=7):比较概率 = 2/(7-3+1) = 2/5;直觉:Z_{3,7} 有 5 个元素,每个等概率第一个被选为 pivot;只有 z_3 或 z_7 第一个被选时才发生比较 → 2/5。

      7.5 性能对比与实践考量

      What:把快速排序与其他 O(n lg n) 算法对比——快速排序通常是实践最优。

      Why:理解「为什么快速排序」成为标准——不仅渐近最优,常数因子也小。

      How:对比矩阵:

      算法 最坏 期望 / 平均 原地

      稳定性

      实践常数

      插入排序

      Θ(n²)

      Θ(n²)

      小(n < 10)

      归并排序

      Θ(n lg n)

      Θ(n lg n)

      堆排序

      Θ(n lg n)

      Θ(n lg n)

      中大

      快速排序

      Θ(n²)

      Θ(n lg n)

      RANDOMIZED-QUICKSORT

      Θ(n²) (罕见)

      Θ(n lg n)

      常见优化(实践中):

      1. 小数组用插入排序:当 n < ~10 时切换到插入排序(更小的常数)。

      2. 三数取中 (median-of-3):pivot = median(A[lo], A[mid], A[hi]),避免已排序输入的最坏。

      3. 三路划分 (Dutch national flag):处理重复键,把 == pivot 的单独成一段。

      4. 尾递归优化:先递归小数组,大数组用循环——栈深度 O(lg n)。

      When

      1. 默认排序 → 快速排序(实践中)。

      2. 需要稳定排序 → 归并排序。

      3. 最坏情况性能保证 → 堆排序。

      4. 数据接近有序 → Timsort(归并 + 插入混合,Python/Java 默认)。

      5. 重复键多 → 三路划分快速排序。

      Example:Unix sort 默认用快速排序变体;C++ std::sort 用 introsort(快速 + 堆 + 插入混合,遇到深度过深时切换到堆排序保证最坏 O(n lg n));Python/Java 的 Timsort 是归并 + 插入混合,对部分有序输入极快。

      三、关键图表

      核心公式对照表
      式编号 内容

      式 7.1

      最坏情况递归式:T(n) = max_q (T(q) + T(n-q-1)) + Θ(n)

      式 7.2

      期望比较次数:E[X] = Σ Pr{z_i 与 z_j 比较}

      式 7.3

      Pr{z_i 与 z_j 比较} = 2/(j-i+1)

      式 7.4

      E[X] = O(n lg n)

      循环不变量

      A[p..i] ≤ x, A[i+1..j-1] > x, A[r] = x

      PARTITION 时间

      Θ(n)

      QUICKSORT 最坏

      Θ(n²)

      QUICKSORT 期望

      Θ(n lg n)

      平衡划分类比

      任何常数比例划分都给出 Θ(n lg n)(图 7.4 9:1 划分)

      四、思维导图

      mindmap
        root((第 7 章 快速排序))
          算法
            分治
            PARTITION
            Lomuto
            原地
          划分
            pivot=A[r]
            i边界推进
            不变量
          性能
            最坏Θ(n²)
            最佳Θ(n lg n)
            平衡划分
          随机化
            RANDOM(p,r)
            避免最坏
            期望Θ(n lg n)
          期望分析
            指示变量
            X_ij
            2/(j-i+1)
            ln n
          实践
            插入小数组
            三数取中
            三路划分
            尾递归
          对比
            归并稳定
            堆最坏O(n lg n)
            QUICKSORT实践最优

      五、重点与易错点

      1. QUICKSORT 原地排序:与归并排序(需要 O(n) 辅助空间)相比,快速排序省空间——这是它在实践中常胜的关键之一。

      2. Lomuto vs Hoare 划分:本书 PARTITION 是 Lomuto 版(用 A[r] 作 pivot);原版 Hoare 划分(用 A[p],双指针从两端靠拢)在某些场景更快(问题 7-1)。

      3. 最坏情况 Θ(n²) 不容忽视:已排序 / 逆序输入 + Lomuto PARTITION = 最坏——实际工程必须用 RANDOMIZED-QUICKSORT 或洗牌。

      4. 期望与最坏的差异:QUICKSORT 的「期望 Θ(n lg n) 但最坏 Θ(n²)」是算法的本质特征;这是 RANDOMIZED 版本的价值——把「最坏情况」的概率降至可忽略。

      5. 引理 7.1 的核心洞察:z_i 与 z_j 比较 ⟺ Z_{ij} 中第一个 pivot 是 z_i 或 z_j。这种「等价于第一个 pivot」的转换是分析的关键。

      6. PARTITION 的循环不变量与 HEAPSORT 的循环不变量同源:都是「在某个区间上保持某种性质」——第 2 章循环不变量的思想贯穿全章。

      7. 「已排序输入是最坏」的直觉反例:直觉上「已排序」似乎应该是最佳——但对 Lomuto QUICKSORT 它是最坏,因为每次选最大/最小作 pivot。这是「为什么需要随机化」的最直观论据。

      8. 指示变量法不要求独立(第 5 章引理):即使各 X_{ij} 之间相关(一个比较影响其他比较的可能性),线性期望仍然成立。

      9. 快速排序常数因子小:相比归并排序(每次需要合并)、堆排序(每次 MAX-HEAPIFY),快速排序的内层循环极简(比较 + 可能交换),所以常数小 ~2x。

      10. 「McIlroy 杀手对手」:针对具体非随机化实现可构造 O(n²) 输入;生产代码必须用 RANDOMIZED-QUICKSORT。

      11. 稳定性:QUICKSORT 不稳定——等价元素的相对顺序可能改变;归并排序稳定——选择时考虑此差异。

      12. 三路划分处理重复键:当有大量重复元素时,标准 QUICKSORT 退化为 O(n²);三路划分(< / = / > 三段)处理得当,是 Java 7+ 对引用类型排序的默认。

      13. 尾递归优化降低栈深度:QUICKSORT 栈深度最坏 O(n)(连续最坏划分时);改成「先小后大」递归 → 栈深度 O(lg n)。

      14. 本书 PARTITION 假设元素互异:分析中 Pr{z_i 与 z_j 比较} = 2/(j-i+1) 假设 z_i ≠ z_j;重复键情形单独处理(问题 7-2)。

      15. 快速排序是后续算法的基础:qsort 库、qselect(第 9 章顺序统计)、Strassen 等都用其思想。