CS 61A  /  作业解析
HOMEWORK 03

HW 3:可变性、树、迭代器与生成器ok 6 题通过1 题需课程私有口令

六道真题:一道列表就地修改的陷阱题、三道树递归、一道无限序列归并、一道生成器版树搜索。这份作业是本课程从「值」跨到「对象」的分水岭。

对应讲次:Lecture 8 可变性与数据抽象、Lecture 9 树、Lecture 10 迭代器与生成器 官方题面:cs61a.org/hw/hw03 代码:hw/hw03/hw03.py

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 8Q1 的 check_mutation is inv3_test 永远过不了
递归的三步:base case、递归调用、把结果拼起来Lecture 5、6Q2–Q4、Q6 全部
树抽象四个函数的签名Lecture 9会不自觉写 t[0],虽然能跑但违反抽象屏障
yield 的执行时机Lecture 10Q5、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 节会再说一次。

关于 WWPD

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 一节特意强调了两段的先后顺序:

1 逐个处理 items 里的每一个 item:先把 inventory 中所有已存在的该物品副本删掉(可能有 0 个、1 个、也可能 2 个以上),再在末尾 append 一份。效果是「重复物品只留一份,并且刷新成最新捡到的」。
2 所有 items 都处理完之后,才检查容量:如果此时 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 追加到末尾
2inv2=[11,12,13], inv2, 7[11,12,13]自己捡自己,且无重复、不超容量
3inv3=[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?否 → 退出 while
  • L.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,当且仅当下面两件事至少成立一件:

  1. t 自己的根标签就是 'berry';
  2. t 的某一个分支里有 berry。

这句话直接就是代码。第 1 条写成 label(t) == 'berry';第 2 条写成「对每个分支 b 递归问一遍 berry_finder(b),只要有一个说 True 就 True」。

base case 在哪里?

初学者写完上面的代码常会慌:「我的 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 的和。写成公式:

写成公式 $$\text{size}(t) = 1 + \sum_{b \in \text{branches}(t)} \text{size}(b)$$

这就是全部。注意和 Q2 的结构差异:

berry_finder(存在性)size_of_tree(聚合)
要问几个分支可以提前停(找到就够)必须全部问完(少一个就少算)
怎么合并子结果or(任一为真即真)+(全部相加)
循环里能 return 吗能,命中时提前返回不能,必须累加完再返回
根贡献什么一个判断常数 1

「聚合型」递归有个固定的写法:先设一个累加器(accumulator),循环里更新它,循环结束后返回它。累加器的初值就是「根自己的贡献」,这里是 1。

为什么初值是 1 而不是 0

把 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,满足:

  1. u 里有一条标签为 p 的路径(从根开始);
  2. t 原有的每一条路径在 u 里都还在(也就是不能删东西);
  3. 在满足前两条的前提下,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。

为什么 base case 直接 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,append make_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,append make_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,append make_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: → append make_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),就三选一:

1 next_a == next_b:两边都有这个值。yield 一次,然后两边都往前走。这一步就是去重。如果这里只推进一边,下一轮又会碰到相等,还会再 yield 一次(或者陷入死循环)。
2 next_a < next_b:a 的当前值更小。因为两个序列都递增,b 之后的所有值都 ≥ next_b > next_a,所以 next_a 确定是全局下一个最小值,可以放心 yield。然后只推进 a。
3 否则(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 的所有路径长什么样?分两类。

  1. 根自己就是 target:那么 [label(t)] 就是一条(长度为 1 的)路径。
  2. 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。

  1. yield_paths(t1, 6) 被调用 → 只返回生成器对象,函数体一行没跑。
  2. next(...) 触发执行:label=1 ≠ 6;进入 for,b = 节点2;创建 yield_paths(节点2, 6),for 向它要第一个值。
  3. yield_paths(节点2,6):2 ≠ 6;for 取 b = 节点3;yield_paths(节点3,6) 是空的(3≠6,无分支),内层 for 零次。
  4. 继续 b = 节点4:yield_paths(节点4,6):4 ≠ 6;for 取 b = 节点6;yield_paths(节点6,6):6 == 6 → yield [6],冻结。
  5. 节点4 那层拿到 [6] → yield [4] + [6] = [4, 6],冻结。
  6. 节点2 那层拿到 [4,6] → yield [2, 4, 6],冻结。
  7. 节点1 那层拿到 [2,4,6] → yield [1, 2, 4, 6],冻结。
  8. 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 之后停在哪;哪些值已被预读

真正值得带走的几件事

1 遍历一个正在被修改的容器,永远是 bug。 解法只有两个:遍历一份快照(list(items)),或者倒着遍历索引。Q1 用的是前者,因为它读起来最清楚。这个坑在任何语言里都存在。
2 树递归有两个固定骨架,选对了题就做完一半。 「存在性 / 搜索」型:if 命中: return True 在循环里,return False 在循环外(Q2)。「聚合」型:累加器初始化 → 循环里累加 → 循环外返回(Q3)。「重建」型:造 new_branches 列表 → 逐个决定保留还是递归 → tree(label(t), new_branches)(Q4)。
3 空 for 循环就是树递归的 base case。 Q2、Q3、Q6 都没写显式的 if is_leaf(t),因为叶子的 branches(t) 是空列表,循环零次自然落到后面的语句。写不写显式 base case 是风格问题,但你必须知道叶子情形是怎么被处理掉的。
4 「列表版 → 生成器版」是机械转换。 想不清楚生成器怎么写,就先写返回列表的版本,然后把 result.append(X) 换成 yield X、删掉 result 和 return。Q6 就是这么来的。反过来,看不懂一个生成器,就在脑子里把 yield 换成 append。
5 双参数 next(it, default) 是处理「可能耗尽的迭代器」的标准工具。 它把「异常控制流」换成「哨兵值控制流」,代码从满地 try/except 变成一串 while x is not None。前提是哨兵值不能和真实数据撞车——Q5 的题面专门保证了元素里没有 None。
6 抽象屏障不是形式主义。 Q2–Q4 全程只用 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。