Information and Computer Science 46 — Lecture 7:排序
目录
- 1. 循环数组队列
- 2. 双端队列
- 3. 为什么要学习排序
- 4. 排序基础:键、代价模型、内存、稳定性
- 5. 选择排序
- 6. 插入排序
- 7. 选择排序与插入排序
- 8. 课堂活动(附解答)
- 9. 速查与必记的 5 个要点
UCI ICS 46: Data Structure Implementation and Analysis — Prof. Tom Yeh, Fall 2026, 10/20/26
整理自 Lecture 7 幻灯片(p1–27;归并排序部分 p28–44 在 Lecture 8 中重复,并记录在那边的笔记里)。Lecture 6 的排序幻灯片(其 p18–39,活动 3–6)内容相同,在此一并讲解。补充材料:Alex Thornton 的 ICS 46 笔记 "Stacks, Queues, and Deques" 与 "Comparison-Based Sorting",以及 algs4 §2.1 Elementary Sorts。按概念组织,而不是按资料来源组织。
📅 课程安排:今天的答疑时间(OH)由助教(LA)主持。
Quiz 2 在周五进行——纸质考试,请准时到场;范围包括算法分析(Algorithm Analysis)、ArrayList、链表(Linked List)。
HW 3 周五截止;Lab 3 下周一截止。
课程批准的 AI 工具很快会发布,用于实验与项目作业。抄袭检测将在本季度末进行;学术诚信违规将导致该项记零分,并失去成绩上的弹性(低分会被下调)。
1. 循环数组队列
1.1 为什么不用普通的 ArrayList?
队列在尾部(rear)入队,在头部(front)出队。如果把队列存放在 ArrayList 中并把头部固定在下标 0,那么每次 dequeue() 都必须把其余元素全部左移,代价为 O(n)(幻灯片 p4:[A B C D] → 出队 → [B C D])。
循环数组(circular array)让起始下标可以移动。出队时,头下标只需向前推进,没有元素需要移动,因此出队为 O(1)(幻灯片 p4:出队 A 之后,front 从 0 变为 1,B、C 留在原处)。
📘 课外补充(Thornton,Stacks, Queues, and Deques):正常使用的
std::vector有同样的问题。它总是把元素放在单元 0…size−1 中,因此队列的一端被固定在单元 0,入队或出队中的一个就会变成 Θ(n)。解决办法是使用一个普通数组,让它的头、尾下标可以“浮动”。
1.2 头、尾与环绕
循环数组队列在一个固定大小的数组中实现队列,并维护两个下标:
front_idx:队首元素的位置。rear_idx:按一种约定指向最后一个有效元素,按另一种约定指向下一个空位。幻灯片中的算法采用“下一个空位”。
两个下标都只会向前移动。走到数组末尾时,它们会用取模运算环绕回 0:
index = (index + 1) % capacity因此队列的元素就是从 front 开始向右的各个槽位,必要时会继续回到槽位 0(幻灯片 p5:容量为 6,front_idx = 1,队列为槽位 1–4 中的 A B C D,尾部在槽位 4)。
| 操作 | 算法(幻灯片 p6) | 代价 |
|---|---|---|
| enqueue(value) | array[rear_idx] = value; rear_idx = (rear_idx + 1) % capacity; |
O(1) |
| dequeue() | val = array[front_idx]; front_idx = (front_idx + 1) % capacity; return val |
O(1) |
下面可逐步演示(演示获得焦点时可用 ← / → 键)。本站演示采用 1.3 节中的“记录大小”约定:尾槽位 = (front + n) % capacity。注意观察 F 如何环绕到槽位 0:
{"title": "幻灯片 p4–6:容量为 5 的循环数组队列", "ops": "A B C D - - E F G", "capacity": 5, "interactive": true}⚠️ 易错点:幻灯片 p6 把入队和出队标注为“摊还 O(1)”(amortized O(1))。对于固定容量的循环数组,两者都是普通的最坏情况 O(1)。只有当数组满时扩容,才会出现“摊还”(见活动 1中的挑战题)。
1.3 满与空
如果 rear_idx 指向下一个空槽位,那么队列为空和队列装满所有槽位时都会满足 front == rear。幻灯片给出两种区分方法:
始终留出一个空槽位。 此时
- 空:
front == rear - 满:
(rear + 1) % capacity == front
代价是浪费一个槽位。
- 空:
记录大小
n。空为n == 0,满为n == capacity;此时完全不需要rear,因为它等于(front + n) % capacity。
2. 双端队列
一个双端队列(double-ended queue,读作 “deck”)在两端都可以增加和删除元素(幻灯片 p7):
| 操作 | 含义 |
|---|---|
enqueueFront(x) / enqueueBack(x) |
在前端 / 后端加入 |
dequeueFront() / dequeueBack() |
从前端 / 后端移除并返回 |
front() / back() |
查看前端 / 后端元素,但不移除 |
- 若用头下标指向第一个元素、用尾下标指向下一个元素的存放位置(或指向最后一个元素),双端队列就是一个两个下标都可以双向移动的循环数组。
enqueueFront将front向左移动,并从 0 环绕到capacity − 1:front = (front - 1 + capacity) % capacity。dequeueBack以同样方式将尾部向左移动。 - 它是一种多用途结构:只使用一端,它就是栈;一端加入、另一端移除,它就是队列。
- C++ 标准库提供了它:
std::deque。 - 作为链表实现时,双端队列需要在两端都能删除,因此应使用双向链表(单向链表无法在 O(1) 时间内删除最后一个节点)。参见第 4 讲。
⚠️ 易错点:在 C++ 中,当
front为 0 时,(front - 1) % capacity的结果是 −1,因为%的结果符号与左操作数相同。应先加上capacity再取模。
📝 练习:活动 1在循环数组上实现全部四个双端队列操作,包括扩容。
3. 为什么要学习排序
幻灯片 p10 给出三个理由:
- 实际用途。 排序无处不在:按价格、姓、大小、日期、邮编排序。它是用户界面、数据库查询和数据展示的基础。
- 基准测试。 它是在完全相同的问题上比较不同算法性能的好方法;而且有些排序策略与特定的数据结构相关。
- 解题工具。 先对数据排序,往往是很好的起点(面试时请记住这一点)。它改变了接下来能做的事情:例如,有序数组允许使用二分查找。
排序算法有很多(幻灯片 p11):adaptive heapsort、bitonic sorter(高度并行,在 GPU 上很流行)、block sort、bubble sort、bucket sort、cascade mergesort、cocktail sort、comb sort、flashsort、gnome sort、heapsort、insertion sort、library sort、mergesort、odd-even sort、pancake sort、quicksort、radixsort、selection sort、shell sort、spaghetti sort、treesort,等等。加粗的是本课程学习的算法。今天讲选择排序和插入排序。
4. 排序基础:键、代价模型、内存、稳定性
定义(幻灯片 p12)。
- 排序(sorting)是把集合中的 n 个元素按非递减顺序排列,可以是数值顺序,也可以是字母顺序。
- 当一个元素有多个分量时,键(key)就是我们用来排序的分量,它决定元素之间的相对顺序。例如,一本书可以按书名、按作者(都是字母顺序)或按 ISBN(数值)排序。
两个抽象操作(幻灯片 p13)。 今天讲的每个算法都只通过以下两种操作接触数据:
- 比较(compare):
v是否小于w?在 C++ 中,这就是operator<(为你的类型重载)或一个自定义的比较函数。 - 交换(exchange):交换
a[i]与a[j]。
T temp = a[i]; // manual swap
a[i] = a[j];
a[j] = temp;
std::swap(a[i], a[j]); // standard library (#include <utility>)代价模型(幻灯片 p14)。 我们统计比较次数和交换次数。对于不做交换的算法,则改为统计数组访问次数。并非所有排序都基于比较:例如基数排序就从不比较两个键。
内存(幻灯片 p15)。 额外内存和运行时间同样重要。
| 类别 | 额外内存 |
|---|---|
| 原地(in place) | 相对于被排序的元素,只需常数或对数级的额外内存 |
| 非原地(not in place) | 线性额外内存,与输入规模成正比 |
稳定性(幻灯片 p16)。 如果键相等的元素在输出中保持输入时的相对顺序,则该排序是稳定的(stable)。幻灯片使用按点数排序的扑克牌作例子。输入 7♠ 5♥ 2♥ 5♠:稳定排序得到 2♥ 5♥ 5♠ 7♠(5♥ 原本在前,因此仍然在前);不稳定排序则可能得到 2♥ 5♠ 5♥ 7♠。
📘 课外补充(Thornton,Comparison-Based Sorting):稳定性使你能够依次按多个键排序。先按名字排序,再对结果稳定地按姓排序:最终结果按姓排序,同姓的人仍按名字排序。若第二次排序不稳定,就可能打乱这些同姓的顺序。
5. 选择排序
思路(幻灯片 p18–19)。 把数组分成左侧的已排序子数组和右侧的未排序子数组。重复以下步骤:
- 在未排序部分中找到最小的元素,
- 将它与最左侧未排序的元素交换,
- 把边界向右移动一个元素。
#include <utility>// for std::swap
template <typename T>
void selectionSort(T* arr, int size) {
for (int i = 0; i < size - 1; i++) {
// Find index of smallest remaining element
int min = i;
for (int j = i + 1; j < size; j++) {
if (arr[j] < arr[min]) {
min = j;
}
}
// efficient swap using move semantics
std::swap(arr[i], arr[min]);
}
}不变式(幻灯片 p20)。 第 i 次迭代结束时:
arr[0…i]按升序排列,并且arr[i+1…n−1]中的任何元素都不小于arr[0…i]中的任何元素。
因此,第 i 次迭代之后,arr[0…i] 中保存的就是这些位置的最终值,之后不再被修改。最后一个元素会自动就位,这就是外层循环在 size − 1 处停止的原因。
在幻灯片上的数组 3 44 38 5 47 1 36 26(→ 1 3 5 26 36 38 44 47)上逐步演示:
{"title": "幻灯片 p18–19:对幻灯片数组做选择排序", "array": [3, 44, 38, 5, 47, 1, 36, 26], "interactive": true}分析。
- 比较次数: 第 i 次迭代扫描 n − 1 − i 个元素,因此总数为 (n−1) + (n−2) + … + 1 = n(n−1)/2 ≈ n²/2,即 O(n²)。对每种输入都相同,因为扫描无法提前结束。
- 交换次数: 每次迭代一次,共 n − 1 次,因此为 O(n)(幻灯片的代码在
min == i时也会“交换”)。 - 已经有序时? 仍然需要 n(n−1)/2 次比较,因此仍为 O(n²)。选择排序察觉不到数据已经有序。
- 原地排序: 是,额外只使用
i、j和min。 - 稳定: 否。远距离交换可能使某个元素越过与它相等的元素。例:
5♥ 5♠ 2♥。第 0 趟将 2♥ 与 5♥ 交换,得到2♥ 5♠ 5♥,两个 5 的顺序就颠倒了。
用计数器在 n = 1000 上实测(与公式吻合:1000·999/2 = 499,500):
n=1000 sorted: selection cmps=499500 swaps=999
n=1000 reversed: selection cmps=499500 swaps=999📘 课外补充(algs4 §2.1):由于选择排序最多只进行 n − 1 次交换,当移动元素的代价远高于比较时,它是首选。algs4 中的例子是:一位仓库职员通过阅读标签来重新摆放沉重的箱子。
📝 练习:活动 2用手工追踪选择排序,并回答这些分析问题。
6. 插入排序
思路(幻灯片 p23–25)。 保持左侧一个部分有序的子数组,右侧是未排序的子数组。重复:
- 取出未排序部分的下一个元素,
- 找到它在已排序部分中应当的位置并插入,
- 把边界向右移动一个元素。
这正是大多数人整理手中扑克牌的方式。与选择排序不同,左侧部分只是相对于自身有序:之后的元素仍可以插入其中。
#include <utility>// for std::swap
template <typename T>
void insertionSort(T* arr, int size) {
for (int i = 0; i < size ; i++) {
for (int j = i; j > 0; j--) { // slide: "j–" (see bug note)
if (arr[j] < arr[j-1]) {
std::swap(arr[j], arr[j-1]);
}
else break;
}
}
}⚠️ 幻灯片错误:幻灯片 p26 在内层循环中写的是
j–(一个 en dash),无法通过编译,应为j--。(外层循环从i = 0开始是无害的:第 0 趟什么也不做。)
内层循环会把 arr[j] 向左交换,越过每一个比它大的邻居。遇到第一个不比它大的邻居时,它就跳出(break):它左边的部分都已有序,因此该元素已经就位。这个提前退出正是它与选择排序的全部区别。
在同一幻灯片数组上逐步演示:
{"title": "幻灯片 p23–25:对幻灯片数组做插入排序", "array": [3, 44, 38, 5, 47, 1, 36, 26], "interactive": true}分析。
| 输入 | 比较次数 | 交换次数 | 大 O |
|---|---|---|---|
| 最好:已经有序 | n − 1(每趟:一次比较,然后 break) | 0 | O(n) |
| 最坏:逆序 | n(n−1)/2(每个元素都要一直移到最前面) | n(n−1)/2 | O(n²) |
| 平均(随机) | ≈ n²/4 | ≈ n²/4 | O(n²) |
n=1000 sorted: insertion cmps=999 swaps=0
n=1000 reversed: insertion cmps=499500 swaps=499500- 原地排序: 是。
- 稳定: 是。只有在
arr[j] < arr[j-1](严格小于)时才交换,因此元素永远不会越过与它相等的元素。把幻灯片中的扑克牌 7♠ 5♥ 2♥ 5♠ 按点数排序,得到2H 5H 5S 7S:5♥ 仍在 5♠ 之前。
📘 课外补充(algs4 §2.1):插入排序进行的交换次数恰好等于逆序对(inversion,即顺序错误的元素对)的数量。因此,对于部分有序的数组(逆序对很少,例如一个有序列表后面追加了几个新元素),插入排序几乎以线性时间运行。这就是它常用于小规模或接近有序输入的原因。
📝 练习:活动 3用手工追踪插入排序,并询问最好和最坏情况。
7. 选择排序与插入排序
| 选择排序 | 插入排序 | |
|---|---|---|
| 比较次数,最坏 | n(n−1)/2 → O(n²) | n(n−1)/2 → O(n²)(逆序) |
| 比较次数,最好 | n(n−1)/2 → O(n²)(总是如此) | n − 1 → O(n)(已经有序) |
| 交换次数 | n − 1 → O(n),总是如此 | 0(最好)到 n(n−1)/2(最坏) |
| 原地 | 是 | 是 |
| 稳定 | 否(远距离交换) | 是(只交换相邻且严格更小的元素) |
| 优势 | 交换次数最少 | 在已排序或接近有序的数据上很快 |
💡 关键洞见:两者最坏情况都是 O(n²)。选择排序每次都要付出 n²/2 次比较的代价。插入排序只为实际存在的无序付出代价。
➡️ 接续见第 8 讲:归并排序(归并的实现与追踪,即本幻灯片组的 p28–44,在第 8 讲中重复)。
8. 课堂活动(附解答)
8.1 活动 1:循环数组实现(幻灯片 8)
在循环数组上实现双端队列的以下方法,并更新 front/rear 下标:
enqueueFront(val):把元素加入双端队列的前端enqueueBack(val):把元素加入双端队列的尾端dequeueFront():移除并返回前端的元素dequeueBack():移除并返回尾端的元素
挑战题: 动态扩大循环数组时,如何更新双端队列?(向已满的双端队列加入元素需要扩大容量。)
✍️ 先自己试一试。 提示:保存
front和大小n;这样尾部元素就位于(front + n − 1) % capacity。
解答。 记录 front 和 n(即 1.3 节中的第二种方法)。尾部槽位为 (front + n) % capacity。
void enqueueFront(const T& val) {
if (n == capacity) resize(2 * capacity);
front = (front - 1 + capacity) % capacity; // step left, wrapping
data[front] = val;
n++;
}
void enqueueBack(const T& val) {
if (n == capacity) resize(2 * capacity);
data[(front + n) % capacity] = val; // rear slot
n++;
}
T dequeueFront() {
if (n == 0) throw std::underflow_error("empty deque");
T val = data[front];
front = (front + 1) % capacity;
n--;
return val;
}
T dequeueBack() {
if (n == 0) throw std::underflow_error("empty deque");
T val = data[(front + n - 1) % capacity]; // last element
n--;
return val;
}挑战:扩容。 不能把旧数组逐个单元地复制到相同位置,因为元素可能环绕数组末尾。幻灯片给出三个步骤:
- 分配一个更大的数组(容量加倍)。
- 展开:按双端队列的顺序把元素依次放入新数组:新数组的
[k]= 旧数组的[(front + k) % oldCapacity],k = 0 … n−1。 - 重置
front = 0和rear = size(在记录大小的版本中,rear直接由n推出)。
void resize(int newCap) {
T* bigger = new T[newCap];
for (int k = 0; k < n; k++) // unroll in deque order
bigger[k] = data[(front + k) % capacity];
delete[] data;
data = bigger;
capacity = newCap;
front = 0; // rear slot is now n
}使用加倍策略时,每次入队都是摊还 O(1)(与第 3 讲中 ArrayList 的论证相同);出队为 O(1)。测试:从容量 2 开始,执行 enqueueBack(1); enqueueBack(2); enqueueFront(0); enqueueFront(-1); enqueueBack(3),然后执行一次 dequeueFront()、一次 dequeueBack(),并持续 dequeueFront() 直到队列为空:
deque size=5 capacity=8 : -1 3 0 1 28.2 活动 2:选择排序(幻灯片 21)
(a) 使用选择排序对元素 [12, 10, 16, 11, 9, 7] 进行排序。对算法的每一次迭代都要画出过程。
(b) 运行时间分析:
- 最坏情况运行时间的大 O 记号是什么?比较次数?交换次数?
- 如果数据已经有序会怎样?
- 选择排序是原地排序吗?为什么?
- 它是稳定的吗?为什么?
(第 6 讲的活动 3 和 4 问的是相同的问题。)
✍️ 先自己试一试。
解答 (a)。 每次迭代 i 之后(已排序前缀为 arr[0…i]):
| 迭代后 | 数组 | 交换 |
|---|---|---|
| 开始 | 12 10 16 11 9 7 | |
| 0 | 7 10 16 11 9 12 | 12 ↔ 7 |
| 1 | 7 9 16 11 10 12 | 10 ↔ 9 |
| 2 | 7 9 10 11 16 12 | 16 ↔ 10 |
| 3 | 7 9 10 11 16 12 | 11 已是最小值(自身交换) |
| 4 | 7 9 10 11 12 16 | 16 ↔ 12 |
共 15 次比较(5 + 4 + 3 + 2 + 1 = 6·5/2)和 5 次交换。可在下面检验:
{"title": "活动 2(a):对 [12, 10, 16, 11, 9, 7] 做选择排序", "array": [12, 10, 16, 11, 9, 7]}解答 (b)。
- 最坏情况 O(n²):n(n−1)/2 次比较,n − 1 次交换(O(n))。
- 已经有序:没有帮助,仍需 n(n−1)/2 次比较,O(n²)。寻找最小值的扫描总会进行到末尾。
- 原地排序: 是。额外 O(1) 内存(
i、j、min)。 - 不稳定: 将
arr[i]与远处的最小值交换,可能使arr[i]越过一个相等的键(5♥ 5♠ 2♥→2♥ 5♠ 5♥)。参见第 5 节。
8.3 活动 3:插入排序(幻灯片 27)
(a) 使用插入排序对元素 [12, 10, 16, 11, 9, 7] 进行排序。对算法的每一次迭代都要画出过程。
(b) 最坏情况的时间复杂度是多少?
- 最好情况:何时出现?
- 比较次数?交换次数?
(第 6 讲的活动 5 和 6 问的是相同的问题。)
✍️ 先自己试一试。
解答 (a)。 每一趟 i 之后(已排序前缀为 arr[0…i],加粗为新插入的元素):
| 第 i 趟后 | 数组 | 比较次数 | 交换次数 |
|---|---|---|---|
| 0 | 12 10 16 11 9 7 | 0 | 0 |
| 1 | 10 12 16 11 9 7 | 1 | 1 |
| 2 | 10 12 16 11 9 7 | 1(16 > 12,break) | 0 |
| 3 | 10 11 12 16 9 7 | 3(停在 10 处) | 2 |
| 4 | 9 10 11 12 16 7 | 4(到达最前面) | 4 |
| 5 | 7 9 10 11 12 16 | 5(到达最前面) | 5 |
共 14 次比较,12 次交换。12 即输入中的逆序对数量。可在下面检验:
{"title": "活动 3(a):对 [12, 10, 16, 11, 9, 7] 做插入排序", "array": [12, 10, 16, 11, 9, 7]}解答 (b)。
- 最坏情况 O(n²),出现在输入逆序时:第 i 个元素要一路交换到最前面,因此共 n(n−1)/2 次比较和 n(n−1)/2 次交换。
- 最好情况 O(n),出现在输入已经有序时:每趟只做一次比较,然后
break。即 n − 1 次比较,0 次交换。 - 陷阱:选择排序的最好情况仍然是 O(n²)。插入排序之所以最好情况是线性的,正是因为那个
break。
9. 速查与必记的 5 个要点
循环数组(队列 / 双端队列)
| 公式 | |
|---|---|
| 推进下标 | i = (i + 1) % capacity |
| 向后退一步 | i = (i - 1 + capacity) % capacity |
| 尾部槽位(记录大小 n) | (front + n) % capacity |
| 最后一个元素 | (front + n - 1) % capacity |
| 空 / 满(保留一个空槽位) | front == rear / (rear + 1) % capacity == front |
| 空 / 满(记录大小) | n == 0 / n == capacity |
固定数组上的队列和双端队列操作为 O(1),满时加倍扩容则为摊还 O(1)。链式双端队列需要双向链表。
初等排序
| 最好 | 最坏 | 交换次数 | 原地 | 稳定 | |
|---|---|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | n − 1 | 是 | 否 |
| 插入排序 | O(n)(已排序) | O(n²)(逆序) | = 逆序对数 | 是 | 是 |
必记的 5 个要点
- 循环数组避免了移动元素:出队时移动的是头下标,通过
% capacity实现环绕,因此入队和出队都是 O(1)。 front == rear有歧义:要么保留一个空槽位,要么记录大小。向左移动时,先加上capacity再取模。- 选择排序无论输入是否有序,总是进行 n(n−1)/2 次比较,但只进行 n − 1 次交换。它是原地排序,不稳定。
- 插入排序在已排序输入上为 O(n)(比较一次后 break),在逆序输入上为 O(n²)。它的交换次数等于逆序对数,是原地且稳定的。
- 稳定 = 相等的键保持输入时的顺序。当你按多个键对同一份数据排序时,这一点很重要。