相关笔记

概览

本节介绍 QUICKSORT 算法的基本描述及其核心子程序 PARTITION。快速排序与归并排序一样采用分治法,但关键区别在于:合并步骤无需任何工作。PARTITION 过程选取最后一个元素作为主元(pivot),将数组原地划分为两个子数组——低侧( 主元)和高侧( 主元),然后递归排序两侧。

要点列表:

  • QUICKSORT 的分治三步:分解(PARTITION)、解决(递归排序)、合并(无需操作
  • PARTITION 使用 Lomuto 划分方案,选取 作为主元,运行时间为
  • PARTITION 的正确性通过循环不变式严格证明(初始化/维护/终止三步)
  • 快速排序是原地排序算法(in-place),不需要额外的数组存储空间
  • 快速排序是不稳定排序,可能改变相等元素的相对顺序

知识结构总览

flowchart TD
    A["7.1 快速排序的描述 (QUICKSORT)"] --> B["分治三步流程"]
    A --> C["核心子程序 PARTITION"]
    A --> D["正确性证明"]
    A --> E["算法特性"]

    B --> B1["分解 Divide<br/>PARTITION 划分数组"]
    B --> B2["解决 Conquer<br/>递归排序两个子数组"]
    B --> B3["合并 Combine<br/>无需操作"]

    C --> C1["选取主元 x = A[r]"]
    C --> C2["维护四个区域"]
    C --> C3["返回主元最终位置 q"]

    C2 --> D1["A[p..i]: ≤ x(低侧)"]
    C2 --> D2["A[i+1..j-1]: > x(高侧)"]
    C2 --> D3["A[j..r-1]: 未处理"]
    C2 --> D4["A[r]: = x(主元)"]

    D --> E1["循环不变式三步证明"]
    E1 --> E1a["初始化"]
    E1 --> E1b["维护(两种情况)"]
    E1 --> E1c["终止"]

    E --> E1["原地排序 (in-place)"]
    E --> E2["不稳定排序"]
    E --> E3["PARTITION: Θ(n)"]

核心思想

核心思路

快速排序与归并排序同属分治家族,但工作重心完全不同:

步骤归并排序快速排序
分解按位置从中间一分为二(PARTITION 按主元值划分
解决递归排序两个子数组递归排序两个子数组
合并MERGE 合并两个有序子数组无需操作

快速排序之所以在合并步骤无需工作,是因为 PARTITION 保证了一个关键性质:

  • 左侧所有元素 主元
  • 右侧所有元素 主元
  • 两侧子数组排序后,整个数组自然有序

这就像把一堆学生按身高分成”矮组”和”高组”,分别排好队后直接站在一起——不需要像归并排序那样再逐一比较合并。

QUICKSORT 伪代码

算法执行流程

  1. p >= r,子数组最多一个元素,直接返回
  2. 调用 PARTITION(A, p, r) 将数组划分为低侧和高侧,返回分界点 q
  3. 递归调用 QUICKSORT(A, p, q-1) 排序左半部分(低侧)
  4. 递归调用 QUICKSORT(A, q+1, r) 排序右半部分(高侧)
flowchart TD
    A["QUICKSORT(A, p, r)"] --> B{"p < r?"}
    B -- 否 --> C["直接返回"]
    B -- 是 --> D["q = PARTITION(A, p, r)"]
    D --> E["递归排序左半部分<br/>QUICKSORT(A, p, q-1)"]
    E --> F["递归排序右半部分<br/>QUICKSORT(A, q+1, r)"]
QUICKSORT(A, p, r)
1  if p < r
2     q = PARTITION(A, p, r)        // 划分,主元放到 A[q]
3     QUICKSORT(A, p, q - 1)         // 递归排序低侧
4     QUICKSORT(A, q + 1, r)         // 递归排序高侧

QUICKSORT

输入: 数组 (无序子数组) 输出: 原地排序为非降序序列

算法步骤:

  1. 基准情况:,子数组最多含一个元素,已有序,直接返回
  2. 分解: 调用 PARTITION(A, p, r),将数组划分为 (低侧)和 (高侧),主元 位于正确位置
  3. 解决: 递归调用 QUICKSORT(A, p, q-1)QUICKSORT(A, q+1, r)
  4. 合并: 无需操作——两侧排序后,整个子数组自然有序

初始调用:QUICKSORT(A, 1, n),对整个 元素数组排序。

PARTITION 伪代码(Lomuto 划分方案)

算法执行流程

  1. 选最后一个元素 A[r] 作为主元 pivot
  2. i = p - 1(低侧边界)
  3. 遍历 jpr-1:若 A[j] pivot,则 i++ 并交换 A[i]A[j]
  4. 遍历结束后,交换 A[i+1]A[r],将主元放到低侧与高侧之间
  5. 返回 i+1 作为主元的最终位置 q
flowchart TD
    A["x = A[r](主元)<br/>i = p - 1"] --> B["j = p"]
    B --> C{"j <= r - 1?"}
    C -- 是 --> D{"A[j] <= x?"}
    D -- 是 --> E["i = i + 1<br/>交换 A[i] 与 A[j]"]
    D -- 否 --> F["不操作"]
    E --> G["j = j + 1"]
    F --> G
    G --> C
    C -- 否 --> H["交换 A[i+1] 与 A[r]<br/>主元就位"]
    H --> I["return i + 1"]
PARTITION(A, p, r)
1  x = A[r]                          // 主元
2  i = p - 1                          // 低侧的最高索引
3  for j = p to r - 1                 // 处理除主元外的每个元素
4     if A[j] ≤ x                     // 该元素是否属于低侧?
5        i = i + 1                    // 低侧的新槽位
6        exchange A[i] with A[j]      // 将元素放入低侧
7  exchange A[i + 1] with A[r]        // 主元放到低侧右侧
8  return i + 1                       // 返回主元的新索引

PARTITION 的循环不变式与正确性证明

循环不变式

在 for 循环(第 3-6 行)每次迭代开始时, 对任意数组索引 ,以下条件成立:

  1. ,则 (低侧区域,图 7.2 中的棕色区域)
  2. ,则 (高侧区域,图 7.2 中的蓝色区域)
  3. ,则 (主元,图 7.2 中的黄色区域)

初始化(Initialization):

  • 【循环不变量(初始化)】 验证首次迭代前不变式成立:两个区间为空,主元条件由赋值保证

  • 在第一次迭代之前,
  • 区间 为空,区间 也为空
  • 因此条件 1 和条件 2 平凡满足(对空区间,所有条件都成立)
  • 第 1 行的赋值 x = A[r] 保证条件 3 成立:
  • 循环不变式在首次迭代前成立

维护(Maintenance): 分两种情况讨论(对应图 7.3):

  • 【循环不变量(维护—情况a)】 时,仅递增 ,不变式自动保持

  • 情况 (a):(图 7.3(a))

    • 第 4 行条件为假,不执行 if 内部操作
    • 仅递增 (for 循环自动完成)
    • 递增后, 满足 ,条件 2 成立
    • 条件 1 和条件 3 不受影响
    • 循环不变式得到维护
  • 【循环不变量(维护—情况b)】 时,交换后低侧扩展、高侧右移

  • 情况 (b):(图 7.3(b))

    • 第 5 行将 递增:
    • 第 6 行交换 :交换后 ,条件 1 成立
    • 被交换到 位置的原 元素,在交换前位于高侧区域(因为 ),由循环不变式知其
    • 递增 后,,条件 2 成立
    • 条件 3 不受影响
    • 循环不变式得到维护

终止(Termination):

  • 【循环不变量(终止)】 循环结束时 ,交换主元到分界点,两侧性质成立

  • for 循环执行恰好 次迭代后终止,此时
  • 未检查的子数组 为空
  • 数组中所有元素都属于不变式描述的三个集合之一:
    • (低侧)
    • (高侧)
    • (主元)
  • 第 7 行将主元 (高侧的第一个元素)交换,主元就位于低侧和高侧之间
  • 第 8 行返回 ,即主元的最终位置
  • 此时满足:,且 中的元素严格大于
  • PARTITION 的正确性得证

PARTITION 的运行时间分析

时间复杂度

PARTITION 在大小为 的子数组上的运行时间:

  • 第 1-2 行:常数时间
  • 第 3-6 行的 for 循环:恰好执行 次迭代,每次迭代执行常数时间操作(一次比较、可能的递增和交换),总计
  • 第 7-8 行:常数时间

总运行时间: ====


补充理解与拓展

快速排序的发明——Tony Hoare 与 1960 年的莫斯科

快速排序由英国计算机科学家 Tony Hoare(托尼·霍尔)于 1960 年发明,时年仅 25 岁。当时 Hoare 受英国国家物理实验室(NPL)派遣,赴莫斯科国立大学参与机器翻译项目的研究。为了解决俄语词典中词序排列的效率问题,他发明了这一算法。

关键时间线:

  • 1960 年:Hoare 在莫斯科国立大学发明快速排序的最初版本(使用的是 Hoare 划分方案,而非教材中的 Lomuto 方案)
  • 1961 年:在 Communications of the ACM 上发表简短论文 “Algorithm 64: Quicksort”
  • 1962 年:发表完整版本论文 “Quicksort”(The Computer Journal
  • 1980 年:Hoare 因在程序设计语言理论和快速排序等方面的杰出贡献获得图灵奖

有趣的是,Hoare 最初在实现交换操作时遇到了困难,后来通过图灵奖得主 Niklaus Wirth(Pascal 语言发明者)的建议才完善了算法的正确实现。Hoare 曾在图灵奖演讲中幽默地提到:“我最初认为快速排序太简单了,不值得发表——幸好我最终改变了主意。”

来源:Hoare, C.A.R., “Quicksort”, The Computer Journal, 5(1):10-16, 1962; Hoare, C.A.R., “The Emperor’s Old Clothes”, ACM Turing Award Lecture, 1980.

Lomuto 划分 vs Hoare 划分——两种经典分区方案的深度对比

CLRS 教材使用的是 Lomuto 划分方案(Nicolo Lomuto 在 1990 年代提出),但 Hoare 在 1960 年的原始论文中使用的是另一种方案。两种方案在实际工程中有显著差异:

比较维度Lomuto 划分(CLRS 版本)Hoare 划分(原始版本)
指针数量单指针 从左向右扫描双指针从两端向中间扫描
主元选择固定选取 选取 (或中间元素)
交换次数较多(每个 pivot 的元素都交换)较少(平均约少 3 倍交换)
实现复杂度简单直观稍复杂,需处理指针交叉
重复元素效率低(全部进入低侧)效率较高(从两端均匀分布)
主元最终位置精确在分界点 不一定在分界点(需额外处理)

工程实践中的选择:

  • 性能关键系统(数据库引擎、路由表)通常偏好 Hoare 划分方案,因为交换次数更少、缓存友好性更好
  • 教学场景通常使用 Lomuto 方案,因为更易理解和证明
  • 现代 Java 的 Arrays.sort() 使用的是 Dual-Pivot Quicksort(Yaroslavskiy, 2009),本质上是 Hoare 划分的高级变体,使用两个主元将数组分为三部分
  • C++ 的 std::sort 使用 Introsort(Musser, 1997),内部采用 Hoare 划分方案的变体

来源:Yaroslavskiy, V., “Dual-Pivot Quicksort”, 2009; Musser, D.R., “Introspective Sorting and Selection Algorithms”, Software: Practice and Experience, 27(8):983-993, 1997.


易混淆点与辨析

误区:快速排序和归并排序的分治策略相同

错误理解: “快速排序和归并排序都是分治法,所以它们的工作方式基本相同”

正确理解: 虽然两者都采用分治法,但工作重心完全相反

  • 归并排序:分解阶段简单(),主要工作量在合并阶段
  • 快速排序主要工作量在分解阶段(PARTITION,),合并阶段无需工作(

用一句话概括:归并排序是”easy in divide, hard in combine”;快速排序是”hard in divide, easy in combine”。这种差异导致了两者在空间需求、缓存行为和实际性能上的根本不同。

误区:PARTITION 的循环不变式要求所有区域都有特定性质

错误理解: “循环不变式对数组中的所有区域都做了断言”

正确理解: 循环不变式只对三个区域做了断言,第四个区域(,未处理区域)的元素与 的关系是完全未知的。这正是循环不变式的精妙之处——它只断言已经处理过的元素的性质,对尚未处理的元素不做任何假设。

类比:就像考试阅卷——已经批改的试卷可以给出分数(已知性质),尚未批改的试卷分数未知(未知性质),但这不影响已批改部分的正确性。


习题精选

题号题目描述难度
7.1-1用图 7.1 的方式,演示 PARTITION 在数组 上的操作过程⭐⭐
7.1-2当子数组 中所有元素值相同时,PARTITION 返回的 值是什么?修改 PARTITION 使此时 ⭐⭐⭐
7.1-3简要论证 PARTITION 在大小为 的子数组上的运行时间为 ⭐⭐
7.1-4修改 QUICKSORT 使其按单调递减顺序排序

视频学习指南

资源主题链接说明
MIT 6.006 Lecture 4Heaps and Heap Sorthttps://www.youtube.com/watch?v=B7hVxCmfPtMErik Demaine 讲解快速排序的分治思想与 PARTITION
Abdul BariQuicksort Algorithmhttps://www.youtube.com/watch?v=COk73cpQbFQ逐步动画演示 Lomuto 划分的完整执行过程
Michael SambolQuicksorthttps://www.youtube.com/watch?v=Hoixgm4-P4M2 分钟可视化快速排序的完整递归过程
CS DojoQuicksort Algorithmhttps://www.youtube.com/watch?v=uv2NQVpG0nw含 Hoare 划分 vs Lomuto 划分的对比讲解
WilliamFisetQuicksorthttps://www.youtube.com/watch?v=SLauY6PpjW4排序算法系列,含代码实现与复杂度分析

教材原文

CLRS 第4版 7.1节原文

Quicksort, like merge sort, applies the divide-and-conquer method introduced in Section 2.3.1. Here is the three-step divide-and-conquer process for sorting a subarray :

Divide by partitioning (rearranging) the array into two (possibly empty) subarrays (the low side) and (the high side) such that each element in the low side of the partition is less than or equal to the pivot , which is, in turn, less than or equal to each element in the high side. Compute the index of the pivot as part of this partitioning procedure.

Conquer by calling quicksort recursively to sort each of the subarrays and .

Combine by doing nothing: because the two subarrays are already sorted, no work is needed to combine them. All elements in are sorted and less than or equal to , and all elements in are sorted and greater than or equal to the pivot . The entire subarray cannot help but be sorted!


参见Wiki

  • 快速排序 — 快速排序的基本思想与分治策略

第07章-快速排序 快速排序的描述