第 9 讲 · 数据结构课程阅读笔记

这页整理的是 ICS 46 第 9 讲 的课程笔记,适合按讲复习 C++ 模板、指针与所有权、算法分析和各类数据结构的实现。下面这条路径可以带你回到 Notes、Course Notes、课程主页,或定位到当前笔记。将鼠标悬停在右侧的 ≡ 图标上可以打开目录。

最后编辑于 2026 年 10 月 29 日

Information and Computer Science 46 — Lecture 9:快速排序

目录

UCI ICS 46: Data Structure Implementation and Analysis — Prof. Tom Yeh, Fall 2026, 10/27/26
整理自 Lecture 9 幻灯片(重复并完成了 Lecture 8 末尾开始的快速排序内容);补充材料:algs4 §2.3 Quicksort、algs4 §2.2(下界)以及 Thornton 的 ComparisonBasedSorting 笔记。按概念组织,而不是按资料来源组织。

📅 课程安排:Quiz 3 周五进行(Stacks、Queues、Deques)· HW 5 周五截止 · Lab 4 下周一截止
已批准的 AI 工具已发布:Learning Sentinel(基于学习活动的元认知)与 iGPT(教学型 AI 助教)。提交两部分的同意书以获得访问权限:(1) 为工具访问存储数据;(2) 去标识化的研究用途。完成者可获 ★ 1% 额外学分(本学季稍后还会提供另一种额外学分方式)。

➡️ 快速排序的历史(Hoare,1959)与归并排序见 Lecture 8 笔记。本幻灯片末尾的 Trees 部分见 Lecture 10 笔记。


1. 快速排序:思路

快速排序(quick sort)与归并排序一样是分治法,但工作发生在递归之前,而不是之后(幻灯片 5):

  1. 洗牌:随机打乱数组。
  2. 划分:使得对于某个枢轴下标 j:
    • 元素 a[j] 已经处于最终位置,
    • j 左侧没有更大的元素,
    • j 右侧没有更小的元素。
  3. 递归排序每个子数组。没有合并步骤:两侧都排好后,整个数组就已经有序。
划分前 划分后
布局 v(枢轴位于 lo),之后是未知元素直到 hi ≤ v … v(位于 j)… ≥ v

幻灯片 6 的代码(递归驱动函数与公共入口):

#include <random>
#include <utility>

// quicksort
template <typename T>
void sort(T* a, int lo, int hi)
{
    if (hi <= lo) return;
    int j = partition(a, lo, hi);
    sort(a, lo, j-1);
    sort(a, j+1, hi);
}

template <typename T>
void sort(T* a, int length) {
    std::random_device device;                          // Random shuffle
    std::default_random_engine engine{device()};
    std::shuffle(std::begin(a), std::end(a), engine);

    sort(a, 0, length-1);
}

示例(幻灯片 5,来自 algs4):输入 Q U I C K S O R T E X A M P L E → 洗牌 → K R A T E L E P U I M Q C X O S → 以 K 划分 → E C A I E K L P U T M Q R X O S → 排序左侧 → 排序右侧 → A C E E I K L M O P Q R S T U X。

⚠️ 幻灯片错误(第 6 和第 9 页的代码):
std::shuffle(std::begin(a), std::end(a), engine) 无法编译——a 是 T*,而 std::begin/std::end 需要数组或容器。修复:std::shuffle(a, a + length, engine);
std::shuffle 声明在 <algorithm> 中,而幻灯片没有包含该头文件。修复:添加 #include <algorithm>。

💡 关键洞见:归并排序 = 切分平凡、合并困难。快速排序 = 切分困难(划分)、合并平凡。每次划分都会把一个元素(枢轴)放到最终位置,这就是递归总能终止的原因——每个子数组都严格更小。

2. 划分(Hoare 双指针方案)

目标(幻灯片 7):重新排列 a[lo..hi],使得 a[lo..j-1] <= a[j] <= a[j+1..hi]。

  • 枢轴 v 为 a[lo](“最简单的方法”)。指针 i 从 lo 开始,j 从 hi + 1 开始。
  • 重复以下步骤,直到 i 与 j 交错:
    • i 从左到右扫描,当 a[i] < v 时继续(停在一个 ≥ 枢轴的键上);
    • j 从右到左扫描,当 v < a[j] 时继续(停在一个 ≤ 枢轴的键上);
    • 若 i >= j,跳出;
    • 交换 a[i] 与 a[j]。
  • 最后交换 a[lo] 与 a[j],并返回 j。

为什么与 a[j] 交换,而不是与 a[i] 交换?扫描交错时,j 位于 ≤ v 区域的最后一个元素上,因此把枢轴换到那里,可以保证它左侧的元素都 ≤ v。

幻灯片 8 的代码:

template <typename T>
int partition(T* a, int lo, int hi) {
    int i = lo;
    int j = hi + 1;
    T v = a[lo];
    while (true) {

        // Move i to the right for elements < v

        while (i < hi && a[++i] < v) {
            if (i == hi) break;
        }
        // Move j to the left for elements > v

        while (v < a[--j]) {
            if (j == lo) break;
        }
        if (i >= j) break;         // If pointers cross, stop

        std::swap(a[i], a[j]);     // Swap elements at i and j
    }
    // Place the partitioning item in its correct position
    std::swap(a[lo], a[j]);
    return j;
}

当枢轴是最大或最小的键时,i == hi / j == lo 这两个保护条件使扫描保持在子数组之内。(其中 j == lo 的保护实际上是多余的——a[lo] 就是 v 本身,而 v < v 为假——但它无害。)幻灯片标题把人名拼成了 "Sedgwick",正确拼写是 Robert Sedgewick。

⚠️ 易错点:前置自增很重要。a[++i] 先移动再比较,因此 i 查看的第一个元素是 a[lo+1](枢轴被跳过)。同理,j 从 hi + 1 开始,因此 a[--j] 从 a[hi] 开始。

📘 课外补充:两个扫描都在与枢轴相等的键上停止(使用 <,而不是 <=)。这看似会导致无用的交换,但它正是快速排序在含有大量重复元素的数组上依然快速的原因:当所有键都相等时,停止使 i 与 j 在中间相遇(均匀切分 → n log n);而如果不停止,一个指针会跑到末尾,得到 0 / n−1 的切分 → 二次方时间(algs4 §2.3)。

3. 追踪一次划分

幻灯片 9–35 演示对已洗牌数组 K R A T E L E P U I M Q C X O S 以 v = K 进行的第一次划分:

轮次 i 停在 j 停在 操作 之后的数组
1 1(R ≥ K) 12(C ≤ K) 交换 K C A T E L E P U I M Q R X O S
2 3(T) 9(I) 交换 K C A I E L E P U T M Q R X O S
3 5(L) 6(E) 交换 K C A I E E L P U T M Q R X O S
4 6(L) 5(E) i >= j → 跳出 —
结束 swap(a[0], a[5]),返回 5 E C A I E K L P U T M Q R X O S

划分之后:K 永久位于下标 5,其左侧全部 ≤ K,右侧全部 ≥ K。接着 sort 对 lo = 0, hi = 4 递归(幻灯片 35;幻灯片 37 显示该调用的开始状态:E C A I E,i = 0,j = 5)。

下面可以逐步演示(演示获得焦点时可用 ← / → 键):

{"title": "幻灯片 9–35:partition(a, 0, 15),v = K", "items": "K, R, A, T, E, L, E, P, U, I, M, Q, C, X, O, S", "mode": "partition"}

4. 完整排序追踪

幻灯片 36(algs4 的追踪)列出了每一次 partition 调用。每一行是在以最终位于 j 的枢轴对 a[lo..hi] 划分之后的数组;lo == hi(或 hi < lo)的行是大小为 1/0 的子数组——不进行划分,sort 直接返回。

lo j hi 本次调用之后的数组
0 5 15 E C A I E K L P U T M Q R X O S
0 3 4 E C A E I K L P U T M Q R X O S
0 2 2 A C E E I K …
0 0 1 A C E E I K …
6 6 15 A C E E I K L P U T M Q R X O S
7 9 15 … L M O P T Q R X U S
7 7 8 … L M O P …
10 13 15 … P S Q R T U X
10 12 12 … P R Q S T U X
10 11 11 … P Q R S T U X
14 14 15 … T U X
A C E E I K L M O P Q R S T U X

在幻灯片上,粉色标记左侧调用 sort(a, lo, j-1),蓝色标记右侧调用 sort(a, j+1, hi),红色标记枢轴。注意顺序:左侧子数组完全排序之后,右侧才会被处理(深度优先递归)。

{"title": "幻灯片 36:对已洗牌数组进行完整快速排序", "items": "K, R, A, T, E, L, E, P, U, I, M, Q, C, X, O, S", "mode": "sort", "interactive": true}

📝 练习:活动 1 —— 手工对 3 7 8 5 2 1 9 5 4 排序。

5. 分析:最坏、最好、平均

对 n 个元素调用一次 partition 是 O(n):i 与 j 一起扫过子数组一次(约 n + 1 次比较)。因此一切取决于枢轴落在哪里。

情形 场景 递推式 解
最坏 枢轴总是最小或最大的键 → 切分为 0 和 n−1(例如未洗牌的已排序或逆序输入) T(n) = T(n−1) + n n + (n−1) + … + 2 ≈ n²/2 → O(n²)
最好 枢轴总是中位数 → 两半 T(n) = 2T(n/2) + n lg n 层 × 每层 n 的工作 → O(n log n)
平均 随机洗牌 → 枢轴的秩均匀随机 — O(n log n)

用幻灯片代码测得(不洗牌,规模为 n 的已排序输入):n = 100 → 5,148 次比较,n = 200 → 20,298 次,n = 400 → 80,598 次——约为 n²/2,每当 n 翻倍,次数约变为四倍。规模为 1,000 的同一已排序输入经过洗牌后,比较次数为 12,083 次。

什么能降低最坏情况发生的概率?开始时的随机洗牌:任何固定的输入(已排序、逆序等)都不再糟糕;只有不幸的随机选择才会导致最坏情况,而对于大的 n,这种情况的概率微乎其微。这就是幻灯片 40 中的“概率保证”。

📘 课外补充:平均情况约为 2n ln n ≈ 1.39 n lg n 次比较(algs4 §2.3),而归并排序至多约 n lg n 次。幻灯片 40 中“比归并排序多 39% 的比较”正是由这一比值得来。

📝 练习:活动 2 —— 最坏/最好情况的场景与递推式。

6. 性质与排序算法小结

幻灯片 40,关于快速排序的注释:

  • 比归并排序多 39% 的比较,但实践中更快。 原因:
    • 数据移动更少(归并排序在每一层都要把每个元素复制到 aux 再复制回来);
    • 缓存性能好——划分顺序扫描内存,适合预取;
    • 不需要额外的内存分配。
  • 随机洗牌提供了针对最坏情况的概率保证。
  • 平均 O(n log n),最坏 O(n²)。
  • 原地排序:只在数组内部交换(外加递归栈)。
  • 不稳定:长距离交换可能打乱相等键的顺序。活动 1 中的例子:以第一个 5 为枢轴划分 5 8 7 9 5 4,最后的 swap(a[lo], a[j]) 交换了两个 5。

幻灯片 41 的总结表:

排序 原地 稳定 最好 平均 最坏 备注
选择排序 ✓ O(n²) O(n²) O(n²) n 次交换
插入排序 ✓ ✓ O(n) O(n²) O(n²) 适用于小数组或部分有序的数组
归并排序 ✓ O(n log n) O(n log n) O(n log n) 性能有保证;稳定
快速排序 ✓ O(n log n) O(n log n) O(n²) n log n 的概率保证;实践中最快
  • 所有基于比较的排序算法在最坏情况下的下界都是 n log n(幻灯片写作 "O(n log n)";严格来说这是下界,即 Ω)。
  • 快速排序的表现说明大 O 记号只是现实性能的不完整模型:它的最坏大 O 是所有 n log n 排序中最差的,却仍然是最快的。

📘 课外补充:为什么 n log n 无法被超越:比较排序必须区分输入的全部 n! 种排列,而每次比较只有两种结果,因此某些输入至少需要 lg(n!) ≈ n lg n 次比较(algs4 §2.2)。归并排序达到了这一下界,因此在渐近意义上是最优的。

7. 快速排序的改进

幻灯片 42:

  1. 对小子数组使用插入排序——在极小的子数组上,递归开销占主导,而插入排序在这种规模下很快(与 Lecture 8 中归并排序的技巧相同)。
  2. 改进枢轴选择(并去掉开始时的整体随机洗牌):
    • 使用随机元素作为枢轴(把它交换到 a[lo],然后照常划分);
    • 使用小样本的中位数作为枢轴——平均而言切分更好。

📘 课外补充:algs4 建议切换到插入排序的阈值大约在 5 到 15 个元素之间,并指出取 3 个样本(三数取中)就能获得大部分“样本中位数”的好处。

幻灯片 43 链接了 归并排序 与 快速排序 的“舞蹈”视频——快速排序的舞蹈使用的是与课上不同的划分变体,因此不要用它来核对追踪过程。

8. 完整实现

9. 课堂活动(附解答)

9.1 活动 1:用快速排序排序(幻灯片 38)

用快速排序对下列数组排序: 注:假设枢轴取 lo。

3 7 8 5 2 1 9 5 4

(幻灯片注释:Visualgo 的快速排序实现与本讲介绍的不同。)

✍️ 先自己试一试。 提示:不洗牌;严格按照代码运行 partition——i 停在 ≥ 枢轴的元素上,j 停在 ≤ 枢轴的元素上。

解答。 第一次划分,lo = 0, hi = 8,v = 3:

轮次 i 停在 j 停在 操作 数组
1 1(7) 5(1) 交换 3 1 8 5 2 7 9 5 4
2 2(8) 4(2) 交换 3 1 2 5 8 7 9 5 4
3 3(5) 2(2) 交错 → 跳出
结束 交换 a[0]、a[2];j = 2 2 1 3 5 8 7 9 5 4

所有调用:

lo j hi 之后的数组
0 2 8 2 1 3 5 8 7 9 5 4
0 1 1 1 2 3 5 8 7 9 5 4
3 5 8 1 2 3 5 4 5 9 7 8
3 4 4 1 2 3 4 5 5 9 7 8
6 8 8 1 2 3 4 5 5 8 7 9
6 7 7 1 2 3 4 5 5 7 8 9
1 2 3 4 5 5 7 8 9

陷阱在于对 5 8 7 9 5 4(v = 5)的调用:j 停在第二个 5 上,因为它与枢轴相等(5 < 5 为假),于是两个 5 被交换——快速排序不稳定。请逐步检查:

{"title": "活动 1 检查(幻灯片 38)", "items": "3, 7, 8, 5, 2, 1, 9, 5, 4", "mode": "sort"}

返回 §4 完整排序追踪。

9.2 活动 2:快速排序分析(幻灯片 39)

最坏情况分析 (a) 场景——何时发生? (b) 数学模型——先确定递推式,然后求解。

最好情况分析 (c) 场景——何时发生? (d) 数学模型——先确定递推式,然后求解。

(e) 什么能降低最坏情况发生的可能性?

✍️ 先自己试一试。

解答。

(a) 每个枢轴都是其子数组中最小(或最大)的键,因此每次划分只剥离一个元素:一侧为空,另一侧有 n − 1 个。若枢轴取 a[lo] 且不洗牌,已排序(或逆序)的数组就会出现这种情况。

(b) T(n) = T(n − 1) + n(划分为 O(n))。展开:n + (n − 1) + … + 2 ≈ n²/2 → O(n²)。

(c) 每个枢轴都落在中间(它是中位数),把数组切分为两半。

(d) T(n) = 2T(n/2) + n——即归并排序的递推式:lg n 层,每层 n 的工作 → O(n log n)。

(e) 排序前的随机洗牌(或随机枢轴 / 样本中位数枢轴):这样一来,最坏情况需要极不走运的随机选择,而不是像已排序数组这样常见的输入。

返回 §5 分析。

10. 速查与必记的 5 个要点

操作 代价
partition(a, lo, hi) 对 n = hi − lo + 1 为 O(n)
快速排序,最好 / 平均 O(n log n)
快速排序,最坏 O(n²)(枢轴总是最小/最大)
额外内存 原地(仅递归栈)
稳定? 否
划分规则
枢轴 v = a[lo]
起点 i = lo,j = hi + 1
i 扫描 while (a[++i] < v) —— 停在 ≥ v 上
j 扫描 while (v < a[--j]) —— 停在 ≤ v 上
停止 i >= j
收尾 swap(a[lo], a[j]),返回 j

必记的 5 个要点

  1. 划分把枢轴放到其最终位置,左侧 ≤ v、右侧 ≥ v;然后对 lo..j-1 与 j+1..hi 递归——没有合并步骤。
  2. 两个扫描都在与枢轴相等的键上停止,且最后的交换是与 a[j] 而不是 a[i]——手工追踪的成败就取决于这两个细节。
  3. 最坏情况 T(n) = T(n−1) + n = O(n²),发生在枢轴总是最小或最大时(取 a[lo] 为枢轴且不洗牌的已排序输入);最好情况 T(n) = 2T(n/2) + n = O(n log n)。
  4. 随机洗牌(或随机 / 三数取中的枢轴)使最坏情况几乎不会发生;平均为 O(n log n),比归并排序多约 39% 的比较,但实践中仍更快(移动更少、对缓存友好、无需额外数组)。
  5. 快速排序是原地的但不稳定;归并排序稳定但不是原地的;没有任何基于比较的排序在最坏情况下能突破 n log n。

快捷键[ 上一篇] 下一篇L 切换语言