CS 61A  /  作业解析
HOMEWORK 4

HW 4:面向对象编程、继承与可变树ok 4 项通过

四道题,四种「状态放在哪里」的问法:对象里的状态、树节点里的状态、子类继承来的状态。

对应讲次:Lecture 13 对象与属性、Lecture 14 继承、Lecture 15 可变树 官方题面:cs61a.org/hw/hw04 代码:hw/hw04/hw04.py 验证:本仓库 python3 ok --local → 4 test cases passed! No cases failed.

0. 这份作业在练什么

先说一件容易搞混的事:这份作业的标题是 Object-Oriented Programming, Inheritance, Mutable Trees, 和迭代器 / 生成器 / 异常没有关系。迭代器与生成器是 Lecture 10、 异常是 Lecture 11 的内容,它们在别的作业里练。 本页严格按官方题面 hw04 的四道题走,对应的是第 13、14、15 讲。

这四道题看上去风马牛不相及——一台自动售货机、一棵树的累乘、一棵树的剪枝、一只猫—— 但它们问的是同一个问题的四个侧面:「随时间变化的状态(state)应该存在哪里,由谁负责改它」。

在这门课的前半程,你写的函数几乎都是「纯」的:给定输入算出输出,不留痕迹。 square(3) 昨天返回 9,今天也返回 9。但现实里的东西不是这样: 一台售货机在你投了 7 块钱之后,就和投钱之前不是一个「它」了; 下一次调用 vend() 的结果取决于历史。要表达这种东西,你需要一个能记住东西的容器。

第 13 讲给了这个容器:对象(object)。一个对象就是一堆实例属性(instance attribute) 外加一堆能读写这些属性的方法(method)。第 14 讲告诉你,当两类对象长得很像时, 可以让一个继承(inherit)另一个,只重写不同的部分。第 15 讲则把之前用列表实现的树 换成对象实现,于是树第一次可以被就地修改(mutate)了。

本次要点
  • Q1 VendingMachine:从零设计一个类。难点不在语法,在于想清楚需要哪几个实例属性, 以及每个方法在什么条件下改哪个属性。字符串必须一字不差。
  • Q2 cumulative_mul:可变树上的递归。难点在顺序——先递归改子树,再用改完的子树标签算自己。
  • Q3 prune_small:一边遍历一边删元素的陷阱,以及 max(..., key=...) 与 list.remove 的配合。
  • Q4 Cat:继承。super().__init__(...) 到底做了什么, 为什么 eat 里的 self.talk() 会喵一声而不是打印名字。

做之前该掌握的东西

概念一句话不懂会卡在哪
类 / 实例VendingMachine 是模板,VendingMachine('candy', 10) 造出的才是具体的机器Q1 分不清 self.price 和 price
self方法的第一个参数,调用 v.vend() 时 Python 自动把 v 传进去Q1、Q4 全部
实例属性赋值self.stock = 0 是在这个对象上建立/修改一个绑定Q1 状态更新
可变对象与别名t.branches 是一个真实的列表,别人拿到它就能改它Q2、Q3
树的递归对每个 b in t.branches 递归;叶子是 branches 为空的自然 base caseQ2、Q3
f-stringf'Current balance: ${self.balance}',花括号里的表达式会被求值并插进去Q1 输出格式
一个贯穿全篇的直觉

这四道题里,三道题的函数/方法都返回 None 或者只返回一句话,真正的"结果"是副作用。 cumulative_mul 和 prune_small 的签名都写着 -> None, doctest 里也是先调用、再单独把树打出来看。这是一个信号:不要试图 return 一棵新树, 要原地改。习惯了写纯函数的人在这里最容易翻车。

1. Vending Machine(VendingMachine 类)

题目要什么

实现一个只卖一种商品的自动售货机。它有四个方法:

方法做什么返回什么
__init__(product, price)记住商品名和单价,并把其他状态初始化(构造器,无返回值)
restock(n)库存加 n'Current candy stock: 2'
add_funds(n)投币 n 元余额消息,或者「没货,退钱」
vend()尝试出货出货消息 / 缺货消息 / 缺钱消息

题面反复强调:输出字符串必须和 doctest 一模一样,包括标点和空格。 这不是刁难——它逼你把每一个分支的条件想精确。我们把 doctest 逐行读成一张「状态-输入-输出」表, 边界情况就自己浮出来了:

>>> v = VendingMachine('candy', 10)
>>> v.vend()
'Nothing left to vend. Please restock.'
>>> v.add_funds(15)
'Nothing left to vend. Please restock. Here is your $15.'
>>> v.restock(2)
'Current candy stock: 2'
>>> v.vend()
'Please add $10 more funds.'
>>> v.add_funds(7)
'Current balance: $7'
>>> v.vend()
'Please add $3 more funds.'
>>> v.add_funds(5)
'Current balance: $12'
>>> v.vend()
'Here is your candy and $2 change.'
>>> v.add_funds(10)
'Current balance: $10'
>>> v.vend()
'Here is your candy.'
>>> v.add_funds(15)
'Nothing left to vend. Please restock. Here is your $15.'

从这段 doctest 能榨出五条硬性规则,每一条都值得单独盯一眼:

1 新机器的库存是 0,余额是 0。VendingMachine('candy', 10) 只传了商品和价格, 可第一次 vend() 就说「没货」,说明 stock 必须在 __init__ 里被初始化成 0, 而不是等 restock 第一次调用时才凭空出现。
2 没货时 add_funds 要把钱退回去,而且余额不许变。 注意第二行输出是 Here is your $15.——$15 是这次投的钱,不是余额。 再看最后一行:机器把两个 candy 都卖完后,add_funds(15) 又打印 Here is your $15.。 如果当时把 15 加进了 balance,这个数字就会变成别的。所以缺货分支必须直接 return,不做任何修改。
3 钱不够时报的是「还差多少」,不是「一共要多少」。 库存 2、余额 0、单价 10 时说 Please add $10 more funds.; 投了 7 之后余额 7,说 Please add $3 more funds.。所以这个数是 price - balance。
4 找零和不找零是两句不同的话。余额 12、价 10 → 'Here is your candy and $2 change.'; 余额 10、价 10 → 'Here is your candy.'。差额恰好为 0 时不能输出 and $0 change。这是最容易丢分的一处。
5 成功出货后余额清零。看 'Here is your candy and $2 change.' 之后紧接着 add_funds(10) 返回 'Current balance: $10'——如果那 2 块钱留在机器里, 这里应该是 $12。所以找零是全部吐出来,balance 归 0,不是减去 price 而已。 (本例中两者数值相同,但语义上「余额清零」才是对的,而且下一句 vend() 返回 'Here is your candy.' 印证了此时余额恰好是 10 而非 12。)

第二段 doctest 补了一条:restock 是累加而不是覆盖—— w.restock(3) 两次得到 'Current soda stock: 6'。

怎么想到的

看到「实现一个类」,很多人第一反应是从 __init__ 开始逐个方法往下写。 这条路会写崩,因为你还不知道需要哪些属性。正确的顺序是反过来的:先看方法要回答什么问题, 倒推需要记住什么。

把四个方法的需求列一列:

  • restock 要输出 Current candy stock: 2 → 需要记住商品名和库存。
  • vend 要判断「钱够不够」 → 需要记住单价和已投金额。
  • add_funds 要判断「有没有货」 → 又用到库存。

去重之后就是四个:product、price、stock、balance。 前两个由构造参数直接给定,后两个是「机器一生下来就有、但初始为 0」的状态。这就是 __init__ 的全部内容。

关键一步:把每个方法写成「先排除异常,再做正事」

vend 有三种结局,如果用 if/elif/else 三岔并列去写,条件会互相纠缠 (「有货且钱够」「有货且钱不够」「没货」)。更清爽的写法是守卫式(guard clause): 把不能出货的情况一个一个提前 return 掉,能走到函数末尾的就一定是正常路径。 这样每个 if 只需要判断一个条件,不用带上「有货」这个前提。

我写第一版时在找零那里踩了坑。最初写的是:

change = self.balance - self.price
return f'Here is your {self.product} and ${change} change.'

跑 doctest 立刻挂:余额刚好等于单价时输出成了 'Here is your candy and $0 change.',而期望是 'Here is your candy.'。 于是加一个 if change > 0 分岔。官方解答用的是另一种拼法——先拼半句 message = f'Here is your {self.product}',需要找零时再 += 一段, 最后统一补句号。两种写法都对,本仓库用的是两条完整 return 的版本, 理由是整句摆在眼前,更容易和 doctest 逐字比对;拼接版则容易把句号漏在某个分支里。

还有一个顺序陷阱:change 必须在 self.balance = 0 之前算好。 如果先清零再算差额,得到的就是 -price,输出会变成负数。这是可变状态的通病—— 一旦你改了某个属性,之前基于它算出的东西就作废了,所以先把需要的数捞出来,再动状态。

代码

class VendingMachine:
    def __init__(self, product: str, price: int):
        self.product = product
        self.price = price
        self.stock = 0      # how many units are left inside the machine
        self.balance = 0    # money the customer has inserted so far

    def restock(self, n: int) -> str:
        self.stock += n
        return f'Current {self.product} stock: {self.stock}'

    def add_funds(self, n: int) -> str:
        if self.stock == 0:
            # Refuse the money: the balance is left untouched and n is handed back.
            return f'Nothing left to vend. Please restock. Here is your ${n}.'
        self.balance += n
        return f'Current balance: ${self.balance}'

    def vend(self) -> str:
        if self.stock == 0:
            return 'Nothing left to vend. Please restock.'
        missing = self.price - self.balance
        if missing > 0:
            return f'Please add ${missing} more funds.'
        # Enough money: dispense one unit and reset the balance.
        change = self.balance - self.price
        self.stock -= 1
        self.balance = 0
        if change > 0:
            return f'Here is your {self.product} and ${change} change.'
        return f'Here is your {self.product}.'

逐行说说为什么是这样而不是别样:

  • self.product = product:右边的 product 是局部形参,只在 __init__ 这一帧里活着; 左边的 self.product 是挂在对象上的属性,函数返回后依然存在。 两个名字长得一样纯属方便,写成 self.product = product 不是「自己赋给自己」。
  • self.stock = 0 / self.balance = 0:这两行是整道题最容易漏的。 不写的话,第一次 v.vend() 执行 self.stock == 0 时会抛 AttributeError: 'VendingMachine' object has no attribute 'stock'。 Python 不会替你声明属性,属性是被第一次赋值时才创建出来的。
  • self.stock += n:等价于 self.stock = self.stock + n—— 先读出旧值、加上 n、再写回同一个属性。这就是「累加而非覆盖」的来源。
  • if self.stock == 0:(在 add_funds 里):整个分支体只有一条 return, 一个属性都没碰。这正是规则 2 的要求——退钱意味着这笔钱从来没进过机器。 用的是 n 而不是 self.balance,因为退的是「你刚投的这笔」。
  • missing = self.price - self.balance:起个名字比直接把表达式塞进 f-string 好, 因为它要用两次(一次判断、一次打印)。missing > 0 而不是 != 0: 余额多于单价时 missing 是负数,那属于「够了还要找零」,不该报缺钱。
  • change = self.balance - self.price 写在两行状态修改之前:理由上面说过了。
  • self.stock -= 1:一次只出一件货,不是清空库存。
  • 最后两条 return:change > 0 走带找零的句子,否则(只可能是 change == 0) 走干净的那句。注意两句都以句号结尾,而且 and $2 change 里 $ 紧贴数字、 change 前有一个空格。这些细节直接决定 ok 过不过。
关于 f-string 里的 $

f'Here is your ${n}.' 里,$ 是普通字符,{n} 才是被替换的部分, 结果是 Here is your $15.。别把 $ 写进花括号里,也别忘了它—— 少一个美元符号,ok 就报 doctest 失败。

验证

把第一段 doctest 完整跑一遍,跟踪对象 v 身上的四个属性。 下表每一行是一次方法调用,「调用后」列是执行完毕时的状态。

#调用调用前 stock / balance走哪条分支返回值调用后 stock / balance
0VendingMachine('candy', 10)—__init__对象本身0 / 0
1v.vend()0 / 0stock == 0'Nothing left to vend. Please restock.'0 / 0
2v.add_funds(15)0 / 0stock == 0,退钱'Nothing left to vend. Please restock. Here is your $15.'0 / 0
3v.restock(2)0 / 0累加'Current candy stock: 2'2 / 0
4v.vend()2 / 0missing = 10 - 0 = 10 > 0'Please add $10 more funds.'2 / 0
5v.add_funds(7)2 / 0有货,入账'Current balance: $7'2 / 7
6v.vend()2 / 7missing = 10 - 7 = 3 > 0'Please add $3 more funds.'2 / 7
7v.add_funds(5)2 / 7有货,入账'Current balance: $12'2 / 12
8v.vend()2 / 12missing = -2,change = 2 > 0'Here is your candy and $2 change.'1 / 0
9v.add_funds(10)1 / 0有货,入账'Current balance: $10'1 / 10
10v.vend()1 / 10missing = 0,change = 0'Here is your candy.'0 / 0
11v.add_funds(15)0 / 0stock == 0,退钱'Nothing left to vend. Please restock. Here is your $15.'0 / 0

重点看第 8 行和第 9 行的衔接:第 8 步把 balance 清成 0,所以第 9 步投 10 元后余额正好是 10, 第 10 步才能不找零。要是第 8 步写成 self.balance -= self.price——本例中效果相同(12 - 10 = 2… 等等, 不同!那样余额会剩 2),第 9 步就会输出 'Current balance: $12',doctest 当场失败。 这一步是全题的试金石。

再看第 11 行:stock 在第 10 步被减到 0,所以最后一次投币又回到了「没货退钱」的分支, 输出和第 2 行完全一致——这正是 doctest 特意安排的对称,用来检查你有没有在退钱分支里偷偷改 balance。

第 8 步 v.vend() 执行到一半时的帧:

  Global frame
    VendingMachine ──→ class VendingMachine
    v ─────────────────────────┐
                               ▼
                    ┌─ VendingMachine 实例 ──┐
                    │ product : 'candy'      │
                    │ price   : 10           │
                    │ stock   : 2  → 1       │
                    │ balance : 12 → 0       │
                    └────────────────────────┘
  f1: VendingMachine.vend   [parent = Global]
    self    ──→ 上面那个实例
    missing : -2
    change  : 2          ← 在 balance 被清零之前算好的
常见误区
  • 忘了在 __init__ 里初始化 stock / balance → AttributeError: 'VendingMachine' object has no attribute 'stock'。
  • 把 stock 写成类属性(写在 class 下、方法外的 stock = 0)→ 单机测试能过,但两台机器会互相干扰……其实不会, 因为 self.stock += n 会在实例上新建一个属性遮住类属性; 真正的问题是它让人误以为共享,读代码的人会被绕晕。状态属于单台机器,就写进 __init__。
  • 在缺货分支里也执行了 self.balance += n → 第 11 行会输出 Here is your $15. 但机器里悄悄多了 15 块钱;虽然这一版 doctest 抓不到, ok 的隐藏测试会。守卫分支就该什么都不改。
  • vend 里忘了 self.stock -= 1 → 一台机器能无限出货, 第 10 步会输出 'Here is your candy.' 但第 11 步变成 'Current balance: $15'。
  • 输出用 print 而不是 return → doctest 显示的是 Current balance: $7(无引号)之后还有一行 None,直接失败。 题面里所有期望值都带单引号,这是「返回字符串」的标志。

2. Cumulative Mul(cumulative_mul)

题目要什么

给一棵 Tree,把每个节点的标签换成「它自己的标签 × 以它为根的子树里所有标签」的乘积。 换句话说:每个节点变成它那棵子树的总乘积。函数就地修改这棵树,返回 None。

>>> t = Tree(1, [Tree(3, [Tree(5)]), Tree(7)])
>>> cumulative_mul(t)
>>> t
Tree(105, [Tree(15, [Tree(5)]), Tree(7)])
>>> otherTree = Tree(2, [Tree(1, [Tree(3), Tree(4), Tree(5)]), Tree(6, [Tree(7)])])
>>> cumulative_mul(otherTree)
>>> otherTree
Tree(5040, [Tree(60, [Tree(3), Tree(4), Tree(5)]), Tree(42, [Tree(7)])])

先把第一个例子看懂。原树长这样:

      1
     / \
    3   7
    |
    5
  • 叶子 5:它的子树只有它自己,乘积 = 5,不变。
  • 叶子 7:同理,不变。
  • 节点 3:子树是 {3, 5},乘积 = 15。
  • 根 1:子树是整棵树 {1, 3, 5, 7},乘积 = 105。

结果 Tree(105, [Tree(15, [Tree(5)]), Tree(7)]) 完全对上。 这里有两个必须先想清楚的边界情况:

两个边界
  1. 叶子节点:t.branches 是空列表,乘积就是它自己的标签,标签不变。 所以不需要写 if t.is_leaf()——只要循环体是「对每个分支做点什么」, 空列表天然会跳过循环,代码自动正确。这是树递归里最舒服的一件事:base case 是免费的。
  2. 返回值必须是 None。doctest 里 cumulative_mul(t) 那一行下面什么都没有, 说明这个调用不产生输出。如果你 return t,doctest 会期望空白却看到 Tree(105, ...),直接失败。

怎么想到的

第一个念头往往是:「我需要一个函数算出子树的乘积,然后把它赋给标签。」于是写出:

# 第一版思路(不采用)
def subtree_product(t):
    total = t.label
    for b in t.branches:
        total *= subtree_product(b)
    return total

def cumulative_mul(t):
    t.label = subtree_product(t)      # 只改了根!
    for b in t.branches:
        cumulative_mul(b)             # 再去改子树

这个版本确实能得到正确答案,但它有个致命的性质:subtree_product 会把整棵子树重新扫一遍, 而外层又对每个节点各调一次,总代价是 \(\Theta(n \cdot h)\)(\(h\) 为树高), 链状树上退化成 \(\Theta(n^2)\)。更糟的是它顺序反了——先改根、再改子树。 根改完之后子树还是原值,所以侥幸对;但只要你把两句调换(先递归改子树,再用 subtree_product 算根), 根算出来的就是「已经被放大过的子树标签」的乘积,答案立刻爆炸。这种「对与不对取决于两行的先后」的代码, 是应该被替换掉的。

关键一步:让递归调用帮你把答案准备好

递归的正确用法是相信递归调用(leap of faith):假设 cumulative_mul(b) 已经正确执行完毕, 那么此刻 b.label 就已经等于整棵子树 b 的乘积了。 于是根的乘积 = t.label × 每个 b.label 的连乘,一次乘法搞定,不用再扫子树。

这就是题面 Hint 说的那句话:「小心你修改当前节点标签和处理子树的顺序;哪个该先?」 答案是子树先。这种「先把所有子问题解决,再合并成自己的答案」的模式叫 后序遍历(post-order traversal);反过来「先处理自己再往下」叫前序(pre-order)。 一个实用的判据:如果父节点的答案依赖子节点的答案,就必须后序。

想通这一点后,代码只剩三行。还有个小选择:写一个循环还是两个?官方解答给了两个版本:

两个循环一个循环(本仓库采用)
写法先 for b: cumulative_mul(b),再 for b: total *= b.labelfor b: cumulative_mul(b); t.label *= b.label
为什么对第一个循环跑完,所有子树都已就位每个 b 刚被修好,就立刻拿它去乘
会不会出错不会不会——b 的修复只依赖 b 自己的子树,与兄弟无关

合并成一个循环之所以安全,是因为 cumulative_mul(b) 只碰以 b 为根的那部分, 不会回头改 t.label,也不会改它的兄弟。所以「修好一个就立刻乘进去」和 「全修好再一起乘」结果相同。

代码

def cumulative_mul(t: Tree) -> None:
    # Fix the subtrees first: after each recursive call, b.label already holds
    # the product of everything in that subtree, so one multiplication suffices.
    for b in t.branches:
        cumulative_mul(b)
        t.label = t.label * b.label

逐行讲:

  • for b in t.branches:——遍历所有直接子节点。没有显式 base case: 当 t 是叶子时 t.branches == [],循环体一次都不执行,函数直接结束, t.label 保持原样。这恰好就是「叶子的子树乘积等于它自己」。 写成 if t.is_leaf(): return 也对,但纯属多余。
  • cumulative_mul(b)——先递归。这一行执行完之后,整棵 b 子树都已经被改写成累乘形式, 其中 b.label 就是那棵子树的总乘积。这是本题唯一的关键。 把这行挪到下一行之后,答案就错——那时 b.label 还是原始标签, 根只会乘上直接子节点的原值,孙子辈全被漏掉。
  • t.label = t.label * b.label——把这棵子树的乘积并进自己。 写成 t.label *= b.label 完全等价。注意左边是属性赋值: 它真的改了树对象的状态,这就是函数不需要返回值的原因。 如果这里写成局部变量 total *= b.label 而忘了最后赋回 t.label, 树纹丝不动,doctest 打印出的还是原树。
  • 没有 return 语句——函数末尾自然结束,Python 返回 None,符合 doctest 期望。

验证

拿 t = Tree(1, [Tree(3, [Tree(5)]), Tree(7)]) 真的把调用栈展开。 为了叙述方便,给四个节点起名:根 A(1)、左 B(3)、B 的孩子 C(5)、右 D(7)。

逐步推演
cumulative_mul(A)                       A.branches = [B, D]
├─ 循环第 1 轮,b = B
│  ├─ cumulative_mul(B)                 B.branches = [C]
│  │  ├─ 循环第 1 轮,b = C
│  │  │  ├─ cumulative_mul(C)           C.branches = []  → 循环不执行,直接返回
│  │  │  │                              C.label 仍为 5
│  │  │  └─ B.label = B.label * C.label = 3 * 5 = 15
│  │  └─ 循环结束,返回 None            此刻 B.label = 15
│  └─ A.label = A.label * B.label = 1 * 15 = 15
├─ 循环第 2 轮,b = D
│  ├─ cumulative_mul(D)                 D.branches = []  → 立即返回
│  │                                    D.label 仍为 7
│  └─ A.label = A.label * D.label = 15 * 7 = 105
└─ 循环结束,返回 None                  此刻 A.label = 105

最终各节点标签:A = 105、B = 15、C = 5、D = 7, 打印出来就是 Tree(105, [Tree(15, [Tree(5)]), Tree(7)]),与 doctest 一致。

特别留意 A.label 的演化:1 → 15 → 105。它是被累乘出来的, 每轮循环把一棵已经修好的子树并进来。第一轮结束时 A 的标签 15 是个「半成品」, 只包含左半边;但因为没人在循环中途读它,这个中间状态无害。 这也解释了为什么单循环版本安全。

再快速核一下第二个例子 Tree(2, [Tree(1, [Tree(3), Tree(4), Tree(5)]), Tree(6, [Tree(7)])]):

节点原标签子树里的所有标签新标签
三个叶子3 / 4 / 5各自单独3 / 4 / 5
叶子7{7}7
左子节点1{1,3,4,5}1×3×4×5 = 60
右子节点6{6,7}6×7 = 42
根2全部 {2,1,3,4,5,6,7}2×60×42 = 5040

根那一行值得看仔细:代码算的是 2 * 60 * 42,用的是已经被改过的子节点标签, 而不是原始的 1 和 6。如果误用原值就是 2*1*6 = 12,差得离谱。 2 × 60 × 42 = 5040,与 doctest 的 Tree(5040, ...) 吻合。

常见误区
  • 先乘再递归:
    for b in t.branches:
        t.label *= b.label
        cumulative_mul(b)       # 错:此时 b.label 还是原值
    第一个例子会得到 Tree(21, [Tree(15, [Tree(5)]), Tree(7)])—— 根只乘到了 3 和 7(1×3×7 = 21),5 被漏掉了。
  • 返回而不是修改:写 return total 而没有 t.label = ...。 doctest 里 cumulative_mul(t) 那行会多打印一个数字, 而且随后打印 t 得到的是原封不动的 Tree(1, [Tree(3, [Tree(5)]), Tree(7)])。
  • 写 t.branches = [...] 造新分支:这道题只需要改 label, 不要动 branches。重建分支列表会破坏别处对这些子树的引用。
  • 用 t.label = t.label * cumulative_mul(b) → TypeError: unsupported operand type(s) for *: 'int' and 'NoneType', 因为 cumulative_mul 返回 None。它的产出在树里,不在返回值里。

3. Prune Small(prune_small)

题目要什么

「剪枝(pruning)」就是从树上删掉一些节点。给一棵树 t 和一个数 n: 对每一个节点,如果它的分支多于 n 个,就只保留标签最小的 n 个分支, 其余整棵剪掉。同样是就地修改,返回 None。

>>> t1 = Tree(6)
>>> prune_small(t1, 2)
>>> t1
Tree(6)
>>> t2 = Tree(6, [Tree(3), Tree(4)])
>>> prune_small(t2, 1)
>>> t2
Tree(6, [Tree(3)])
>>> t3 = Tree(6, [Tree(1), Tree(3, [Tree(1), Tree(2), Tree(3)]), Tree(5, [Tree(3), Tree(4)])])
>>> prune_small(t3, 2)
>>> t3
Tree(6, [Tree(1), Tree(3, [Tree(1), Tree(2)])])

三个 doctest 分别覆盖三种情况,读法如下:

1 t1 = Tree(6) 是个孤零零的叶子,0 个分支,0 > 2 不成立,什么都不做。 这说明「分支数不足 n」是合法的,不需要补齐——题目只管砍,不管添。
2 t2 有 2 个分支、n = 1,砍掉一个。留下的是标签更小的 Tree(3), 被删的是标签最大的 Tree(4)。「保留最小的 n 个」=「反复删掉最大的那个,直到只剩 n 个」。
3 t3 是关键例子:根有 3 个分支,删掉标签最大的 Tree(5, [...])—— 注意整棵子树连同它的两个孩子一起消失,不是只删那个节点。 剩下的 Tree(3, [Tree(1), Tree(2), Tree(3)]) 自己有 3 个分支,也要剪成 2 个, 删掉 Tree(3)。而 Tree(1) 是叶子,不动。
一个容易忽略的点:判断依据是分支的标签,不是子树大小

t3 的根删掉的是 Tree(5, [Tree(3), Tree(4)]),它的标签 5 最大。 不要去比较「子树里有几个节点」或者「子树标签之和」——题面说的是 the n branches with the smallest labels,只看分支根节点那一个数。

题面还把骨架给出来了,这本身是很强的提示:

while ____:
    largest = max(____, key=____)
    ____
for b in t.branches:
    ____

结构已经写死:先用一个 while 循环把当前节点剪够,再用一个 for 循环递归处理留下来的分支。 你要填的只是四个空。

怎么想到的

先问:为什么是 while 而不是 for?

直觉上「保留最小的 n 个」应该一步到位:排个序,切前 n 个:

# 一步到位的写法(能过,但骨架不让)
t.branches = sorted(t.branches, key=lambda b: b.label)[:n]

这行确实正确。但骨架逼你用另一条路:反复找出最大的那个删掉,删到只剩 n 个为止。 「反复……直到条件满足」正是 while 的语义,而循环次数事先不知道 (要删几个取决于 len(t.branches) - n),所以不能用 for。

更重要的是,这条路顺带教会你一件事:不要在遍历一个列表的同时删它的元素。 如果你写成

# 危险写法
for b in t.branches:
    if 某条件:
        t.branches.remove(b)      # 一边遍历一边删,会跳过元素

Python 的 for 是用下标往前走的:删掉位置 0 的元素后,原来位置 1 的元素挪到了 0, 而下标已经走到 1 了,于是它被跳过。这个 bug 不会报错,只会静悄悄给出错误答案, 是初学者最难自己发现的一类错。while 写法每轮都重新调用 max(t.branches, ...),看到的永远是最新的列表,天然免疫。

关键一步:max 的 key 参数返回的是元素本身

max([-7, 2, -1], key=abs) 返回 -7 而不是 7—— key 只决定怎么比大小,返回的仍是原来那个元素。 所以 max(t.branches, key=lambda b: b.label) 返回的是一个 Tree 对象, 正好可以直接喂给 t.branches.remove(...)。 如果误写成 max(b.label for b in t.branches),你拿到的是一个整数, remove(整数) 会抛 ValueError: list.remove(x): x not in list, 因为列表里装的是 Tree 而不是 int。

还有一处我一开始想错了:递归的 for 循环该放在 while 之前还是之后? 放之前也能得到正确答案(先把每个子树内部剪好,再决定删谁),但那是白费力气—— 你会先花时间修剪一棵马上就要被整个删掉的子树。放在 while 之后, 被删的子树连碰都不用碰。先砍再往下走,工作量更小,逻辑也更贴合「剪枝」这个词。

最后,base case 在哪?还是那句话:不需要写。叶子的 t.branches 是空列表, len([]) > n 对任何非负 n 都是假,while 一轮都不跑; 接着 for b in [] 也不跑,函数返回。t1 = Tree(6) 那个 doctest 就是在测这个。

代码

def prune_small(t: Tree, n: int) -> None:
    while len(t.branches) > n:
        largest = max(t.branches, key=lambda b: b.label)
        t.branches.remove(largest)
    for b in t.branches:
        prune_small(b, n)

逐行讲:

  • while len(t.branches) > n:——循环条件必须是严格大于。 写成 >= 会多删一个,t2 会剪成 Tree(6); 写成 len(t.branches) != n 会在分支数本来就少于 n 时死循环 (列表已空还在删,最终 ValueError)。t1 这个 doctest 就是来抓后一种错的。
  • largest = max(t.branches, key=lambda b: b.label)—— lambda b: b.label 是一个匿名函数,max 会拿它作用在每个分支上得到比较用的数值。 每轮都重新求一次 max,因为上一轮删掉之后最大值变了。 若有多个分支标签并列最大,max 返回最先出现的那个; remove 也删最先出现的那个,两者一致,不会出错。
  • t.branches.remove(largest)——list.remove 按值查找并删除第一个匹配项。 这里的「值相等」用的是 ==;Tree 类没有定义 __eq__, 所以 == 退化成 is(同一个对象),恰好删掉我们找到的那一棵,不会误伤标签相同的兄弟。 这是原地修改 branches 这个列表,不是重新绑定 t.branches, 所以任何别处持有这个列表的引用也会看到变化。
  • for b in t.branches:——此刻列表已经稳定(while 结束意味着长度 ≤ n), 再也不会在遍历期间被改动,所以用 for 安全。 循环体里 prune_small(b, n) 只会改 b.branches,不会改 t.branches。
  • prune_small(b, n)——n 原样传下去,因为规则对每个节点都一样。
  • 依然没有 return,返回 None。

验证

用第三个 doctest 展开。原树:

             6
       ┌─────┼─────┐
       1     3     5
           ┌─┼─┐  ┌┴┐
           1 2 3  3 4
逐步推演:prune_small(t3, 2)
prune_small(根6, 2)
  branches = [T1, T3, T5],len = 3 > 2  → 进入 while
    第 1 轮:max 按 label 比较 1, 3, 5 → 选中 T5(标签 5)
             t.branches.remove(T5)
             branches 变成 [T1, T3],len = 2
    条件 2 > 2 为假 → 退出 while
    ★ T5 连同它的两个孩子 Tree(3)、Tree(4) 一起从树上消失,
      而且我们从来没有对它调用过 prune_small——省下的工作
  for b in [T1, T3]:
  ├─ prune_small(T1, 2)          T1 = Tree(1),branches = []
  │    len([]) = 0 > 2 假 → while 不跑
  │    for b in [] → 不跑
  │    返回 None,T1 保持 Tree(1)
  └─ prune_small(T3, 2)          T3 = Tree(3, [Tree(1), Tree(2), Tree(3)])
       branches len = 3 > 2 → 进入 while
         第 1 轮:max 比较 1, 2, 3 → 选中 Tree(3)
                  remove 后 branches = [Tree(1), Tree(2)],len = 2
       2 > 2 假 → 退出
       for b in [Tree(1), Tree(2)]:
       ├─ prune_small(Tree(1), 2) → 叶子,无事发生
       └─ prune_small(Tree(2), 2) → 叶子,无事发生
       返回 None
  返回 None

剩下的树:

       6
     ┌─┴─┐
     1   3
       ┌─┴─┐
       1   2

打印即 Tree(6, [Tree(1), Tree(3, [Tree(1), Tree(2)])]),与 doctest 一致。

再看谁指向谁。remove 之前,内存里是这样:

t3 ──→ Tree 实例(根)
          label    : 6
          branches ──→ [ ●, ●, ● ]   ← 一个真实的 list 对象
                         │  │  └──→ Tree(5) 实例 ──→ branches [Tree(3), Tree(4)]
                         │  └─────→ Tree(3) 实例 ──→ branches [Tree(1),Tree(2),Tree(3)]
                         └────────→ Tree(1) 实例 ──→ branches []

执行 t.branches.remove(largest) 之后:

          branches ──→ [ ●, ● ]      ← 同一个 list 对象,长度变成 2
                         │  └─────→ Tree(3) 实例
                         └────────→ Tree(1) 实例

          Tree(5) 实例本身还在内存里,只是再没有人引用它,
          于是被垃圾回收。它的两个孩子随之一起消失。

这张图解释了「为什么删一个分支等于删一整棵子树」:树的结构完全由引用维系, 剪断父亲到儿子的那根引用,整个下游就都够不着了。也解释了为什么必须用 t.branches.remove(...) 而不是 t.branches = 新列表—— 前者改的是那个列表对象本身,语义上等价,但如果有别的变量也指着这个列表 (比如你在别处写了 bs = t.branches),只有前者能让它一起看到变化。

常见误区
  • max 忘了 key:max(t.branches) → TypeError: '>' not supported between instances of 'Tree' and 'Tree'。 Python 不知道两棵树谁大,必须你告诉它比什么。
  • key=b.label 而不是 key=lambda b: b.label: NameError: name 'b' is not defined。key 要的是一个函数, Python 会用每个元素调用它,不是一个现成的值。
  • 用 if 代替 while:if len(t.branches) > n: 只删一个。 分支数是 5、n 是 2 时会剩 4 个,doctest 的三个例子恰好都只需删一次, 所以这个 bug 能骗过本地 doctest,却过不了 ok 的完整测试。
  • 递归写成 prune_small(b.branches, n):传进去的是列表不是树, 下一层 t.branches 会报 AttributeError: 'list' object has no attribute 'branches'。 递归的参数类型必须和函数签名一致——这是「递归的信仰之跃」能成立的前提。
  • 把递归 for 放进 while 里:每删一个分支就把剩下的全部重剪一遍, 结果仍对但做了大量重复工作,而且如果有人在递归里改了标签,行为会变得难以预测。

4. Cat(继承 Pet)

题目要什么

已经有一个 Pet 类:

class Pet:

    def __init__(self, name: str, owner: str) -> None:
        self.name = name
        self.owner = owner

    def talk(self) -> None:
        print(self.name)

要写一个 Cat 类继承它,补上三个方法(eat 题目已经给好,不用写):

>>> my_cat = Cat("Furball", "Me", lives=2)
>>> my_cat.talk()
Meow!
>>> my_cat.name
'Furball'
>>> my_cat.lose_life()
>>> my_cat.is_alive
True
>>> my_cat.eat("poison")
Meow!
Furball ate a poison!
>>> my_cat.is_alive
False
>>> my_cat.lose_life()
'Cat is dead x_x'

这段 doctest 里藏着五条规格,一条条挖出来:

1 my_cat.name 返回 'Furball':说明 Cat.__init__ 必须也把 name 和 owner 存好。既然 Pet.__init__ 已经干了这活,就别重写,调用它。
2 my_cat.talk() 打印 Meow! 而不是 Furball: 说明 Cat 覆盖(override)了 Pet.talk。用的是 print 不是 return—— doctest 里 Meow! 没有引号,而 'Furball' 有。
3 第一次 my_cat.lose_life() 没有任何输出:既不打印也不返回值(返回 None)。 猫是 lives=2 出生的,掉一条还剩 1,还活着,所以 is_alive 是 True。 说明 lives 和 is_alive 都是 Cat 独有的实例属性—— Pet.__init__ 里根本没有它们,得自己加。
4 my_cat.eat("poison") 打印两行:Meow! 然后 Furball ate a poison!, 并且把 is_alive 设成 False。这个方法是题目给的,重点是理解它为什么会喵—— 它的第一行是 self.talk()。
5 最后一次 my_cat.lose_life() 返回字符串 'Cat is dead x_x'(有引号,是返回值不是打印)。 注意此时 lives 还是 1——猫不是耗光生命死的,是被毒死的。 所以 lose_life 的判断依据必须是 is_alive,不能是 lives == 0。 这是本题设计得最精巧的一处陷阱。
注意 Pet 有两个版本

题面开头「Inheritance」那段科普里的 Pet 在 __init__ 里写了 self.is_alive = True。但本题实际给你的 Pet 没有这一行, 只有 name 和 owner。以实际代码为准:is_alive 得由 Cat 自己初始化, 指望父类替你设会得到 AttributeError: 'Cat' object has no attribute 'is_alive'。

怎么想到的

继承题的思考起点永远是同一个问题:哪些东西子类和父类一样(继承就好),哪些不一样(要覆盖或新增)。 列个表:

成员Pet 有吗Cat 该怎么办为什么
name、owner有复用——调 Pet.__init__猫也有名字和主人,行为完全一样
lives没有新增实例属性只有猫有九条命
is_alive没有新增,初始 Truedoctest 要读它
talk有(打印名字)覆盖猫要喵
lose_life没有新增方法猫特有
eat没有题目已给—

「复用父类的 __init__」有两种写法,都在这门课里出现过:

写法形式说明
显式类名调用Pet.__init__(self, name, owner)把 Pet.__init__ 当普通函数用,self 得手动传。官方解答用的是这个。
super()super().__init__(name, owner)super() 代表「以父类的身份看待 self」,self 自动传入。本仓库用的是这个。

两者在这道题里效果完全相同。倾向 super() 的理由是:如果哪天把 class Cat(Pet) 改成继承别的类,super() 不用跟着改;而写死 Pet. 就要改两处。 但要记住一个语法差别:super().__init__(name, owner) 不写 self, Pet.__init__(self, name, owner) 必须写 self。 这是最常见的错误来源,写混了会得到 TypeError: __init__() missing 1 required positional argument: 'owner'。

关键一步:lose_life 的三种状态

初看 lose_life 只有「掉一条命」和「死了」两种情况,写着写着会发现是三种:

  1. 已经死了(is_alive 为 False)→ 返回 'Cat is dead x_x',不再减命。
  2. 活着,掉完还有命 → lives -= 1,返回 None。
  3. 活着,这是最后一条命 → lives -= 1 后变 0,把 is_alive 设成 False,仍返回 None。

把第 1 种写成守卫(提前 return),后两种就能合并成「减一,然后检查是不是归零了」,代码非常短。

我第一版把守卫条件写成了 if self.lives == 0:,跑最后一行 doctest 直接挂: 猫被毒死时 lives 还是 1,所以这个条件为假,代码继续往下把 lives 减成 0, 返回 None,而期望是 'Cat is dead x_x'。改成 if not self.is_alive: 才对。 教训是:is_alive 才是「死没死」的唯一真相,lives 只是导致死亡的原因之一 (另一个原因是毒药,它绕过 lives 直接改 is_alive)。 两个属性之间不是等价关系,别用一个去推断另一个。

代码

class Cat(Pet):
    def __init__(self, name: str, owner: str, lives: int = 9) -> None:
        assert type(lives) == int and  lives > 0
        super().__init__(name, owner)   # reuse Pet's setup of name and owner
        self.lives = lives
        self.is_alive = True

    def talk(self) -> None:
        """A cat says 'Meow!' when asked to talk."""
        print('Meow!')

    def lose_life(self) -> str | None:
        if not self.is_alive:
            return 'Cat is dead x_x'
        self.lives -= 1
        if self.lives == 0:
            self.is_alive = False

    def eat(self, thing: str) -> None:
        self.talk()
        print(f"{self.name} ate a {thing}!")
        if thing == "poison":
            self.is_alive = False

逐行讲:

  • class Cat(Pet):——括号里的 Pet 是基类(base class)。 它的作用是:当你在 Cat 实例上找不到某个属性或方法时,Python 会自动去 Pet 里找。 这条查找链叫属性查找(attribute lookup)。
  • assert type(lives) == int and lives > 0——题目给的,原样保留 (包括那两个空格,本仓库代码与题面逐字一致)。它保证参数合法,与解题逻辑无关。
  • super().__init__(name, owner)——调用 Pet.__init__, 把 self(就是这只新猫)传进去,于是 self.name = 'Furball'、 self.owner = 'Me' 这两行是在猫身上执行的,不是在什么虚构的 Pet 对象上。 没有 Pet 实例被创建——继承不产生第二个对象,只有一只猫。
  • self.lives = lives——右边 lives 是形参,默认值 9; doctest 用 lives=2 覆盖了默认值。
  • self.is_alive = True——放在 super().__init__ 之后之前都行, 因为父类不碰它。但放在后面是好习惯:先让父类把它那部分建好,再补自己的。
  • def talk(self)——同名方法定义在 Cat 里,就遮住了 Pet.talk。 Python 查找 my_cat.talk 时先在 Cat 里找到,根本不会去看 Pet。 这里没有用 super().talk(),因为猫不需要先报名字再喵—— 对比题面 Dog 的例子,那个是先 super().talk() 再补一句。
  • if not self.is_alive: return 'Cat is dead x_x'——守卫。 命中之后什么状态都不改,可以反复调用,每次都返回同一句话。
  • self.lives -= 1——走到这里说明猫活着,一定要减。
  • if self.lives == 0: self.is_alive = False——只在归零那一刻翻转。 注意这个 if 没有 else,也没有 return, 函数走到底返回 None,正是 doctest 里那个「什么都不显示」。 如果这里手滑写 return self.is_alive,doctest 会看到多出来的 False。
  • eat 里的 self.talk()——这是继承里最该盯住的一行。 eat 是写给一般宠物的代码,但 self 在运行时绑定的是一只 Cat, 所以 self.talk 查到的是 Cat.talk,打印 Meow!。 这叫动态分派(dynamic dispatch):调用哪个 talk 不由代码写在哪个类里决定, 而由 self 实际是什么类型决定。

验证

把整段 doctest 走一遍,跟踪 my_cat 的三个属性。

逐步推演
>>> my_cat = Cat("Furball", "Me", lives=2)
   assert 通过(2 是正整数)
   super().__init__("Furball", "Me")  →  self.name='Furball', self.owner='Me'
   self.lives = 2
   self.is_alive = True
   状态:name='Furball'  owner='Me'  lives=2  is_alive=True

>>> my_cat.talk()
   在 Cat 里找到 talk → print('Meow!')
   输出:Meow!            返回 None(doctest 不显示 None)

>>> my_cat.name
   实例属性直接命中 → 'Furball'

>>> my_cat.lose_life()
   not self.is_alive → not True → False,守卫不触发
   self.lives = 2 - 1 = 1
   self.lives == 0 ? 1 == 0 → 假,不改 is_alive
   函数结束,返回 None → doctest 无输出 ✓
   状态:lives=1  is_alive=True

>>> my_cat.is_alive
   True ✓

>>> my_cat.eat("poison")
   self.talk()  →  动态分派到 Cat.talk  →  输出 Meow!
   print(f"{self.name} ate a {thing}!")  →  输出 Furball ate a poison!
   thing == "poison" → 真 → self.is_alive = False
   状态:lives=1(没变!) is_alive=False

>>> my_cat.is_alive
   False ✓

>>> my_cat.lose_life()
   not self.is_alive → not False → True,守卫触发
   return 'Cat is dead x_x'   ← doctest 显示带引号的字符串 ✓
   (lives 保持 1,没有被减)

最后一步再强调一遍:此刻 lives 是 1,如果守卫条件写的是 self.lives == 0, 它是假的,程序会继续往下把 lives 减成 0、把 is_alive 设成 False(本来就是 False)、 返回 None。doctest 期望一行 'Cat is dead x_x',实际什么都没有,测试失败。 doctest 里 lives=2 而不是 lives=1,就是为了让这两种写法产生不同结果。

属性查找的过程画出来是这样:

              ┌─ class Pet ─────────────┐
              │ __init__  →  函数        │
              │ talk      →  函数(打印 name)│
              └───────▲─────────────────┘
                      │ (Cat 的基类)
              ┌─ class Cat ─────────────┐
              │ __init__  →  函数        │
              │ talk      →  函数(打印 Meow!)│  ← 遮住了 Pet.talk
              │ lose_life →  函数        │
              │ eat       →  函数        │
              └───────▲─────────────────┘
                      │ (my_cat 的类)
              ┌─ Cat 实例 ──────────────┐
   my_cat ──→ │ name     : 'Furball'    │
              │ owner    : 'Me'         │
              │ lives    : 2 → 1        │
              │ is_alive : True → False │
              └─────────────────────────┘

求值 my_cat.talk:
  1. 先看实例自己的属性字典 —— 没有叫 talk 的
  2. 再看它的类 Cat —— 找到了,用 Cat.talk,查找结束
  3. 永远不会走到 Pet.talk

求值 my_cat.owner:
  1. 实例属性字典里有 —— 直接返回 'Me'
  (这个绑定是 Pet.__init__ 在 super() 调用中写进去的)
核心结论

继承没有创造「一个 Pet 加一个 Cat」两个对象,也没有把 Pet 的代码复制进 Cat。 它只做了一件事:给 Cat 类挂上一个「找不到就去这儿找」的指针。 所有属性查找都沿着「实例 → 类 → 基类 → 基类的基类 → …」走,第一个命中的胜出。 覆盖(override)就是在更靠前的位置放一个同名的东西。

常见误区
  • 忘了调用父类的 __init__:my_cat.name 报 AttributeError: 'Cat' object has no attribute 'name'。 定义 Cat.__init__ 会完全遮住 Pet.__init__, 父类那两行赋值不会自动发生。
  • super().__init__(self, name, owner)(多传了 self)→ TypeError: __init__() takes 3 positional arguments but 4 were given。 super() 已经帮你绑定了 self。
  • Pet.__init__(name, owner)(少传了 self)→ TypeError: __init__() missing 1 required positional argument: 'owner', 因为 name 被当成了 self。
  • talk 里写 return 'Meow!' → doctest 期望裸的 Meow!,实际得到 'Meow!'(带引号),失败。而且 eat 的输出也会少一行。
  • lose_life 用 lives 判断死活 → 最后一行 doctest 失败,理由见上。
  • 把 lives 写成类属性(class Cat(Pet): lives = 9)→ 所有猫共享一个计数,self.lives -= 1 虽然会在实例上新建属性把它遮住, 但语义混乱,而且默认值应该通过参数默认值 lives: int = 9 表达,题目已经这么写了。

整份作业回顾

这四道题合起来在讲一件事:当程序里出现「会变的东西」时,把它放进对象里,让方法成为改它的唯一入口。

回到第 0 节那个问题——纯函数不够用是因为它记不住历史。对象解决了这个问题, 但也带来了新的思维负担:现在你必须时刻问「这一行改了什么?改完之后谁看到了新值?」 这四道题分别从不同角度逼你回答它:

题目核心手法真正学到的东西迁移到哪里
VendingMachine从方法需求倒推实例属性;守卫式分支「状态该有哪几个」不是拍脑袋定的,是被方法的需求逼出来的;读了状态再改状态,顺序不能反任何要建模「一台机器 / 一个账户 / 一局游戏」的场景;后面的 Ants 项目全靠这个
cumulative_mul后序遍历:先递归修好子树,再用子树结果算自己相信递归调用已经把子问题解决了(leap of faith),于是合并步骤只需一次乘法而不是重新扫一遍所有「父节点的答案依赖子节点答案」的树问题:求高度、求和、判平衡
prune_smallwhile + max(key=…) + list.remove;先剪后递归遍历列表时不能删它的元素;剪断一根引用等于删掉整棵子树;key 参数返回的是元素本身排行榜取 Top-K、图的剪枝、任何「反复取极值并移除」的循环
Cat继承、覆盖、super()、动态分派继承是「查找链」不是「代码复制」;父类写的 self.talk() 会调到子类版本;两个状态属性之间不要互相推断Lecture 14 的 __repr__/__str__、Ants 项目的各种蚂蚁子类

三个可以带走的判断法则

1 签名写着 -> None,就别 return。 Q2 和 Q3 都是这样,doctest 的形式(调用那行下面空白,然后单独打印对象)是最可靠的信号。 反过来,doctest 里期望值带引号就说明要 return 字符串,不带引号就是 print。
2 树递归几乎不需要显式 base case。 Q2、Q3 都靠「for b in t.branches 遇到空列表自动跳过」终止。 只有当叶子需要特殊行为(而不是「什么都不做」)时才写 if t.is_leaf()。
3 先把要用的值取出来,再动状态。 Q1 的 change = self.balance - self.price 必须在 self.balance = 0 之前; Q2 的 cumulative_mul(b) 必须在 t.label *= b.label 之前。 同一条原则的两种形态:可变状态让语句的顺序变成了语义的一部分。
验证状态

本仓库 hw/hw04/ 下运行 python3 ok --local 的结果是 4 test cases passed! No cases failed.—— 四道题(VendingMachine、cumulative_mul、prune_small、Cat)全部通过。 本页贴出的每一段代码都与 hw/hw04/hw04.py 里通过评分器的真实代码逐字一致。 这份作业没有 WWPD(What Would Python Display)概念题,tests/ 目录下只有一个空的 __init__.py,所有测试都由 hw04.ok 直接从 docstring 里的 doctest 生成。