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

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

最后编辑于 2026 年 10 月 22 日

Information and Computer Science 46 — Lecture 7:排序

目录

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。幻灯片给出两种区分方法:

  1. 始终留出一个空槽位。 此时

    • 空:front == rear
    • 满:(rear + 1) % capacity == front

    代价是浪费一个槽位。

  2. 记录大小 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 给出三个理由:

  1. 实际用途。 排序无处不在:按价格、姓、大小、日期、邮编排序。它是用户界面、数据库查询和数据展示的基础。
  2. 基准测试。 它是在完全相同的问题上比较不同算法性能的好方法;而且有些排序策略与特定的数据结构相关。
  3. 解题工具。 先对数据排序,往往是很好的起点(面试时请记住这一点)。它改变了接下来能做的事情:例如,有序数组允许使用二分查找。

排序算法有很多(幻灯片 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 下标:

  1. enqueueFront(val):把元素加入双端队列的前端
  2. enqueueBack(val):把元素加入双端队列的尾端
  3. dequeueFront():移除并返回前端的元素
  4. 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;
}

挑战:扩容。 不能把旧数组逐个单元地复制到相同位置,因为元素可能环绕数组末尾。幻灯片给出三个步骤:

  1. 分配一个更大的数组(容量加倍)。
  2. 展开:按双端队列的顺序把元素依次放入新数组:新数组的 [k] = 旧数组的 [(front + k) % oldCapacity],k = 0 … n−1。
  3. 重置 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 2

8.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 个要点

  1. 循环数组避免了移动元素:出队时移动的是头下标,通过 % capacity 实现环绕,因此入队和出队都是 O(1)。
  2. front == rear 有歧义:要么保留一个空槽位,要么记录大小。向左移动时,先加上 capacity 再取模。
  3. 选择排序无论输入是否有序,总是进行 n(n−1)/2 次比较,但只进行 n − 1 次交换。它是原地排序,不稳定。
  4. 插入排序在已排序输入上为 O(n)(比较一次后 break),在逆序输入上为 O(n²)。它的交换次数等于逆序对数,是原地且稳定的。
  5. 稳定 = 相等的键保持输入时的顺序。当你按多个键对同一份数据排序时,这一点很重要。

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