HW 3:可变性、树、迭代器与生成器ok 6 题通过1 题需课程私有口令
六道真题:一道列表就地修改的陷阱题、三道树递归、一道无限序列归并、一道生成器版树搜索。这份作业是本课程从「值」跨到「对象」的分水岭。
0. 这份作业在练什么
HW 3 一共七题,官方把它们分成三组:Mutability(可变性)一题、Trees(树)三题、Iterators / Generators(迭代器与生成器)两题,最后加一道课程问卷。这三组看似互不相干,其实是同一条主线上的三个台阶。
前两周你写的每个函数都遵守同一个契约:拿到参数,算出一个新值,返回它。函数外面的世界一点没变。这种函数叫「纯函数(pure function)」,它最大的好处是你只要盯着 return 那一行就能知道它干了什么。
从 Lecture 8 起,这个契约被打破了。列表(list)是可变对象(mutable object):lst.append(3) 不返回任何有用的东西,它改变了列表本身。于是「谁指向这个列表」这件事第一次变得要命——如果两个名字指向同一个列表,改一个另一个也变了。Q1 inventory_pickup 就是专门为这件事设计的陷阱题:题面故意让 items 和 inventory 是同一个列表对象,逼你想清楚「一边遍历一边删」会发生什么。
第二组三道树题,练的是另一件事:数据抽象(data abstraction)加树递归。树在 61A 里不是 Python 内置类型,而是用列表拼出来的一套约定:tree(label, branches) 造树,label(t) 取根标签,branches(t) 取分支列表。课程反复强调:只准用这三个函数加 is_leaf,不许写 t[0]、t == x、len(t)。这道纪律不是形式主义——它保证了你写的代码在树的底层实现换掉之后仍然能跑,而这正是「抽象屏障(abstraction barrier)」的全部意义。
第三组两道生成器题,练的是把「一个函数」变成「一个可以暂停的过程」。yield 让函数在中途交出一个值、冻结全部局部状态、等下一次 next 再从原地醒来。有了它,你才能写出处理无限序列的程序——Q5 merge 的 doctest 里真的传了两个死循环生成器进去。
- 可变性:
list.remove/append/pop就地改动;别名(aliasing)意味着一次修改、多处可见。遍历一个正在被修改的列表是经典 bug 源。 - 树数据抽象:只通过
tree/label/branches/is_leaf访问树。树递归的骨架永远是「处理根 +for b in branches(t)递归处理每个分支」。 - 树构造 vs 树遍历:
berry_finder和size_of_tree只读;make_path要造出一棵新树,思路完全不同。 - 迭代器协议:
iter(x)拿迭代器,next(it)取下一个,耗尽时抛StopIteration。双参数next(it, default)用默认值代替异常,是本次的关键工具。 - 生成器:函数体里有
yield就是生成器函数;调用它不执行函数体,只返回一个生成器对象。生成器可以递归调用自己,用for ... in 递归调用把子结果一个个转发上来。
做之前应该会什么
| 前置知识 | 在哪学的 | 不会的话会卡在哪 |
|---|---|---|
列表索引、切片 p[1:] | Lecture 7 序列 | Q4 传不下去剩余路径 |
对象标识 is 与相等 == 的区别 | Lecture 8 | Q1 的 check_mutation is inv3_test 永远过不了 |
| 递归的三步:base case、递归调用、把结果拼起来 | Lecture 5、6 | Q2–Q4、Q6 全部 |
| 树抽象四个函数的签名 | Lecture 9 | 会不自觉写 t[0],虽然能跑但违反抽象屏障 |
yield 的执行时机 | Lecture 10 | Q5、Q6 |
在 hw/hw03/ 目录下运行 python3 ok --local --score,得到的分数是:
Point breakdown
inventory_pickup: 1.0/1
berry_finder: 1.0/1
size_of_tree: 1.0/1
make_path: 1.0/1
merge: 1.0/1
yield_paths: 1.0/1
midsem_survey: 0.0/1
Score:
Total: 6.0
六道编程题全部通过。唯独 midsem_survey 这一题拿不到分,而且永远拿不到——它要求填入 Berkeley 学生填完期中问卷后才会显示的一串口令(passphrase),ok 用 SHA-224 哈希比对:
def midsem_survey(p):
"""
You do not need to understand this code.
>>> midsem_survey(passphrase)
'2bf925d47c03503d3ebe5a6fc12d479b8d12f14c0494b43deba963a0'
"""
import hashlib
return hashlib.sha224(p.encode('utf-8')).hexdigest()
这串目标哈希是公开的,但原文只有选课学生能拿到。本仓库保持占位值 passphrase = 'REPLACE_THIS_WITH_PASSPHRASE' 不变,因此这题报错、得 0 分。这是课程访问权限问题,不是代码问题,本页不会去猜、也不会去爆破这个口令。下面第 7 节会再说一次。
HW 3 的 tests/ 目录里没有 What-Would-Python-Display 形式的概念题(只有一个空的 __init__.py),全部七题都由 hw03.py 里的 doctest 直接评分。所以本页不设 WWPD 章节;但每道题的「验证」小节里,我都会像做 WWPD 一样把求值过程逐步写出来。
1. Q1 inventory_pickup:一边遍历一边改的陷阱
题目要什么
函数签名是 inventory_pickup(inventory, items, capacity)。想象一个游戏背包:inventory 是当前背包里的东西(一个列表,从左到右按「最早捡到的在最前面」排序),items 是这一轮要捡起来的东西,capacity 是背包容量。
规则拆成两段,题面用 Timing 一节特意强调了两段的先后顺序:
items 里的每一个 item:先把 inventory 中所有已存在的该物品副本删掉(可能有 0 个、1 个、也可能 2 个以上),再在末尾 append 一份。效果是「重复物品只留一份,并且刷新成最新捡到的」。len(inventory) > capacity,就从最前面(最老的)开始删,一直删到长度正好等于 capacity。可能要删不止一个。另外两条硬性约束,比规则本身更容易踩:
- 必须就地修改(in place):不许
inventory = [...]重新绑定,不许返回一个新列表。函数返回的必须是那个原来的列表对象本身。doctest 里check_mutation is inv3_test要求为True,用的是is(对象标识)而不是==,专门卡这一点。 items可能就是inventory本身。题面的 Hint 直接点破了:items may be the SAME list object as inventory。doctest 第二、三例正是inventory_pickup(inv2, inv2, 7)和inventory_pickup(inv3, inv3, 3)。
把六个 doctest 摆在一起看,每个都在测一件不同的事:
| 用例 | 输入 | 期望输出 | 在测什么 |
|---|---|---|---|
| 1 | [1,2,1,3,1], [1,4], 10 | [2,3,1,4] | 删掉多个重复的 1,再把 1 追加到末尾 |
| 2 | inv2=[11,12,13], inv2, 7 | [11,12,13] | 自己捡自己,且无重复、不超容量 |
| 3 | inv3=[1,2,1,3,1], inv3, 3 | [2,3,1],且 is 为 True | 自己捡自己 + 有重复 + 超容量 + 就地性 |
| 4 | [1,2,3,4], [5..10], 10 | [1,...,10] | 正好等于容量,不该删 |
| 5 | [1,2,3,4], [5,6,7,8], 6 | [3,4,5,6,7,8] | 超出 2 个,要删两次头部 |
| 6 | ['hello','world'], ['hi','hello'], 4 | ['world','hi','hello'] | 元素是字符串;'hello' 被移到末尾 |
怎么想到的
先写一个「显然正确」的版本,看它在哪儿崩——这是本题最好的学法。
第一版(天真版):
for item in items:
if item in inventory:
inventory.remove(item)
inventory.append(item)
拿用例 1 试:inventory = [1,2,1,3,1],items = [1,4]。处理 item = 1:1 in inventory 为真,remove(1) 只删掉第一个 1,得到 [2,1,3,1],然后 append 1 得 [2,1,3,1,1]。处理 4 得 [2,1,3,1,1,4]。期望是 [2,3,1,4]。崩了。
原因很具体:list.remove(x) 的文档说得清清楚楚——Remove the first occurrence。而题面说的是「Remove all existing copies of the item (0, 1, 2+)」。所以 if 得换成 while:
while item in inventory:
inventory.remove(item)
这个 while 循环读起来像绕口令,但它是对的:每转一圈删掉一个,直到 item in inventory 为假为止。(也可以倒着遍历索引删,但代码更长更容易错。)
很多人想到的是这个写法:
for i in range(len(inventory)):
if inventory[i] == item:
inventory.pop(i)
range(len(inventory)) 在循环开始时就固定了上界,而列表在缩短。跑 [1,1,1] 会得到 IndexError: pop index out of range;就算不报错,也会漏删——删掉 i 位置的元素后,原来 i+1 的元素挪到了 i,而下一轮却去看 i+1 了。正向遍历时删元素必然跳过元素,这是所有语言的通病。
第二版(改成 while 之后),再拿用例 3 试,这里 items 就是 inventory 自己:
for item in items: # items 和 inventory 是同一个列表!
while item in inventory:
inventory.remove(item)
inventory.append(item)
Python 的 for x in lst 底层是「取一个索引 0、1、2…,每次问列表要那个位置的元素,越界就停」。它不会预先复制列表。所以我们在循环体里疯狂地删元素、加元素,循环自己的游标却仍在按 0、1、2… 往前爬。结果是:读到的元素跟原始的 [1,2,1,3,1] 完全对不上,什么时候停也说不准。
把「要捡哪些东西」这份清单,在开始改动之前先拍一张快照。
pickup_order = list(items)
list(items) 创建了一个新列表对象,内容和 items 当时一模一样。之后无论 inventory 怎么被改(哪怕它就是 items),pickup_order 都纹丝不动。于是「读清单」和「改背包」这两件事被彻底解耦。
注意区别:pickup_order = items(不加 list)只是给同一个对象起了第二个名字,什么问题都解决不了。list(items) 才是复制。
最后是容量修剪。天真的写法会把它塞进 for 循环里,每 append 一个就检查一次。题面的 Timing 一节专门警告了不能这样:必须全部处理完再统一修剪。为什么这个区别有影响?看用例 3:inv3 = [1,2,1,3,1],capacity = 3。如果边处理边修剪,处理第一个 item 时长度就已经超了 3,会提前删掉本该保留的元素,后面全乱。统一到最后修剪,逻辑才和题面一致。
修剪本身用 while 而不是 if,因为可能超出不止一个(用例 5 超出 2 个)。删「最老的」= 删索引 0 的 = inventory.pop(0)。
代码
def inventory_pickup(inventory: list, items: list, capacity: int) -> list:
# items may be the same list object as inventory, and we are about to
# mutate inventory. Snapshot the pickup order first so that the loop
# is not affected by the mutations we make below.
pickup_order = list(items)
for item in pickup_order:
# Remove every existing copy of item, then add one fresh copy
# at the end (so it counts as the most recently picked up).
while item in inventory:
inventory.remove(item)
inventory.append(item)
# Only after all items are processed do we enforce the capacity,
# dropping the oldest entries (at the front) first.
while len(inventory) > capacity:
inventory.pop(0)
return inventory
逐行说为什么:
| 行 | 为什么是这样而不是别样 |
|---|---|
pickup_order = list(items) | 唯一能同时应付「items 是独立列表」和「items 就是 inventory」两种情形的写法。写 items[:]、items.copy() 等价。不能写 = items。 |
for item in pickup_order: | 遍历的是快照,循环期间它不会被任何操作改动,所以次数和内容都是确定的。 |
while item in inventory: | 题面要求删掉所有副本;remove 一次只删一个,所以要循环。in 判断为假时自然退出,天然处理了「0 个副本」的情况——一次都不进循环。 |
inventory.remove(item) | 就地删除,返回 None。千万不要写 inventory = inventory.remove(item),那会把 inventory 这个名字绑到 None 上,下一行立刻 AttributeError: 'NoneType' object has no attribute 'append'。 |
inventory.append(item) | 放到末尾 = 标记为「最新捡到」。这一步在删除之后,保证同一物品在列表里只剩一份且位置最靠后。 |
while len(inventory) > capacity: | 循环在 for 外面,对应题面的 Timing 要求。用 while 不用 if,因为可能要删多个。 |
inventory.pop(0) | 删最前面的 = 最老的。pop() 不带参数删的是末尾,方向反了。 |
return inventory | 返回的是那个从头到尾没被重新绑定过的原始列表对象,所以 check_mutation is inv3_test 为 True。 |
验证
手动追踪用例 3,它同时踩中了三个陷阱(自引用、多重复、超容量):
>>> inv3 = [1, 2, 1, 3, 1]
>>> check_mutation = inv3
>>> inv3_test = inventory_pickup(inv3, inv3, 3)
先看谁指向谁。调用发生时,有四个名字指向同一个列表对象(记它为 L):
全局帧 Global
inv3 ──────────┐
check_mutation ─┼──→ L = [1, 2, 1, 3, 1]
│
inventory_pickup 帧 │
inventory ──────┤
items ──────────┘
capacity = 3
第 0 步:pickup_order = list(items) 造出一个全新列表 S = [1, 2, 1, 3, 1]。此后 S 再也不变。环境图里多了一个箭头,指向一个和 L 不同的对象:
pickup_order ──→ S = [1, 2, 1, 3, 1] (独立对象,与 L 无关)
循环第 1 轮,item = S[0] = 1:
1 in L?是 →L.remove(1)→L = [2, 1, 3, 1]1 in L?是 →L.remove(1)→L = [2, 3, 1]1 in L?是 →L.remove(1)→L = [2, 3]1 in L?否 → 退出 whileL.append(1)→L = [2, 3, 1]
循环第 2 轮,item = S[1] = 2:
2 in L?是 → remove →L = [3, 1];再问,否,退出- append(2) →
L = [3, 1, 2]
循环第 3 轮,item = S[2] = 1:
- remove(1) →
L = [3, 2];再问,否 - append(1) →
L = [3, 2, 1]
循环第 4 轮,item = S[3] = 3:
- remove(3) →
L = [2, 1] - append(3) →
L = [2, 1, 3]
循环第 5 轮,item = S[4] = 1:
- remove(1) →
L = [2, 3] - append(1) →
L = [2, 3, 1]
S 走完,退出 for。
修剪阶段:len(L) = 3,capacity = 3,3 > 3 为假,一次都不删。
返回 L,即 [2, 3, 1]。与 doctest 期望一致。
并且 check_mutation、inv3、inv3_test 三个名字自始至终指向同一个 L,所以 check_mutation is inv3_test 得 True。
注意这里有个容易被忽略的精妙之处:如果没有第 0 步的快照,for item in items 遍历的就是 L 本身。第 1 轮读 L[0] = 1,循环体把 L 改成了 [2, 3, 1];第 2 轮 for 去读 L[1],那是 3,而不是原始清单里的 2。整条捡拾顺序被篡改,结果必错。
再验一下用例 5,它检验修剪要删多次:inventory = [1,2,3,4],items = [5,6,7,8],capacity = 6。四个 item 都不在背包里,所以 while 一次都不进,直接 append 四次,得 [1,2,3,4,5,6,7,8],长度 8。修剪:8 > 6 → pop(0) → [2,...,8] 长 7;7 > 6 → pop(0) → [3,4,5,6,7,8] 长 6;6 > 6 假,停。输出 [3, 4, 5, 6, 7, 8],正确。若这里写的是 if 而不是 while,只会删一次,得到长度 7 的列表,直接挂。
最后验用例 6,元素是字符串:['hello','world'] 捡 ['hi','hello']。'hi' 不在里面,直接 append → ['hello','world','hi'];'hello' 在里面,删掉 → ['world','hi'],再 append → ['world','hi','hello']。长度 3 不超 4。输出 ['world', 'hi', 'hello']。这个例子说明「删了再加」不是白折腾——它把老物品挪到了队尾,改变了后续被修剪的优先级。
inventory = list(...)或inventory = inventory[:capacity]:把名字重新绑到新对象上。函数返回值看起来对,但check_mutation is inv3_test会是False,外面的inv3也没变。ok 报Expected True but got False。if item in inventory: inventory.remove(item):漏删重复项,用例 1 就挂。- 把修剪写进 for 循环内:用例 3 出错。
- 用
inventory.pop()修剪:删掉了最新的而不是最老的,用例 5 得到[1,2,3,4,5,6]。 - 忘了
return inventory:函数返回None,doctest 全红。
2. Q2 berry_finder:树里有没有浆果
题目要什么
校园里的松鼠想知道哪棵树上有浆果。写 berry_finder(t):如果树 t 里存在任意一个节点的标签是字符串 'berry',返回 True,否则返回 False。
四个 doctest:
>>> scrat = tree('berry')
>>> berry_finder(scrat)
True
>>> sproul = tree('roots', [tree('branch1', [tree('leaf'), tree('berry')]), tree('branch2')])
>>> berry_finder(sproul)
True
>>> numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])])
>>> berry_finder(numbers)
False
>>> t = tree(1, [tree('berry',[tree('not berry')])])
>>> berry_finder(t)
True
要点在于这四个例子覆盖的边界:
scrat是一棵只有根、没有分支的树(叶子),而根就是'berry'。所以「根本身是不是 berry」必须单独检查,不能只往分支里找。sproul的 berry 藏在第二层的第二个分支里。说明要深入,而且要横向找完所有分支。numbers全是数字,一个 berry 都没有 → 必须能返回False,而不是掉进None。- 第四个例子里 berry 节点底下还有东西(
'not berry'),说明命中不一定发生在叶子上;同时'not berry' != 'berry',比较必须是整体相等,不是子串包含。
题面明确写着:That's how you work with trees. No t == x or t[0] or x in t or list(t), etc.
虽然本课的树底层就是列表,写 if 'berry' in t 甚至能碰巧过掉某些用例,但那是穿透抽象屏障。而且它是错的:'berry' in t 只检查第一层(根标签和各个分支整体),对 sproul 会返回 False。抽象屏障不只是规矩,它同时在提醒你「树是有层次的,不是一维列表」。
怎么想到的
树递归题的第一个动作永远一样:问自己「如果我已经有一个能解决更小的树的函数,我怎么用它解决当前这棵树?」
对本题:假设我已经会判断「某棵子树里有没有 berry」。那么整棵树 t 里有 berry,当且仅当下面两件事至少成立一件:
t自己的根标签就是'berry';t的某一个分支里有 berry。
这句话直接就是代码。第 1 条写成 label(t) == 'berry';第 2 条写成「对每个分支 b 递归问一遍 berry_finder(b),只要有一个说 True 就 True」。
初学者写完上面的代码常会慌:「我的 base case 呢?」 这题看不到显式的 if is_leaf(t): return ...。
它是隐含的。当 t 是叶子时,branches(t) 返回空列表 [],for b in [] 一次都不执行,于是控制流直接落到函数最后一行 return False。空的 for 循环就是这道题的 base case。
这是树递归里非常常见的模式:只要你的递归是「遍历 branches(t)」,叶子情形往往自动被 for 循环的「零次迭代」处理掉,不需要另写一个分支。硬要写 if is_leaf(t): return label(t) == 'berry' 也对,只是重复了。
还有一处需要想清楚:为什么不能写成
for b in branches(t):
return berry_finder(b) # 错!
这个写法在第一个分支上就 return 了,后面的分支永远看不到。对 sproul 来说,第一个分支 branch1 里确实有 berry,碰巧对;但如果 berry 在 branch2 里,就会漏掉。
正确的模式是「找到才提前返回,找不到继续」:
for b in branches(t):
if berry_finder(b):
return True
# 所有分支都问过了,都没有
return False
这个 if 递归调用: return True ... 循环外 return False 的骨架,是所有「存在性」搜索题的通用形状。记住它。
代码
def berry_finder(t):
if label(t) == 'berry':
return True
for b in branches(t):
if berry_finder(b):
return True
return False
| 行 | 为什么是这样 |
|---|---|
if label(t) == 'berry': return True | 先检查根。少了这一行,tree('berry')(用例 1)会返回 False——因为它没有分支可递归。用 label(t) 而不是 t[0],遵守抽象屏障。 |
for b in branches(t): | 横向扫过所有分支。branches(t) 返回的是分支树组成的列表,每个 b 本身就是一棵合法的树,可以直接递归。 |
if berry_finder(b): return True | 递归下探。注意是「如果递归说找到了才返回」,不是无条件 return。这保证了找不到时继续检查下一个分支。 |
return False | 唯一的「没找到」出口:根不是 berry,且所有分支都递归说没有。也顺带充当叶子的 base case。这一行必须在 for 循环外面——写进循环里就变成「第一个分支没找到就直接放弃」。 |
验证
把 sproul 的调用栈真的展开一遍。这棵树是:
roots
branch1
leaf
berry
branch2
berry_finder(sproul)
berry_finder(roots) │ label = 'roots' ≠ 'berry' │ branches = [branch1树, branch2树] │ 第 1 个分支 b = branch1树 → │ ┌ berry_finder(branch1) │ │ label = 'branch1' ≠ 'berry' │ │ branches = [leaf树, berry树] │ │ 第 1 个分支 b = leaf树 → │ │ ┌ berry_finder(leaf) │ │ │ label = 'leaf' ≠ 'berry' │ │ │ branches = [] → for 循环零次 │ │ └ return False │ │ if False: 不返回,继续下一个分支 │ │ 第 2 个分支 b = berry树 → │ │ ┌ berry_finder(berry) │ │ │ label = 'berry' == 'berry' │ │ └ return True ← 命中 │ │ if True: → return True │ └ return True │ if True: → return True └ return True
最终结果 True。注意 branch2 根本没被访问——一旦在 branch1 里找到,外层的 if berry_finder(b): return True 立刻返回,for 循环提前终止。这就是短路搜索。
再看 numbers(全是数字,答案应为 False):berry_finder(1) 的根不是 berry,依次递归 2、3、6。berry_finder(2) 是叶子 → False;berry_finder(3) 递归 4、5 都 False,自己也 False;berry_finder(6) 递归 7 得 False,自己 False。三个分支全 False,for 循环正常走完,落到最后一行 return False。这里 7 个节点全部被访问了一遍——找不到的时候必须穷举,没有捷径。
第四个用例 tree(1, [tree('berry', [tree('not berry')])]):根是 1,唯一分支的根是 'berry',递归第一行就命中返回 True,'not berry' 那层压根不会被检查。这个用例是在防「必须递归到叶子才判断」的错误写法。
- 忘了检查根:只写 for 循环。
berry_finder(tree('berry'))返回False,ok 报Expected True but got False。 - 循环里无条件 return:
for b in branches(t): return berry_finder(b),只看第一个分支。 return False写在循环里:for b in ...: if ...: return True; return False(return False缩进到了 for 内部),第一个分支没命中就返回 False。- 什么都不返回:漏掉最后的
return False,函数隐式返回None。doctest 期望False却什么都没打印出来(因为 Python 交互式解释器不显示None),ok 报Expected False but got nothing,非常迷惑人。 - 用
'berry' in label(t):会把'not berry'也算成 berry。虽然本题四个用例碰巧不会因此出错('berry'那层先命中了),但语义是错的。
3. Q3 size_of_tree:数一数有几个节点
题目要什么
写 size_of_tree(t),返回树 t 里节点(entry)的总数——包括根、所有中间节点、所有叶子。
>>> numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])])
>>> print_tree(numbers)
1
2
3
4
5
6
7
>>> size_of_tree(numbers)
7
数一下 print_tree 打出来的行数:1、2、3、4、5、6、7 一共 7 行,答案就是 7。「节点数」等于 print_tree 的输出行数,这个对应关系很好记——因为 print_tree 正是对每个节点打印一行。
虽然只给了一个 doctest,边界情况仍要想清楚:单节点树 tree(5) 的答案应该是 1,不是 0。这决定了 base case 该返回什么。
怎么想到的
和 Q2 用完全一样的套路:假设我已经会数子树的节点数,怎么数当前这棵?
一棵树的节点 = 根这一个 + 各个分支里的节点。而「各个分支里的节点」正好是 size_of_tree(b) 对每个分支 b 的和。写成公式:
这就是全部。注意和 Q2 的结构差异:
berry_finder(存在性) | size_of_tree(聚合) | |
|---|---|---|
| 要问几个分支 | 可以提前停(找到就够) | 必须全部问完(少一个就少算) |
| 怎么合并子结果 | or(任一为真即真) | +(全部相加) |
| 循环里能 return 吗 | 能,命中时提前返回 | 不能,必须累加完再返回 |
| 根贡献什么 | 一个判断 | 常数 1 |
「聚合型」递归有个固定的写法:先设一个累加器(accumulator),循环里更新它,循环结束后返回它。累加器的初值就是「根自己的贡献」,这里是 1。
把 total = 1 读成「先把根算进去」。这样一来,叶子情形自动正确:叶子的 branches(t) 是空的,for 循环零次,直接 return 1——一棵只有根的树确实有 1 个节点。
如果写成 total = 0 再在循环里加,叶子会返回 0,整棵树的计数会少掉每一个节点,最终得 0。
顺带一提,这题也可以写成一行:
return 1 + sum([size_of_tree(b) for b in branches(t)])
意思完全一样(sum([]) 是 0,叶子仍得 1)。本仓库用的是显式循环版,因为它更直白地展示了「累加器」这个模式,而且当你以后要在循环里做更复杂的事时不用重写。
代码
def size_of_tree(t):
# The root counts as one entry; add the sizes of all the branches.
total = 1
for b in branches(t):
total += size_of_tree(b)
return total
| 行 | 为什么是这样 |
|---|---|
total = 1 | 把根节点自己算进去。同时它就是叶子的返回值(base case),不需要单独的 if is_leaf(t)。 |
for b in branches(t): | 必须遍历每一个分支,不能提前退出。用 branches(t) 而不是 t[1:]。 |
total += size_of_tree(b) | 信任递归:size_of_tree(b) 会给出分支 b 的正确节点数。这里不需要关心 b 有多深、长什么样。 |
return total | 在循环外面。写进循环里就只加了第一个分支。 |
写 total += size_of_tree(b) 这一行时,你不应该在脑子里展开 size_of_tree(b) 的执行过程。你只需要相信:这个函数的契约是「给它一棵树,还你节点数」,而 b 是一棵合法的、比 t 小的树,所以调用它是安全且正确的。
这叫「递归的信仰之跃(recursive leap of faith)」。它不是偷懒,而是唯一可行的思考方式——真去在脑子里展开三层以上,人就爆栈了。你要做的只有两件事:(1) 确认递归调用的参数确实更小(b 比 t 少至少一层),(2) 确认 base case 正确(这里是叶子返回 1)。这两条成立,整个函数就成立。
验证
展开 numbers = tree(1, [tree(2), tree(3, [tree(4), tree(5)]), tree(6, [tree(7)])]):
size_of_tree(1) total = 1
├─ size_of_tree(2) total = 1, branches=[] → return 1
│ 外层 total = 1 + 1 = 2
├─ size_of_tree(3) total = 1
│ ├─ size_of_tree(4) → return 1 total = 2
│ └─ size_of_tree(5) → return 1 total = 3
│ return 3
│ 外层 total = 2 + 3 = 5
└─ size_of_tree(6) total = 1
└─ size_of_tree(7) → return 1 total = 2
return 2
外层 total = 5 + 2 = 7
return 7
逐层回代写成算式更清楚:
size(1) = 1 + size(2) + size(3) + size(6)
= 1 + 1 + size(3) + size(6)
size(3) = 1 + size(4) + size(5) = 1 + 1 + 1 = 3
size(6) = 1 + size(7) = 1 + 1 = 2
size(1) = 1 + 1 + 3 + 2 = 7 ✓
递归调用的总次数正好等于节点数 7——每个节点被访问且仅被访问一次。这也说明这个算法是 $\Theta(n)$ 的,$n$ 为节点数,不可能更快,因为你必须看过每个节点才能数清楚。
total = 0:所有叶子返回 0,整棵树返回 0。ok 报Expected 7 but got 0。return total缩进进了 for 循环:只加上第一个分支就返回,numbers得 2。- 写成
total + size_of_tree(b)(忘了赋值 / 忘了+=):算出来的值被丢弃,结果恒为 1。这类「算了但没存」的 bug 不报错,最难查。 - 只数叶子:把题目理解成「有几片叶子」,写
if is_leaf(t): return 1然后return sum(...)(不加 1)。numbers会得到 4(叶子是 2、4、5、7)。题目问的是 entries,包含中间节点。 return len(t):既违反抽象屏障又是错的——len(t)是「1 + 分支数」,对numbers返回 4。
4. Q4 make_path:往树里"补"出一条路径
这是 HW 3 里最难的一题。前两题只是读树,这题要造一棵新树,而且要求「加的节点尽可能少」。
题目要什么
先把题面那段绕口的定义翻译成人话。
什么是路径(path):题面开头说 A path is a sequence of trees in which each is the parent of the next. 也就是从某个节点出发,一路往下走(每步走到自己的某个分支),走过的节点标签依次排列,就是一条路径的标签序列。has_path(t, p) 的意思是「从 t 的根出发,能不能沿着 p 里的标签一步步走下去」。
要求:make_path(t, p) 返回一棵新树 u,满足:
u里有一条标签为p的路径(从根开始);t原有的每一条路径在u里都还在(也就是不能删东西);- 在满足前两条的前提下,
u的节点数最少。
加上两条格式约定:新加的节点要放在其父节点分支列表的最后;t 的标签保证互不相同(unique)。还有一条 assert p[0] == label(t),保证路径的第一个标签就是根标签,否则根本无从谈起。
「节点数最少」这条是整题的关键。它的实际含义是:能复用现成的分支就复用,实在没有才新建。看第三个 doctest 就明白了:
>>> t2 = tree(5, [tree(6), tree(7)])
>>> t1 = tree(3, [tree(4), t2])
>>> print_tree(make_path(t1, [3, 4, 8, 9]))
3
4
8
9
5
6
7
原树 t1 是:根 3,分支 4(叶子)和 5(下挂 6、7)。要补的路径是 3 → 4 → 8 → 9。根 3 有了;4 这个分支已经存在,所以不新建,直接在它下面继续补 8 → 9。最后加了两个节点(8 和 9)。如果不复用而是新建一个 4,就要加三个节点,不是最少。
四个 doctest 分别测什么:
| 用例 | 路径 p | 情况 |
|---|---|---|
| 1 | [3, 5, 7] | 路径已完全存在 → 返回的树必须和 t1 相等(== t1 为 True),一个节点都不加 |
| 2 | [3, 8, 9, 1] | 从第二个标签就不存在 → 整条新分支挂在最后 |
| 3 | [3, 4, 8, 9] | 部分复用:4 存在,8、9 新建 |
| 4 | [2, 3, 5, 6, 8] | 深层部分复用:2、3、5、6 全存在,只在 6 下面新建一个 8 |
用例 4 最能说明「新节点放最后」这条规则:
>>> print_tree(make_path(tree(2, [tree(1), t1]), [2, 3, 5, 6, 8]))
2
1
3
4
5
6
8
7
注意 5 下面的顺序是 6(下挂新加的 8)然后 7——6 还在原来的位置上,没被挪到末尾。「放最后」只针对新造出来的节点;被复用的原有分支要留在原位。这一条极容易写错。
怎么想到的
题面给了骨架(skeleton),填空即可,但为了真正理解,先假装没有骨架,自己推一遍。
第一步:确定递归的"变小"方向。 树递归的参数变小方式是「从 t 降到某个 b in branches(t)」。而 p 是一个列表,它变小的方式是切片 p[1:]。这两个必须同步变小:既然 p[0] 已经被 assert 确认等于 label(t),那么剩下要处理的就是「在某个分支里补出 p[1:] 这条路径」。所以递归调用长这样:
make_path(某个分支, p[1:])
而根据 assert 的要求,这个「某个分支」的标签必须等于 p[1:][0],也就是 p[1]。这一条约束直接决定了整题的结构:我们只能对标签等于 p[1] 的那个分支递归。
第二步:base case。 什么时候不用再递归?当 len(p) == 1 时。因为 assert 已经保证 p[0] == label(t),len(p) == 1 就意味着 p == [label(t)]——这条「路径」只有根一个节点,而 t 的根本来就在那儿,什么都不用加。所以 return t。题面的 Hint 里也是这么说的:if p has length 1 then t contains a path with labels p, so you can just return t。
return t 而不是 return tree(label(t))
因为要求 2 是「t 原有的所有路径都要保留」。如果这里返回 tree(label(t)),就把 t 底下的整棵子树都砍掉了。用例 1 make_path(t1, [3, 5, 7]) 会得到 3 → 5 → 7,丢掉了 4 和 6,== t1 为 False。
第三步:递归情形。 现在 len(p) >= 2。我们要为返回的树重新组装一个分支列表 new_branches。遍历原有的每个分支 b:
- 如果
label(b) == p[1]:这就是那个能被复用并延长的分支。把它换成make_path(b, p[1:])——递归会在b内部继续补路径,同时保留b原有的一切。 - 否则:这个分支和要补的路径无关,原样保留,
new_branches.append(b)。
遍历完,可能一个匹配的分支都没找到。这时就得凭空造一条新分支出来,挂在最后。新分支的根标签必须是 p[1],它底下还要继续有 p[2], p[3], ...。骨架给的写法是 make_path(____, ____),填进去就是:
make_path(tree(p[1]), p[1:])
读作:「先造一个只有根 p[1] 的光杆树,然后让 make_path 自己去把 p[1:] 这条路径补进这棵光杆树里」。这一步很妙——不用写循环去手工串起 p[1] → p[2] → ... ,直接把活儿交给递归。传进去的树的根是 p[1],路径的头也是 p[1],assert 自然满足。
found_p1 这个标志位为什么必需
骨架里预设了 found_p1 = False,很多人不理解为什么不能在循环里直接处理完就完事。
原因是:「有没有找到匹配的分支」这个信息只有在整个循环走完之后才能确定,而「要不要新建分支」的决定必须等到那时候才能做。你不能在循环内部说「这个分支不匹配,所以我要新建一个」——后面可能还有匹配的。
还有一层:题面明确说 Updating it to True when a branch is found with label(b) == p[1] ensures that the path is added only to this branch。因为 t 的标签是 unique 的,最多只有一个分支匹配,所以这个标志位不会出现「匹配了两次」的歧义。
第四步:把结果包起来。 return tree(label(t), new_branches)。根标签不变(它就是 p[0]),分支换成新组装的列表。
没有。make_path 全程用 tree(...) 构造新树,从不调用 append 去改 branches(t)。这和 Q1 的 inventory_pickup 形成鲜明对比——一个是可变风格(就地改),一个是函数式风格(造新的)。
题面的 Trees 一节写着 There's no way to change a tree (that doesn't violate an abstraction barrier):树抽象只提供构造器和选择器,不提供修改器。所以想「改」树,唯一合法的办法就是「照着旧的造一棵新的,顺手把想改的地方改掉」。
不过要注意:new_branches.append(b) 里的 b 是原树的分支对象本身,没有被深拷贝。所以返回的新树和原树共享那些没被修改的子树。这在本题没问题(因为谁都不去改它们),但值得心里有数。
代码
def make_path(t, p):
assert p[0] == label(t), 'It is not possible to make this path'
if len(p) == 1:
# p is just [label(t)], so t already contains the path.
return t
new_branches = []
found_p1 = False
for b in branches(t):
if label(b) == p[1]:
# Extend this existing branch instead of creating a new one.
found_p1 = True
new_branches.append(make_path(b, p[1:]))
else:
new_branches.append(b)
if not found_p1:
# No branch to extend, so build a brand new one at the end.
new_branches.append(make_path(tree(p[1]), p[1:]))
return tree(label(t), new_branches)
| 行 | 为什么是这样而不是别样 |
|---|---|
assert p[0] == label(t) | 题面已给。它的价值不只是报错——它简化了 base case:有了这条保证,len(p) == 1 就等价于 p == [label(t)],不用再比一次标签。 |
if len(p) == 1: return t | 返回 t 整棵,保住原有全部子树。是「不加任何节点」的出口。 |
new_branches = [] | 不能直接改 branches(t)——那是选择器返回的东西,改它违反抽象屏障(而且 t[1:] 返回的是切片副本,改了也没用)。所以另起一个列表重新组装。 |
found_p1 = False | 记录循环中是否遇到过匹配分支。必须在循环之前初始化。 |
if label(b) == p[1]: | 比较的是分支的标签和路径的第二个元素。不是 p[0](那是 t 自己的标签),也不是 b == p[1](b 是一棵树,不是标签)。 |
new_branches.append(make_path(b, p[1:])) | 用递归结果替换原分支,而不是两个都放进去。p[1:] 的首元素是 p[1],正好等于 label(b),递归里的 assert 成立。 |
else: new_branches.append(b) | 无关分支原样保留,且保持原顺序——这正是用例 4 里 6 排在 7 前面的原因。 |
if not found_p1: | 在循环外面。写进循环里会给每个不匹配的分支都新建一次。 |
make_path(tree(p[1]), p[1:]) | 把「串出剩余路径」这件事交给递归。tree(p[1]) 是个无分支的光杆树;递归进去后 found_p1 必然为 False(它没有任何分支),于是一层层往下新建,直到 len(p) == 1 触底。 |
new_branches.append(...)(这一句) | append 到末尾,满足「新节点放在父节点分支列表最后」。 |
return tree(label(t), new_branches) | 用构造器造新树。label(t) 不变。 |
验证
把最复杂的用例 4 完整展开。原树是 tree(2, [tree(1), t1]),其中 t1 = tree(3, [tree(4), t2]),t2 = tree(5, [tree(6), tree(7)])。画出来:
2
1
3
4
5
6
7
要补的路径 p = [2, 3, 5, 6, 8]。
第 1 层:make_path(节点2, [2,3,5,6,8])
- assert:
p[0]=2 == label(t)=2✓ len(p)=5 ≠ 1,进入递归情形。p[1] = 3。- 遍历
branches=[节点1, 节点3]:b = 节点1,label(b)=1 ≠ 3→ 原样 append。new_branches = [节点1]b = 节点3,label(b)=3 == 3→found_p1 = True,appendmake_path(节点3, [3,5,6,8])← 下探
第 2 层:make_path(节点3, [3,5,6,8])
- assert:3 == 3 ✓。
len=4 ≠ 1。p[1] = 5。 - 遍历
branches=[节点4, 节点5]:b = 节点4,4 ≠ 5→ 原样保留b = 节点5,5 == 5→found_p1 = True,appendmake_path(节点5, [5,6,8])← 下探
第 3 层:make_path(节点5, [5,6,8])
- assert:5 == 5 ✓。
len=3 ≠ 1。p[1] = 6。 - 遍历
branches=[节点6, 节点7]:b = 节点6,6 == 6→found_p1 = True,appendmake_path(节点6, [6,8])← 下探b = 节点7,7 ≠ 6→ 原样保留,append 到 6 后面
第 4 层:make_path(节点6, [6,8])
- assert:6 == 6 ✓。
len=2 ≠ 1。p[1] = 8。 branches(节点6)是空的(6 是叶子)→ for 循环零次,new_branches = [],found_p1仍是False。- 触发
if not found_p1:→ appendmake_path(tree(8), [8])← 下探
第 5 层:make_path(tree(8), [8])
- assert:8 == 8 ✓。
len(p) == 1→ 命中 base case,返回tree(8)
开始回代:
第 5 层 → tree(8)
第 4 层:new_branches = [tree(8)]
返回 tree(6, [tree(8)]) 即 6
8
第 3 层:new_branches = [tree(6,[tree(8)]), 节点7]
返回 tree(5, [tree(6,[tree(8)]), tree(7)])
即 5
6
8
7
第 2 层:new_branches = [节点4, 上面那棵5]
返回 tree(3, [tree(4), 上面那棵5])
即 3
4
5
6
8
7
第 1 层:new_branches = [节点1, 上面那棵3]
返回 tree(2, [tree(1), 上面那棵3])
print_tree 打出:
2
1
3
4
5
6
8
7
与 doctest 完全一致。
特别看第 3 层:新节点 8 是挂在 6 下面的,而 6 本身在 5 的分支列表里还是第一位,7 还在第二位。这就是「被复用的分支保持原位、只有新建的分支才追加到末尾」的直接体现。如果错误地写成「先 append 所有不匹配的,再 append 匹配后的递归结果」,输出会变成 7 在前 6 在后,doctest 挂掉。
再快速验用例 1:make_path(t1, [3, 5, 7])。第 1 层 p[1]=5,分支 4 不匹配保留,分支 5 匹配 → 递归 make_path(节点5, [5,7])。第 2 层 p[1]=7,分支 6 不匹配保留,分支 7 匹配 → 递归 make_path(节点7, [7])。第 3 层 len(p)==1 → 返回 节点7 原物。回代:tree(5, [节点6, 节点7])、tree(3, [节点4, 那棵5])。结构和标签与 t1 一模一样,所以 == t1 为 True(列表的 == 是逐元素递归比较值,不要求是同一个对象)。一个节点都没加,符合「最少」。
再验用例 2:make_path(t1, [3, 8, 9, 1])。第 1 层 p[1]=8,分支 4 和 5 都不匹配,全部原样保留,found_p1 保持 False。于是走 if not found_p1,append make_path(tree(8), [8,9,1])。这一支里 tree(8) 没有分支,又是 found_p1 = False,继续 make_path(tree(9), [9,1]),再 make_path(tree(1), [1]) → base case 返回 tree(1)。回代得 tree(9,[tree(1)])、tree(8,[tree(9,[tree(1)])])。最终 tree(3, [tree(4), t2, 那棵8]),打印为:
3
4
5
6
7
8
9
1
新造的 8 果然在最后。
- base case 写成
return tree(p[0])或return tree(label(t)):把子树砍光了。用例 1 会返回一条光杆路径,== t1为 False。 - 匹配时把原分支和递归结果都 append 进去:树里出现两个标签相同的分支,节点数不是最少,
print_tree多出一整块。 - 递归传
p而不是p[1:]:p永远不变小,无限递归 →RecursionError: maximum recursion depth exceeded。 - 递归传
p[2:]:跳了一层,assert 立刻炸 ——AssertionError: It is not possible to make this path。这个 assert 其实是你的朋友,它把「路径和树对不上」这种错误就地暴露出来,而不是让你得到一棵形状诡异的树。 found_p1忘了置 True:既延长了已有分支,又额外新建一个同标签分支,出现重复。- 新建分支写成
tree(p[1], ...)手工串路径:比如new_branches.append(tree(p[1], [tree(p[2], [...])])),写不出来也不该写——递归已经能干这事了。 - 用
t[0]/t[1:]代替label/branches:能跑,但违反抽象屏障,考试会扣分。
5. Q5 merge:归并两个(可能无限的)有序序列
题目要什么
merge(incr_a, incr_b) 接收两个严格递增的可迭代对象,按从小到大的顺序 yield 它们的元素,去掉重复。
题面给的假设要一条条读清楚,它们决定了算法能有多简单:
| 假设 | 它让你省掉什么 |
|---|---|
| 两个序列各自严格递增 | 不用排序,只要每次挑两个「当前头」里小的那个,输出就一定是有序的 |
| 各自内部无重复 | 重复只可能来自「两边都有同一个值」,不用在单边去重 |
元素都不是 None | 可以拿 None 当「这一路已经没货了」的哨兵值(sentinel) |
| 可能是无限的 | ——这条不是省事,是加难度:绝对不能先 list(incr_a) 或者数长度 |
两个 doctest:
>>> m = merge([0, 2, 4, 6, 8, 10, 12, 14], [0, 3, 6, 9, 12, 15])
>>> type(m)
<class 'generator'>
>>> list(m)
[0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15]
>>> def big(n):
... k = 0
... while True: yield k; k += n
>>> m = merge(big(2), big(3))
>>> [next(m) for _ in range(11)]
[0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15]
type(m) 是 <class 'generator'>——这行 doctest 在强制你必须用 yield 写,返回一个列表是过不了的。
第二个例子里 big(2) 是 0,2,4,6,... 无限,big(3) 是 0,3,6,9,... 无限。取前 11 个,结果和第一个例子一模一样。这个设计很巧:同样的答案,一次来自有限列表,一次来自无限流,说明你的代码必须既能处理耗尽、又能处理永不耗尽。
怎么想到的
先想「如果两个都是有限列表,我会怎么合并」。答案是归并排序里的经典 merge:两根手指分别指着两个列表的当前位置,比一比谁小就输出谁、那根手指前进一格;相等就输出一次、两根手指都前进(这就是去重)。某一边走完了,把另一边剩下的全倒出来。
难点在于「手指」怎么表示。 如果是列表,用下标 i、j 就行。但题目说可能无限——无限序列没有下标可言,也不能 len。这时唯一的接口是迭代器协议:iter() 拿到迭代器,next() 一次要一个。
骨架前两行已经替你写好了:
iter_a, iter_b = iter(incr_a), iter(incr_b)
next_a, next_b = next(iter_a, None), next(iter_b, None)
读懂这两行,这题就解决一半了。
第一行:把「可迭代对象(iterable)」统一转成「迭代器(iterator)」。为什么要转?因为列表是 iterable 但不是 iterator,你不能对列表直接 next()(会得到 TypeError: 'list' object is not an iterator)。而生成器已经是 iterator,对它调 iter() 返回它自己、状态不变。所以这一行让两种输入走同一条路。
第二行:next(it, default) 是 next 的双参数版本:正常情况下和 next(it) 一样,但当迭代器耗尽时,它不抛 StopIteration,而是返回 default。这里 default 是 None。
于是 next_a 这个变量身兼两职:既是「a 这一路当前最小的、还没输出的值」,又是「a 这一路的状态」——只要它是 None,就说明 a 已经空了。题面保证元素里没有 None,所以这个哨兵不会和真实数据混淆。这就是为什么题面要特意写 none of the elements of either are None。
核心循环。 只要两边都还有货(next_a is not None and next_b is not None),就三选一:
next_a == next_b:两边都有这个值。yield 一次,然后两边都往前走。这一步就是去重。如果这里只推进一边,下一轮又会碰到相等,还会再 yield 一次(或者陷入死循环)。next_a < next_b:a 的当前值更小。因为两个序列都递增,b 之后的所有值都 ≥ next_b > next_a,所以 next_a 确定是全局下一个最小值,可以放心 yield。然后只推进 a。next_b < next_a):对称地 yield next_b,只推进 b。收尾。 循环退出说明至少有一边空了。这时另一边可能还有剩余(有限情形),要全部倒出来。写成两个 while:
while next_a is not None:
yield next_a
next_a = next(iter_a, None)
while next_b is not None:
yield next_b
next_b = next(iter_b, None)
这两个循环最多只有一个会真正执行(另一个的哨兵已经是 None),所以不用写 if/else 去判断是哪边空了——空的那个 while 条件直接为假,零次迭代。这是个很省事的写法。
yield from 收尾
有人会想:剩下的直接 yield from iter_a 不就完了?
不行,会漏掉一个元素。 因为 next_a 里已经预先取出了一个还没 yield 的值,它已经不在 iter_a 里了。正确的写法得是 yield next_a; yield from iter_a。这个「预读一个(lookahead)」的状态是本题最容易踩空的地方——变量 next_a 不只是个临时值,它是已从迭代器取出但尚未输出的缓冲区。
把 merge(big(2), big(3)) 想成一条流水线:只有当外面调用 next(m) 时,merge 的函数体才会往前跑,跑到下一个 yield 就冻住。跑到那个 yield 之前,它最多只从 iter_a / iter_b 各取了有限个元素。
换句话说:while True 在 big 里,while next_a is not None and next_b is not None 在 merge 里,两个「无限循环」谁都不会真的跑无限次——因为每转一圈都会撞上 yield 交出控制权。惰性求值(lazy evaluation)就是这个意思。
反过来,list(m) 对无限的 m 会真的挂死——它一直要下一个直到 StopIteration,而那永远不来。所以 doctest 对无限情形用的是 [next(m) for _ in range(11)] 而不是 list(m)。
代码
def merge(incr_a, incr_b):
iter_a, iter_b = iter(incr_a), iter(incr_b)
next_a, next_b = next(iter_a, None), next(iter_b, None)
while next_a is not None and next_b is not None:
if next_a == next_b:
# Same value in both: yield it once and advance both iterators.
yield next_a
next_a, next_b = next(iter_a, None), next(iter_b, None)
elif next_a < next_b:
yield next_a
next_a = next(iter_a, None)
else:
yield next_b
next_b = next(iter_b, None)
# One iterator is exhausted; yield whatever is left of the other.
while next_a is not None:
yield next_a
next_a = next(iter_a, None)
while next_b is not None:
yield next_b
next_b = next(iter_b, None)
| 行 | 为什么是这样 |
|---|---|
while next_a is not None and next_b is not None: | 用 is not None 而不是 != None:判断的是「是不是那个哨兵对象」,语义更准,也避免元素自定义了 __eq__ 时出岔子。两边都有货才能比较。 |
if next_a == next_b: | 必须放在最前面。如果先写 if next_a < next_b,相等的情况会落到 else(当作 b 更小),yield 一次 b、只推进 b;下一轮 next_a 还是老值,可能又和新的 next_b 比出问题——最直接的后果是 0 被输出两次。 |
next_a, next_b = next(iter_a, None), next(iter_b, None) | 相等时同时推进两边,这是去重的实现。写成两行也可以,写成元组同时赋值更整齐。 |
elif next_a < next_b: | 用 elif 而不是新的 if——相等的情况已经被上一分支吃掉了。 |
else: | 不写 elif next_b < next_a,因为三种关系已穷尽。 |
两个收尾 while | 倒出剩余。它们必须写在主循环外面。注意第一次 yield 的是已经在手里的 next_a,之后才继续取。 |
整个函数没有 return 值 | 生成器函数靠函数体执行完毕来触发 StopIteration。对无限输入,函数体永远执行不完,也就永远不会 StopIteration——这正是我们要的。 |
验证
追踪第一个 doctest:merge([0,2,4,6,8,10,12,14], [0,3,6,9,12,15]),然后 list(m)。
初始化后 next_a = 0,next_b = 0。下表每一行是主循环的一轮:
轮 next_a next_b 比较 yield 之后 next_a next_b
1 0 0 相等 0 2 3
2 2 3 a<b 2 4 3
3 4 3 b<a 3 4 6
4 4 6 a<b 4 6 6
5 6 6 相等 6 8 9
6 8 9 a<b 8 10 9
7 10 9 b<a 9 10 12
8 10 12 a<b 10 12 12
9 12 12 相等 12 14 15
10 14 15 a<b 14 None 15
↑ a 耗尽
第 10 轮之后 next_a is None,主循环条件为假,退出。
收尾:第一个 while(next_a is not None)条件为假,零次。第二个 while:next_b = 15 不是 None → yield 15,再取 → None,退出。
函数体执行完毕,生成器抛出 StopIteration,list 停止收集。
收集到的序列:[0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15],与 doctest 一致。
数一下:a 有 8 个元素,b 有 6 个,共 14 个,输出 11 个。差的 3 个正是两边共有的 0、6、12 各被合并掉一次。
再看无限版 merge(big(2), big(3))。big(2) 产生 0,2,4,6,8,10,12,14,16,...;big(3) 产生 0,3,6,9,12,15,18,...。前 11 个的推演和上表逐行相同,因为前 14 个元素完全一样。区别只在第 10 轮之后:这里 next_a 不是 None 而是 16,主循环继续跑第 11 轮(16 vs 15 → yield 15)。但 [next(m) for _ in range(11)] 只要 11 个,拿到 15 之后就不再问了,merge 停在那个 yield 上永久冻结。输出同样是 [0, 2, 3, 4, 6, 8, 9, 10, 12, 14, 15]。
m = merge(big(2), big(3)) 这一句执行完,merge 的函数体一行都没跑——连 iter_a, iter_b = ... 都没执行。m 只是一个「记着从哪开始跑」的生成器对象。
第一次 next(m) 才进入函数体:执行 iter() 两行、执行 next(iter_a, None)(这会让 big(2) 跑到它的第一个 yield 交出 0,然后 big(2) 也冻住)、进主循环、判断相等、执行 yield next_a —— 此时 0 被交出去,merge 就地冻结,局部变量 iter_a、iter_b、next_a、next_b 全部原封保存。
第二次 next(m):从 yield 的下一行继续,执行 next_a, next_b = next(iter_a, None), next(iter_b, None)(唤醒两个 big 各跑一步),回到 while 条件,再往下……如此往复。
三个生成器(m 和两个 big)像三个协作的暂停/恢复的进程,任何时刻只有一个在跑。这就是 Lecture 10 讲的核心机制。
- 用
return而不是yield:type(m)得到<class 'list'>,第一行 doctest 就挂。 - 先
list(incr_a)转成列表:有限情形能过,无限情形直接挂死,ok 超时。 - 相等分支只推进一边:
0会被输出两次,得到[0, 0, 2, 3, ...]。 - 相等判断放在
<后面:同上,去重失效。 - 用
next(iter_a)单参数版:迭代器耗尽时抛StopIteration。在生成器函数体内部,未捕获的StopIteration在 Python 3.7+ 会被转成RuntimeError: generator raised StopIteration——报错信息完全指不到问题所在,非常难查。双参数next就是为了绕开这个。 - 收尾用
yield from iter_a:丢掉预读在next_a里的那个元素。用例 1 会漏掉15。 - 忘了收尾循环:一边耗尽后另一边的剩余全丢。用例 1 输出到 14 就停了。
- 把收尾循环缩进进主循环里:主循环条件保证两边都非 None,收尾循环内部会重复输出,逻辑全乱。
6. Q6 yield_paths:递归的生成器
这题把树递归和生成器捏在一起,是 HW 3 的收官题,也是最能体现「生成器为什么好用」的一题。
题目要什么
yield_paths(t, target):yield 出每一条从 t 的根到某个标签为 target 的节点的路径。每条路径表示成一个标签列表,从根标签开始,到 target 结束。顺序任意(doctest 里用 sorted 消除顺序影响)。
>>> t1 = tree(1, [tree(2, [tree(3), tree(4, [tree(6)]), tree(5)]), tree(5)])
>>> print_tree(t1)
1
2
3
4
6
5
5
>>> next(yield_paths(t1, 6))
[1, 2, 4, 6]
>>> path_to_5 = yield_paths(t1, 5)
>>> sorted(list(path_to_5))
[[1, 2, 5], [1, 5]]
关键观察:
- 可能有多条路径。
t1里有两个标签为 5 的节点(一个在 2 底下,一个是根的直接分支),所以有两条路径。这题和 Q4 不同,标签不保证 unique。 - 命中之后还要继续往下找。看第二个树:
>>> t2 = tree(0, [tree(2, [t1])])
>>> path_to_2 = yield_paths(t2, 2)
>>> sorted(list(path_to_2))
[[0, 2], [0, 2, 1, 2]]
t2 是 0 → 2 → 1 → (2 → ...)。找 target = 2,有两条答案:[0, 2](那个直接分支)和 [0, 2, 1, 2](更深处的那个 2)。第一个 2 被命中之后,不能停下来,还得继续搜它的子树——这一点让代码不能写成「命中就 return」的形状。
next(yield_paths(t1, 6))只取第一条,说明返回的必须是生成器(能被next),而且是惰性的:只要一条时不该把整棵树搜完。
题面给的骨架已经框死了结构:
if label(t) == target:
yield ____
for b in branches(t):
for ____ in ____:
yield ____
怎么想到的
题面 Hint 说得对:先假装它不是生成器函数,想清楚递归怎么写。
问:从 t 的根到 target 的所有路径长什么样?分两类。
- 根自己就是 target:那么
[label(t)]就是一条(长度为 1 的)路径。 - target 在某个分支
b里面:设P是「从b的根到 target 的某条路径」。那么在b前面接上t的根,[label(t)] + P就是一条从t到 target 的路径。
而「从 b 的根到 target 的所有路径」,正是 yield_paths(b, target) 的定义。递归结构就出来了。
如果写成返回列表的普通函数,第二类会长这样:
result = []
if label(t) == target:
result.append([label(t)])
for b in branches(t):
for path in yield_paths_list(b, target):
result.append([label(t)] + path)
return result
改成生成器,只要做一个机械的替换:把每个 result.append(X) 换成 yield X,删掉 result 和最后的 return。
if label(t) == target:
yield [label(t)]
for b in branches(t):
for path in yield_paths(b, target):
yield [label(t)] + path
这个「列表版 → 生成器版」的机械转换在 61A 里会反复用到。它之所以成立,是因为两者的逻辑结构完全相同,只是「攒起来一次性给」变成了「产一个给一个」。
为什么内层是 for 而不是别的? 因为 yield_paths(b, target) 返回的是一个生成器对象,不是列表。生成器是迭代器,迭代器是可迭代的,所以可以直接 for path in 它。每转一圈,内层生成器就往前跑到它的下一个 yield,把一条路径交给我们;我们在前面拼上 label(t),再 yield 出去。一条一条地转发,而不是先收集完。 这正是 next(yield_paths(t1, 6)) 能只搜一部分就返回的原因。
return
看到「找到了就返回」的直觉会让人写 if label(t) == target: return [label(t)]。在生成器函数里,return 的含义是「立即结束这个生成器」(抛 StopIteration),而不是「产出一个值」。
更重要的是语义:用例 yield_paths(t2, 2) 要求在命中 2 之后继续搜索它的子树,找出 [0, 2, 1, 2]。所以 yield [label(t)] 之后不能有任何形式的提前退出——那个 for 循环必须照常执行。这是本题最容易错的地方,也是骨架里 if 和 for 是平级(不是 if/else)的原因。
为什么是 [label(t)] + path 而不是 path.append(label(t))? 两个理由:一是位置——根标签要放在最前面,append 是放最后;二是可变性——path 是下层生成器交上来的列表对象,就地改它会污染下层(虽然本题下层每次都新建列表,但依赖这一点很危险)。[label(t)] + path 创建一个新列表,干净。这也解释了为什么这个算法在深度为 $d$ 的树上每条路径要 $\Theta(d^2)$ 的拼接开销——每层都复制一次。对本题的规模无所谓。
base case 在哪? 又是隐含的。叶子节点的 branches(t) 是空的,for 循环零次;如果它的标签也不是 target,函数体直接跑完 → 这个生成器不产出任何值就 StopIteration。「产出零条路径」是完全合法的结果。
代码
def yield_paths(t, target):
if label(t) == target:
yield [label(t)]
for b in branches(t):
# Every path from b to target becomes a path from t once we
# put label(t) at the front.
for path in yield_paths(b, target):
yield [label(t)] + path
| 行 | 为什么是这样 |
|---|---|
if label(t) == target: | 检查根自己。没有 else:命中之后仍要往下搜。 |
yield [label(t)] | yield 的是一个只含一个元素的列表,不是 label(t) 本身。因为约定「路径是标签的列表」;上层要对它做 [label(t)] + path,两边都得是列表。写成 yield label(t) 会在上层触发 TypeError: can only concatenate list (not "int") to list。 |
for b in branches(t): | 逐个分支搜。这个循环必须在 if 之外、无条件执行。 |
for path in yield_paths(b, target): | 直接遍历递归调用返回的生成器对象。每转一圈拿到 b 内部的一条完整路径。若 b 里没有 target,这个生成器产出零个值,循环零次——自然跳过,不需要 if 判断。 |
yield [label(t)] + path | 在下层路径前面接上当前根标签,转发出去。+ 造新列表,不改 path。 |
yield from
不能直接用。yield from yield_paths(b, target) 会把下层路径原封不动转发上去,少了 [label(t)] + 这一步,得到的是从 b 开始的路径而不是从 t 开始的。yield from 只适合「原样转发」,一旦每个值都要加工,就得写显式的 for ... yield。
写成 yield from ([label(t)] + p for p in yield_paths(b, target)) 倒是等价,但不比 for 循环清楚。
验证
追踪 sorted(list(yield_paths(t2, 2)))。t2 的结构:
0
2
1
2
3
4
6
5
5
yield_paths(节点0, 2) │ label=0 ≠ 2 → 不 yield │ 分支:[节点2] │ ┌ yield_paths(节点2, 2) │ │ label=2 == 2 → ★ yield [2] │ │ 分支:[节点1] │ │ ┌ yield_paths(节点1, 2) │ │ │ label=1 ≠ 2 │ │ │ 分支:[节点2', 节点5] (节点2' 是 t1 里的那个 2) │ │ │ ┌ yield_paths(节点2', 2) │ │ │ │ label=2 == 2 → ★ yield [2] │ │ │ │ 分支:[节点3, 节点4, 节点5] │ │ │ │ yield_paths(节点3,2): 3≠2, 无分支 → 零条 │ │ │ │ yield_paths(节点4,2): 4≠2 → 递归 节点6: 6≠2 无分支 → 零条 → 零条 │ │ │ │ yield_paths(节点5,2): 5≠2, 无分支 → 零条 │ │ │ └ 总产出:[2] │ │ │ 转发:yield [1] + [2] = ★ [1, 2] │ │ │ ┌ yield_paths(节点5, 2): 5≠2, 无分支 → 零条 │ │ └ 总产出:[1, 2] │ │ 转发:yield [2] + [1,2] = ★ [2, 1, 2] │ └ 总产出:[2]、[2, 1, 2] │ 转发:yield [0] + [2] = ★ [0, 2] │ yield [0] + [2,1,2] = ★ [0, 2, 1, 2] └ 总产出:[0, 2]、[0, 2, 1, 2]
list(...) 收集到 [[0, 2], [0, 2, 1, 2]];sorted 后仍是这个顺序(列表按字典序比较,[0,2] 是 [0,2,1,2] 的前缀,短的排前面)。与 doctest 一致。
注意 yield_paths(节点2, 2) 那一层:它先 yield 了 [2],然后继续进入 for 循环搜 节点1,从而挖出了第二条。如果写成 if ...: return [label(t)],深处那个 2 就永远找不到,结果只有 [[0, 2]]。
再验 next(yield_paths(t1, 6)),重点看惰性:
t1 = 1 → [2 → [3, 4 → [6], 5], 5],target = 6。
yield_paths(t1, 6)被调用 → 只返回生成器对象,函数体一行没跑。next(...)触发执行:label=1 ≠ 6;进入 for,b = 节点2;创建yield_paths(节点2, 6),for 向它要第一个值。yield_paths(节点2,6):2 ≠ 6;for 取b = 节点3;yield_paths(节点3,6)是空的(3≠6,无分支),内层 for 零次。- 继续
b = 节点4:yield_paths(节点4,6):4 ≠ 6;for 取b = 节点6;yield_paths(节点6,6):6 == 6→ yield[6],冻结。 - 节点4 那层拿到
[6]→ yield[4] + [6] = [4, 6],冻结。 - 节点2 那层拿到
[4,6]→ yield[2, 4, 6],冻结。 - 节点1 那层拿到
[2,4,6]→ yield[1, 2, 4, 6],冻结。 next返回[1, 2, 4, 6]。
节点 5(无论哪一个)从头到尾没被访问过。 五个生成器对象此刻全部处于冻结状态,如果再调一次 next,它们会从各自的 yield 后面接着跑(结果是 StopIteration,因为 6 只出现一次)。这就是惰性求值带来的好处:要多少算多少。
最后验 sorted(list(yield_paths(t1, 5)))。t1 里两个 5:一个在 2 的第三个分支,一个是根的第二个分支。产出顺序是先深后浅(因为 2 这个分支排在前面):先得 [1, 2, 5],再得 [1, 5]。list 收集为 [[1,2,5], [1,5]],sorted 之后:比较 [1,2,5] 和 [1,5],首元素都是 1,第二元素 2 < 5,所以 [1,2,5] 在前。结果 [[1, 2, 5], [1, 5]],与 doctest 一致。doctest 特意用 sorted 就是因为题面说了「顺序任意」。
yield label(t)而不是yield [label(t)]:上层[label(t)] + path报TypeError: can only concatenate list (not "int") to list。- 写成
if ... : yield ... else: for ...:命中后不再深入,yield_paths(t2, 2)只得到[[0, 2]]。 - 用
return代替yield:生成器立即终止,一条路径都不给。 - 忘了拼接根标签:直接
yield path,得到的是从中间某节点开始的片段,如[2, 4, 6]。 path.insert(0, label(t))后yield path:就地改了下层的列表。本题恰好能过(每条路径只被一个上层用一次),但这是危险习惯——一旦同一个列表被两个地方引用,就会互相污染。- 试图先
list(yield_paths(b, target))再遍历:功能上对,但破坏了惰性,next(...)会把整棵子树搜完。本题不会失败,但违背了用生成器的初衷。 - 写
for path in yield_paths(t, target)(把b打成t):无限递归,RecursionError。
7. Q7 midsem_survey:本仓库无法通过的那一题
这题不考编程。题面要求:填写课程的 Mid-Semester Feedback 问卷,提交后页面会显示一串口令(passphrase),把它填进 hw03.py 里:
passphrase = 'REPLACE_THIS_WITH_PASSPHRASE'
ok 的检查方式是对你填的字符串做 SHA-224 哈希,和一个写死在 docstring 里的目标值比对:
def midsem_survey(p):
"""
You do not need to understand this code.
>>> midsem_survey(passphrase)
'2bf925d47c03503d3ebe5a6fc12d479b8d12f14c0494b43deba963a0'
"""
import hashlib
return hashlib.sha224(p.encode('utf-8')).hexdigest()
本仓库跑 python3 ok --local -q midsem_survey 的实际输出:
>>> midsem_survey(passphrase)
'f65fb8fdaeda6d85eb3089dcdf7784836dde30e260c0ad31b9b2e533'
# Error: expected
# '2bf925d47c03503d3ebe5a6fc12d479b8d12f14c0494b43deba963a0'
# but got
# 'f65fb8fdaeda6d85eb3089dcdf7784836dde30e260c0ad31b9b2e533'
Score: 0.0/1
f65fb8... 正是占位字符串 'REPLACE_THIS_WITH_PASSPHRASE' 的 SHA-224。
问卷链接只对登录 berkeley.edu 邮箱的选课学生开放,口令原文不会公开。而 SHA-224 是单向哈希:给定 2bf925d4...,没有任何办法反推出原文,只能穷举猜测。本页不做这件事,也不建议你做——那既不是这门课要教的,也无助于理解任何知识点。
结论:自学者在这题上得 0 分是正常且无法避免的,与代码质量无关。 HW 3 共 7 题,本仓库 6 题满分,总分 6.0。
课程为什么不直接把口令明文写在 ok 文件里?因为那样任何人打开文件就能抄。存哈希值可以在不泄露原文的前提下验证你是否知道原文——这和网站存密码的做法是同一个思路(真实系统还会加盐、用更慢的算法)。
顺带一提,你在 lab 里见过的 ok 的 unlock 机制(要求你手动输入 WWPD 的答案)用的也是这个原理:答案以哈希形式存在 tests/ 里,你输对了它才放行,但你翻文件是翻不出答案的。HW 3 的 tests/ 目录是空的,所以本次作业没有 unlock 环节。
整份作业回顾
HW 3 表面上是三个互不相干的话题,实际上它在教你三种不同的「函数与世界的关系」:
| 风格 | 代表题 | 函数做了什么 | 你必须盯住什么 |
|---|---|---|---|
| 可变式(imperative) | Q1 inventory_pickup | 就地改动传进来的对象,外面能看见 | 谁指向谁;遍历中被修改的容器 |
| 函数式(functional) | Q2–Q4 | 只读输入,造出新值返回 | 递归的参数是否真的变小;base case 返回什么 |
| 惰性式(lazy) | Q5、Q6 | 被问一次算一步,随时冻结 | yield 之后停在哪;哪些值已被预读 |
真正值得带走的几件事
list(items)),或者倒着遍历索引。Q1 用的是前者,因为它读起来最清楚。这个坑在任何语言里都存在。if 命中: return True 在循环里,return False 在循环外(Q2)。「聚合」型:累加器初始化 → 循环里累加 → 循环外返回(Q3)。「重建」型:造 new_branches 列表 → 逐个决定保留还是递归 → tree(label(t), new_branches)(Q4)。for 循环就是树递归的 base case。 Q2、Q3、Q6 都没写显式的 if is_leaf(t),因为叶子的 branches(t) 是空列表,循环零次自然落到后面的语句。写不写显式 base case 是风格问题,但你必须知道叶子情形是怎么被处理掉的。result.append(X) 换成 yield X、删掉 result 和 return。Q6 就是这么来的。反过来,看不懂一个生成器,就在脑子里把 yield 换成 append。next(it, default) 是处理「可能耗尽的迭代器」的标准工具。 它把「异常控制流」换成「哨兵值控制流」,代码从满地 try/except 变成一串 while x is not None。前提是哨兵值不能和真实数据撞车——Q5 的题面专门保证了元素里没有 None。tree / label / branches。这不只是为了拿分:写 'berry' in t 这种穿透屏障的代码,往往顺带把语义也搞错了(它只检查第一层)。屏障逼你按树的真实结构思考。题目速查
| 题目 | 核心手法 | 最容易错的地方 | 迁移到哪里 |
|---|---|---|---|
Q1 inventory_pickup | 快照 + while ... in ...: remove + 末尾 pop(0) 修剪 | 重新绑定名字导致失去就地性;if 当 while 用 | 任何「一边读一边改同一个容器」的场景;LRU 缓存淘汰 |
Q2 berry_finder | 存在性搜索骨架:循环内 return True,循环外 return False | 忘了检查根;return False 缩进错位 | 「树里有没有 X」「图里能不能到达 Y」 |
Q3 size_of_tree | 累加器:total = 1 → 循环累加 → 返回 | 初值写 0;return 缩进进循环 | 求高度、求叶子数、求标签之和 |
Q4 make_path | 重建树:new_branches + found 标志位 + 递归造新分支 | base case 砍掉子树;找不到时的新建写在循环里 | Trie(前缀树)插入、文件系统 mkdir -p |
Q5 merge | 双指针 + 哨兵 None + 相等时双推进 | 相等判断没放最前;收尾漏掉预读的那个元素 | 归并排序、流式 join、Scheme 的 streams |
Q6 yield_paths | 递归生成器:for x in 递归调用: yield 加工(x) | if/else 写成互斥;yield 的不是列表 | 枚举所有解的搜索(组合、全排列、迷宫路径) |
Q7 midsem_survey | ——(需课程私有口令) | —— | 了解一下单向哈希做校验的思路 |
自己再练一遍
下面几道是基于本次手法的变式,答案折叠在里面。建议先自己写再看。
1. 写 height(t):返回树的高度(单节点树高度为 0)。
def height(t):
if is_leaf(t):
return 0
return 1 + max([height(b) for b in branches(t)])
这题需要显式 base case,因为 max([]) 会报 ValueError: max() arg is an empty sequence——不像 sum([]) 有个自然的 0。这正说明「空循环当 base case」不是万能的,要看聚合操作有没有单位元。
2. 如果把 Q5 的相等分支删掉(只留 < 和 else),merge([0,2],[0,3]) 输出什么?
[0, 0, 2, 3]。第一轮 next_a=0、next_b=0,0 < 0 为假,落到 else:yield next_b 也就是 0,只推进 b(next_b 变 3)。第二轮 next_a=0 还在,0 < 3 真 → yield 0。于是 0 出现两次,去重失效。
3. Q6 里如果把 yield [label(t)] + path 改成 yield path + [label(t)],next(yield_paths(t1, 6)) 得到什么?
[6, 4, 2, 1]。每层都把自己的标签接在后面,于是路径整个反了过来,变成从 target 到根。结构上仍然是同一条路径,但顺序不符合题目要求。想修正也简单:最后 [::-1],不过直接前接更自然。
4. Q1 如果把 pickup_order = list(items) 改成 pickup_order = items,用例 2(inventory_pickup(inv2, inv2, 7),inv2 = [11,12,13])会输出什么?
得到 [12, 11, 13],而 doctest 期望 [11, 12, 13],挂掉。实际跑一遍(L 就是 inv2):
索引 0:读 L[0] = 11 → remove(11) 得 [12,13] → append(11) 得 [12,13,11]
索引 1:读 L[1] = 13 → remove(13) 得 [12,11] → append(13) 得 [12,11,13]
索引 2:读 L[2] = 13 → remove(13) 得 [12,11] → append(13) 得 [12,11,13]
索引 3:越界,for 结束
注意两处荒唐:12 从头到尾没被当作 item 处理过(它一直待在索引 0,而游标已经走过去了),而 13 被处理了两次(第一次处理后它被搬到末尾,正好又落在游标前方)。列表长度恰好没变,所以循环还能正常结束——但读到的元素序列 11, 13, 13 和原始清单 11, 12, 13 已经对不上了。这就是为什么必须快照:不是它一定会崩,而是你没法预测它会读到什么。
5. Q4 中,如果树的标签不保证 unique,比如 tree(1, [tree(2), tree(2)]) 要补路径 [1, 2, 3],现有代码会怎样?
两个分支的标签都是 2,都会匹配 label(b) == p[1],于是两个分支都被递归延长,各自长出一个 3。结果树里出现两条 1→2→3,节点数不是最少。题面写 Assume that the labels of t are unique 就是为了排除这种情况。想支持重复标签的话,得在 if 里加上 and not found_p1。