问题求解流程与可变性
一、「会做」与「写对」之间的距离
- 这一篇对应官方 Lecture 17(Problem Solving)与 Lecture 18(Mutation):前半段给出一套可复用的求解流程,后半段讲一处容易混淆的区分——修改对象 与 重新赋值。
综合练习的代码往往并不难懂。但面对一个空 docstring 时,写出来的实现常常边界漏一个、参数忘了缩小。
原因不是不会,而是没有流程。一上手就写代码,依赖灵感和记忆,灵感一断就难以继续。
这一篇先给流程,再给一个必须靠流程才能理清的语法点。
二、四步流程:不可跳步
| 步骤 | 做什么 | 作用 |
|---|---|---|
| ① 列边界 | 空 / 单个 / 最小 / 极值,四类列清 | 边界漏写是最常见的错误来源 |
| ② 写 docstring 例子 | 先把「测试」写出来 | 例子就是验收标准 |
| ③ 手工推演最小例子 | 取最小的那组,在纸上推一遍 | 帮助发现逻辑漏洞 |
| ④ 写代码 | 照着 ①③ 的结论实现 | 写代码变成填空 |
第 ③ 步最常被跳过,也最关键。 手工推演一个最小例子只需一两分钟,却能省去后面更长时间的调试。
三、流程示范:max_path_sum
问题:求从根到每个叶子的路径上,节点标签之和的最大值。
① 列边界:叶子(不能再往下走)、空树(本例保证非空,暂不处理)。
② 写例子:Tree(1, [Tree(2), Tree(3)]) 的两条路径是 1+2=3、1+3=4,答案是 4。
③ 手工推演:根节点自身不算完整路径,必须走到叶子才算——所以只有叶子能作为出口。
④ 写代码:
1 | class Tree: |
输出:
1 | 7 |
路径 1+2+4=7 与 1+3=4,取 7。
注意 max([max_path_sum(b) for b in t.branches]) 里那个 max——它要求 branches 非空。所以开头那句 if t.is_leaf() 不是防御性冗余,是让 max 不报错的前提。这正是第 ① 步「列边界」带来的收益。
flowchart TB
P0["列边界"] --> P1["写 docstring 例子"]
P1 --> P2["手工推演最小例子"]
P2 --> P3["写代码"]
P3 --> P4["再跑一遍例子"]
P4 -.->|"不符,回退"| P0
P2 -.->|"发现 max([]) 会报错"| E["补上 is_leaf 分支"]
四、修改 vs 重新赋值
先看一段代码,猜猜输出:
1 | a = [1, 2] |
输出:
1 | [1, 2, 3] [1, 2, 3] |
对照结果非常有信息量:
| 写法 | 叫什么 | 改了什么 | 别的名字受影响吗 |
|---|---|---|---|
a.append(3) |
修改(mutation) | 列表对象内部 | 受影响(b 也变) |
c = c + [3] |
重新赋值(reassignment) | 帧里名字的指向 | 不受影响(d 不变) |
一行口诀:.append() 打的是对象,= 打的是名字。
环境图上两者长得完全不一样:
flowchart LR
subgraph M["修改:动对象内部"]
F1["frame<br/>a → ●<br/>b → ●"] --> O1["list: [1, 2, 3]<br/>(同一个对象被填了新值)"]
end
subgraph R["重新赋值:动名字的指向"]
F2["frame<br/>c → ●<br/>d → ●"] --> O2["list: [1, 2]<br/>(d 还指着它)"]
F2 --> O3["list: [1, 2, 3]<br/>(c 改指这个新对象)"]
end
判断速查:问自己一句「还有别的名字会跟着变吗?」会,就是修改;不会,就是重新赋值。
五、nonlocal:让内层函数能改外层
「记账钱包」是 61A 的经典例子,先看会报错的写法:
1 | def make_withdraw_bad(balance): |
输出:
1 | 报错类型: UnboundLocalError |
原因是 Python 的一条规则:函数体里只要对某个名字赋值,这个名字就是局部变量。于是 balance = balance - amount 里右边的 balance 也被当成局部变量,而它此时还没被绑定——读不到,报错。
加上 nonlocal 就对了:
1 | def make_withdraw(balance): |
输出:
1 | 70 |
nonlocal 只能绑定外层已经存在的名字。外层没有,直接 SyntaxError,连跑都跑不起来。
六、同一个钱包,另一种写法
不用 nonlocal,改用列表的修改,同样能保存状态:
1 | def make_withdraw(balance): |
输出:
1 | 70 |
行为一模一样,但环境图完全不同:
| 写法 | 环境图上的动作 | 要不要 nonlocal |
|---|---|---|
nonlocal balance |
每次 withdraw 帧里有一条赋值箭头指回 f1 的 balance |
要 |
b[0] = ... |
b 从头到尾指着同一个列表对象,只有格子里的数字在变 |
不要 |
第二种不需要 nonlocal,因为它从来没有对 b 这个名字赋值——它改的是 b 指着的那个列表内部。这又是「修改 vs 重新赋值」的一次应用。
最后确认一件事:每次调用 make_withdraw 都会造一个新的 balance:
1 | def make_withdraw(balance): |
输出:
1 | 70 40 70 |
w1 和 w2 各有自己的帧,各记各的账。
七、小结
| 概念 | 一句话 | 证据 |
|---|---|---|
| 四步流程 | 列边界 → 写例子 → 手工推演 → 写代码 | 第 ③ 步最省时间 |
| 修改 | .append() 改对象内部 |
a.append(3) 后 b 也变 |
| 重新赋值 | = 改名字指向 |
c = c + [3] 后 d 不变 |
| 判据 | 「还有别的名字会跟着变吗」 | 会 → 修改 |
nonlocal |
让内层能重新赋值外层变量 | 缺了 → UnboundLocalError |
| 列表代理 | 改 b[0] 不需要 nonlocal |
因为它没对 b 赋值 |
| 帧隔离 | 每次调用 make_* 造新状态 |
w1(30) 不影响 w2 |
三条能带走的:
- 先列边界,再写代码。 边界漏写是最常见的错误来源,
max_path_sum里那句is_leaf()就是列边界带来的收益。 .append()作用于对象,=作用于名字。 判断「别的名字会不会跟着变」,便能立刻分清修改与重新赋值。nonlocal是「我要给外层的名字重新赋值」的声明。 只改列表内部不需要它——这就是那个列表代理写法的全部要点。

