HW 4:面向对象编程、继承与可变树ok 4 项通过
四道题,四种「状态放在哪里」的问法:对象里的状态、树节点里的状态、子类继承来的状态。
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 case | Q2、Q3 |
| f-string | f'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 能榨出五条硬性规则,每一条都值得单独盯一眼:
VendingMachine('candy', 10) 只传了商品和价格,
可第一次 vend() 就说「没货」,说明 stock 必须在 __init__ 里被初始化成 0,
而不是等 restock 第一次调用时才凭空出现。add_funds 要把钱退回去,而且余额不许变。
注意第二行输出是 Here is your $15.——$15 是这次投的钱,不是余额。
再看最后一行:机器把两个 candy 都卖完后,add_funds(15) 又打印 Here is your $15.。
如果当时把 15 加进了 balance,这个数字就会变成别的。所以缺货分支必须直接 return,不做任何修改。Please add $10 more funds.;
投了 7 之后余额 7,说 Please add $3 more funds.。所以这个数是 price - balance。'Here is your candy and $2 change.';
余额 10、价 10 → 'Here is your candy.'。差额恰好为 0 时不能输出
and $0 change。这是最容易丢分的一处。'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'Here is your ${n}.' 里,$ 是普通字符,{n} 才是被替换的部分,
结果是 Here is your $15.。别把 $ 写进花括号里,也别忘了它——
少一个美元符号,ok 就报 doctest 失败。
验证
把第一段 doctest 完整跑一遍,跟踪对象 v 身上的四个属性。
下表每一行是一次方法调用,「调用后」列是执行完毕时的状态。
| # | 调用 | 调用前 stock / balance | 走哪条分支 | 返回值 | 调用后 stock / balance |
|---|---|---|---|---|---|
| 0 | VendingMachine('candy', 10) | — | __init__ | 对象本身 | 0 / 0 |
| 1 | v.vend() | 0 / 0 | stock == 0 | 'Nothing left to vend. Please restock.' | 0 / 0 |
| 2 | v.add_funds(15) | 0 / 0 | stock == 0,退钱 | 'Nothing left to vend. Please restock. Here is your $15.' | 0 / 0 |
| 3 | v.restock(2) | 0 / 0 | 累加 | 'Current candy stock: 2' | 2 / 0 |
| 4 | v.vend() | 2 / 0 | missing = 10 - 0 = 10 > 0 | 'Please add $10 more funds.' | 2 / 0 |
| 5 | v.add_funds(7) | 2 / 0 | 有货,入账 | 'Current balance: $7' | 2 / 7 |
| 6 | v.vend() | 2 / 7 | missing = 10 - 7 = 3 > 0 | 'Please add $3 more funds.' | 2 / 7 |
| 7 | v.add_funds(5) | 2 / 7 | 有货,入账 | 'Current balance: $12' | 2 / 12 |
| 8 | v.vend() | 2 / 12 | missing = -2,change = 2 > 0 | 'Here is your candy and $2 change.' | 1 / 0 |
| 9 | v.add_funds(10) | 1 / 0 | 有货,入账 | 'Current balance: $10' | 1 / 10 |
| 10 | v.vend() | 1 / 10 | missing = 0,change = 0 | 'Here is your candy.' | 0 / 0 |
| 11 | v.add_funds(15) | 0 / 0 | stock == 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)]) 完全对上。
这里有两个必须先想清楚的边界情况:
- 叶子节点:
t.branches是空列表,乘积就是它自己的标签,标签不变。 所以不需要写if t.is_leaf()——只要循环体是「对每个分支做点什么」, 空列表天然会跳过循环,代码自动正确。这是树递归里最舒服的一件事:base case 是免费的。 - 返回值必须是
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.label | for 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 分别覆盖三种情况,读法如下:
t1 = Tree(6) 是个孤零零的叶子,0 个分支,0 > 2 不成立,什么都不做。
这说明「分支数不足 n」是合法的,不需要补齐——题目只管砍,不管添。t2 有 2 个分支、n = 1,砍掉一个。留下的是标签更小的 Tree(3),
被删的是标签最大的 Tree(4)。「保留最小的 n 个」=「反复删掉最大的那个,直到只剩 n 个」。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 里藏着五条规格,一条条挖出来:
my_cat.name 返回 'Furball':说明 Cat.__init__ 必须也把
name 和 owner 存好。既然 Pet.__init__ 已经干了这活,就别重写,调用它。my_cat.talk() 打印 Meow! 而不是 Furball:
说明 Cat 覆盖(override)了 Pet.talk。用的是 print 不是 return——
doctest 里 Meow! 没有引号,而 'Furball' 有。my_cat.lose_life() 没有任何输出:既不打印也不返回值(返回 None)。
猫是 lives=2 出生的,掉一条还剩 1,还活着,所以 is_alive 是 True。
说明 lives 和 is_alive 都是 Cat 独有的实例属性——
Pet.__init__ 里根本没有它们,得自己加。my_cat.eat("poison") 打印两行:Meow! 然后 Furball ate a poison!,
并且把 is_alive 设成 False。这个方法是题目给的,重点是理解它为什么会喵——
它的第一行是 self.talk()。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 | 没有 | 新增,初始 True | doctest 要读它 |
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 只有「掉一条命」和「死了」两种情况,写着写着会发现是三种:
- 已经死了(
is_alive为False)→ 返回'Cat is dead x_x',不再减命。 - 活着,掉完还有命 →
lives -= 1,返回None。 - 活着,这是最后一条命 →
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_small | while + max(key=…) + list.remove;先剪后递归 | 遍历列表时不能删它的元素;剪断一根引用等于删掉整棵子树;key 参数返回的是元素本身 | 排行榜取 Top-K、图的剪枝、任何「反复取极值并移除」的循环 |
Cat | 继承、覆盖、super()、动态分派 | 继承是「查找链」不是「代码复制」;父类写的 self.talk() 会调到子类版本;两个状态属性之间不要互相推断 | Lecture 14 的 __repr__/__str__、Ants 项目的各种蚂蚁子类 |
三个可以带走的判断法则
-> None,就别 return。
Q2 和 Q3 都是这样,doctest 的形式(调用那行下面空白,然后单独打印对象)是最可靠的信号。
反过来,doctest 里期望值带引号就说明要 return 字符串,不带引号就是 print。for b in t.branches 遇到空列表自动跳过」终止。
只有当叶子需要特殊行为(而不是「什么都不做」)时才写 if t.is_leaf()。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 生成。