Information and Computer Science 46 — Lecture 9:快速排序
目录
- 1. 快速排序:思路
- 2. 划分(Hoare 双指针方案)
- 3. 追踪一次划分
- 4. 完整排序追踪
- 5. 分析:最坏、最好、平均
- 6. 性质与排序算法小结
- 7. 快速排序的改进
- 8. 完整实现
- 9. 课堂活动(附解答)
- 10. 速查与必记的 5 个要点
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):
- 洗牌:随机打乱数组。
- 划分:使得对于某个枢轴下标
j:- 元素
a[j]已经处于最终位置, j左侧没有更大的元素,j右侧没有更小的元素。
- 元素
- 递归排序每个子数组。没有合并步骤:两侧都排好后,整个数组就已经有序。
| 划分前 | 划分后 | |
|---|---|---|
| 布局 | 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:
- 对小子数组使用插入排序——在极小的子数组上,递归开销占主导,而插入排序在这种规模下很快(与 Lecture 8 中归并排序的技巧相同)。
- 改进枢轴选择(并去掉开始时的整体随机洗牌):
- 使用随机元素作为枢轴(把它交换到
a[lo],然后照常划分); - 使用小样本的中位数作为枢轴——平均而言切分更好。
- 使用随机元素作为枢轴(把它交换到
📘 课外补充:algs4 建议切换到插入排序的阈值大约在 5 到 15 个元素之间,并指出取 3 个样本(三数取中)就能获得大部分“样本中位数”的好处。
幻灯片 43 链接了 归并排序 与 快速排序 的“舞蹈”视频——快速排序的舞蹈使用的是与课上不同的划分变体,因此不要用它来核对追踪过程。
8. 完整实现
#include <algorithm>
#include <random>
#include <utility>
template <typename T>
int partition(T* a, int lo, int hi) {
int i = lo;
int j = hi + 1;
T v = a[lo];
while (true) {
while (i < hi && a[++i] < v) {
if (i == hi) break;
}
while (v < a[--j]) {
if (j == lo) break;
}
if (i >= j) break;
std::swap(a[i], a[j]);
}
std::swap(a[lo], a[j]);
return j;
}
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;
std::default_random_engine engine{device()};
std::shuffle(a, a + length, engine); // fixed
sort(a, 0, length - 1);
}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 个要点
- 划分把枢轴放到其最终位置,左侧
≤ v、右侧≥ v;然后对lo..j-1与j+1..hi递归——没有合并步骤。 - 两个扫描都在与枢轴相等的键上停止,且最后的交换是与
a[j]而不是a[i]——手工追踪的成败就取决于这两个细节。 - 最坏情况 T(n) = T(n−1) + n = O(n²),发生在枢轴总是最小或最大时(取
a[lo]为枢轴且不洗牌的已排序输入);最好情况 T(n) = 2T(n/2) + n = O(n log n)。 - 随机洗牌(或随机 / 三数取中的枢轴)使最坏情况几乎不会发生;平均为 O(n log n),比归并排序多约 39% 的比较,但实践中仍更快(移动更少、对缓存友好、无需额外数组)。
- 快速排序是原地的但不稳定;归并排序稳定但不是原地的;没有任何基于比较的排序在最坏情况下能突破 n log n。