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

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

最后编辑于 2026 年 10 月 22 日

Information and Computer Science 46 — Lecture 6:递归分析

目录

UCI ICS 46: Data Structure Implementation and Analysis — Prof. Tom Yeh, Fall 2026, 10/15/26
整理自 Lecture 6 幻灯片(p1–17;幻灯片的排序部分 p18–39 在 Lecture 7 中重复,并记录在那边的笔记里),补充材料为 Alex Thornton 的 ICS 46 笔记 "Asymptotic Analysis of Recursion"。按概念组织,而不是按资料来源组织。

📅 课程安排:
学习活动 2 在周五 10/16 进行——请提前做好准备:笔记本电脑充好电、装好扩展、输入随机 ID。
Quiz 2 下周五 10/23:算法分析(Algorithm Analysis)、ArrayList、链表(Linked List)。
HW 2 截止周五 10/16;Lab 2 截止周一 10/19。


1. 抽象数据类型与数据结构

本讲开头先把做什么(what)与怎么实现(how)分开。

  • 抽象数据类型(abstract data type,ADT)是“做什么”:存放哪些数据、有哪些操作。它是一份行为契约,不涉及内存布局。
  • 数据结构(data structure,DS)是“怎么做”:数据如何组织、存储和操作。它关乎内存布局、效率以及每个操作背后的算法。
ADT(“做什么”) 操作 实现它的数据结构(“怎么做”)
List 有序集合上的 add()、remove()、get() ArrayList(动态扩容的数组)、LinkedList(节点指针)
Stack LIFO:push()、pop()、peek() 基于数组的栈、链式栈(单向链表节点)
Queue FIFO:enqueue()、dequeue() 循环数组(环形缓冲区)、链式队列(head/tail 指针)

例如:考试中说“一个 Stack”指的是 ADT;“以 head 为栈顶的链式栈”则是它的一种数据结构。它的 push 是 O(1),是因为数据结构,而不是因为 ADT。


2. 为什么要分析递归

有些算法和数据结构用递归来写最合适,因此我们需要像分析循环那样求出它们的运行时间。幻灯片中的例子:

int factorial(int n)
{
    if (n == 0)
        return 1;
    else
        return n * factorial(n - 1);
}

最坏情况时间复杂度是多少? O(n)。factorial(n) 总共做 n 次乘法和 n + 1 次调用(factorial(n)、factorial(n-1),……,factorial(0)),每次调用做常数工作。

⚠️ 易错点:“没有循环,所以是 O(1)”是错的。工作量藏在递归调用里:factorial(10) 显然比 factorial(5) 做得多。要始终问:调用了多少次,每次调用花多少代价。


3. 两步法

要求一个递归算法的时间复杂度:

  1. 建立递推式:一个表示运行时间 T(n) 的方程,
    • 由一个或多个规模小于 n 的子问题,
    • 加上递归调用之外所做的额外工作组成。
  2. 求解递推式(近似地),使用以下两种方法之一:
    • 方法 A——递归树法(第 5–6 节)
    • 方法 B——主定理(第 7 节)

每个递推式还需要一个基底情况(base case,例如 T(0) 或 T(1)),它是一个常数。


4. 建立递推式:阶乘

对于上面的 factorial 函数:

T(0) = a
T(n) = b + T(n - 1)      for n > 0
  • T(0) 是 n == 0 时的时间。a 是检查 n == 0 并返回 1 的常数时间。
  • 对于 n > 0,b 是除递归调用之外所有工作的常数时间(检查、n - 1、乘法、返回)。
  • T(n − 1) 是递归调用的时间,它由同一个递推式描述。

从代码读出递推式:数出递归调用的次数(这里是一次),写出每次调用得到的规模(这里是 n − 1),再加上函数体其余部分的代价(这里是常数 b)。

📘 课外补充(Thornton,Asymptotic Analysis of Recursion):另一种求解方法是重复代入(repeated substitution)。不断代入,直到出现规律:
T(n) = b + T(n−1) = 2b + T(n−2) = 3b + T(n−3) = … = jb + T(n−j)。
(这一规律可以用归纳法对所有 j 证明。)取 j = n 到达基底情况:T(n) = nb + T(0) = nb + a,它线性增长,因此 T(n) 是 Θ(n)。下一节的递归树通过画图得到同样的求和。


5. 方法 A:阶乘的递归树

每次调用画一个节点,把该调用在递归调用之外所做的工作写在节点旁边,然后全部加起来。对于 factorial(5)(幻灯片 p9),这棵“树”是一条链:

调用 层 递推式项 该节点的工作量
factorial(5) = 5 * factorial(4) 0 T(n) b
factorial(4) = 4 * factorial(3) 1 T(n−1) b
factorial(3) = 3 * factorial(2) 2 T(n−2) b
factorial(2) = 2 * factorial(1) 3 T(n−3) b
factorial(1) = 1 * factorial(0) 4 T(n−4) b
factorial(0) = 1 基底 T(0) = 常数 a
  • 共有 n 层(外加基底情况)。
  • 每层花费常数时间 b。
  • T(n) = 层数 × 每层时间 = n·b(+ a)⇒ O(n)。

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

{"title": "幻灯片 p9:factorial(5) 的递归树", "recurrence": "T(n) = b + T(n-1)", "n": 5, "base": "a", "code": "factorial"}

真实代码的调用次数也与此相符:一个带计数的 factorial(在函数开头加 ++calls)输出

factorial(5) = 120, calls = 6

这正是 n + 1 = 6 次调用:五次代价为 b,一次基底情况代价为 a。


6. 分治递推式的递归树

幻灯片 p10–12 分析了一个“未知算法”:它把输入对半拆分两次,并在每次调用中做线性工作:

T(n) = 2T(n/2) + n
T(1) = c,  where c is a constant

本方法的规则(幻灯片 p12):把每个操作计入执行它的那个函数调用(树节点)。在更深的递归调用中做的工作,分别计在它们的节点上。因此,输入长度为 m 的节点只计 m(即 “+ n” 项在其自身规模下的值),不多计。

层 调用数 每次调用的输入规模 每次调用的工作量 该层合计
0 1 n n n
1 2 n/2 n/2 n
2 4 n/4 n/4 n
3 8 n/8 n/8 n
… … … … n
log₂ n(叶子) 2^(log₂ n) = n 1 c c·n
  • 规模每层减半,因此经过 log₂ n 次减半后达到 1。树高为 log₂ n,计入叶子层共有 log₂ n + 1 层。
  • 共有 n 个叶子,每个都是基底情况 T(1) = c。
  • 每个非叶层总共花费 n:2^i 次调用,每次 n/2^i。
  • 总计:n·log₂ n + c·n ⇒ O(n log n)。

下面逐步演示。输入框允许你输入任意形如 aT(n/b) + f(n) 的递推式(例如活动 2 中的那些):

{"title": "幻灯片 p10–12:T(n) = 2T(n/2) + n,n = 8 时的图示", "recurrence": "T(n) = 2T(n/2) + n", "n": 8, "interactive": true}

一个具有完全相同形状的真实函数,每个 “+ n” 步计一个单位,每个基底情况计一个单位(c = 1):

long work = 0;

void unknown(int n)                      // T(n) = 2T(n/2) + n
{
    if (n == 1) { ++work; return; }      // base case: c
    for (int i = 0; i < n; ++i) ++work;  // the "+ n"
    unknown(n / 2);
    unknown(n / 2);
}
unknown(8): work = 32
unknown(16): work = 80
unknown(1024): work = 11264

每个结果都与 n·log₂ n + n 相符:8·3 + 8 = 32,16·4 + 16 = 80,1024·10 + 1024 = 11264。

💡 关键洞察:对于递归树,你需要三个数:有多少层、每层有多少节点、每个节点做多少工作。总量等于各层合计之和。

📝 练习:活动 0 要求求出这棵树的层数和叶子数。活动 1 用该方法分析二分查找以及 2T(n/2) + c。


7. 方法 B:主定理

7.1 公式

主定理是一个用于求解所有子问题规模相同的递推式的公式。它把递归树方法打包成即插即用的结果,因此比画树更快。

设 T(n)=a⋅T(n/b)+O(nd)T(n) = a \cdot T(n/b) + O(n^d),则

T(n)={O(ndlog⁡n)if a=bdO(nd)if a<bdO(nlog⁡ba)if a>bd T(n) = \begin{cases} O(n^d \log n) & \text{if } a = b^d \\ O(n^d) & \text{if } a < b^d \\ O(n^{\log_b a}) & \text{if } a > b^d \end{cases}

三个参数:

  • a:子问题数量(每次调用的递归调用数)
  • b:输入规模缩小的倍数(每个子问题的规模为 n/b)
  • d:非递归工作为 n^d,即创建所有子问题并合并其解的工作

使用时,读出 a、b、d,计算 b^d,再与 a 比较。

⚠️ 易错点:常数额外工作,即 O(1) 或 "+ c",意味着 d = 0(n⁰ = 1),因此 b^d = 1。在 T(n) = 2T(n/2) + c 中,比较的是 2 与 1,而不是 2 与 2。

7.2 为什么是三种情形:工作量在哪里

幻灯片 p15 的树展示了这个公式的来源。第 0 层有 1 个节点,做 f(n)。第 1 层有 a 个节点,每个做 f(n/b)。第 i 层有 a^i 个节点,处理规模为 n/b^i 的输入。最底层有 a^(log_b n) = n^(log_b a) 个叶子,每个做 f(1)。

若 f(n) = n^d,则第 i 层的代价为 a^i · (n/b^i)^d = n^d · (a/b^d)^i。因此每往下一层,该层的总量乘以 a / b^d:

情形 往下各层的总量 直观理解(幻灯片) 结果
a = b^d 保持不变(每层 n^d,共 log_b n + 1 层) 工作在树中平衡 O(n^d log n)
a < b^d 按几何级数递减 根节点的工作占主导 O(n^d)
a > b^d 按几何级数递增 叶子的工作占主导 O(n^(log_b a)),即叶子的数量

下面演示幻灯片中“叶子占主导”的例子;在输入框中输入其他递推式,可以看到另外两种情形:

{"title": "幻灯片 p16:T(n) = 4T(n/2) + n,n = 4 时的图示", "recurrence": "T(n) = 4T(n/2) + n", "n": 4, "interactive": true}

7.3 典型例题(幻灯片 16)

递推式 a b d a 与 b^d T(n)
T(n) = 4T(n/2) + O(n) 4 2 1 4 > 2 O(n^(log₂ 4)) = O(n²)
T(n) = 3T(n/2) + O(n) 3 2 1 3 > 2 O(n^(log₂ 3)) ≈ O(n^1.6)(log₂ 3 ≈ 1.585)

作为对比,第 6 节的递推式 T(n) = 2T(n/2) + n 中 a = 2、b = 2、d = 1,所以 2 = 2¹,属于平衡情形:O(n log n),与递归树的结果相同。

7.4 不适用的情况

幻灯片 p14 问:主定理能否求解 T(n) = T(n/3) + T(2n/3) + n? 不能。 两个子问题的规模不同(n/3 和 2n/3),因此不存在单一的 b。改用递归树:每个完整层的代价至多为 n(同一层的规模之和至多为 n)。最长路径每步缩小为 2/3,因此约有 log_{3/2} n 层。总计 O(n log n)。

⚠️ 易错点:主定理要求形如 a·T(n/b) + n^d、子问题规模相等的递推式。T(n−1) 型递推式(如阶乘)以及不均匀划分都不适用。这些情况请使用递归树(或代入法)。

📝 练习:活动 2 有八个递推式需要用主定理求解。

➡️ 续见 Lecture 7:基于比较的排序、选择排序、插入排序(幻灯片 p18–39 在那里重复讲解)。


8. 课堂活动(附解答)

8.1 活动 0:树的层数与叶子数(幻灯片 11)

对于下面这棵树

T(n) = 2T(n/2) + n
T(1) = c, where c is a constant

它有多少层、多少个叶子?

✍️ 先自己试一试。 提示:把 n 连续减半,要减多少次才能到 1?

解答:输入规模每层减半,减到 1 需要 log₂ n 次减半。因此树高为 log₂ n;若计入根层(第 0 层)与叶子层,共 log₂ n + 1 层(幻灯片 p12 将高度标为 log₂ n)。节点数每层翻倍,所以叶子层有 2^(log₂ n) = n 个叶子。每个非叶层花费 n,叶子层花费 c·n,因此 T(n) = n log₂ n + cn = O(n log n)。见第 6 节。

8.2 活动 1:递归树方法(幻灯片 13)

(a) 用递归树方法求二分查找的大 O 时间复杂度:

T(n) = T(n/2) + c
T(1) = c

(b) 用递归树求下面递推式的大 O 时间复杂度:

T(n) = 2T(n/2) + c

(幻灯片上两部分都标成了 "a)"。)

✍️ 先自己试一试。

解答 (a):O(log n)。 每次调用只做一次递归调用,处理一半的范围,并做常数工作 c(计算 mid,一次比较)。这棵“树”是一条链,每层一个节点。共 log₂ n + 1 层,每层代价 c,因此 T(n) = c(log₂ n + 1) = O(log n)。下面是一个具有这种形状的递归二分查找:

bool binarySearch(const int* arr, int lo, int hi, int target)
{
    if (lo > hi) return false;           // empty range
    int mid = lo + (hi - lo) / 2;
    if (arr[mid] == target) return true;
    if (target < arr[mid])
        return binarySearch(arr, lo, mid - 1, target);
    return binarySearch(arr, mid + 1, hi, target);
}

在一个 16 元素的数组中查找一个不存在的值(最坏情况),会产生 6 次调用:范围大小分别为 16、8、4、2、1,以及最后的空范围。也就是 log₂ 16 + 1 = 5 层,外加本版本用作基底情况的空范围调用。

解答 (b):O(n)。 第 i 层有 2^i 个节点,每个代价 c,因此各层的合计为 c、2c、4c、……、n·c(即 n 个叶子)。这个等比求和为 c(2n − 1) = O(n)。工作量每层翻倍,所以叶子占主导。两个递推式只差 "T" 与 "2T" 这一处,却使答案从 log n 变成了 n。下面检查这个求和(n = 16:31c = c(2·16 − 1)):

{"title": "活动 1(b):T(n) = 2T(n/2) + c,n = 16 时的图示", "recurrence": "T(n) = 2T(n/2) + c", "n": 16}

8.3 活动 2:主定理(幻灯片 17)

用主定理求解 T(n)=a⋅T(n/b)+O(nd)T(n) = a \cdot T(n/b) + O(n^d):

(a) 写出二分查找的递推式,并用主定理求解。

(b) T(n) = T(n/2) + O(n)

(c) T(n) = 2T(n/2) + O(1)

(d) T(n) = 2T(n/2) + O(n)

(e) T(n) = 4T(n/2) + n

(f) T(n) = 4T(n/2) + n²

(g) T(n) = 4T(n/2) + n³

(h) T(n) = 3T(2n/3) + n

✍️ 先自己试一试。 对每一题写出 a、b、d,然后比较 a 与 b^d。

解答。

递推式 a b d b^d 情形 T(n)
(a) T(n) = T(n/2) + O(1) 1 2 0 1 a = b^d O(n⁰ log n) = O(log n)
(b) T(n) = T(n/2) + O(n) 1 2 1 2 a < b^d O(n)
(c) T(n) = 2T(n/2) + O(1) 2 2 0 1 a > b^d O(n^(log₂ 2)) = O(n)
(d) T(n) = 2T(n/2) + O(n) 2 2 1 2 a = b^d O(n log n)
(e) T(n) = 4T(n/2) + n 4 2 1 2 a > b^d O(n^(log₂ 4)) = O(n²)
(f) T(n) = 4T(n/2) + n² 4 2 2 4 a = b^d O(n² log n)
(g) T(n) = 4T(n/2) + n³ 4 2 3 8 a < b^d O(n³)
(h) T(n) = 3T(2n/3) + n 3 3/2 1 1.5 a > b^d O(n^(log_{3/2} 3)) ≈ O(n^2.71)

关键陷阱:

  • (a) 和 (c) 的额外工作为 O(1),因此 d = 0。(a) 与活动 1(a)相符。
  • (c) 与活动 1(b)的递归树结论一致:O(n)。
  • (e)–(g) 保持 a = 4、b = 2,只改变 d,依次走过三种情形:叶子主导、平衡、根主导。
  • (h):规模缩小到 2n/3,因此缩小因子为 b = n ÷ (2n/3) = 3/2,而不是 2/3 或 3。于是 log_{3/2} 3 = ln 3 / ln 1.5 ≈ 1.0986 / 0.4055 ≈ 2.71。

回到第 7 节。


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

配方: (1) 写出 T(n) = (对较小输入的递归调用)+ (它们之外的工作),再加上基底情况;(2) 用递归树或主定理求解。

主定理,适用于 T(n) = aT(n/b) + O(n^d):

比较 工作在哪里 T(n)
a = b^d 各层平衡 O(n^d log n)
a < b^d 根节点占主导 O(n^d)
a > b^d 叶子占主导 O(n^(log_b a))

本讲常见的递推式:

递推式 例子 T(n)
T(n) = T(n−1) + c 阶乘 O(n)
T(n) = T(n/2) + c 二分查找 O(log n)
T(n) = 2T(n/2) + c 活动 1(b) O(n)
T(n) = T(n/2) + n 活动 2(b) O(n)
T(n) = 2T(n/2) + n 幻灯片 p10–12 O(n log n)
T(n) = 4T(n/2) + n 幻灯片 p16 O(n²)
T(n) = 3T(n/2) + n 幻灯片 p16 O(n^(log₂ 3)) ≈ O(n^1.6)

必记的 5 个要点

  1. 没有循环的递归函数并不自动是 O(1):要数调用次数以及每次调用的工作量(factorial 是 O(n))。
  2. 在递推式中,T(更小的规模) 代表递归调用;额外项是它们之外所做的工作。O(1) 的额外工作意味着 d = 0。
  3. 递归树:每个节点只计自己的工作,合计每一层,再把各层相加。2T(n/2) + n 有 log₂ n + 1 层,每层 n,因此为 O(n log n)。
  4. 主定理:读出 a、b、d,比较 a 与 b^d。相等得 n^d log n,较小得 n^d,较大得 n^(log_b a)。
  5. 主定理要求规模为 n/b 的等规模子问题。它无法处理 T(n−1) 或 T(n/3) + T(2n/3) + n 这类不均匀划分;这些情况请画递归树。

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