LECTURE 17

效率:同一个问题,为什么有的算法几微秒、有的要跑一百万年

学会用「增长阶」而不是秒表来衡量一段代码的代价,并且能对着代码逐行数出它的时间复杂度。

教材:Composing Programs §2.8 Efficiency 对应作业:Lab 08 / HW 05

0. 本讲导读

到目前为止,你判断一段代码好不好的标准只有一个:它跑出来的答案对不对。递归、高阶函数、可变数据、面向对象、链表——每一讲都在教你「怎么把一个想法正确地表达成代码」。

本讲换一把尺子。正确只是及格线。 同一个问题往往有很多种正确的写法,它们的答案完全一样,但代价可以相差到荒谬的程度。本讲最震撼的一个例子是斐波那契数:

  • 递归版 fib(100):要做大约 1.1 × 1021 次函数调用。按本机实测的速度算,跑完需要一百万年以上。
  • 迭代版 fib(100):本机实测 不到万分之一秒。fib(100000)(十万项,结果有两万多位)也只要 0.05 秒。

两段代码都不到十行,都完全正确,差距却是天文数字。这个差距不来自「Python 慢」,不来自「电脑不够快」——换台快一百万倍的机器,递归版还是跑不完。差距来自算法本身的结构。

那么怎么把这种差距说清楚?说「递归版慢」太含糊;说「递归版在我机器上跑了 0.27 秒」又依赖于机器。本讲给出的答案是增长阶(order of growth):不看绝对时间,只看输入规模变大时,代价按什么规律涨。涨得慢的算法,规模一大就必然赢,跟机器无关。这套语言写出来就是你早晚会在各种地方见到的 O(n)、O(log n)、O(2n)。

它和前后讲的关系:上一讲的链表让你亲眼看到「在开头插入元素,链表快、Python list 慢」这个现象——本讲给这个现象一个名字和一套算法。往后学 Scheme、解释器、SQL,你会一再遇到「同一件事有多种实现」的选择题,本讲教的就是做这种选择题的依据。真正把这套东西展开讲的是 CS 61B 和 CS 170;61A 只要求你能对着一段几十行的代码,说出它的最坏情况运行时间是哪一档。

核心结论
  • 衡量算法效率不用秒、不用行数、不用函数调用次数,而用运行时间随输入规模 n 增长的函数形式。理由:前三者都依赖机器、语言和写法,唯独增长的形式是算法自己的性质。
  • 本课要求认得五档:常数 O(1) < 对数 O(log n) < 线性 O(n) < 平方 O(n2) < 指数 O(2n)。判别口诀是看「n 变大一点,代价怎么变」。
  • 分析方法只有一句话:逐行数代价 → 循环体乘以迭代次数 / 递归画出调用树 → 丢掉常数因子和低阶项。没有万能公式,每段代码都得老老实实读。
  • 递归的代价不看递归深度,看调用树的节点总数。fib 一次分裂成两个子调用,节点数按指数涨;exp 一次只生一个子调用且规模减半,节点数按对数涨。
  • 记忆化(memoization)把重复的子问题结果缓存下来,能把树递归 fib 从 O(2n) 压到 O(n)——代价是多用 O(n) 的内存。这是时间换空间的典型。
  • Big O 严格讲是上界,所以说「迭代 fib 是 O(2n)」在数学上不算错。但本课问「运行时间是多少」时要的是精确的最坏情况,答案是 O(n)。

1. 为什么需要一把新尺子

先把问题摆清楚:解决同一个问题常常有很多种算法,我们凭什么说 A 比 B 好?

一个直觉的答案是:A 跑得比 B 快,A 就比 B 好。 这个直觉方向没错,但直接拿它当标准会立刻出问题。

为什么不能直接用「跑了多少秒」

因为秒数不是算法的性质,是这次运行的性质。它至少受三样东西影响:

  • 硬件不同。同一段代码在你的笔记本和在服务器上跑,时间可以差十倍。那到底哪个数字代表这个算法?
  • 同一台机器上也不稳定。后台有没有别的程序、缓存是冷是热、这一刻 CPU 降不降频,都会让结果抖动。测两次得到两个数是常态。
  • 没法预测。你测了 n = 1000 用了 0.1 秒,那 n = 100000 要多久?秒数本身回答不了,除非你真的再跑一次。

下面这组数据是本机实测的,它同时展示了「秒数有用」和「秒数不够用」两面:

操作(本机实测)耗时说明
对 20 万个元素逐个 L.append(i)0.0065 秒每次都往末尾加
对 20 万个元素逐个 L.insert(0, i)3.48 秒每次都往开头插

差了 500 多倍。上一讲的链表 demo 说的就是这件事:Python 的 list 在末尾加元素很便宜,在开头插元素很贵,因为后面所有元素都得整体往后挪一格。但「500 倍」这个数字换台机器就变了。真正不变的、可以写进教科书的,是「往开头插 n 个元素,总代价按 n2 涨;往末尾加 n 个元素,总代价按 n 涨」。

为什么也不能用「代码有几行」

  • 一行代码里的活儿可多可少。x = 1 是一行,x = sorted(big_list) 也是一行,后者干的活是前者的几百万倍。
  • 语言语法不一样。同一个算法,Python 写 5 行,C 写 20 行,Scheme 写 3 行——算法没变,行数变了三次,那行数显然不是算法的性质。

为什么也不能用「调用了多少次函数」

这个比前两个靠谱,本讲后面数 exp 和 fib 的调用次数时用的就是它。但它不能当通用标准,因为每个函数体内部干的活多少不一样:调用 10 次 square 和调用 10 次「排序一百万个数」完全不是一回事。它只在「所有调用干的活量级相同」时才是好的代理指标(fib 恰好满足,所以我们用它)。

核心结论

我们真正要描述的东西是:随着输入规模 n 变大,这个算法要做的工作量按什么函数形式增长。

注意这句话里两个要点。第一,永远是「相对于某个输入规模」——n 通常是列表长度、数字大小、字符串长度,也可能有多个(比如两个列表的长度 m 和 n)。谈复杂度不说清 n 是什么,等于没说。第二,关心的是「函数形式」而不是具体数值:是直线、是抛物线,还是一条冲天而起的指数曲线。

直觉

把它想成挑车:你不会用「这辆车昨天从家开到公司花了 23 分钟」来描述一辆车(那取决于路况),你会说「百公里油耗 6 升」。油耗是车的性质,时间是这一趟的性质。 增长阶就是算法的油耗。

而且油耗低的车在长途上一定赢。同理,增长阶低的算法在 n 足够大时一定赢——哪怕它在 n = 5 的时候还落后。这就是为什么可以理直气壮地丢掉常数因子:常数因子只影响「n 多大之后开始赢」,不影响「最终会赢」。

2. 求幂:同一个函数,两种递归,两种命运

求幂(exponentiation)就是算 bn:把数 b 自乘 n 次。b 叫底数(base),n 叫指数(exponent)。它可以递归地定义:

$$b^n = \begin{cases} 1 & n = 0 \\ b \cdot b^{\,n-1} & n > 0 \end{cases}$$

这个定义直译成代码就是:

def exp(b: int, n: int) -> int:
    if n == 0:
        return 1
    return b * exp(b, n - 1)

(b: int 和 -> int 是类型标注(type annotation),只是写给人看的注释,Python 运行时完全不检查它们。)

把它真的展开一遍

SPEC 里说得对:不要满足于「递归调用自身处理更小的问题」这种废话。exp(2, 5) 到底发生了什么,一层一层写出来:

逐步推演:exp(2, 5) 的递归展开
exp(2, 5)   n≠0 → 返回 2 * exp(2, 4)     ← 卡住,等下面算完
  exp(2, 4)   n≠0 → 返回 2 * exp(2, 3)   ← 卡住
    exp(2, 3)   n≠0 → 返回 2 * exp(2, 2) ← 卡住
      exp(2, 2)   n≠0 → 返回 2 * exp(2, 1)
        exp(2, 1)   n≠0 → 返回 2 * exp(2, 0)
          exp(2, 0)   n==0 → 返回 1      ← base case,终于有确定的值了

现在逐层回代(从最里面往外算):
          exp(2, 0) = 1
        exp(2, 1) = 2 * 1  = 2
      exp(2, 2) = 2 * 2  = 4
    exp(2, 3) = 2 * 4  = 8
  exp(2, 4) = 2 * 8  = 16
exp(2, 5) = 2 * 16 = 32

这里有一件对理解代价至关重要的事:在 exp(2, 0) 返回之前,那 6 个帧是同时活着的。exp(2, 5) 这一帧里的 return b * exp(b, n - 1) 只算了一半——它必须等 exp(2, 4) 给出值才能做那次乘法,所以它的帧不能销毁。画成环境图就是一摞:

环境图:exp(2, 0) 即将返回的那一瞬间
Global frame
    exp  ──→ func exp(b, n) [parent=Global]

f1: exp [parent=Global]     b ──→ 2   n ──→ 5    正在算 2 * exp(2, 4)
f2: exp [parent=Global]     b ──→ 2   n ──→ 4    正在算 2 * exp(2, 3)
f3: exp [parent=Global]     b ──→ 2   n ──→ 3    正在算 2 * exp(2, 2)
f4: exp [parent=Global]     b ──→ 2   n ──→ 2    正在算 2 * exp(2, 1)
f5: exp [parent=Global]     b ──→ 2   n ──→ 1    正在算 2 * exp(2, 0)
f6: exp [parent=Global]     b ──→ 2   n ──→ 0    返回值 ──→ 1

注意每一帧的 parent 都是 Global,不是上一帧——parent 由「函数定义在哪儿」决定,exp 定义在全局,所以它每次被调用产生的帧的 parent 永远是 Global。帧的堆叠顺序是「谁在等谁返回」,跟 parent 链是两码事。这一点在 Lecture 05 讲过,这里再确认一次,因为下一节讲空间复杂度要用到:同时活着的帧数 = 递归深度。

数一数调用次数

n对 exp 的调用次数
01
12
23
……
nn + 1

规律一眼可见:n 每加 1,调用次数就加 1。这叫线性时间(linear time),记作 O(n)。

换一个递归定义

上面那个定义把 n 减 1。但数学上还有另一个同样正确的递归分解,它利用了平方:

$$b^n = \begin{cases} 1 & n = 0 \\ \left(b^{\,\lfloor n/2 \rfloor}\right)^2 & n \bmod 2 = 0 \\ b \cdot b^{\,n-1} & n \bmod 2 = 1 \end{cases}$$

翻译成代码:

def exp(b: int, n: int) -> int:
    if n == 0:
        return 1
    elif n % 2 == 0:
        temp = exp(b, n // 2)
        return temp * temp
    else:
        return b * exp(b, n - 1)
Exponentiation 3 of 3 幻灯片:n 与调用次数对照表
右边是快速求幂的代码,左边一列是不同 n 下 exp 被调用的次数,括号里是 n 的下降路径。看 n = 64 那一行:64 → 32 → 16 → 8 → 4 → 2 → 1 → 0 只用了 8 次调用;而上一版要 65 次。关键在于偶数那一支把 n 折半而不是减一。
逐步推演:exp(2, 8) 的递归展开
exp(2, 8)   8 是偶数 → temp = exp(2, 4),然后返回 temp * temp
  exp(2, 4)   4 是偶数 → temp = exp(2, 2),然后返回 temp * temp
    exp(2, 2)   2 是偶数 → temp = exp(2, 1),然后返回 temp * temp
      exp(2, 1)   1 是奇数 → 返回 2 * exp(2, 0)
        exp(2, 0)   n==0 → 返回 1

回代:
        exp(2, 0) = 1
      exp(2, 1) = 2 * 1 = 2
    exp(2, 2) = 2 * 2 = 4
  exp(2, 4) = 4 * 4 = 16
exp(2, 8) = 16 * 16 = 256

只用了 5 次调用就算出了 28。旧版本要 9 次。差距在 n 大的时候会拉开:

n旧版 exp 调用次数新版 exp 调用次数n 的下降路径(新版)
0110
1221 → 0
2332 → 1 → 0
8958 → 4 → 2 → 1 → 0
6465864 → 32 → 16 → 8 → 4 → 2 → 1 → 0
10010110100 → 50 → 25 → 24 → 12 → 6 → 3 → 2 → 1 → 0

(表里 8、64 两行与幻灯片一致;1、100 两行是我在本机用计数器实测的。)

为什么是对数? 想清楚这一点比记住结论重要得多。看 n 的下降路径:偶数时 n 折半,奇数时 n 减 1(而 n − 1 立刻变成偶数,下一步就折半)。所以最多每两步,n 就至少折一次半。一个数 n 能被折多少次半才降到 1?答案就是 log2n。n = 64 折 6 次到 1,n = 1024 折 10 次,n = 1000000 折 20 次。

这就是对数时间(logarithmic time),记作 O(log n)。它的标志性特征是:n 翻倍,代价只增加一个常数。n 从 64 涨到 128,调用次数从 8 变成 9——涨了 1。

直觉

O(log n) 有多快?如果 n 是全世界 80 亿人,log2(8 × 109) ≈ 33。也就是说,一个 O(log n) 的算法处理全人类规模的数据,只需要 33 步左右。这就是为什么二分查找、平衡树这些「每一步砍掉一半」的结构在工程上无处不在。

反过来记:看见「每一步把问题规模除以一个大于 1 的常数」,八成就是 O(log n)。

常见误区

误区一:以为 return exp(b, n // 2) * exp(b, n // 2) 和用 temp 是一回事。

# 错误写法:看起来只是把 temp 展开了
def exp_bad(b, n):
    if n == 0:
        return 1
    elif n % 2 == 0:
        return exp_bad(b, n // 2) * exp_bad(b, n // 2)   # 算了两遍!
    else:
        return b * exp_bad(b, n - 1)

这段代码答案完全正确,不会报任何错——这正是它危险的地方。但它把同一个子问题算了两遍,于是调用树又变成每层分裂成两支,整个优化白做,退回成 O(n)。temp = exp(b, n // 2) 这一行的全部意义就是:算一次,存起来,用两次。

误区二:把 // 写成 /。 n / 2 得到的是 float,于是 n % 2 == 0 后面某一步会拿到 2.0、1.0 这样的浮点数。1.0 == 0 是 False,1.0 % 2 == 0 也是 False,程序会走进奇数分支,然后 n 变成 0.0——而 0.0 == 0 是 True,所以居然还能停下来。答案也许还对,但类型脏了,而且遇到大整数时 / 会直接抛:

>>> 10**400 / 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
OverflowError: integer division result too large for a float

整数除法一律用 //。

3. 斐波那契:树递归为什么会爆炸

斐波那契数列(Fibonacci sequence):0, 1, 1, 2, 3, 5, 8, 13, 21, …,每一项是前两项之和。递归定义直译成代码只有三行:

def fib(n):
    if n == 0 or n == 1:
        return n
    return fib(n - 1) + fib(n - 2)

这段代码完全正确,而且优雅得几乎是递归的教科书范例。fib(0) 返回 0,fib(1) 返回 1,fib(35) 返回 9227465——全对。

问题在于它有两个递归调用。exp 每次只生一个孩子,所以它的调用结构是一根链;fib 每次生两个孩子,调用结构是一棵树。这就是树递归(tree recursion),而树的节点数是随深度指数增长的。

把 fib(5) 的调用树完整画出来

逐步推演:fib(5) 的完整调用树
fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1) → 1      ← base case
│   │   │   └── fib(0) → 0      ← base case
│   │   │   fib(2) = 1 + 0 = 1
│   │   └── fib(1) → 1
│   │   fib(3) = 1 + 1 = 2
│   └── fib(2)
│       ├── fib(1) → 1
│       └── fib(0) → 0
│       fib(2) = 1 + 0 = 1
│   fib(4) = 2 + 1 = 3
└── fib(3)
    ├── fib(2)
    │   ├── fib(1) → 1
    │   └── fib(0) → 0
    │   fib(2) = 1 + 0 = 1
    └── fib(1) → 1
    fib(3) = 1 + 1 = 2

fib(5) = 3 + 2 = 5

数一数这棵树上有多少个节点:15 个。也就是说,算 fib(5) 一共调用了 15 次 fib——为了得到一个 5。

而且请看清楚这棵树里的重复:

  • fib(3) 被完整地算了 2 次
  • fib(2) 被完整地算了 3 次
  • fib(1) 被算了 5 次,fib(0) 被算了 3 次

每一次 fib(3) 都从零开始把它下面那一整棵子树重算一遍,尽管答案永远是 2。这就是指数爆炸的来源:不是问题难,是同一个子问题被反复求解。

调用次数到底涨得多快

下面这张表是我在本机加计数器实测出来的(不是估算):

nfib(n) 的值对 fib 的调用次数比上一档增加
5515—
1055177×11.8
206 76521 891×124
30832 0402 692 537×123

n 每加 10,调用次数就乘以 123 左右——注意是「乘以」,不是「加上」。这正是指数时间(exponential time)的定义特征:n 增加一个常数,代价乘以一个常数。(123 开十次方约等于 1.618,正是黄金比例 φ;准确地说调用次数等于 2·fib(n+1) − 1,增长率是 φn,而 φn 属于 O(2n) 这一档。)

注意:fib(100) 到底要跑多久

课上有一个 fib(100) 的 demo。用上面的公式算:调用次数 = 2 × fib(101) − 1 ≈ 1.146 × 1021 次。

本机实测递归 fib(32) 用了 0.272 秒,做了约 7.05 × 106 次调用,即每秒约 2.6 × 107 次。那么:

$$\frac{1.146\times10^{21}}{2.6\times10^{7}} \approx 4.4\times10^{13}\ \text{s} \approx 1.4\times10^{6}\ \text{yr}$$

一百四十万年。这个数字的意义在于:它不是「慢」,它是「永远算不完」。买一台快一千倍的机器,还要一千四百年。指数级的差距不可能靠硬件补上,只能换算法。

顺带说一句,如果你真在终端里敲了 fib(100),它不会报错,只会一直转下去,直到你按 Ctrl-C。它的递归深度只有 100,远没到 Python 默认的 1000 层上限,所以不会触发 RecursionError——问题出在宽度,不是深度。

直觉

为什么 exp 也是递归却只要 O(n),fib 却要 O(2n)?看每一层分裂成几支。

  • exp:一个调用生一个孩子 → 调用结构是一根链,长度 n → 节点数 ≈ n。
  • fib:一个调用生两个孩子 → 调用结构是一棵二叉树,深度 n → 节点数 ≈ 2n。
  • 快速求幂:一个调用生一个孩子,且规模折半 → 链长只有 log n → 节点数 ≈ log n。

所以分析递归的复杂度,问自己两个问题就够了:(a)每次调用产生几个子调用?(b)每次调用把 n 缩小了多少?

常见误区

「递归深度是 n,所以是 O(n)」——这是本讲最容易踩的坑。 深度和节点数是两回事:fib(30) 的递归深度只有 30,但节点数是 269 万。运行时间由节点总数决定(每个节点都要真的执行一遍函数体),深度只决定同时活着的帧数,那是空间复杂度的事。

记住这组对照:fib 的时间复杂度是 O(2n),空间复杂度是 O(n)。

4. 记忆化:把算过的答案记住

上一节把病因诊断清楚了:同一个子问题被反复求解。那治法就很自然——算过一次就把答案存起来,下次直接拿。 这个技术叫记忆化(memoization)。

它能成立有一个前提,幻灯片专门点出来了:fib 是一个纯函数(pure function)——给定同样的实参,它每次的返回值都一样,而且不产生任何副作用。所以「上次算 fib(3) 得到 2」这条信息这次仍然有效。如果函数会读时间、读随机数、改全局变量,缓存就会给出错误答案。

Fibonacci: Memoization 幻灯片,含 memo 函数与 @memo 装饰的 fib
右边是完整实现:memo 接受一个函数 f,在自己的帧里建一个空字典 cache,然后返回一个内层函数 memoized。memoized 的逻辑只有三行——没算过就算一次存进去,然后无论如何都从缓存里取。左边强调 @memo 这种装饰器语法不在本课考纲内,但记忆化的思想在。
def memo(f):
    cache = {}
    def memoized(n):
        if n not in cache:
            cache[n] = f(n)
        return cache[n]
    return memoized

@memo
def fib(n):
    if n == 0 or n == 1:
        return n
    return fib(n - 1) + fib(n - 2)

先看懂 memo 本身:这是一个闭包

memo 用上了 Lecture 04 的全套高阶函数机制,值得逐步拆开。执行 fib = memo(fib)(这就是 @memo 的含义)的时候:

逐步推演:@memo 那一行到底做了什么
1 def fib(n): ... 先照常执行,全局帧里 fib 绑定到那个「原始的、没缓存的」函数对象。把它叫作 func fib(n)。
2 求值 memo(fib):先查到 memo,再求值实参 fib 得到 func fib(n),然后调用,新建帧 f1,形参 f 绑定到 func fib(n)。
3 在 f1 里执行 cache = {}:f1 帧里多了一个名字 cache,指向一个空字典对象。
4 在 f1 里执行 def memoized(n): ...:创建函数对象 func memoized(n),它的 parent 是 f1(因为它是在 f1 里被 def 的)。这就是闭包——它记住了 f1,因而能看见 cache 和 f。
5 return memoized 把这个函数对象交回去。
6 装饰器把返回值重新绑定到名字 fib 上。现在全局帧里 fib 指向的是 func memoized(n),原来那个 func fib(n) 只剩 f1 帧里的 f 还指着它。
环境图:@memo 执行完毕之后
Global frame
    memo  ──→ func memo(f) [parent=Global]
    fib   ──→ func memoized(n) [parent=f1]      ← 注意!fib 已经不是原来那个函数了

f1: memo [parent=Global]
    f      ──→ func fib(n) [parent=Global]      ← 原始的、未加缓存的 fib
    cache  ──→ {}                               ← 这个字典只有 memoized 能看见
    返回值  ──→ func memoized(n)

为什么记忆化能对递归生效?这是整段代码最妙、也最容易看漏的地方。 原始 fib 函数体里写的是 fib(n - 1) + fib(n - 2)。这两个 fib 是名字,要到运行时才去查——而 fib 定义在全局,所以它的帧的 parent 是 Global,查 fib 会查到全局帧里现在的那个绑定,也就是 memoized!

于是每一次内部递归调用都会先经过缓存检查。如果名字查找不是「运行时才查」,这个技巧根本不成立。

加了缓存以后到底省了多少

再跑一遍 fib(5),这次跟踪的是「原始 fib 函数体被真正执行了几次」:

逐步推演:记忆化 fib(5)
fib(5)  cache={}            5 不在 cache → 执行原始函数体 → 需要 fib(4) + fib(3)
  fib(4)  4 不在 cache → 执行函数体 → 需要 fib(3) + fib(2)
    fib(3)  3 不在 cache → 执行函数体 → 需要 fib(2) + fib(1)
      fib(2)  2 不在 cache → 执行函数体 → 需要 fib(1) + fib(0)
        fib(1)  1 不在 cache → 执行函数体 → 返回 1,存 cache[1] = 1
        fib(0)  0 不在 cache → 执行函数体 → 返回 0,存 cache[0] = 0
      fib(2) = 1 + 0 = 1,存 cache[2] = 1
      fib(1)  1 在 cache 里!直接返回 1,函数体一次都不执行
    fib(3) = 1 + 1 = 2,存 cache[3] = 2
    fib(2)  2 在 cache 里!直接返回 1
  fib(4) = 2 + 1 = 3,存 cache[4] = 3
  fib(3)  3 在 cache 里!直接返回 2
fib(5) = 3 + 2 = 5,存 cache[5] = 5

最终 cache = {1: 1, 0: 0, 2: 1, 3: 2, 4: 3, 5: 5}
原始函数体执行次数:6 次(n = 0,1,2,3,4,5 各一次)

从 15 次降到 6 次。而且规律很清楚:每个不同的 n 只会真正算一次,所以原始函数体总共执行 n + 1 次。这是 O(n)——从指数直接降到线性。本机实测确认:n = 5 时 6 次,n = 10 时 11 次,n = 30 时 31 次。

实际效果:记忆化版的 fib(100) 本机耗时 4.8 × 10-5 秒,结果是 354224848179261915075。而不带缓存的版本需要一百四十万年。同样的三行递归定义,只是外面套了一个字典。

常见误区

误区一:把 cache = {} 写进 memoized 里。

def memo(f):
    def memoized(n):
        cache = {}          # 错!
        if n not in cache:
            cache[n] = f(n)
        return cache[n]
    return memoized

这样每次调用 memoized 都会新建一个空字典,缓存永远是空的,一条记录都留不住。代码不报错、答案也对,但速度跟没加缓存一模一样(其实还更慢)。cache 必须在 memo 的帧里,在所有调用之间共享——这正是闭包存在的意义。

误区二:以为可以给带副作用的函数加缓存。 如果被缓存的函数会 print、会改列表、会读随机数,那么第二次调用时函数体根本不执行,副作用就消失了。给 fib 加个 print(n) 你会发现打印次数远少于预期——这不是 bug,是缓存在起作用。

误区三:以为记忆化解除了递归深度限制。 记忆化的 fib(1000) 会崩:

>>> fib(1000)
Traceback (most recent call last):
  ...
RecursionError: maximum recursion depth exceeded

因为第一次计算时链条仍然是 fib(1000) → fib(999) → … → fib(1),深度 1000,超过了 sys.getrecursionlimit() 返回的默认值 1000。缓存省的是宽度(重复的子树),不是深度。 想算 fib(1000),用下一节的迭代版。

5. 迭代版 fib:不用缓存也能线性

记忆化保留了递归的写法,用额外内存换时间。还有第三条路:彻底不递归。想想人手算斐波那契是怎么算的——你不会从 fib(5) 往下拆,你会从 0, 1 开始往上加:0, 1, 1, 2, 3, 5。这个过程只需要记住最近两个数。

def fib(n):
    curr, nxt = 0, 1
    k = 0
    while k < n:
        curr, nxt = nxt, curr + nxt
        k += 1
    return curr

(写 nxt 不写 next,是为了不遮蔽 Python 内置函数 next。)

逐轮追踪 fib(5)

循环里的三个名字:curr 是「当前这一项」,nxt 是「下一项」,k 是「已经往前走了几步」。循环不变式(loop invariant)是:每轮循环开始时,curr == fib(k) 且 nxt == fib(k+1)。

时刻kcurrnxt条件 k < 5
进入循环前0010 < 5 → True
第 1 轮结束1111 < 5 → True
第 2 轮结束2122 < 5 → True
第 3 轮结束3233 < 5 → True
第 4 轮结束4354 < 5 → True
第 5 轮结束5585 < 5 → False,退出

返回 curr,也就是 5。对照不变式:退出时 k == 5,所以 curr == fib(5) == 5。✓

循环体执行 n 次,每次做的都是常数量的工作(一次加法、两次赋值、一次比较)。所以是 O(n) ——线性时间。而且它只用了三个名字,不管 n 多大都是三个,空间是 O(1)。

逐步推演:curr, nxt = nxt, curr + nxt 这一行

这是同时赋值(simultaneous assignment),求值规则很关键:

1 先把等号右边的所有表达式全部求值完,得到一个值的元组。假设此刻 curr 是 2、nxt 是 3,那么右边求出 (3, 5)。
2 然后才依次绑定:curr ← 3,nxt ← 5。
3 关键在于第 2 步里用的是第 1 步算好的值,而不是重新去查名字。所以 curr 被改掉这件事,影响不到 curr + nxt 的结果。
常见误区

把同时赋值拆成两行。 这是本节唯一但极高频的错误:

def bad_fib(n):
    curr, nxt = 0, 1
    k = 0
    while k < n:
        curr = nxt          # curr 已经变了
        nxt = curr + nxt    # 这里的 curr 是新值,不是老值!
        k += 1
    return curr

不会报错,会安静地给出错误答案。本机实测 [bad_fib(i) for i in range(8)] 的输出是:

[0, 1, 2, 4, 8, 16, 32, 64]

正确答案本该是 [0, 1, 1, 2, 3, 5, 8, 13]。它算出来的是 2 的幂——因为第二行里 curr 已经等于 nxt 了,curr + nxt 变成了 nxt + nxt,也就是每轮翻倍。

要拆成两行,必须先用临时变量把老值存下来:

        old_curr = curr
        curr = nxt
        nxt = old_curr + nxt

三个版本横向对比

版本时间复杂度空间复杂度fib(100) 本机实测写起来
朴素递归O(2n)O(n)(帧栈深 n)约 1.4 × 106 年3 行,最贴近数学定义
记忆化递归O(n)O(n)(缓存 + 帧栈)4.8 × 10-5 秒递归部分不动,外面套 8 行
迭代O(n)O(1)< 10-4 秒;fib(100000) 也只要 0.05 秒7 行,要自己想清不变式

三个都返回 354224848179261915075。正确性完全一样,代价天差地别。

6. 增长阶与大 O 记号

前面三节一直在用「线性」「对数」「指数」这些词。现在把它们变成一套正式的语言。

增长阶(order of growth):用一个数学函数描述「运行时间随输入规模 n 增长的方式」。写法是 大 O 记号(Big O notation),比如 O(n)。

Orders of Growth 2 of 4 幻灯片:五种增长阶的对照表
本课要求掌握的五档,从快到慢排列。最值得记的是第三列的判别法——它说的不是「代价有多大」,而是「n 变化时代价怎么变」:对数是「n 翻倍才多花一个常数」,线性是「n 加一就多花一个常数」,指数是「n 加一就乘上一个常数」。
增长阶大 O判别法:n 变化时代价怎么变本讲的例子
常数 ConstantO(1)n 变大完全不影响运行时间字典查一个键、列表末尾 append
对数 LogarithmicO(log n)n 翻倍,运行时间只增加一个常数快速求幂、二分查找
线性 LinearO(n)n 加 1,运行时间只增加一个常数朴素求幂、迭代 fib、遍历列表
平方 QuadraticO(n2)n 加 1,运行时间增加「n 乘一个常数」嵌套循环、后面的 bar
指数 ExponentialO(2n)n 加 1,运行时间乘以一个常数朴素递归 fib、树递归

注意两处「技术上」的补充说明,幻灯片用灰字标出来了:

  • O(log n) 里的对数底数是几其实无所谓。因为换底只差一个常数因子(log2n = log10n / log102),而常数因子要丢掉。所以直接写 O(log n),不写底数。
  • O(2n) 里的底数不一定是 2,3n、φn 都算指数级。但底数在这里不能随便换——3n 和 2n 差的是 1.5n,那不是常数。写 O(2n) 是这一档的习惯代号。

为什么这五档差距这么大

Big-O Complexity Chart:各增长阶曲线对比图
横轴是输入规模,纵轴是操作次数。注意 O(n!)、O(2n)、O(n2) 三条线几乎是贴着纵轴垂直冲上去的——输入才刚开始增长,代价就已经上天了。而 O(log n) 和 O(1) 那两条紧贴横轴的线几乎是平的。这张图想传达的就是:增长阶的差别不是「快一点慢一点」,是质变。

把抽象曲线换成具体数字更有冲击力。假设一次操作要 1 纳秒(10-9 秒):

nO(log n)O(n)O(n2)O(2n)
103 ns10 ns100 ns1 μs
204 ns20 ns400 ns1 ms
506 ns50 ns2.5 μs13 天
1007 ns100 ns10 μs4 × 1013 年
1 000 00020 ns1 ms约 17 分钟不必算了

读这张表最该注意的不是指数那一列有多离谱,而是平方那一列:n = 100 时它还只要 10 微秒,看起来毫无问题;n = 一百万时就要 17 分钟了。真实工程里绝大多数性能事故是 O(n2) 造成的,因为它在小数据上完全看不出毛病,测试全过,上线以后数据一涨就炸。

注意:Big O 严格讲是「上界」

数学上 O(f(n)) 表示渐进上界(upper bound)——「不会比 f(n) 涨得更快」。按这个定义,说「迭代版 fib 是 O(2n)」并没有错,因为它确实「最多」跑指数时间,只不过这个上界松得毫无信息量,就像说「我身高不超过 3 米」。

但本课不这么用。 幻灯片明确说明:当我们问「这段代码的运行时间是多少」时,除非另有说明,要的是精确的最坏情况(exact worst case)运行时间——不是最好情况,也不是平均情况。所以考试上写「迭代 fib 是 O(2n)」会被判错,正确答案是 O(n)。

(如果你以后学 CS 61B / CS 170,会见到 Θ(Theta,紧确界)和 Ω(Omega,下界);61A 统一用 O 表示紧确的最坏情况。)

直觉:为什么可以丢掉常数和低阶项

假设算法 A 要 100n 步,算法 B 要 n²/100 步。n 小的时候 B 完胜(n = 100 时 A 要 10000 步,B 只要 100 步)。但 n = 10000 时,A 要 106 步,B 要 106 步——打平;再往后 B 就一路输下去,且越输越惨。

常数因子只决定「从哪个 n 开始输」,决定不了「最终谁输」。 增长阶关心的是 n 趋于很大时的结局,所以常数被丢掉。同理 n2 + n 里的那个 n,在 n = 10000 时只占万分之一,丢了不影响判断。

反过来提醒一句:这不代表常数在现实中不重要。 如果你的 n 永远不超过 20,一个常数因子小的 O(n2) 算法完全可能比 O(n log n) 的快。增长阶是规模大时的判据。

7. 时间复杂度分析:拿到一段代码该怎么下手

正式名称是时间复杂度分析(time complexity analysis),也叫运行时分析(runtime analysis)或渐进分析(asymptotic analysis)。

注意:没有万能公式

幻灯片用大写 IMPORTANT 强调了这一点:不存在一个公式,把代码丢进去就吐出复杂度。 你必须读懂这段代码在干什么。下面四步是起手式,不是算法。

分析四步走
1 逐行读,判断每一行本身要花多少时间(大多数是 O(1),但要小心那些藏着循环的调用,比如 in、len、切片、sorted)。
2 遇到循环:把「循环体一轮的代价」乘以「循环跑多少轮」。轮数要用 n 表示出来。
3 遇到递归:画出调用树,把每个节点上做的工作加起来。通常就是「节点数 × 每个节点的代价」。
4 丢掉常数因子和低阶项,只留最高阶的那一项。

哪些操作是 O(1)

第 1 步要能一眼判断单行代价,得先有一张常数时间操作的清单:

大 O名称典型例子
O(1)常数算术运算;给变量(重新)赋值;lst.append(x) 往列表末尾加;字典的增 / 查 / 改一个键值对
O(log n)对数每一轮循环或每一次递归把输入除以某个常数因子,例如快速求幂
O(n)线性把一个序列或字典从头到尾遍历一遍
O(n2)平方(更一般地 nk 叫多项式 polynomial)嵌套循环
O(2n)指数(更一般地 kn)朴素递归 fib;树递归
常见误区:一行不代表 O(1)

初学者最容易漏掉的是藏在一行里的循环。这些都是 O(n),不是 O(1):

x in lst          # 列表成员检查:最坏要把整个列表扫一遍
lst.index(x)      # 同上
lst.insert(0, x)  # 要把后面所有元素往后挪
lst[1:]           # 切片会复制出一个新列表
sum(lst)          # 遍历
max(lst)          # 遍历

对比一下本机实测(列表 / 集合都有 200 万个元素,查最后一个):

表达式耗时复杂度
1999999 in lst(lst 是 list)0.0062 秒O(n)
1999999 in st(st 是 set)0.0000017 秒O(1)

差 3600 倍,代码却只差一个字母。「一行 = 一步」是错觉;要问的是这一行背后做了多少工作。

完整走一遍:分析 bonk

Time Complexity 3 of 4 幻灯片:bonk 函数逐行标注代价并求和化简
每一行右边标着它的代价,右侧大括号标出「循环跑约 log₂n 轮」。下半部分是求和与化简的完整过程:先把循环体乘上轮数,再丢低阶项(那个 O(2)),最后丢常数因子(那个 5),得到 O(log n)。注意化简过程是写出来的,不是猜出来的。
def bonk(n):
    sum = 0
    while n >= 2:
        sum += n
        n = n / 2
    return sum
逐步推演:bonk 的运行时
1 sum = 0:一次赋值 → O(1),只执行一次。
2 while n >= 2:条件里一次比较 → O(1),每轮执行一次。
3 sum += n:一次加法 + 一次赋值 → O(2)。
4 n = n / 2:一次除法 + 一次赋值 → O(2)。
5 return sum:求值一个名字并返回 → O(1),只执行一次。
6 循环跑几轮? 每轮 n 折半,从 n 降到小于 2,需要约 log2n 轮。

把它们加起来:

$$\begin{aligned} T(n) &= O(1) + \log_2 n \cdot \bigl(O(1) + O(2) + O(2)\bigr) + O(1) \\ &= O(5 \cdot \log_2 n) + O(2) \\ &= O(5 \cdot \log_2 n) \quad \text{(drop lower order terms)} \\ &= O(\log n) \quad \text{(drop constant factors)} \end{aligned}$$

三处需要看仔细:

  • 循环外的 O(1) 只算一次,循环内的要乘轮数。 这是第 2 步的全部内容,也是最常出错的地方。
  • 丢低阶项:那个 O(2) 是循环外那两行(sum = 0 和 return sum)的和。相比 5·log₂n,常数项在 n 大时可以忽略。
  • 丢常数因子:5 没了,底数 2 也没了(换底只差常数),剩下 O(log n)。
直觉

其实熟练之后不用把 O(1)、O(2) 一个个数出来。你只需要问两件事:循环体里有没有「非常数代价」的东西?循环跑多少轮? bonk 的循环体全是常数操作,轮数是 log n,所以答案就是 O(log n)。上面那套逐行标注是给你在还不熟的时候用的脚手架。

注意:bonk 里的两个真实隐患

幻灯片没提,但这段代码有两个值得知道的问题(分析复杂度时不影响结论,写真代码时会咬人):

第一,n = n / 2 用的是真除法,结果是 float。所以 bonk(1024) 返回的是 2046.0 而不是 2046。

第二,对巨大的整数会直接抛异常:

>>> bonk(10**400)
Traceback (most recent call last):
  ...
OverflowError: integer division result too large for a float

因为 float 存不下这么大的数。改成 n = n // 2 两个问题都没了。

另外 sum = 0 这一行遮蔽(shadow)了 Python 内置的 sum 函数——在这个函数体里之后就没法用 sum(lst) 了。61A 的课件里这么写是为了简短,你自己写代码时换个名字(total)比较安全。

课外一句

幻灯片提到:LLM 用的 transformer 架构有一个瓶颈就是矩阵乘法是多项式级的算法。效率不只是考试题——它直接决定一个软件能不能用、能耗多少、能不能推广。想深入的话,形式化的复杂度分析在 CS 61B 和 CS 170,机器学习方向在 CS 189 / CS 182 / DATA 188。Python 各种内置数据结构的复杂度也有现成的表可查。

8. 实战一:两种查找

下面三节是课上的随堂练习,代码来自 17.py。先自己分析,再看解答。 任务都是一样的:说出运行时间关于 n 的表达式,n 是 nums 的长度。

def search_list1(nums, target_num):
    """Returns the index of TARGET_NUM in sorted list NUMS or -1 if not found.
    >>> search_list1([1, 2, 3, 4], 3)
    2
    >>> search_list1([14, 23, 37, 48, 59], 23)
    1
    >>> search_list1([14, 23, 37, 48, 59], 47)
    -1
    """
    found_index = -1
    for i, elem in enumerate(nums):
        if elem == target_num:
            found_index = i
            break
    return found_index

search_list1:线性查找

逐行数:

逐步推演:search_list1 的运行时
1 found_index = -1:赋值 → O(1),执行一次。
2 for i, elem in enumerate(nums):enumerate 是惰性的(每次只吐一对),取一个元素是 O(1)。循环最多跑 n 轮。
3 if elem == target_num:整数比较 → O(1),每轮一次。
4 循环体里 found_index = i 和 break 都是 O(1),而且最多执行一次。
5 return found_index → O(1)。
6 合计:O(1) + n · O(1) + O(1) = O(n)。

答案:O(n),线性时间。这和官方解答 17-sol.py 里标注的 # O(n) 一致。

那个 break 值得单独说一句。它确实能让运气好的时候提前结束——目标在第一个位置时只跑一轮。但本课要的是最坏情况:目标在最后一个位置,或者根本不在列表里(比如 doctest 里找 47 那个例子),循环必须跑满 n 轮。所以答案还是 O(n)。

常见误区

「有 break 所以平均只跑一半,是 O(n/2)」——两处都不对。

第一,O(n/2) 就是 O(n),那个 1/2 是常数因子,要丢掉。写 O(n/2) 在形式上不算错但没意义,写 O(n) 就够了。

第二,我们问的不是平均情况。除非题目明说,一律按最坏情况回答。

search_list2:二分查找

def search_list2(nums, target_num):
    """Returns the index of TARGET_NUM in sorted list NUMS or -1 if not found.
    >>> search_list2([1, 2, 3, 4], 3)
    2
    >>> search_list2([14, 23, 37, 48, 59], 23)
    1
    >>> search_list2([14, 23, 37, 48, 59], 47)
    -1
    """
    min_index = 1
    max_index = len(nums)
    while min_index <= max_index:
        middle_index = (min_index + max_index) // 2
        if target_num == nums[middle_index]:
            return middle_index
        elif target_num > nums[middle_index]:
            min_index = middle_index + 1
        else:
            max_index = middle_index - 1
    return -1

这是二分查找(binary search)。它成立的前提是 docstring 里那个词:NUMS 是有序的。因为有序,看一眼中间那个数就能断定目标在左半边还是右半边,一刀砍掉一半。

逐步推演:search_list2([14, 23, 37, 48, 59], 47)
初始: min_index = 1, max_index = 5

第 1 轮: 1 <= 5 成立
         middle_index = (1 + 5) // 2 = 3
         nums[3] 是 48
         47 == 48 ? 否
         47 >  48 ? 否 → 目标在左半边 → max_index = 3 - 1 = 2

第 2 轮: 1 <= 2 成立
         middle_index = (1 + 2) // 2 = 1
         nums[1] 是 23
         47 == 23 ? 否
         47 >  23 ? 是 → 目标在右半边 → min_index = 1 + 1 = 2

第 3 轮: 2 <= 2 成立
         middle_index = (2 + 2) // 2 = 2
         nums[2] 是 37
         47 == 37 ? 否
         47 >  37 ? 是 → min_index = 2 + 1 = 3

第 4 轮检查: 3 <= 2 不成立 → 退出循环 → return -1   ✓

搜索区间的宽度依次是 5 → 2 → 1 → 0,每轮至少砍掉一半。这就是复杂度的来源:

逐步推演:search_list2 的运行时
1 循环外:两次赋值 + 一次 len(nums)。len 对 list 是 O(1)(长度是存好的,不用数),所以是 O(1)。
2 循环体:一次加法、一次整除、一次索引 nums[middle_index](列表按下标取值是 O(1))、若干比较和赋值——全是常数操作,一轮 O(1)。
3 循环跑几轮? 区间宽度从 n 开始,每轮至少减半:n → n/2 → n/4 → … → 1 → 0。折到 1 需要 log2n 步。
4 合计:O(1) + log2n · O(1) = O(log n)。

答案:O(log n),和官方解答一致。

实测一下这个差距(在一个 200 万元素的有序列表里找最后一个元素):

算法复杂度本机耗时大致步数
线性查找O(n)0.036 秒2 000 000
二分查找O(log n)0.0000064 秒log₂(2×106) ≈ 21

快了约 5600 倍。而且这个倍数会随 n 继续拉大——n 翻倍时,线性查找的时间翻倍,二分查找只多做 1 步。

注意:这份随堂代码里有一个真实的越界 bug

课件里的 search_list2 用的是 min_index = 1 和 max_index = len(nums)。正确的初值应该是 0 和 len(nums) - 1,因为 Python 的下标是 0 到 n−1。

三个 doctest 恰好都通过了(我本机跑过 doctest.testmod,6 项全过),所以这个 bug 藏得很深。但换个输入立刻现形:

>>> search_list2([1, 2, 3, 4], 1)
-1

1 明明在下标 0,却报告找不到——因为区间从下标 1 起,0 号位置永远搜不到。更糟的是:

>>> search_list2([1, 2], 5)
Traceback (most recent call last):
  ...
IndexError: list index out of range

因为 max_index 初值是 len(nums),middle_index 可能取到 len(nums) 这个不存在的下标。

这不影响本节的复杂度结论(O(log n) 照旧),但它是一个很好的提醒:doctest 全过不等于代码正确。要修的话,把两行改成:

    min_index = 0
    max_index = len(nums) - 1

(本站坚持只写核实过的内容:上面两条报错都是我在本机真实跑出来的,不是推测。)

9. 实战二:素数判定与嵌套调用

is_prime1 与 is_prime2

这两个函数都判断 n 是不是素数。注意这里的 n 是那个数字本身,不是列表长度——「相对于什么规模」每次都要问清楚。

def is_prime1(n):
    for i in range(2, n):
        if n % i == 0:
            return False
    return True


def is_prime2(n):
    i = 2
    while i * i <= n:
        if n % i == 0:
            return False
        i += 1
    return True

is_prime1:循环变量从 2 走到 n−1,最多跑 n − 2 轮,每轮做一次取模和一次比较(都是 O(1))。答案:O(n)。

那个 return False 同样只在运气好时提前结束(比如 n 是偶数,第一轮就返回)。最坏情况是 n 真的是素数——循环一轮都省不掉。所以 O(n)。

is_prime2:循环条件是 i * i <= n,也就是 i <= √n。所以最多跑 √n 轮,每轮仍是 O(1)。答案:O(√n)。

逐步推演:为什么只查到 √n 就够了

这不是复杂度技巧,是数论事实。假设 n 有一个因数 a,那么 n = a · b,b 也是因数。

1 如果 a 和 b 都大于 √n,那么 a · b > √n · √n = n,与 a · b = n 矛盾。
2 所以每一对因数里,至少有一个不超过 √n。
3 于是:只要 2 到 √n 之间没有找到任何因数,n 就必定是素数——大于 √n 的那一半根本不用查。

举例 n = 36:因数对是 (2,18)、(3,12)、(4,9)、(6,6)。每一对的左边都 ≤ 6 = √36。

本机实测(n = 1000003,是素数,也就是两个函数的最坏情况):

函数复杂度循环轮数本机耗时
is_prime1(1000003)O(n)约 1060.0202 秒
is_prime2(1000003)O(√n)约 1030.000049 秒
注意:O(√n) 不在那张五档表里

幻灯片的增长阶表只列了常数 / 对数 / 线性 / 平方 / 指数五档,但 O(√n) 是一个完全合法的答案,别硬往五档里塞。它的位置在 O(log n) 和 O(n) 之间:

$$O(1) \;<\; O(\log n) \;<\; O(\sqrt{n}) \;<\; O(n) \;<\; O(n\log n) \;<\; O(n^2) \;<\; O(2^n)$$

(√n 也可以写成 n0.5,属于多项式 nk 那一族,k = 0.5。)

顺带记一个真实缺陷:is_prime1(1) 和 is_prime2(1) 都返回 True(本机实测),但 1 不是素数。因为 range(2, 1) 是空的、2*2 <= 1 一开始就为假,两个循环都一轮不跑,直接落到 return True。真要用得加一行 if n < 2: return False。本节只分析复杂度,但顺手指出来——复杂度对了不代表函数对了。

bar 与 biz:嵌套调用

def bar(n):
    i, sum = 1, 0
    while i <= n:
        sum += biz(n)
        i += 1
    return sum

def biz(n):
    i, sum = 1, 0
    while i <= n:
        sum += i**3
        i += 1
    return sum

这题的全部难点在一行:sum += biz(n)。它看起来只是一次加法赋值,但右边藏着一整个循环。

逐步推演:bar 的运行时
1 先分析被调用者 biz。 它的循环从 i = 1 走到 i = n,跑 n 轮;循环体是 sum += i**3(一次乘方 + 一次加法 + 一次赋值)和 i += 1,都是 O(1)。所以 biz(n) 是 O(n)。
2 再看 bar 的循环跑几轮。 同样是 i 从 1 到 n,n 轮。
3 每轮的代价是多少? 循环体是 sum += biz(n)——这一轮的代价 = 调用 biz(n) 的代价 = O(n),加上 i += 1 的 O(1)。
4 相乘:n 轮 × 每轮 O(n) = O(n2)。

写成式子:

$$T_{\text{bar}}(n) = O(1) + n \cdot \bigl(T_{\text{biz}}(n) + O(1)\bigr) = O(1) + n \cdot O(n) = O(n^2)$$

答案:O(n2),和官方解答标注一致。

关键在于 biz 拿到的实参是 n,不是 i——所以内层循环每一轮都跑满 n 次,跟外层进行到第几轮无关。总操作数正好是 n × n。

本机实测验证(平方级的标志:n 翻倍,时间约变 4 倍):

nbar(n) 耗时相比上一行
5000.046 秒—
10000.163 秒×3.5
20000.629 秒×3.9
常见误区

误区一:只看 bar 自己的循环,回答 O(n)。 这是这道题最主要的陷阱。函数调用的代价是被调用函数的完整代价,不是 O(1)。 分析嵌套调用必须从最里层开始往外算。

误区二:看到 i**3 就慌,以为是 O(n3)。 i**3 是「i 的立方」,指数是固定的 3,不随 n 变。它就是一次乘方运算,算 O(1)。真正让复杂度变高的是循环轮数和递归节点数,不是算式里出现了 3 次方。

误区三:以为 biz(i) 会把复杂度降到 O(n)。 假如把那行改成 sum += biz(i),总操作数变成 1 + 2 + … + n = n(n+1)/2,仍然是 O(n2)——只是常数因子从 1 变成 1/2 而已,而常数因子要丢掉。

误区四:忽略了 sum 被遮蔽。 i, sum = 1, 0 让局部名字 sum 盖住了内置函数 sum。函数体内之后再想用 sum([1,2,3]) 就会得到 TypeError: 'int' object is not callable。这段课件代码本身没这个需求所以能跑,但这是一个真实存在的坏习惯。

10. 空间复杂度

时间不是唯一的代价。空间复杂度(space complexity)问的是同一个问题的另一面:这个算法运行时要占多少内存? 分析方法完全一样——看内存占用随 n 的增长形式,丢常数和低阶项。

对本课来说,最主要的内存开销就是帧。每一个还没返回的函数调用都占着一帧,帧里有形参和局部变量。所以:

核心结论

递归函数的空间复杂度 = 最大递归深度 × 每帧的大小。 注意是深度,不是节点总数——因为兄弟节点不会同时活着:fib(n-1) 那整棵子树算完、帧全部销毁之后,fib(n-2) 才开始。

这正好解释了第 3 节末尾那个对照:

版本时间空间空间为什么是这样
朴素递归 fibO(2n)O(n)调用树有 2n 个节点,但最深只有 n 层,任一时刻活着的帧不超过 n 个
记忆化 fibO(n)O(n)帧栈最深 n 层,外加一个存了 n 条记录的 cache 字典。两个 O(n) 相加还是 O(n)
迭代 fibO(n)O(1)只有 curr、nxt、k 三个名字,n 多大都是三个,一层帧都不额外开
朴素 expO(n)O(n)帧栈叠了 n + 1 层(见第 2 节的环境图)
快速 expO(log n)O(log n)帧栈只叠 log n 层

幻灯片点明的正是这一条:递归和记忆化版本的 fib 空间复杂度都比迭代版高,因为递归要开一堆帧,每帧还带着自己的局部变量。

时间与空间常常互换

记忆化是最干净的例子:它花掉 O(n) 的额外内存,换来时间从 O(2n) 降到 O(n)。这笔买卖显然划算,但不是所有场合都划算——如果内存本来就紧张(嵌入式设备、超大数据集),你可能宁愿多算一会儿。「时间-空间权衡(time-space tradeoff)」是工程里天天要做的决定。

注意:空间不够会直接报错,而且报得很干脆

时间不够只是慢,空间不够是崩。Python 对帧栈深度有硬限制(sys.getrecursionlimit() 默认返回 1000),超了就抛异常:

>>> fib(1000)          # 记忆化版本,时间上明明是 O(n)
Traceback (most recent call last):
  ...
RecursionError: maximum recursion depth exceeded

而迭代版 fib(100000) 本机只要 0.05 秒,一点问题没有——因为它不叠帧。

这就是「空间复杂度」在实践中最直接的表现:O(n) 空间的递归算法有一个它自己的天花板,O(1) 空间的迭代算法没有。

本课的定位

幻灯片明说了:空间复杂度在 61A 里不是重点。你需要理解「递归比迭代费内存、原因是帧」这个概念,以及「时间和空间常有取舍」这个直觉,但不会被要求做精细的空间分析。

另外幻灯片提到一个现实:技术面试里通常不仅要你在一小时内想出算法,还要你分析它的时间和空间复杂度。所以这块内容值得知道,即使不考。

本讲小结

概念要点典型陷阱
为什么不用秒数秒数受硬件、后台负载影响,且无法外推;增长阶才是算法自身的性质拿一次计时结果当算法的性能结论
增长阶五档O(1) < O(log n) < O(n) < O(n2) < O(2n);O(√n) 夹在 log 和 n 之间硬把 O(√n) 塞进五档里
判别口诀log:n 翻倍才多花常数;线性:n 加 1 多花常数;指数:n 加 1 乘上常数只背例子不理解规律,换个代码就不会
分析四步逐行数 → 循环体乘轮数 → 递归画调用树数节点 → 丢常数和低阶项把循环外的一次性开销也乘上了轮数
递归的时间看调用树的节点总数:每次分裂几支、每次规模缩小多少用递归深度当运行时间,把 O(2n) 的 fib 说成 O(n)
递归的空间看最大深度(同时活着的帧数),兄弟子树不同时占内存把节点总数当空间,把 fib 的空间说成 O(2n)
exp 两版减 1 → O(n);折半(偶数取平方)→ O(log n)把 temp 展开成两次递归调用,优化白做
fib 三版朴素 O(2n)/O(n);记忆化 O(n)/O(n);迭代 O(n)/O(1)把 curr, nxt = nxt, curr + nxt 拆成两行,算出 2 的幂
记忆化cache 必须放在外层函数的帧里;靠「名字运行时才查」让递归调用也走缓存cache = {} 写进内层函数,缓存永远为空但不报错
单行不等于 O(1)x in lst、lst.insert(0,x)、切片、sum、max 都是 O(n)把 sum += biz(n) 当成一次加法,答成 O(n)
Big O 的用法数学上是上界;本课要的是精确的最坏情况因为有 break 就答平均情况,或答 O(n/2)

一张速查:看到什么,答什么

代码长什么样多半是
没有循环也没有递归,全是算术和赋值O(1)
每轮 n = n // 2 或 i = i * 2O(log n)
循环条件是 i * i <= nO(√n)
一重循环走遍 n 个元素,或每次 n - 1 的单支递归O(n)
双重循环;或循环里调用了一个 O(n) 的函数O(n2)
一次调用里出现两个(或多个)递归调用,规模只减常数O(2n)

这张表是起手直觉,不是判决书。真遇到代码,还是得按第 7 节那四步老实数一遍——尤其要盯住「循环里调用的那个函数自己要花多少时间」。

动手练习

下面每一题都建议先自己算出答案再展开。所有代码我都在本机跑过,答案里给的数字都是实测值。

练习 1:三段代码,各是什么复杂度

def a(n):
    total = 0
    for i in range(n):
        for j in range(i):
            total += 1
    return total

def b(n):
    total = 0
    i = 1
    while i < n:
        for j in range(n):
            total += 1
        i = i * 2
    return total

def c(n):
    total = 0
    i = n
    while i > 1:
        total += 1
        i = i // 2
    return total
看答案

a(n) 是 O(n2)。 陷阱在于内层循环的轮数是 i 而不是 n,很多人会因此觉得「不是完整的 n×n,所以不算平方」。数一数总轮数:i = 0 时 0 轮,i = 1 时 1 轮……i = n−1 时 n−1 轮,合计 0 + 1 + … + (n−1) = n(n−1)/2。n(n−1)/2 = n2/2 − n/2,丢掉常数因子 1/2 和低阶项 n/2,剩下 O(n2)。本机实测 [a(n) for n in range(6)] 得到 [0, 0, 1, 3, 6, 10],正是三角形数。

b(n) 是 O(n log n)。 外层 i = i * 2,i 从 1 翻倍到 ≥ n,跑 log2n 轮;内层每次都完整跑 n 轮。相乘得 n·log n。实测 b(16) 返回 64 = 16 × 4,而 4 = log₂16。✓ 这一档没在幻灯片的五档表里,但它是排序算法的典型复杂度,值得认得。

c(n) 是 O(log n)。 每轮把 i 折半,n → n/2 → n/4 → … → 1,共 log₂n 轮,循环体全是 O(1)。这就是最标准的对数模式。

要点:判断复杂度不能只数「有几层循环」。 b 和 a 都是两层,一个 O(n log n) 一个 O(n2);c 只有一层却是 O(log n)。决定性的是每一层各跑多少轮。

练习 2:这个「去重」函数为什么慢

def unique(lst):
    """返回 lst 中不重复的元素,保持原顺序。
    >>> unique([1, 2, 1, 3, 2])
    [1, 2, 3]
    """
    result = []
    for x in lst:
        if x not in result:
            result.append(x)
    return result

它的复杂度是多少(n 是 lst 的长度)?怎么改进?

看答案

O(n2)。 关键是 x not in result 这一行——它不是 O(1)。列表的成员检查要从头扫到尾,代价是 O(len(result)),而 result 最长可以到 n。外层循环 n 轮 × 每轮最多 O(n) = O(n2)。

最坏情况是 lst 里全都不重复:result 一路涨到 n,而且每次检查都要扫完整个 result 才发现「不在里面」。

改进:再拿一个 set 记录见过的元素,因为集合的成员检查是 O(1):

def unique_fast(lst):
    result = []
    seen = set()
    for x in lst:
        if x not in seen:
            seen.add(x)
            result.append(x)
    return result

现在外层 n 轮 × 每轮 O(1) = O(n)。代价是多用了一个 O(n) 大小的集合——又一次时间换空间。

这个差距有多大?本机实测:在 200 万元素的容器里做一次成员检查,list 要 0.0062 秒,set 只要 0.0000017 秒。

练习 3:数调用次数

def g(n):
    if n <= 0:
        return 1
    return g(n - 1) + g(n - 1)

g(4) 一共调用了多少次 g?g 的时间复杂度是多少?空间复杂度呢?如果把函数体改成 x = g(n - 1); return x + x,答案会变吗?

看答案

调用次数:31 次。 每个非 base case 的调用生两个孩子,深度 4,所以是一棵满二叉树:1 + 2 + 4 + 8 + 16 = 31,也就是 2n+1 − 1。本机加计数器实测:n = 0,1,2,3,4 分别是 1, 3, 7, 15, 31。✓

时间:O(2n)。 节点数按 2n 涨,每个节点做常数工作。

空间:O(n)。 最大深度是 n,同时活着的帧不超过 n + 1 个。左子树全部算完并销毁之后,右子树才开始——兄弟不同时占内存。

改成 x = g(n - 1); return x + x 之后:变成 O(n)。 因为现在每次只调用一次,调用结构从二叉树塌成一根长度为 n 的链,节点数从 2n 降到 n + 1。返回值完全一样(都是 2n),代价却是天壤之别。

这正是第 2 节 exp 那个 temp 的意义,从反面再演示一次:把同一个子问题的结果存下来复用,而不是重算,往往就是指数和线性的分界线。

练习 4:记忆化的执行顺序

def memo(f):
    cache = {}
    def memoized(n):
        if n not in cache:
            cache[n] = f(n)
        return cache[n]
    return memoized

@memo
def fib(n):
    print(n)
    if n == 0 or n == 1:
        return n
    return fib(n - 1) + fib(n - 2)

调用 fib(4),屏幕上会打印出哪些数字,按什么顺序?cache 最终是什么?

看答案

打印 4, 3, 2, 1, 0,各一次,一共 5 行。本机实测确认。

顺着走一遍:

fib(4)  cache 空 → 执行函数体 → 打印 4 → 需要 fib(3) + fib(2)
  fib(3)  不在 cache → 打印 3 → 需要 fib(2) + fib(1)
    fib(2)  不在 cache → 打印 2 → 需要 fib(1) + fib(0)
      fib(1)  不在 cache → 打印 1 → 返回 1,cache[1] = 1
      fib(0)  不在 cache → 打印 0 → 返回 0,cache[0] = 0
    fib(2) = 1,cache[2] = 1
    fib(1)  在 cache 里 → 直接返回 1,函数体不执行,不打印
  fib(3) = 2,cache[3] = 2
  fib(2)  在 cache 里 → 直接返回 1,不打印
fib(4) = 3,cache[4] = 3

最终 cache 是 {1: 1, 0: 0, 2: 1, 3: 2, 4: 3}(插入顺序如此,因为 fib(1) 先于 fib(0) 完成)。

这道题的意义:打印次数正好等于「原始函数体被真正执行的次数」,也就是 n + 1 次。没有缓存的话,fib(4) 会打印 9 次(调用树有 9 个节点)。

顺带回答一个常被问到的问题:为什么函数体里那两个 fib 会走到缓存版本? 因为 @memo 把全局帧里的名字 fib 重新绑定到了 memoized,而函数体里的 fib 是运行时才去环境里查的名字。查的时候查到的就是新绑定。如果名字在定义时就被写死,这个技巧根本不成立。

练习 5:判断题

下面每句话对不对?说出理由。

  1. 一个 O(n2) 的算法在任何输入下都比 O(n) 的算法慢。
  2. 说「迭代版 fib 是 O(2n)」是错的。
  3. 递归 fib 的空间复杂度是 O(2n),因为它要做 2n 次调用。
  4. lst.append(x) 和 lst.insert(0, x) 都是往列表里加一个元素,复杂度相同。
  5. 快速求幂里 temp = exp(b, n // 2) 换成 return exp(b, n // 2) * exp(b, n // 2),结果和复杂度都不变。
看答案

1. 错。 增长阶描述的是 n 足够大时的行为。n 小的时候,常数因子完全可能让 O(n2) 的算法更快——比如 100n 步 vs n2/100 步,在 n < 10000 时后者一路领先。增长阶保证的是「最终会赢」,不是「一直赢」。

2. 分场合,但在这门课里算错。 严格数学定义下 O 是上界,迭代 fib 确实「不超过指数时间」,所以这句话在数学上成立。但幻灯片明确规定:本课问运行时间时要的是精确的最坏情况,考试上必须写 O(n)。

3. 错。 混淆了节点总数和最大深度。时间由节点总数决定(O(2n)),空间由同时活着的帧数决定,也就是最大深度(O(n))。fib(n-1) 那整棵子树的帧在 fib(n-2) 开始之前就已经全部销毁了。

4. 错。 append 是 O(1)(直接放到末尾),insert(0, x) 是 O(n)(后面每个元素都要往后挪一格)。本机实测:做 20 万次 append 用 0.0065 秒,做 20 万次 insert(0, x) 用 3.48 秒,差 500 多倍。这也正是上一讲链表 demo 想说明的事。

5. 结果不变,复杂度变了。 两个 exp(b, n // 2) 返回的值当然相同(纯函数),所以答案对。但它把同一个子问题算了两遍,调用结构从一根链变回一棵二叉树,复杂度从 O(log n) 退化成 O(n)。temp 那一行不是为了少打几个字,是为了「算一次、用两次」。