Cats:打字速度分析ok 1036 项通过
十道必做题加一道选做题,从「按条件挑一段文字」一路做到「递归计算编辑距离」再到「多人对战计分」。这个项目真正在教的,是把一个功能完整的程序拆成一串各自可测的纯函数。
0. 这份作业在练什么
Cats 是一个真的能跑起来的打字测速网站的后端。官方把完整版部署在 cats.cs61a.org 上:你在浏览器里看到一段文字,照着敲,页面实时给你算速度、算正确率、自动纠正你刚刚敲错的单词,还能和另一个人同场竞速。这个项目要你亲手写出其中最核心的那部分逻辑。
但你打开 cats.py 会发现一件让人意外的事:没有一行代码和网络、和浏览器、和界面有关。你要填的十个空全都是纯函数——给定输入、返回输出、不碰外部状态(唯一的例外是 report_progress 要调用传进来的 upload,而那个 upload 也是参数,不是全局变量)。GUI、HTTP 服务器、多人对战的服务器通信全都写好了,它们只是在某个时刻调用你的函数。
这件事本身就是这个项目最想教的东西:一个复杂系统之所以能被写出来,是因为它被拆成了一堆各自能独立验证的小函数。「计算正确率」不需要知道文字是从哪来的;「找出最接近的单词」不需要知道用户是不是在打字。每个函数只做一件事,接口用参数说清楚,于是每个函数都能被一行 doctest 单独测。你在 lab 里练的那些「写一个函数」,到这里第一次被组装成一个系统。
四个阶段各练什么
| 阶段 | 题目 | 真正在练的技能 | 依赖的讲次 |
|---|---|---|---|
| Phase 1 Typing | Q1 pick、Q2 about、Q3 accuracy、Q4 wpm | 把函数当参数传(高阶函数)、返回函数(闭包)、列表遍历与索引对齐 | Lecture 7(序列) |
| Phase 2 Autocorrect | Q5 autocorrect、Q6 furry_fixes、Q7 minimum_mewtations | min 的 key 参数、字符串切片递归、树形递归与剪枝 | Lecture 7、递归相关讲次 |
| Phase 3 Multiplayer | Q8 report_progress、Q9 time_per_word、Q10 fastest_words | 字典构造、嵌套列表、「不许改动入参」这条纪律 | Lecture 8(可变性) |
| Phase 4 EC(选做) | QEC memo_diff | 装饰器、记忆化、可变的闭包状态 | Lecture 8、Lecture 10 |
做之前你至少要熟练三件事:(a) 函数是值,可以放进变量、传进参数、从函数里返回;(b) 列表的索引、切片、len、range;(c) 递归的写法——先写 base case,再写「把问题缩小一点然后信任递归」的那一步。第三件事在 Q6 和 Q7 里会被推到极限。
- 高阶函数不是花招,是解耦手段。
pick不知道「什么样的段落算合格」,它只知道去问select;autocorrect不知道「两个词差多远」,它只知道去问diff_function。这让同一个pick能配合任意筛选规则复用。 min(seq, key=f)天然满足「平局取最靠前」。这不是巧合,是min的实现契约:它用严格小于比较,后来者不会顶掉前面的等值元素。Q5 和 Q10 都靠这一条免掉了手写平局逻辑。limit参数的意义是剪枝,不是校验。Q6、Q7 的自动评分器会数你的函数调用次数。一旦发现「已经超预算」就必须立刻返回,不能算完再判断。- Q7 的三路递归是这门课第一次真正的树形递归。把 add / remove / substitute 各自对应到哪个切片上,是全项目最容易搞混的一步。
- 「不许改动入参」是一条硬性接口约定。Q10 的 doctest 会在调用之后重新打印
p0、p1来验证这一点。用sort()就地排序会当场翻车。
本页贴出的每一段代码都取自本仓库 proj/cats/cats.py,与实际文件逐字一致。在 proj/cats/ 下运行 python3 ok --local 的结果是:
---------------------------------------------------------------------
Test summary
1036 test cases passed! No cases failed.
包含选做的 Problem EC 在内,全部题目通过。这个数字看着大是因为 ok 的测试文件里塞了大量随机生成的用例(tests/01.py 一个文件就有五万多字节),并不是有 1036 道题。
1. Problem 1:pick
题目要什么
打字测试要先给用户一段文字。用户可能说「我想打关于猫的段落」,于是程序需要从一大堆候选段落里,按某个条件筛出合格的,再取其中第 k 个。pick 就干这件事。
三个参数:paragraphs 是候选段落组成的列表(每个元素是字符串);select 是一个函数,喂给它一个段落,它返回 True 或 False;k 是一个非负整数。返回值是「所有让 select 返回 True 的段落中,第 k 个」。
>>> ps = ['hi', 'how are you', 'fine']
>>> s = lambda p: len(p) <= 4
>>> pick(ps, s, 0)
'hi'
>>> pick(ps, s, 1)
'fine'
>>> pick(ps, s, 2)
''
把这三行读懂,题目就基本清楚了。s 判断「长度不超过 4」,三个候选里 'hi'(长度 2)和 'fine'(长度 4)合格,'how are you'(长度 11)不合格。合格的段落按原顺序排成 ['hi', 'fine'],于是第 0 个是 'hi',第 1 个是 'fine'。
关键的边界情况有两条,都要点明:
k从 0 开始。k = 0要的是第一个合格段落,不是第二个。这是 off-by-one 最容易翻车的地方。- 数量不够时返回空字符串
'',不是None,也不是报错。上面例子里只有 2 个合格段落,pick(ps, s, 2)找不到「第 2 个」(因为下标只有 0 和 1),返回''。ok 测试里有一整批用例专门打这个:pick(ps, s, 3)、pick(ps, s, 4)、pick(ps, s, 5)全都要求''。
还有一条隐含信息,题面用了一句 Hint 特意点出来:不要去猜 select 内部怎么实现。你只知道它接受一个段落、返回布尔值。这不是「你不需要知道」,而是「你不能知道」——ok 测试会传各种奇怪的 lambda 进来,比如 lambda p: p == 'VQwSwNX'。你的代码必须对任何符合这个接口的函数都成立。
怎么想到的
第一反应大概是这样:「先把合格的全挑出来,然后取第 k 个。」这个想法完全正确,写出来是:
ok_ones = []
for p in paragraphs:
if select(p):
ok_ones.append(p)
return ok_ones[k]
然后你会立刻撞上第一个问题:ok_ones[k] 在 k 越界时会抛 IndexError: list index out of range,而题目要求返回 ''。补一句判断就能解决:
if k < len(ok_ones):
return ok_ones[k]
return ''
这版是对的,能过所有测试。但停在这里就浪费了这道题。值得多问一句:我真的需要把所有合格段落都存下来吗?
不需要。我要的只是第 k 个。一旦找到它,后面的段落我一眼都不用看。列表只是被我用来「计数」的——我关心的其实是「到目前为止已经见过几个合格的」,这是一个整数,不是一个列表。
把「收集全部再取第 k 个」换成「边走边数,数到 k 就交货」。这个转换在这门课里会反复出现:如果你只用到列表的长度,那就别建这个列表,建一个计数器。它省的不只是内存——更重要的是,遇到目标就能立刻 return,不必扫完剩下的元素。
转换之后的逻辑是:维护一个 matches_so_far,初始为 0。遍历段落,遇到合格的就先问「我现在数到的这个,是不是第 k 个?」——注意先判断再自增。因为 matches_so_far 的语义是「在我之前已经有几个合格的」,所以当它等于 k 时,眼前这一个恰好就是下标为 k 的那个。
这里有个特别容易写反的地方。如果你先自增再判断:
if select(paragraph):
matches_so_far += 1
if matches_so_far == k: # 错!
return paragraph
那么第一个合格段落会让 matches_so_far 变成 1,于是 k = 1 时返回的是第一个合格段落——而正确答案是第二个。整个结果偏移一位,k = 0 则永远返回 ''。这就是「下标从 0 开始」这条边界的具体杀伤方式。
代码
def pick(paragraphs: list[str], select, k: int) -> str:
# BEGIN PROBLEM 1
# Walk through the paragraphs, counting only the ones that pass SELECT.
matches_so_far = 0
for paragraph in paragraphs:
if select(paragraph):
if matches_so_far == k:
return paragraph
matches_so_far += 1
return '' # fewer than k + 1 paragraphs qualified
# END PROBLEM 1
逐行看:
matches_so_far = 0:计数器的语义必须先定死——「在当前这个段落之前,已经遇到过几个合格段落」。想清楚这句话,后面的判断顺序就不会写反。for paragraph in paragraphs::直接遍历元素,不用range(len(paragraphs))。因为我从头到尾没用到「这是第几个段落」这个信息——我只关心「这是第几个合格段落」,而那由计数器负责。用不到下标就不要取下标,这能少一整类 off-by-one 错误。if select(paragraph)::把判断权完全交给参数。注意这里写的是select(paragraph)而不是select(paragraph) == True——后者不但啰嗦,而且当select返回的是「真值但不是True」的东西时还会出错。if matches_so_far == k: return paragraph:先判断,后自增。命中就立刻返回,函数就此结束,剩下的段落连select都不会被调用。matches_so_far += 1:没命中,说明这个合格段落的下标小于k,把它记进账里继续往后找。return '':这一行只有在for循环自然跑完时才会执行到——也就是所有段落都看过了,合格的数量却不足k + 1个。这个位置很重要:它必须在for外面。写在for里面(哪怕是else分支)会让第一个不合格段落就直接返回''。
误区一:把 return '' 缩进进循环里。写成
for paragraph in paragraphs:
if select(paragraph):
...
return '' # 缩进错了
这样第一次迭代无论如何都会返回 ''。这个错误不会报语法错,测试却会全红——pick(ps, s, 0) 期待 'hi' 得到 ''。
误区二:把 select 当成布尔值而不是函数。写成 if select:——这判断的是「select 这个函数对象本身是不是真值」。函数对象永远是真值,于是所有段落都被当成合格的,pick(ps, s, 1) 会返回 'how are you'。这是刚接触高阶函数时的经典失误:函数名是值,函数名加括号才是调用。
误区三:返回 None 而不是 ''。如果你漏写最后一行,Python 会默认返回 None。ok 报的错长这样:
# Error: expected
# ''
# but got
# None
而且这个 bug 会传染——run_typing_test 里用 if not source: 判断是否还有段落,None 恰好也是假值,所以命令行界面看起来居然是好的,只有 ok 会抓到它。
验证
拿 doctest 的第二组调用 pick(ps, s, 1) 手动追一遍。此时 paragraphs = ['hi', 'how are you', 'fine'],select = lambda p: len(p) <= 4,k = 1。
| 迭代 | paragraph | select(paragraph) | 进入前 matches_so_far | 做了什么 |
|---|---|---|---|---|
| 1 | 'hi' | len('hi')=2 <= 4 → True | 0 | 0 == 1 为假 → 不返回,计数器变 1 |
| 2 | 'how are you' | len(...)=11 <= 4 → False | 1 | 整个 if 跳过,计数器不动 |
| 3 | 'fine' | len('fine')=4 <= 4 → True | 1 | 1 == 1 为真 → 返回 'fine' |
第三次迭代直接 return,函数结束,最后那句 return '' 根本没被执行到。输出 'fine',与 doctest 一致。
再追一遍 pick(ps, s, 2)。前两次迭代和上面完全一样(计数器走到 1);第三次 'fine' 合格,判断 1 == 2 为假,于是不返回,计数器变成 2。循环没有更多元素了,自然结束,执行 return ''。输出 '',与 doctest 一致。
注意这里 matches_so_far 最终值是 2,恰好等于合格段落的总数——这印证了计数器的语义定义是自洽的:循环结束时它就是「一共见过几个合格的」,而 k = 2 要求至少有 3 个才行。
cats.py 底部的 run_typing_test 里有这么一段(这部分是官方给好的,不用你写):
i = 0
while True:
source = pick(paragraphs, select, i)
if not source:
print("No more paragraphs about", topics, "are available.")
return
...
i += 1
它靠 i 递增来「一段接一段地取」,靠返回空字符串来判断「取完了」。这就是为什么题目坚持要返回 '' 而不是抛异常——空字符串在这里被当成一个哨兵值(sentinel),让调用方能用一句 if not source 就优雅地收场。这也顺便解释了 select 为什么必须是参数:run_typing_test 在没有指定话题时传的是 lambda p: True,指定了话题就传 about(topics)——同一个 pick,两种行为。
2. Problem 2:about
题目要什么
Q1 的 pick 需要一个 select 函数,但 Q1 并没有教你怎么造一个。about 就是造 select 的工厂。
about(keywords) 接受一个关键词列表,返回一个函数。这个返回的函数接受一个段落,判断段落里是否出现了 keywords 中的任何一个词,是则 True,否则 False。
>>> about_dogs = about(['dog', 'dogs', 'pup', 'puppy'])
>>> pick(['Cute Dog!', 'That is a cat.', 'Nice pup!'], about_dogs, 0)
'Cute Dog!'
>>> pick(['Cute Dog!', 'That is a cat.', 'Nice pup.'], about_dogs, 1)
'Nice pup.'
注意第二行发生了什么:about(...) 的返回值被存进 about_dogs,然后整个塞进 pick 当参数。这就是 Q1 和 Q2 的接缝——两道题合起来才是一个完整的「按话题挑段落」功能。
题面用三条要求把「什么叫段落里出现了这个词」定义得很死:
| 要求 | 具体含义 | 反例 |
|---|---|---|
| 忽略大小写 | 'Dog'、'DOG'、'dOg' 都算命中 'dog' | about(['dogs'])('"DOGS" stands for ...') → True |
| 忽略标点 | 'Dog!'、'dog,' 去掉标点后是 'Dog'、'dog',算命中 | 'Cute Dog!' 命中 'dog' |
| 只匹配完整单词 | 子串不算。'dogs' 里含有 'dog',但它不能命中关键词 'dog' | about(['dogs'])("AdogsParagraph") → False |
ok 测试里有几组用例把这三条要求同时施加,值得逐个看清楚它们各自在考什么:
>>> dogs = about(['dogs', 'hounds'])
>>> dogs('A paragraph about cats.')
False
>>> dogs('Release the Hounds!')
True
>>> dogs('"DOGS" stands for Department Of Geophysical Science.')
True
>>> dogs('Do gs and ho unds don\'t count')
False
>>> dogs("AdogsParagraph")
False
'Release the Hounds!':'Hounds!'要先去掉!、再小写成'hounds',两步都做才命中。'"DOGS" stands for ...':'"DOGS"'两侧都有引号,去标点后是'DOGS',小写成'dogs',命中。'Do gs and ho unds don\'t count':'Do'、'gs'、'ho'、'unds'都是独立的词,没有一个等于'dogs'或'hounds'。这条用例专门打「直接在整段字符串里搜子串」的写法——如果你写'dogs' in paragraph,这句照样不会命中(因为中间有空格),但下一条就会出事。"AdogsParagraph":整段只有一个词'AdogsParagraph',它包含子串'dogs',但它本身不等于'dogs'。这一条是专门给in判子串的写法准备的坟墓。
还有一行不起眼但重要的代码,官方已经写在函数开头:
assert all([lower(x) == x for x in keywords]), "keywords should be lowercase."
它保证了 keywords 里的词一定已经是小写的。这条 assert 直接决定了你的设计:既然关键词侧已经小写,你就只需要把段落侧转成小写,不必两边都转。
怎么想到的
这道题的思维路径分两段,一段是「返回函数」这个结构,一段是「怎么比较词」这个细节。
第一段:为什么要返回函数
看到「实现 about,它返回一个函数」,第一反应通常是懵:为什么不直接写一个 contains(paragraph, keywords)?
答案在 pick 的签名里。pick(paragraphs, select, k) 规定了 select 只接受一个参数。你手里的 contains 要两个参数,塞不进去。要么改 pick 的签名(题目不许),要么想办法把 keywords 这个参数「提前固定住」,让剩下的函数只差一个 paragraph。
这就是闭包(closure)的用武之地:在一个函数里定义另一个函数,内层函数可以直接用外层函数的参数,而不必把它再写进自己的参数表。外层 about 负责「记住 keywords」,内层 select 负责「接受 paragraph 并回答」。keywords 对内层来说是自由变量(free variable),它通过环境的父帧链被找到。
把这件事落到环境图上,才算真的懂了。执行 about_dogs = about(['dog', 'dogs', 'pup', 'puppy']) 之后:
Global 帧
about → func about(keywords) [parent = Global]
about_dogs → func select(paragraph) [parent = f1]
f1: about 帧 [parent = Global]
keywords → ['dog', 'dogs', 'pup', 'puppy']
select → func select(paragraph) [parent = f1]
重点在 about_dogs 指向的那个函数值上写着 parent = f1。about 已经返回了,但 f1 这个帧没有消失——因为还有一个函数值指着它。以后每次调用 about_dogs('Cute Dog!'),都会新建一个帧,它的父帧是 f1,于是在里面查 keywords 时能顺着父帧链找到那个列表。
调用 about_dogs('Cute Dog!') 时:
f2: select 帧 [parent = f1] ← 注意 parent 不是 Global
paragraph → 'Cute Dog!'
查 paragraph → f2 里有,直接用
查 keywords → f2 里没有 → 去 parent f1 找 → 找到 ['dog', ...]
如果 select 的 parent 是 Global(比如你在全局定义了 select 再返回它),keywords 就查不到,会报 NameError: name 'keywords' is not defined。父帧是定义处所在的帧,不是调用处所在的帧——这是这门课反复强调的一条,Q2 是它第一次真正派上用场。
第二段:怎么比较词
结构定了,剩下的是「给定 paragraph 和 keywords,怎么判断」。最省事的写法是:
for word in keywords:
if word in paragraph: # 错:这是子串判断
return True
return False
这版在 'A paragraph about dogs.' 上能过,在 "AdogsParagraph" 上直接翻车——'dogs' in "AdogsParagraph" 是 True,但答案应该是 False。而且它连大小写都处理不了:'dogs' in 'Release the DOGS' 是 False。
所以必须换个方向:不要在整段字符串里找关键词,而是先把段落切成一个个独立的词,再拿每个词去和关键词做「相等」比较。一旦从「子串包含」换成「元素相等」,「只匹配完整单词」这条要求就自动满足了——因为切出来的 'AdogsParagraph' 和 'dogs' 根本不相等。
那么切之前要做什么?段落里有标点和大写,得先归一化。utils.py 提供了三个现成的工具:
| 函数 | 作用 | 例子 |
|---|---|---|
remove_punctuation(s) | 删掉所有 string.punctuation 里的字符 | "It's a lovely day, don't you think?" → 'Its a lovely day dont you think' |
lower(s) | 全部转小写 | "hello WORLD" → 'hello world' |
split(s) | 按空白切成词列表 | "a b c" → ['a', 'b', 'c'] |
三个操作的顺序有讲究吗?lower 和 remove_punctuation 谁先谁后其实无所谓(转小写不产生标点,删标点不产生大写),但 split 必须放在最后——它返回的是列表,而前两个函数只吃字符串。写成 remove_punctuation(split(paragraph)) 会报 AttributeError: 'list' object has no attribute 'strip'。
代码
def about(keywords: list[str]):
assert all([lower(x) == x for x in keywords]), "keywords should be lowercase."
# BEGIN PROBLEM 2
def select(paragraph):
# Normalize the paragraph so comparisons ignore case and punctuation,
# then compare whole words (not substrings) against the keywords.
words = split(lower(remove_punctuation(paragraph)))
for word in words:
if word in keywords:
return True
return False
return select
# END PROBLEM 2
逐行看:
def select(paragraph)::定义在about内部,因此它的 parent 帧就是about的帧,keywords对它可见。参数只有一个,正好符合pick对select的要求。split(lower(remove_punctuation(paragraph))):三层嵌套,从内往外执行。remove_punctuation先把'Cute Dog!'变成'Cute Dog',lower再变成'cute dog',split最后切成['cute', 'dog']。注意这三步都没有修改paragraph——字符串是不可变(immutable)的,每一步都返回新字符串。if word in keywords::这里的in和刚才批判的in不是一回事。word in keywords中keywords是列表,in做的是「列表里有没有这个元素」的相等判断;而word in paragraph中paragraph是字符串,in做的是子串判断。同一个运算符,因为右操作数类型不同而语义不同——这是 Python 里最容易被忽略的一处陷阱。return True:只要有一个词命中就够了,立刻返回,不必看完剩下的词。return False:循环跑完还没命中,说明一个都没有。位置同样必须在for外面。return select:返回函数对象本身,不加括号。写成return select()会立刻调用它,而调用需要paragraph参数,报TypeError: select() missing 1 required positional argument: 'paragraph'。
误区一:return select()。加了括号就变成「调用并返回结果」,报的是 TypeError: about.<locals>.select() missing 1 required positional argument: 'paragraph'。记住:select 是名字,指向函数值;select(x) 是调用表达式,产生返回值。这道题要的是前者。
误区二:把关键词也做归一化。写成 if word in [lower(k) for k in keywords]。功能上没错(因为 assert 保证了它们本来就是小写,这一步是空操作),但它暴露了你没读那行 assert。更糟的是把 remove_punctuation 也套上去——关键词里本来就不该有标点,套了反而可能出意外。
误区三:把 select 定义在 about 外面。那样 keywords 就成了未定义的全局名字,调用时报 NameError: name 'keywords' is not defined。这个错的隐蔽之处在于:定义时不报错,只有真正调用返回的函数时才报——因为 Python 在定义函数体时不查名字,只在执行时查。
误区四:用 paragraph.split() 之后再逐词删标点。比如 [remove_punctuation(w) for w in split(paragraph)]。这在大多数情况下和标准写法结果相同,但对含内部标点的词(如 "don't")行为一致,对像 'Bsm(<opPLeF' 这种测试用例也一致。它不算错,只是多绕了一圈。真正的问题是很多人只做了 split 忘了删标点,于是 'Dog!' 永远匹配不上 'dog'。
验证
追一遍 ok 测试里那个最刁钻的用例:dogs = about(['dogs', 'hounds']),然后 dogs('"DOGS" stands for Department Of Geophysical Science.')。
about(['dogs', 'hounds']) 求值:先跑 assert,lower('dogs') == 'dogs' 且 lower('hounds') == 'hounds',通过。然后 def select 在 about 的帧里创建函数值,return select 把它交出去,绑定到全局的 dogs。dogs('"DOGS" stands for ...')。新建帧,parent 指向 about 那一帧,paragraph 绑定到这个字符串。remove_punctuation(paragraph):删掉 " 和 .,得到 'DOGS stands for Department Of Geophysical Science'。lower(...):得到 'dogs stands for department of geophysical science'。split(...):得到 ['dogs', 'stands', 'for', 'department', 'of', 'geophysical', 'science']。word = 'dogs'。查 keywords——本帧没有,去 parent 找到 ['dogs', 'hounds']。'dogs' in ['dogs', 'hounds'] 为 True,返回 True。与 ok 期望的 True 一致。
再追一遍 dogs("AdogsParagraph"),看「只匹配完整单词」是怎么被自动满足的:
remove_punctuation("AdogsParagraph") → 'AdogsParagraph'(没有标点可删)。lower(...) → 'adogsparagraph'。split(...) → ['adogsparagraph'],只有一个元素。'adogsparagraph' in ['dogs', 'hounds']。列表的 in 逐个做相等比较:'adogsparagraph' == 'dogs' 假,'adogsparagraph' == 'hounds' 假。结果 False。False。对比一下:如果第 4 步写的是 'dogs' in 'adogsparagraph'(字符串子串判断),结果会是 True,测试就挂了。「切成词再比相等」和「直接搜子串」的区别,全部体现在这一个用例上。
现在两道题拼起来了。命令行里跑 python3 cats.py -t cats kittens 时,run_typing_test(['cats', 'kittens']) 会执行 select = about(['cats', 'kittens']),把返回的函数交给 pick(paragraphs, select, i)。pick 完全不知道「关于猫」是什么意思,about 完全不知道段落是从文件里读来的还是随机洗过牌的。两个函数各自只懂一件事,靠「函数作为值」这个机制拼在一起——这就是高阶函数在真实代码里的样子。
3. Problem 3:accuracy
题目要什么
用户照着 source 敲出了 entered,问:敲对了百分之几?
「敲对」的定义严格得有点反直觉:把两个字符串各自按空白切成词,然后逐位置比较——entered 的第 0 个词必须和 source 的第 0 个词完全相同,第 1 个对第 1 个,以此类推。「完全相同」包括大小写和标点。返回值是「对上的词数 ÷ entered 的词数 × 100」。
「一个词」的定义也要注意:任何被空白分隔的字符序列都算一个词,所以 "dog;" 是一个词,不是 "dog" 加一个分号。这和 Q2 完全相反——Q2 要去标点、要小写,Q3 什么都不做,原样比。
>>> accuracy('Cute Dog!', 'Cute Dog.')
50.0
>>> accuracy('A Cute Dog!', 'Cute Dog.')
0.0
>>> accuracy('cute Dog.', 'Cute Dog.')
50.0
>>> accuracy('Cute Dog. I say!', 'Cute Dog.')
50.0
>>> accuracy('Cute', 'Cute Dog.')
100.0
>>> accuracy('', 'Cute Dog.')
0.0
>>> accuracy('', '')
100.0
这七行几乎就是完整的规格说明书。逐条读:
| 调用 | entered 切词 | source 切词 | 为什么是这个答案 |
|---|---|---|---|
accuracy('Cute Dog!', 'Cute Dog.') | ['Cute', 'Dog!'] | ['Cute', 'Dog.'] | 位置 0 相同,位置 1 因标点不同而不同。1/2 = 50.0 |
accuracy('A Cute Dog!', 'Cute Dog.') | ['A', 'Cute', 'Dog!'] | ['Cute', 'Dog.'] | 多打了一个 'A',后面全部错位:'A'≠'Cute'、'Cute'≠'Dog.'、'Dog!' 没有对手。0/3 = 0.0 |
accuracy('cute Dog.', 'Cute Dog.') | ['cute', 'Dog.'] | ['Cute', 'Dog.'] | 大小写也要算错。1/2 = 50.0 |
accuracy('Cute Dog. I say!', 'Cute Dog.') | 4 个词 | 2 个词 | 前两个对上,多出的 'I'、'say!' 在 source 里没有对应位置,算错。2/4 = 50.0 |
accuracy('Cute', 'Cute Dog.') | ['Cute'] | ['Cute', 'Dog.'] | 打少了不算错。分母是 entered 的长度 1,对上 1 个。1/1 = 100.0 |
accuracy('', 'Cute Dog.') | [] | ['Cute', 'Dog.'] | 什么都没打,0.0(不是「0/0」) |
accuracy('', '') | [] | [] | 都空,规定为 100.0 |
题面还额外规定了一条 doctest 里没有的:entered 非空但 source 为空时返回 0.0。四条空串规则合起来是:
entered空、source空 → 100.0entered空、source非空 → 0.0entered非空、source空 → 0.0- 都非空 → 正常计算
注意前两条不能合并成「entered 空就返回 0.0」,因为双空要 100.0。这是最容易漏的一处。
怎么想到的
核心是两个数字:分子「对上几个」和分母「除以几」。
分母先定:题目说的是「entered 中有多大比例的词是对的」,所以分母是 len(entered_words)。这一点从 accuracy('Cute', 'Cute Dog.') 返回 100.0 可以反推——如果分母是 source 的长度,答案会是 50.0。
分子稍微绕一点。最直接的想法是「遍历 entered_words,看每个词和 source_words 对应位置是否相同」:
correct = 0
for i in range(len(entered_words)):
if entered_words[i] == source_words[i]: # 越界风险
correct += 1
这版在 accuracy('Cute Dog. I say!', 'Cute Dog.') 上会崩:i 走到 2 时,source_words[2] 越界,报 IndexError: list index out of range。
怎么修?想清楚多出来的那些词该算什么。题面说得很明确:「如果 entered 比 source 长,那些没有对应词的多余部分全都算错。」既然它们必然算错,那就根本不用去比——只需要比较两个列表都存在的那些位置,剩下的自动就是错的(因为它们不会给 correct 贡献,但会算进分母)。
把循环范围从 range(len(entered_words)) 改成 range(min(len(entered_words), len(source_words)))。
这一个 min 同时解决了两个方向的长度不匹配:entered 更长时,多出的词不进循环、不计入 correct,但仍在分母里,正好符合「算错」;entered 更短时,source 多出的词根本不该被考虑,因为分母是 entered 的长度,用户没打的部分不该被追究。
这就是「打少了不算错」为什么能自动成立:accuracy('Cute', 'Cute Dog.') 中 min(1, 2) = 1,只比位置 0,对上了,correct = 1,分母也是 1,得 100.0。
剩下的就是空串。为什么必须单独处理?因为分母会变成 0。accuracy('', 'Cute Dog.') 里 entered_words 是 [],len 为 0,执行 correct / 0 * 100 会报 ZeroDivisionError: division by zero。所以在做除法之前必须先把空的情况拦下来。
「entered 非空但 source 空」这条要不要单独写?其实不写也对——min(n, 0) = 0,循环一次都不跑,correct = 0,返回 0 / n * 100 = 0.0,正好是要求的答案。但显式写出来更清楚地表达了意图,也让读代码的人一眼看到四种边界都被考虑过了。
代码
def accuracy(entered: str, source: str) -> float:
entered_words = split(entered)
source_words = split(source)
# BEGIN PROBLEM 3
if len(entered_words) == 0 and len(source_words) == 0:
return 100.0
if len(entered_words) == 0 or len(source_words) == 0:
return 0.0
# Only positions that exist in both lists can possibly match; any extra
# word typed past the end of the source counts as incorrect.
correct = 0
for i in range(min(len(entered_words), len(source_words))):
if entered_words[i] == source_words[i]:
correct += 1
return correct / len(entered_words) * 100
# END PROBLEM 3
逐行看:
entered_words = split(entered)、source_words = split(source):这两行是官方给好的。用split(即str.split())而不是split(' ')很关键——无参的split会按任意连续空白切分并丢掉空串,所以''.split()得到[]而不是[''],'a b'.split()得到['a', 'b']而不是['a', '', 'b']。如果它返回[''],空串检测就得改写成别的样子。if ... and ...: return 100.0:双空必须先判,而且必须用and。顺序不能和下一行调换——如果先写or那行,双空的情况会先命中它返回 0.0,永远走不到 100.0 那行。if ... or ...: return 0.0:走到这里说明「不是双空」,那么or命中就意味着「恰好一个是空的」,无论哪一个空都返回 0.0。这两行的组合本质上是在做三分支判断,靠的是「先判更严格的条件」这个顺序。correct = 0:累加器,语义是「已确认相同的位置数」。for i in range(min(...))::这里必须用下标遍历而不能用for w in entered_words,因为比较是按位置进行的,我需要同时访问两个列表的同一个下标。这是「什么时候该用range(len(...))」的典型答案:需要位置信息时才用。if entered_words[i] == source_words[i]::直接用==比字符串,不做任何归一化。对比 Q2 的三层处理,这里的「什么都不做」正是题目要求的——大小写和标点都要严格对上。return correct / len(entered_words) * 100:/是真除法(true division),在 Python 3 里返回float,所以1 / 2 * 100得到50.0而不是50。doctest 期望的正是带小数点的50.0,用//会得到0,测试直接挂。
误区一:用整除 //。写成 correct // len(entered_words) * 100,accuracy('Cute Dog!', 'Cute Dog.') 会算成 1 // 2 * 100 = 0 * 100 = 0。ok 报:
# Error: expected
# 50.0
# but got
# 0
误区二:先乘 100 再除但用错括号。correct / (len(entered_words) * 100) 会得到 0.005 而不是 50.0。运算符优先级下 a / b * 100 是从左到右算的,等价于 (a / b) * 100,不加括号反而是对的。
误区三:漏掉双空返回 100.0。只写了 if len(entered_words) == 0: return 0.0,于是 accuracy('', '') 返回 0.0,期望 100.0。这是本题唯一一处「直觉给不出答案、只能靠读题面」的规定。
误区四:分母写成 len(source_words)。accuracy('Cute', 'Cute Dog.') 会返回 50.0,期望 100.0。这个错很难自查,因为「用户少打了一半却给 100 分」听起来不合理——但打字测试里,你只被评判你实际敲下的那部分,没敲的部分靠 WPM 那个指标去体现。
误区五:为了避免越界而 try/except IndexError。能跑通,但把「多余的词算错」这个业务规则藏进了异常处理里,读代码的人看不出意图。用 min 是在表达规则,用 except 是在掩盖崩溃。
验证
追第二个 doctest:accuracy('A Cute Dog!', 'Cute Dog.'),期望 0.0。这是最能说明「按位置对齐」有多严格的一个例子。
entered_words = split('A Cute Dog!') → ['A', 'Cute', 'Dog!'],长度 3。source_words = split('Cute Dog.') → ['Cute', 'Dog.'],长度 2。if 都跳过。min(3, 2) = 2,所以 range(2),只比较 i = 0 和 i = 1。i | entered_words[i] | source_words[i] | 相等? | correct |
|---|---|---|---|---|
| 0 | 'A' | 'Cute' | 否 | 0 |
| 1 | 'Cute' | 'Dog.' | 否 | 0 |
| — | 'Dog!' | (不存在) | 不比较 | 0 |
return 0 / 3 * 100 → 0.0。
看清楚发生了什么:用户其实把 'Cute' 和 'Dog!' 都打出来了,只是在最前面多打了一个 'A',结果整行的对齐全部错位,正确率从 50% 掉到 0%。这是逐位置比较的必然后果,也是真实打字测试的行为——少打一个空格就会导致后面全红。
再追一个反方向的:accuracy('Cute', 'Cute Dog.'),期望 100.0。
entered_words = ['Cute'](长度 1),source_words = ['Cute', 'Dog.'](长度 2)。if。min(1, 2) = 1,只比 i = 0:'Cute' == 'Cute' 为真,correct 变 1。return 1 / 1 * 100 → 100.0。同一个 min,在两个方向上做的事完全不同:上一例里它保护了 source_words 不越界,这一例里它防止了把用户没打的词也算进来。一个表达式承担两个职责,这也是为什么值得花时间想清楚它的语义,而不是随手加个 try。
4. Problem 4:wpm
题目要什么
算打字速度,单位是「每分钟多少个词」(words per minute, WPM)。给定用户敲出的字符串 entered 和用掉的秒数 elapsed,返回速度。
但这里的「词」不是真的词。题面特意强调:WPM 里的一个「词」被定义为 5 个字符,空格也算字符。为什么这么定?因为如果按真实单词数算,打一段全是 "a I is" 的文字会比打 "antidisestablishmentarianism" 快得多,测出来的速度就没法横向比较了。用固定的 5 字符当一个「标准词」,测试结果才不被文章的词长分布带偏。
公式因此是两步:
$$\text{WPM} = \frac{\text{len(entered)} / 5}{\text{elapsed} / 60}$$分子是「折算成多少个标准词」,分母是「用了多少分钟」。
>>> wpm('hello friend hello buddy hello', 15)
24.0
>>> wpm('0123456789', 60)
2.0
题面自己给了一个更好懂的例子:"I am glad!" 有 10 个字符(引号不算),折合 10 / 5 = 2 个词;如果花了 30 秒也就是半分钟,速度就是 2 / 0.5 = 4 WPM。
- 空格计入字符数。
'hello friend hello buddy hello'里有 4 个空格,总长是 30 而不是 26。 - 不要
split。这道题从头到尾不关心词的边界,只数字符。上一题刚split完,这题反而不能切。 elapsed的单位是秒,WPM 的单位是「每分钟」。不换算就会得到一个大 60 倍的数。函数第一行的assert elapsed > 0已经保证了不会除零,你不用自己判。
怎么想到的
这是全项目最简单的一题,但它值得认真对待的地方在于:怎么把一句自然语言描述的公式,翻译成不会出错的代码。
直接写成一行是很有诱惑力的:
return len(entered) / 5 / (elapsed / 60)
它是对的,但你得盯着它看两秒才能确认括号有没有问题。/ 是左结合的,a / 5 / b 等于 (a / 5) / b——恰好是想要的。可如果手一抖写成 len(entered) / 5 / elapsed / 60,就变成了 ((len / 5) / elapsed) / 60,答案小了 3600 倍,而且这个错误从代码外形上几乎看不出来。
把公式拆成两个有名字的中间量:words_typed 和 minutes。名字本身就是文档——读代码的人不需要在脑子里重算优先级,只需要确认「标准词数 ÷ 分钟数 = 每分钟词数」这句话对不对。
这不是为了「代码更漂亮」。在这门课里,一个中间变量的名字,是你对这个表达式含义的断言。写下 minutes = elapsed / 60 的那一刻,你就把「单位换算」这件事从脑子里挪到了纸面上,之后再也不会忘。
另一个要想清楚的是:为什么不用 round?doctest 期望 24.0,而 30 / 5 / (15 / 60) 算出来正好是 24.0,不需要取整。但很多真实输入会算出 47.63157894736842 这种数——题目没要求取整,所以一律不要自作主张地 round。加了 round 反而会让 ok 里那些随机生成的用例挂掉,因为它们的期望值是完整精度的浮点数。
代码
def wpm(entered: str, elapsed: int) -> float:
assert elapsed > 0, "Elapsed time must be positive"
# BEGIN PROBLEM 4
# A "word" is 5 characters, and elapsed is in seconds (60 seconds a minute).
words_typed = len(entered) / 5
minutes = elapsed / 60
return words_typed / minutes
# END PROBLEM 4
逐行看:
assert elapsed > 0, ...:官方给好的。它的作用不是防御性编程,而是把前置条件写进代码——有了它,你在下面写除法时可以确信分母不为零。如果没有它,你就得自己想「elapsed 为 0 怎么办」,而题目其实没定义那种情况。看到 assert 就该意识到:这个分支不归我管。words_typed = len(entered) / 5:len数的是字符(包括空格、标点、数字),这正是题目要的。用/而非//:打了 7 个字符应该算 1.4 个词,不是 1 个词。整除会让短输入的速度被系统性低估。minutes = elapsed / 60:单位换算独立成行。同样用/——打了 15 秒就是 0.25 分钟,用//会得到 0,下一行直接ZeroDivisionError。return words_typed / minutes:两个已经是float的量相除,结果必然是float,天然满足 doctest 里24.0那个小数点。
误区一:忘了秒转分。写成 return len(entered) / 5 / elapsed。wpm('hello friend hello buddy hello', 15) 会返回 0.4,期望 24.0——正好差 60 倍。这个错误的特征很好认:结果小得离谱。
误区二:反过来乘了 60。写成 len(entered) / 5 * 60 * elapsed。结果会大得离谱。判断方向的土办法:花的时间越长,速度应该越慢,所以 elapsed 必须在分母上(或者说,除以它)。
误区三:用 len(split(entered)) 数词。这是被 Q3 的惯性带的。wpm('hello friend hello buddy hello', 15) 会算成 5 / 0.25 = 20.0,期望 24.0。差得不多,所以很容易误以为是「精度问题」而去乱加 round,越改越错。
误区四:用 // 做字符折算。wpm('0123456789', 60) 恰好整除,看不出问题;但一旦长度不是 5 的倍数就会偏小。ok 里的随机用例会立刻抓到。
验证
追第一个 doctest:wpm('hello friend hello buddy hello', 15),期望 24.0。
'hello'(5) + ' '(1) + 'friend'(6) + ' '(1) + 'hello'(5) + ' '(1) + 'buddy'(5) + ' '(1) + 'hello'(5) = 30。5 个词 + 4 个空格。assert 15 > 0 通过。words_typed = 30 / 5 = 6.0。折合 6 个标准词——注意真实词数是 5,这两个数不一样,正说明「标准词」是另一套计量。minutes = 15 / 60 = 0.25。return 6.0 / 0.25 = 24.0。与 doctest 一致。顺手验证一下量纲:一刻钟打 6 个标准词,一分钟就是 4 倍即 24 个,合理。
再追第二个:wpm('0123456789', 60),期望 2.0。字符串长度是 10(十个数字字符——这也顺便说明「字符」不限于字母)。words_typed = 10 / 5 = 2.0,minutes = 60 / 60 = 1.0,返回 2.0 / 1.0 = 2.0。
四道题写完,一个完整的单机打字测试就能跑了。在 proj/cats/ 下执行 python3 cats.py -t cats kittens,你会看到 run_typing_test 把它们串起来:
select = about(topics) # Q2 造出筛选器
source = pick(paragraphs, select, i) # Q1 挑出第 i 段
start = datetime.now()
entered = input() # 用户敲字
elapsed = (datetime.now() - start).total_seconds()
print("Words per minute:", wpm(entered, elapsed)) # Q4
print("Accuracy: ", accuracy(entered, source)) # Q3
四个纯函数 + 一点点 I/O,就是一个能用的程序。注意这五行里没有任何一处需要知道另一个函数的内部实现——wpm 不知道文字从哪来,accuracy 不知道计时是怎么做的。这就是 Phase 1 真正在示范的东西。
5. Problem 5:autocorrect
题目要什么
Phase 2 开始做自动纠错:用户敲完一个词按空格,如果这个词不在词典里但很接近词典里的某个词,就替换成那个词。autocorrect 是这个功能的调度中心。
四个参数:entered_word(用户敲的词)、word_list(词典)、diff_function(一个衡量「两个词差多远」的函数)、limit(容忍上限)。规则三条:
entered_word 本来就在 word_list 里,原样返回——用户没打错,不要多事。word_list 中与 entered_word 差异最小的那个词。差异由 diff_function(entered_word, 候选词, limit) 给出。limit,说明这不像是个 typo(可能是个生僻词或者乱码),返回 entered_word 本身,不做纠正。还有一条平局规则,题面用 Important 标了出来:如果多个词的差异并列最小,返回在 word_list 里出现最早(下标最小)的那个。
diff function 的接口是固定的三参数:(entered_word, source_word, limit),返回一个数。注意参数顺序——用户敲的词在前,词典里的词在后。这一点在 ok 测试里被专门检验过:
>>> length_ratio = lambda w1, w2, limit: len(w2) / len(w1) # 一个不对称的 diff
>>> autocorrect("aaa", ["a"], length_ratio, 2)
'a'
如果你把参数顺序写反,length_ratio 会算成 len('aaa') / len('a') = 3.0,大于 limit = 2,于是返回 'aaa';正确顺序算出来是 1/3,小于 2,返回 'a'。这个测试的存在本身就说明有很多人会写反。
再看几组 ok 用例,把三条规则各自的触发点看清楚:
>>> abs_diff = lambda w1, w2, limit: abs(len(w2) - len(w1))
>>> autocorrect("cul", ["culture", "cult", "cultivate"], abs_diff, 10)
'cult'
>>> autocorrect("cul", ["culture", "cult", "cultivate"], abs_diff, 0)
'cul'
>>> autocorrect("wor", ["worry", "car", "part"], abs_diff, 10)
'car'
- 第一组:三个候选与
'cul'(长 3)的长度差分别是 4、1、6,最小的是'cult',且 1 ≤ 10,返回'cult'。 - 第二组:同样算出最小差是 1,但
limit = 0,1 > 0,触发第三条规则,返回原词'cul'。同样的输入、同样的最优候选,只因为 limit 变了就完全不纠正。 - 第三组:
'wor'长 3,三个候选长 5、3、4,差为 2、0、1。最小的是'car'(差 0)。注意abs_diff只看长度,它认为'car'比'worry'更像'wor'——结果荒谬,但autocorrect照做不误。这正说明autocorrect不负责判断「像不像」,它只负责按diff_function的口径找最小值。
平局规则也有专门的用例:
>>> matching_diff = lambda w1, w2, limit: sum([w1[i] != w2[i] for i in range(min(len(w1), len(w2)))])
>>> autocorrect("bwe", ["awe", "bye"], matching_diff, 10)
'awe'
>>> autocorrect("bwe", ["bye", "awe"], matching_diff, 10)
'bye'
两个候选的差都是 1('bwe' vs 'awe' 差第一个字符,vs 'bye' 差第二个字符)。同样的两个词,只是在列表里的顺序换了,答案就跟着换——这就是「平局取最靠前」。
怎么想到的
「在一堆候选里找出使某个函数值最小的那个」,这是一个模式,值得单独拎出来。手写版本长这样:
best_word = word_list[0]
best_diff = diff_function(entered_word, best_word, limit)
for w in word_list[1:]:
d = diff_function(entered_word, w, limit)
if d < best_diff:
best_diff = d
best_word = w
这版是对的,而且平局规则也自动满足了——因为条件写的是严格小于 <。当后面的词差异相等时,d < best_diff 为假,不更新,于是先来的词保住了位置。如果你手滑写成 <=,平局就变成「取最靠后」,那两个 "bwe" 用例会全挂。
但题面给了一条 Note,提示了更短的写法:min 带 key 参数。
min(seq, key=f) 的语义是:返回 seq 中使 f(x) 最小的那个 x(注意返回的是 x 本身,不是 f(x))。而 CPython 的 min 内部就是上面那段循环,用的正是严格小于比较——所以「平局取最靠前」是 min 的既定行为,不是碰巧。
这意味着你不需要写任何平局处理代码。这条性质在 Q10 fastest_words 里还会再用一次。
用 min 就得提供一个 key。key 必须是单参数函数,而 diff_function 要三个参数。怎么办?和 Q2 遇到的问题一模一样:把不变的参数固定住。这次用 lambda:
diff_from_entered = lambda source_word: diff_function(entered_word, source_word, limit)
entered_word 和 limit 在整个 autocorrect 调用期间都不变,把它们「焊」进 lambda 里,剩下的就只有一个待填的槽 source_word。这和 about 返回 select 是同一个手法——只不过那次用的是 def,这次用的是 lambda。
剩下两条规则的顺序需要想一下。
第一条为什么必须放在最前面?假设 entered_word 就在词典里,你跳过第一条直接去算 min。理论上它和自己的差异应该是 0,肯定最小,结果也对。但这有两个问题:一是白算了一整轮 diff_function(词典有上万个词时这很贵);二是没有任何规定说 diff_function(w, w, limit) 一定返回 0。ok 里就有这种 diff:
>>> ten_diff = lambda w1, w2, limit: 10 # 永远返回 10
>>> autocorrect("hwllo", ["butter", "hello", "potato"], ten_diff, 20)
'butter'
这个 diff 对所有词都返回 10,全体平局,于是返回下标最小的 'butter'。如果测的是 autocorrect("butter", [...], ten_diff, 20),没有第一条规则的实现会返回 'butter'(碰巧对),但换成 autocorrect("hello", ["butter", "hello"], ten_diff, 20) 就会返回 'butter' 而不是 'hello'——把用户打对的词给改错了。ok 里确实有这类用例:autocorrect('bridgemaking', ['seeds', 'bridgemaking', ...], ..., 0) 期望 'bridgemaking',而 limit = 0 会让第三条规则把所有纠正都否掉——只有第一条规则能救它。
第三条为什么必须放在最后?因为你得先知道「最小差异是多少」才能判断它是否超限。逻辑顺序是:先找出最优候选 → 再看它够不够好 → 不够好就放弃纠正。
代码
@memo
def autocorrect(entered_word: str, word_list: list[str], diff_function, limit: int) -> str:
# BEGIN PROBLEM 5
if entered_word in word_list:
return entered_word
# How far each candidate source word is from what the user typed.
diff_from_entered = lambda source_word: diff_function(entered_word, source_word, limit)
# min returns the *first* item achieving the smallest key, which is exactly
# the tie-breaking rule we want (earliest index in word_list wins).
closest_word = min(word_list, key=diff_from_entered)
if diff_from_entered(closest_word) > limit:
return entered_word # even the best match is too far off to be a typo
return closest_word
# END PROBLEM 5
逐行看:
@memo:这是选做题 Problem EC 要求加的装饰器(第 11 节详细讲)。做 Q5 时这一行还不存在,加了它 Q5 也照样通过——它只是把结果缓存起来,不改变返回值。if entered_word in word_list: return entered_word:第一条规则。word_list是列表,这个in做的是元素相等判断(回顾 Q2 里对in两种语义的辨析)。diff_from_entered = lambda source_word: ...:把三参数函数包成单参数函数。写成具名变量而不是直接内联进min(...),是因为它下面还要再用一次——if diff_from_entered(closest_word) > limit。closest_word = min(word_list, key=diff_from_entered):min会对word_list的每个元素调用一次key,然后返回让key最小的那个元素本身。不是返回最小的 diff 值——这一点极易搞混。if diff_from_entered(closest_word) > limit::第三条规则。注意这里又调了一次diff_function。理论上可以让min同时给出值和词(比如对(diff, word)元组取min),但那样会破坏平局规则——元组比较在 diff 相等时会去比字符串的字典序,导致返回的不是下标最小的那个而是字母序最小的那个。多调一次函数,换来平局规则的正确,这笔交易是划算的。return closest_word:走到这里说明最小差异 ≤ limit,纠正成立。
题面说的是「如果最低差异大于 limit,返回原词」。所以差异恰好等于 limit 时仍然要纠正。ok 用这组用例卡这一点:
>>> first_diff = lambda w1, w2, limit: 1 if w1[0] != w2[0] else 0
>>> autocorrect("inside", ["idea", "insider"], first_diff, 0.5)
'idea'
两个候选首字母都是 'i',与 'inside' 的首字母相同,差都是 0。平局,取下标 0 的 'idea'。0 > 0.5 为假,所以纠正成立——哪怕纠正的结果毫无道理。注意 limit 可以是小数,别假设它是整数。
验证
追一遍 autocorrect("bwe", ["bye", "awe"], matching_diff, 10),期望 'bye'。其中 matching_diff 数的是「前 min(len) 个位置上有几个字符不同」。
'bwe' in ['bye', 'awe']?'bwe' == 'bye' 假,'bwe' == 'awe' 假 → False,不走第一条规则。diff_from_entered,它闭包住 entered_word = 'bwe'、limit = 10、diff_function = matching_diff。min 开始遍历。第一个元素 'bye':matching_diff('bwe', 'bye', 10)——逐位比较 bvsb 同、wvsy 异、evse 同,得 1。当前最优 = 'bye',最优值 = 1。'awe':bvsa 异、wvsw 同、evse 同,得 1。判断 1 < 1 → 假,不更新。最优仍是 'bye'。closest_word = 'bye'。再算一次 diff_from_entered('bye') = 1,判断 1 > 10 → 假。'bye'。第 4 步是全题的题眼。把列表换成 ["awe", "bye"] 重跑一遍:第 3 步先算 'awe' 得 1,第 4 步算 'bye' 得 1,同样 1 < 1 为假不更新,于是最优是 'awe'。结果只由入场顺序决定,这就是 min 的严格小于比较在起作用。
再追一个触发第三条规则的:autocorrect("cul", ["culture", "cult", "cultivate"], abs_diff, 0),期望 'cul'。
| 步骤 | 候选 | abs_diff('cul', 候选, 0) | min 的当前最优 |
|---|---|---|---|
| 1 | 'culture' | abs(7 - 3) = 4 | 'culture'(4) |
| 2 | 'cult' | abs(4 - 3) = 1 | 1 < 4 真 → 'cult'(1) |
| 3 | 'cultivate' | abs(9 - 3) = 6 | 6 < 1 假 → 仍是 'cult' |
closest_word = 'cult',再算一次它的 diff 得 1,判断 1 > 0 → 真,于是执行 return entered_word,返回 'cul'。与期望一致。
误区一:diff 参数顺序写反。写成 diff_function(source_word, entered_word, limit)。对称的 diff 函数(如 abs_diff、后面的 furry_fixes)察觉不到,但 length_ratio 那个用例会立刻挂。记法:「entered 在前」和 furry_fixes(entered, source, limit) 的签名一致,整个项目的 diff 函数都遵守这个顺序。
误区二:min 忘了 key。写成 min(word_list),那是按字符串字典序取最小,会返回 'awe' 之类和差异毫无关系的东西。
误区三:min 返回值当成 diff 值用。写成 if min(word_list, key=...) > limit——这是在拿字符串和数字比大小,报 TypeError: '>' not supported between instances of 'str' and 'int'。
误区四:用 min([(diff_from_entered(w), w) for w in word_list])。看起来聪明,但元组比较在第一项相等时会比第二项,即字符串字典序。autocorrect("bwe", ["bye", "awe"], matching_diff, 10) 会返回 'awe'(字典序更小),期望 'bye'。平局规则被悄悄改成了「字母序最小」。
误区五:漏掉「entered_word 在词典里」这一条。大部分用例仍能过,但 autocorrect('bridgemaking', ['seeds', 'bridgemaking', ...], ..., 0) 会因为 limit = 0 而返回原词——碰巧也对;换成 limit 较大的用例就会把正确的词改掉。这个 bug 在真实使用时的表现是:你打对的常用词会被莫名其妙地换成别的词。
6. Problem 6:furry_fixes
题目要什么
Q5 造好了调度中心,但真正判断「两个词差多远」的活还没人干。furry_fixes 是第一个 diff function。
它的规则很朴素:把 entered 和 source 左对齐,逐位置比较,数出有几个位置的字符不同;如果两个词长度不等,把长度差也加进去。
>>> big_limit = 10
>>> furry_fixes("nice", "rice", big_limit) # Substitute: n -> r
1
>>> furry_fixes("range", "rungs", big_limit) # Substitute: a -> u, e -> s
2
>>> furry_fixes("pill", "pillage", big_limit) # Don't substitute anything, length difference of 3.
3
>>> furry_fixes("goodbye", "good", big_limit) # Don't substitute anything, length difference of 3.
3
>>> furry_fixes("roses", "arose", big_limit) # Substitute: r -> a, o -> r, s -> o, e -> s, s -> e
5
>>> furry_fixes("rose", "hello", big_limit) # Substitute: r->h, o->e, s->l, e->l, length difference of 1.
5
看第五行。"roses" 和 "arose" 在人眼里非常像——把 a 插到最前面再删掉最后的 s 就行了。但 furry_fixes 严格左对齐逐位比:r/a、o/r、s/o、e/s、s/e,五个位置全不同,答案是 5。ok 里还有更极端的:furry_fixes("hash", "ash", big_limit) 是 4,虽然这两个词只差一个开头的 h。
furry_fixes 只会「替换」,不会「插入」和「删除」。所以一旦开头多了或少了一个字符,后面全部错位,代价爆炸。这正是 Q7 minimum_mewtations 存在的理由——它把插入和删除也纳入考虑。Q6 和 Q7 的关系,就是「先做一个笨的,看清它笨在哪,再做聪明的」。
题目还有两条硬性约束:
- 不许用
while、for、列表推导式。必须用递归。ok 会用tests/construct_check.py做语法树检查,写了循环直接判失败——不是「答案错」,是「语法违规」。 - 差异一旦超过
limit,就返回任何大于limit的数,并且要尽可能少做计算。题面说得很直白:「as soon as we know the difference is above limit, it makes sense to stop making recursive calls, saving time, even if the returned difference won't be exactly correct.」有一个自动评分测试专门数你的函数调用次数。
为什么允许返回不精确的值?回看 Q5:autocorrect 只用 diff 值做两件事——找最小、和 limit 比大小。一旦某个候选的差异超了 limit,它就注定被淘汰,此时「超了 1」和「超了 100」对结果没有任何区别。继续算下去是纯浪费。题面用一组对照说明了这个要求有多严格:
>>> limit = 4
>>> furry_fixes("roses", "arose", limit) > limit
True
>>> furry_fixes("rosesabcdefghijklm", "arosenopqrstuvwxyz", limit) > limit
True
第二个调用的字符串长了 13 倍,但耗时必须差不多——因为在第 5 个字符处就已经超预算,后面 13 个字符根本不该被看。
怎么想到的
第一步:从循环翻译成递归
如果允许写循环,这题两行就完了:数出前 min(len) 个位置有几个不同,加上 abs 长度差。但题目不许。
递归的思路完全不同:不要想「怎么把所有位置都数一遍」,要想「怎么把问题变小一点点,然后把剩下的交给自己」。
对字符串来说,「变小一点点」的标准动作是切片:s[1:] 是去掉第一个字符后的字符串。于是问题变成:
entered[0] 和 source[0]。furry_fixes(entered[1:], source[1:], limit)。1 + furry_fixes(entered[1:], source[1:], ...)。这就是全部的递归结构。注意两边同时切掉第一个字符——因为 furry_fixes 只允许替换,位置 i 永远对着位置 i,不存在错位。(Q7 会打破这一点,那时切片方式才是真正的难点。)
第二步:base case 是什么
递归总要停。切到什么时候停?其中一个字符串变空的时候。此时剩下的另一个字符串有多长,就要付多少钱——这正是题目说的「长度差」。
怎么写?一个漂亮的技巧是 return len(entered) + len(source)。因为进入这个分支时至少有一个是空串,它的 len 是 0,所以这个和实际上就等于「非空那个的长度」。不用写 if entered == '': return len(source) else: return len(entered),一个加法同时覆盖了两种情况,还顺带覆盖了「两个都空」(返回 0)。
第三步:limit 剪枝放在哪
这是这道题真正需要动脑的地方,题面的 Hint 也在提示它:「you will need more than one base case」。
先想清楚 limit 在递归中怎么传。每次发生替换(花掉 1 块钱),剩余预算就该减 1,所以递归调用传 limit - 1;字符相同时没花钱,预算不变,传 limit。
于是「超预算」的信号就是 limit 变成了负数。为什么是负数而不是 0?因为 limit = 0 意味着「还剩 0 块钱,但还没超支」——如果此时两个串正好都空了,答案是 0,完全合法(ok 里就有 furry_fixes("awesome", "awesome", 0) 期望 0)。只有花掉的钱严格超过预算,才算失败。
剪枝必须放在函数最开头,在做任何其他判断之前。这就是「stop making recursive calls」的字面执行:一进门就发现没钱了,立刻掉头,连 entered[0] 都不看。
limit + 1 恰好是对的
limit 已经是负数了,limit + 1 可能是 0、-1、-2……这看起来荒谬——不是要求「返回大于原 limit 的数」吗?返回 0 怎么可能大于原来的 limit?
关键在于:这个返回值不会直接交给调用者,它要沿着调用栈一层层加上那些累积的 1 +。
设原始预算是 \(L\),走到剪枝点时已经发生了 \(k\) 次替换。那么当前这层的 limit 就是 \(L - k\),剪枝触发意味着 \(L - k < 0\)。这一层返回 \(L - k + 1\),回代时要加上外层累积的 \(k\) 个 1,最终结果是:
不多不少,恰好是 \(L + 1\)——满足「任何大于 limit 的数」这个要求,而且是最小的那个。这个设计的精巧之处在于:每层只管自己的局部预算,全局的正确性自动浮现出来。
代码
def furry_fixes(entered: str, source: str, limit: int) -> int:
# BEGIN PROBLEM 6
if limit < 0:
# We have already spent more changes than allowed; stop recursing and
# report a value that is guaranteed to exceed the original limit.
return limit + 1
elif entered == '' or source == '':
# One word ran out: every leftover character is a length difference.
return len(entered) + len(source)
elif entered[0] == source[0]:
# Characters line up, so no substitution is needed here.
return furry_fixes(entered[1:], source[1:], limit)
else:
# Substitute this character and continue with one less of the budget.
return 1 + furry_fixes(entered[1:], source[1:], limit - 1)
# END PROBLEM 6
四个分支,顺序不能动。逐条说为什么:
if limit < 0: return limit + 1——必须是第一个分支。如果把它放到第二位(先判空串),那么在furry_fixes("", "abcdefgh", -1)这种情况下会返回 8 而不是 0,虽然都大于原 limit,但它多做了一次len;更严重的是,如果放到最后,剪枝就完全失效了——因为前面的分支已经把递归发出去了。elif entered == '' or source == '':——第二个 base case。用or而不是两个独立的if,因为len(entered) + len(source)这个表达式对两种情况通吃。注意它也覆盖了「两个都是空串」,此时返回 0。elif entered[0] == source[0]:——能安全取下标[0],全靠上一个分支已经排除了空串。如果把空串检查删掉,这里会报IndexError: string index out of range。分支顺序在这里承担了「守卫」职责。return furry_fixes(entered[1:], source[1:], limit)——注意没有1 +,limit也没减。字符相同是免费的。else: return 1 + furry_fixes(entered[1:], source[1:], limit - 1)——1 +和limit - 1必须成对出现。它们是同一件事的两面:花了一块钱(结果加 1),预算少一块(limit 减 1)。只写一个就会出错:漏了1 +结果永远偏小;漏了limit - 1剪枝永不触发,性能测试挂掉。
elif 不是可有可无的
如果把 elif 都换成独立的 if,代码碰巧还是对的(因为每个分支都 return)。但用 elif 表达的是「这四种情况互斥且穷尽」,读代码的人能立刻确认没有遗漏。递归函数的分支结构就是它的正确性证明,写清楚它值得。
验证:完整展开调用栈
追 furry_fixes("rose", "hello", 10),期望 5。"rose" 长 4,"hello" 长 5,没有一个位置的字符相同,所以是「4 次替换 + 1 个长度差」。
furry_fixes("rose", "hello", 10)
limit=10 不小于 0;两串都非空;'r' != 'h' → 走 else
= 1 + furry_fixes("ose", "ello", 9)
│
│ limit=9 不小于 0;都非空;'o' != 'e' → else
= 1 + furry_fixes("se", "llo", 8)
│
│ limit=8 不小于 0;都非空;'s' != 'l' → else
= 1 + furry_fixes("e", "lo", 7)
│
│ limit=7 不小于 0;都非空;'e' != 'l' → else
= 1 + furry_fixes("", "o", 6)
│
│ limit=6 不小于 0;entered == '' → base case
= len("") + len("o") = 0 + 1 = 1
现在逐层回代:
furry_fixes("", "o", 6) = 1
furry_fixes("e", "lo", 7) = 1 + 1 = 2
furry_fixes("se", "llo", 8) = 1 + 2 = 3
furry_fixes("ose", "ello", 9)= 1 + 3 = 4
furry_fixes("rose", "hello", 10) = 1 + 4 = 5 ← 答案
4 个 1 + 对应 4 次替换,最后的 1 来自「hello 比 rose 多出的那个 o」。和 doctest 注释里写的「Substitute: r->h, o->e, s->l, e->l, length difference of 1」完全对上。
再看一个字符相同的例子
追 furry_fixes("pill", "pillage", 10),期望 3:
furry_fixes("pill", "pillage", 10) 'p'=='p' → 免费
= furry_fixes("ill", "illage", 10) 'i'=='i' → 免费
= furry_fixes("ll", "llage", 10) 'l'=='l' → 免费
= furry_fixes("l", "lage", 10) 'l'=='l' → 免费
= furry_fixes("", "age", 10) entered == '' → base
= 0 + 3 = 3
注意这条链上一个 1 + 都没有,limit 也始终是 10——四次全是免费分支。最后 base case 一把算出长度差 3。这说明「长度差」在这个实现里不需要单独处理,它自然地落在 base case 里。
剪枝真的省了计算吗
追 furry_fixes("roses", "arose", 4),题面要求它 > 4。
furry_fixes("roses", "arose", 4) 'r'!='a' → 1 + f("oses","rose",3)
furry_fixes("oses", "rose", 3) 'o'!='r' → 1 + f("ses","ose",2)
furry_fixes("ses", "ose", 2) 's'!='o' → 1 + f("es","se",1)
furry_fixes("es", "se", 1) 'e'!='s' → 1 + f("s","e",0)
furry_fixes("s", "e", 0) limit=0 不小于 0;都非空;'s'!='e'
→ 1 + f("","",-1)
furry_fixes("", "", -1) limit < 0 → 立刻返回 -1 + 1 = 0
★ 一步都没往下走
回代:0 → 1 → 2 → 3 → 4 → 5。返回 5,确实 > 4。这里 \(L = 4\)、\(k = 5\),按前面的推导应该得 \(L + 1 = 5\)——对上了。
现在把字符串加长到 furry_fixes("rosesabcdefghijklm", "arosenopqrstuvwxyz", 4)。前 5 个字符的比较过程与上面完全一样,limit 同样降到 -1,同样在第 6 层立刻返回。后面那 13 个字符一次都没被访问。这就是题面说的「两个调用耗时应该差不多」。
反过来,如果没有 limit < 0 这个 base case,第二个调用会一路递归 18 层才碰到空串。字符串再长十倍,代价就再涨十倍。而 autocorrect 要对词典里每一个词都调一次 diff——data/words.txt 有几万个词,这个差距是致命的。
误区一:用了循环。哪怕写得再对,ok 会直接判违规:
# Error: Illegal use of 'for' in furry_fixes
检查由 tests/construct_check.py 完成,它解析你的语法树,不看运行结果。列表推导式内部也含循环,同样不行。
误区二:剪枝写成 if limit == 0。这会误伤合法情况:furry_fixes("awesome", "awesome", 0) 应该返回 0,但一进门就被 limit == 0 拦下返回 1。ok 有这条用例。预算为 0 不等于超支。
误区三:剪枝返回 0 或者 limit。返回 0 会让超限的候选看起来「完美匹配」,autocorrect 会挑中一个八竿子打不着的词。返回 limit 则在 > limit 判断里刚好卡在边界上,纠正会被错误地接受。必须是 limit + 1。
误区四:切片写成 entered[1:] 但 source 忘了切。写成 furry_fixes(entered[1:], source, limit)——source 永远不变短,递归永不终止,报 RecursionError: maximum recursion depth exceeded。
误区五:把长度差单独算一遍再加上去。比如先 return abs(len(entered) - len(source)) + 递归结果——每一层都会加一次长度差,结果被重复累加。长度差只该在 base case 出现一次。
7. Problem 7:minimum_mewtations
题目要什么
Q6 的 furry_fixes 只会替换,于是 "hash" 和 "ash" 这种只差一个开头字母的词被判成差 4。minimum_mewtations 要修好这件事:它计算编辑距离(edit distance)——把 entered 变成 source 最少需要几次编辑操作。
允许三种操作,每种算一次:
| 操作 | 含义 | 题面的例子 |
|---|---|---|
| Add(插入) | 往 entered 里加一个字母 | 给 "itten" 加 "k" 得到 "kitten" |
| Remove(删除) | 从 entered 里删一个字母 | 从 "scat" 删 "s" 得到 "cat" |
| Substitute(替换) | 把 entered 里一个字母换成另一个 | 把 "zaguar" 的 "z" 换成 "j" 得到 "jaguar" |
>>> big_limit = 10
>>> minimum_mewtations("cats", "scat", big_limit) # cats -> scats -> scat
2
>>> minimum_mewtations("purng", "purring", big_limit) # purng -> purrng -> purring
2
>>> minimum_mewtations("ckiteus", "kittens", big_limit) # ckiteus -> kiteus -> kitteus -> kittens
3
对比一下 Q6 的答案就能看出提升有多大:
| 词对 | furry_fixes | minimum_mewtations | 为什么 |
|---|---|---|---|
"hash" → "ash" | 4 | 1 | 删掉开头的 h 即可 |
"roses" → "arose" | 5 | 2 | 前面插 a,末尾删 s |
"speling" → "spelling" | 4 | 1 | 插一个 l |
最后一行最能说明问题:这是一个真实的拼写错误,Q6 的 autocorrect("speling", small_words_list, furry_fixes, 10) 会纠成 'peeling'(荒谬),Q7 则纠成 'spelling'(正确)。ok 里有一对并列的测试专门展示这个对比。
约束和 Q6 一样但更严:超 limit 要立刻停,有 call count 测试;另外题面加了一条 Important——不许用任何辅助函数,因为评分器会 trace 你的调用栈,看到额外的函数名就判失败。
怎么想到的
第一步:三个操作分别对应哪个切片
这是全项目最容易搞混的一步,值得慢慢来。
递归的框架和 Q6 一样:看 entered[0] 和 source[0]。如果不同,现在有三条路可走,每条路都花 1 次编辑,然后把「剩下的」交给递归。问题是:走完这一步之后,「剩下的」分别是什么?
「往 entered 前面插入一个字母」——插什么?当然是插 source[0],这样这个位置就对上了。插完之后,entered 的开头多了一个字符,它和 source[0] 已经匹配、可以双双划掉。划掉之后:entered 一个字符都没少(原来的字符全都还在),source 少了第一个。所以递归调用是 mm(entered, source[1:], limit - 1)。
验证一下题面的例子:"itten" 加 "k" 变 "kitten"。这里 entered = "itten",source = "kitten",加的正是 source[0] = 'k';加完之后要比的是 "itten" 和 "itten"——entered 没动,source 掉了头。
「从 entered 里删掉一个字母」——删哪个?删 entered[0],因为它显然对不上。删完之后 entered 少了第一个字符,source 一个都没少。所以是 mm(entered[1:], source, limit - 1)。
题面例子:从 "scat" 删 "s" 得 "cat"。entered 掉头,source 不动。
「把 entered[0] 换成 source[0]」——换完这两个就对上了,双双划掉。所以是 mm(entered[1:], source[1:], limit - 1)。这一支和 Q6 的 else 分支一模一样,这就是题面 Hint 说的「其中一个递归调用会和 furry_fixes 很像」。
Add 动 source,Remove 动 entered,Substitute 两个都动。
直觉是:加一个字符相当于让 source 「被消耗掉」一个而 entered 不付出;删一个字符相当于让 entered 「被消耗掉」一个而 source 不付出;替换则两边同时消耗一个。如果 add 和 remove 写反,你会发现 mm("hash", "ash", 10) 返回 1,mm("ash", "hash", 10) 也返回 1(因为这两个操作互为镜像,对称输入察觉不到),但 mm("cats", "scat", 10) 之类的非对称情形就会出错。
三条路都走一遍,取代价最小的,再加上自己花的这 1 次:
return 1 + min(add, remove, substitute)
这就是所谓的树形递归(tree recursion)——一次调用分裂成三次,调用图不再是一条链而是一棵树。Q6 每层只有一个分支,Q7 有三个,代价从线性变成指数级。这也是为什么剪枝在这题变得性命攸关。
第二步:字符相同的时候,要不要也试三条路
如果 entered[0] == source[0],最自然的做法是「免费划掉,继续」:mm(entered[1:], source[1:], limit)。但严格来说,此时也可以选择做一次插入或删除——万一那样更划算呢?
答案是不可能更划算。这是编辑距离的一条经典性质:如果两个串的首字符相同,那么存在一个最优编辑方案,其中这两个字符是互相匹配的。直觉是:假设某个最优方案没有让它们匹配,那它一定花了钱去动其中一个;把那笔钱省下来、改成让它们匹配,得到的方案不会更差。
题面在 Common Mistakes 里专门点了这件事:「Given that both strings start with the same character h, what is the most effective approach? Should the function even attempt to add or remove?」
如果你在首字符相同时仍然展开三条支路,答案还是对的,但调用次数会爆炸,EC 那道题的 call_count 测试直接挂。更要命的是在真实词典上跑 autocorrect 会慢到无法忍受——大部分英文单词都以相同字母开头,这个分支被触发的频率极高。
第三步:两个剪枝 base case
第一个和 Q6 完全一样:limit < 0 时返回 limit + 1。前面推导过的「回代后恰好得到 \(L+1\)」在这里同样成立。
第二个是 Q7 独有的,也是最容易漏的:长度差超过 limit 就直接放弃。
为什么这条成立?想想三种操作对长度的影响:插入让长度 +1,删除让长度 −1,替换不改变长度。所以每一次编辑最多把长度差缩小 1。如果现在 entered 和 source 的长度差是 5,那么至少要 5 次编辑才可能让它们长度相等,遑论相同。若此时 limit 只有 2,这条路彻底没戏,立刻返回 limit + 1。
题面的 Common Mistakes 用一个具体例子逼问这一点:minimum_mewtations("maooo", "mao", 0)。长度差是 2,预算是 0——不需要做任何递归就能断定不可能。没有这条 base case 的实现会先匹配掉 m、a、o,再展开三条支路,白白多跑一堆调用。
第四步:base case 的顺序
三个提前返回的条件写成什么顺序?
limit < 0 — 最先。已经超支,别的什么都不用算。entered == '' or source == '' — 第二。一方空了,剩下的只能全靠插入(或全靠删除),代价就是剩余长度,len(entered) + len(source) 一式通吃。放在长度差检查之前更安全:此时答案是精确的,没必要用 limit + 1 这个粗糙值去替代。abs(len(entered) - len(source)) > limit — 第三。走到这里两个串都非空,长度差的剪枝才有意义。entered[0] == source[0] — 第四。前面已经保证非空,取下标安全。else — 三路展开。代码
@memo_diff
def minimum_mewtations(entered: str, source: str, limit: int) -> int:
if limit < 0:
# Out of budget: any value above the original limit will do, and
# returning right away keeps us from exploring a hopeless branch.
return limit + 1
elif entered == '' or source == '':
# Only insertions (or only deletions) are left, one per leftover char.
return len(entered) + len(source)
elif abs(len(entered) - len(source)) > limit:
# Fixing a length gap of N costs at least N edits, so this is hopeless.
return limit + 1
# Recursive cases go below here
elif entered[0] == source[0]:
# The first characters already agree, so no edit is needed here and
# trying add/remove/substitute could never beat skipping both.
return minimum_mewtations(entered[1:], source[1:], limit)
else:
add = minimum_mewtations(entered, source[1:], limit - 1) # insert source[0]
remove = minimum_mewtations(entered[1:], source, limit - 1) # delete entered[0]
substitute = minimum_mewtations(entered[1:], source[1:], limit - 1)
return 1 + min(add, remove, substitute)
# Ignore the line below
minimum_mewtations = count(minimum_mewtations)
几处需要专门解释:
@memo_diff:Problem EC 加的记忆化装饰器(第 11 节讲)。做 Q7 时没有它,代码照样对,只是慢很多。minimum_mewtations = count(minimum_mewtations):这行是官方给好的,注释写着Ignore the line below,但它其实值得看一眼。count定义在utils.py里,它把函数包一层,每调用一次就给counted.call_count加 1。因为这行赋值改的是全局名字minimum_mewtations,而函数体内的递归调用查的也是这个全局名字,所以每一次递归调用都会被计数。这正是评分器数调用次数的机制。- 三个递归调用都写在
else里,且都提前算好再取min。这意味着三条支路无论如何都会被完整探索一遍——Python 不会因为add已经等于 0 就跳过remove。这是这个实现的代价,也是 EC 那道题要靠记忆化来救的原因。 - 三个变量名
add/remove/substitute不是装饰,它们是防止切片写错的最后一道防线。写下add = mm(entered, source[1:], ...)时,名字会逼你确认「插入到底动的是哪一边」。
起始代码里可能带着 assert False, 'Remove this line'。忘了删,运行时会报 AssertionError: Remove this line。题面用 Important 特意提醒过。
验证:展开 minimum_mewtations("cats", "scat", 10)
期望 2,doctest 注释给的路径是 cats -> scats -> scat(先在前面插 s,再删掉末尾的 s)。看看递归是怎么找到它的。
mm("cats", "scat", 10)
limit=10 ≥ 0 ✓
两串都非空 ✓
|4 - 4| = 0,不大于 10 ✓
'c' != 's' → 展开三条支路(每条预算降到 9)
add = mm("cats", "cat", 9) ← source 掉头(插入 's')
remove = mm("ats", "scat", 9) ← entered 掉头(删掉 'c')
substitute = mm("ats", "cat", 9) ← 两边都掉头(把 'c' 换成 's')
先把 add 这一支完整走完,因为它就是最优解所在:
mm("cats", "cat", 9)
|4 - 3| = 1,不大于 9 ✓
'c' == 'c' → 免费划掉,limit 保持 9
= mm("ats", "at", 9)
'a' == 'a' → 免费
= mm("ts", "t", 9)
't' == 't' → 免费
= mm("s", "", 9)
source == '' → base case
= len("s") + len("") = 1 + 0 = 1
⇒ add = 1
这条链上没有任何一次三路分叉:插入 s 之后,"cats" 和 "cat" 前三个字符全对上,最后剩一个 "s" 要删掉——正是「scats -> scat」那一步。
另外两支的值(自行展开会分别再分叉出去,这里给结果):
| 支路 | 调用 | 返回值 | 含义 |
|---|---|---|---|
| add | mm("cats", "cat", 9) | 1 | 插入 s 之后还需 1 次编辑 |
| remove | mm("ats", "scat", 9) | 3 | 删掉 c 是条烂路,后面还要补 3 次 |
| substitute | mm("ats", "cat", 9) | 2 | 把 c 换成 s 之后还需 2 次 |
min(add, remove, substitute) = min(1, 3, 2) = 1 return 1 + 1 = 2 ← 与 doctest 一致
那个 1 + 就是「插入 s」这一次操作本身的代价,min 里的 1 是「插完之后还要删掉末尾的 s」。两步,正好对应注释里的 cats -> scats -> scat。
再看长度差剪枝怎么起作用
追题面 Common Mistakes 里那个例子:minimum_mewtations("maooo", "mao", 0)。
limit = 0,不小于 0,跳过第一个 base case。abs(5 - 3) = 2,2 > 0 → 命中!立刻返回 0 + 1 = 1。如果没有第三个 base case 会怎样?'m' == 'm' 免费划掉、'a' == 'a' 免费划掉、'o' == 'o' 免费划掉,到 mm("oo", "", 0) 才因为 source 空而返回 2。答案还是「大于 limit」,但白跑了三层。放在几万个词的词典上,这种浪费是以百万次调用计的。
调用次数实测
在 proj/cats/ 目录下跑:
>>> import cats
>>> cats.minimum_mewtations.call_count = 0
>>> cats.minimum_mewtations("rlogcul", "logical", 10)
3
>>> cats.minimum_mewtations.call_count
296
296 次调用(这个数字包含了 EC 记忆化生效后的效果),EC 测试的门槛是 <= 350,通过。把首字符相同时的贪心分支去掉、或者把长度差剪枝去掉,这个数字会立刻冲破门槛。
误区一:add 和 remove 的切片写反。最常见的错误,没有之一。写成 add = mm(entered[1:], source, limit-1)——这实际上是 remove。对称的输入察觉不到,但 mm("cats", "scat", 10) 会得到 3 而不是 2。自查办法:拿 mm("hash", "ash", 10) 和 mm("ash", "hash", 10) 一起测,两个都应该是 1;再测 mm("wird", "wir", 10) 和 mm("wird", "bwird", 10),也都是 1。四个方向都对了才算切片没写反。
误区二:首字符相同时也展开三路。写成把 entered[0] == source[0] 那个分支删掉,直接进 else。答案仍然正确(因为 substitute 支路在字符相同时其实等价于免费划掉……不,并不等价——substitute 支路会 1 +,代价多算了 1)。所以这样写答案都是错的:mm("wind", "wind", 10) 会返回 4 而不是 0。
误区三:用了辅助函数。比如为了少写点重复代码,定义一个 def helper(a, b, l)。ok 的 EC 测试会 trace 调用栈并断言函数名集合恰好是 {'counted', 'minimum_mewtations', 'memoized'},多一个就挂。
误区四:min 里漏了一个支路。写成 1 + min(add, substitute),那么所有需要删除的情形都算不对:mm("goodbye", "good", 10) 会偏大。
误区五:以为 1 + min(...) 可以提前短路。有人会想「既然 add 已经是 0 了,能不能不算 remove」。可以,但需要写成嵌套的 if,而且收益远不如直接上记忆化。这就是 Problem EC 存在的理由。
8. Problem 8:report_progress
题目要什么
Phase 3 做多人对战。你和对手各自跑一个 cats_gui.py,两边都往 CS 61A 的服务器上报进度,服务器再把对方的进度转发给你,于是屏幕上能看到两只猫在赛跑。report_progress 就是「上报进度」这一步。
每当用户敲完一个词,这个函数被调用一次。四个参数:entered(到目前为止敲出的词组成的列表)、source(原文的词列表)、user_id(你的编号)、upload(一个用来上报的函数)。
「进度」的定义是关键:从头开始连续敲对的词数,除以原文总词数。注意「连续」——一旦某个词错了,后面即使全对也不算。
report_progress(["Hello", "ths", "is"], ["Hello", "this", "is", "wrong"], ...)
题面给的这个例子进度是 0.25。用户敲了三个词,第一个 "Hello" 对,第二个 "ths" 错(原文是 "this"),于是从这里截断,第三个 "is" 虽然和原文对上了也不算。所以连续正确数是 1,原文有 4 个词,1/4 = 0.25。
函数要做两件事:
upload,参数是一个字典 {'id': user_id, 'progress': progress}——两个键,键名必须一字不差。>>> print_progress = lambda d: print('ID:', d['id'], 'Progress:', d['progress'])
>>> entered = ['how', 'are', 'you']
>>> source = ['how', 'are', 'you', 'doing', 'today']
>>> report_progress(entered, source, 2, print_progress)
ID: 2 Progress: 0.6
0.6
>>> report_progress(['how', 'aree'], source, 3, print_progress)
ID: 3 Progress: 0.2
0.2
这个 doctest 的输出有两行,而且它们的性质完全不同:
| 输出行 | 来自哪里 | 如果漏了会怎样 |
|---|---|---|
ID: 2 Progress: 0.6 | upload(...) 内部的 print 的副作用 | 只剩一行 0.6,ok 报缺少输出 |
0.6 | 函数的返回值被交互式解释器回显 | 只剩 ID: ... 那行,返回 None |
doctest 里那两行看起来都是「输出」,但机制完全不同。upload 恰好是个会打印的 lambda,所以它的调用产生了第一行;第二行是 REPL 在回显表达式的值。如果你只 upload 不 return,函数返回 None,REPL 不显示 None,于是第二行凭空消失。这是 CS 61A 反复强调的那个母题在项目里的实际后果——而且这里的 return 真的有用:多人模式的 GUI 要拿这个返回值去画进度条。
题面还给了一条重要保证:entered 里的词数永远不会多于 source。这条保证让边界处理简单了不少。另外 ok 里有一批用例测 entered 为空的情形:
>>> report_progress([], ['smopple'], 22, print_progress)
ID: 22 Progress: 0.0
0.0
怎么想到的
「从头开始数连续对的个数」,这个动作用 for 循环写会有点别扭:
correct = 0
for i in range(len(entered)):
if entered[i] == source[i]:
correct += 1
else:
break # 遇错即停
能用,但 break 的存在说明「循环条件」其实不只是「走完列表」——还有一个「一直对着」的条件。当一个 for 循环里出现 break,往往意味着 while 更贴切:把两个终止条件都写进循环条件里,读代码的人一眼就能看到什么时候会停。
while correct < len(entered) and entered[correct] == source[correct]
两个条件用 and 连起来,顺序不能换。and 是短路(short-circuit)的:如果左边为假,右边根本不会求值。当 correct 已经等于 len(entered) 时,左边为假,于是 entered[correct] 这个越界访问压根不会执行。
写反成 entered[correct] == source[correct] and correct < len(entered),第一个条件就会在 correct == len(entered) 时越界,报 IndexError: list index out of range。这是「短路求值不只是性能优化,它是正确性的一部分」最干净的例子。
循环变量 correct 同时扮演两个角色:它既是「已经确认对了几个」,又是「下一个要检查的下标」。这两件事能合一,正是因为「连续从头开始」这个条件——前 correct 个都对,那么下一个要看的就是下标 correct。想清楚这一点,循环体里只需要 correct += 1 一行。
为什么不用担心 source[correct] 越界?靠题面那条保证:len(entered) <= len(source)。既然 correct < len(entered),那必然 correct < len(source)。这就是「读题面给的保证」的价值——它让你少写一个条件。
最后是 upload。它是参数,不是全局函数——这又是一次高阶函数的应用。真实运行时传进来的是往 CS 61A 服务器发 HTTP 请求的函数;测试时传进来的是 print_progress 这个只打印的 lambda。report_progress 完全不知道也不需要知道区别,于是它能在没有网络的环境里被 doctest 测试。这就是整个项目的设计哲学在 Phase 3 的体现。
代码
def report_progress(entered: list[str], source: list[str], user_id: int, upload) -> float:
# BEGIN PROBLEM 8
# Count how many words are correct before the first mistake.
correct = 0
while correct < len(entered) and entered[correct] == source[correct]:
correct += 1
progress = correct / len(source)
upload({'id': user_id, 'progress': progress})
return progress
# END PROBLEM 8
逐行看:
correct = 0:既是计数器又是游标。while correct < len(entered) and ...:先边界后内容,靠短路保护索引。循环在两种情况下退出——走完了entered(全对),或者撞上第一个错词。退出时correct恰好是「连续正确的词数」,两种退出方式共用同一个语义。progress = correct / len(source):分母是source的长度,不是entered的。这和 Q3accuracy恰好相反,值得停下来想一秒:accuracy 问的是「你敲的东西里对了多少」,progress 问的是「整篇文章你走了多远」。两个指标回答不同的问题,分母自然不同。upload({'id': user_id, 'progress': progress}):字典字面量直接内联。键名是字符串'id'和'progress',服务器和 GUI 都按这两个名字取值,写错一个字母就传不过去。return progress:不能漏。把它删掉 doctest 会少一行输出。
Phase 3 的三道题都涉及列表,Q10 有明确的「不许修改入参」要求。Q8 这里天然安全:整个函数只读 entered 和 source,没有 append、没有 sort、没有下标赋值。correct 是个整数,整数是不可变的,correct += 1 是重新绑定而不是修改对象。传进来的那两个列表从头到尾没被碰过一下。
验证
追第二个 doctest:report_progress(['how', 'aree'], ['how', 'are', 'you', 'doing', 'today'], 3, print_progress),期望打印 ID: 3 Progress: 0.2 并返回 0.2。
| 轮次 | correct | 左条件 correct < 2 | 右条件 | 结果 |
|---|---|---|---|---|
| 1 | 0 | 0 < 2 真 | entered[0]='how' vs source[0]='how' → 真 | 进入循环体,correct 变 1 |
| 2 | 1 | 1 < 2 真 | entered[1]='aree' vs source[1]='are' → 假 | 退出循环 |
correct = 1,len(source) = 5,progress = 1 / 5 = 0.2。upload({'id': 3, 'progress': 0.2})。upload 此刻绑定的是 print_progress,于是打印 ID: 3 Progress: 0.2。这是副作用,发生在 return 之前。return 0.2。REPL 回显 0.2。两行输出,顺序也对:先有 upload 的打印,后有返回值的回显。
再追一个「全对但没打完」的:report_progress(['how', 'are', 'you'], source, 2, print_progress),期望 0.6。
correct=0: 0 < 3 真, 'how'=='how' 真 → correct=1 correct=1: 1 < 3 真, 'are'=='are' 真 → correct=2 correct=2: 2 < 3 真, 'you'=='you' 真 → correct=3 correct=3: 3 < 3 假 → 短路,右边 entered[3] 不求值 → 退出 progress = 3 / 5 = 0.6
最后一轮是短路的功劳。如果 and 两边写反,第四轮会先算 entered[3]——而 entered 只有 3 个元素,当场 IndexError。这个 bug 只在「全部敲对」时才触发,前几个测试都能过,非常隐蔽。
最后看空列表:report_progress([], ['smopple'], 22, print_progress)。correct = 0,循环条件左边 0 < 0 为假,一次都不进循环。progress = 0 / 1 = 0.0,打印 ID: 22 Progress: 0.0,返回 0.0。空列表不需要任何特殊处理——因为 while 的条件在循环开始之前就检查了,这是 while 相对 do-while 的好处。
误区一:只 upload 不 return。ok 报:
# Error: expected
# ID: 2 Progress: 0.6
# 0.6
# but got
# ID: 2 Progress: 0.6
少了一行。看到这种「少一行」的报错,第一反应就该去找漏掉的 return。
误区二:数「总共对了几个」而不是「连续对了几个」。写成 Q3 那种全遍历累加。report_progress(['I', 'hve', 'begun', 'to', 'type'], ['I', 'have', 'begun', 'to', 'type'], 3, ...) 会算成 4/5 = 0.8,而 ok 期望 0.2——只有第一个词对,第二个词一错就全截断。这是 Q8 和 Q3 最容易串味的地方。
误区三:分母用 len(entered)。那样只要敲对了当前所有词,进度永远是 1.0,进度条一开始就满格。
误区四:字典键名写错。写成 {'user_id': ..., 'progress': ...}。print_progress 里 d['id'] 会报 KeyError: 'id'。
误区五:and 两边顺序写反。见上文,只在「全对」时崩,是最难自查的一类 bug。
9. Problem 9:time_per_word
题目要什么
比赛结束后要出一份战报:每个玩家打每个词各花了多久。服务器记录的是时间戳——玩家在第几秒完成了第几个词。要把它转换成时长。
两个参数:words 是大家一起打的词列表;timestamps_per_player 是一个「列表的列表」,第 i 个内层列表记录第 i 个玩家的时间戳。返回一个字典 {'words': words, 'times': times},其中 times[i][j] 是「玩家 i 打 words[j] 花的时间」。
题面给的例子是理解的关键:
timestamps_per_player = [[1, 3, 5], [2, 5, 6]]
times = [[2, 2], [3, 1]]
注意长度变了:内层列表从 3 个元素变成 2 个。为什么?因为题面这句话:「The first value of each list within timestamps_per_player represents the initial starting time for each player.」
玩家 0 的 [1, 3, 5] 读作:第 1 秒开始打字,第 3 秒打完第一个词,第 5 秒打完第二个词。所以第一个词花了 3 - 1 = 2 秒,第二个词花了 5 - 3 = 2 秒。
n 个时间戳产生 n − 1 个时长。这是本题唯一的难点,也是唯一容易出 off-by-one 的地方。times[i] 的长度必须等于 len(words),而 timestamps_per_player[i] 的长度是 len(words) + 1。
看 ok 里的一个用例,把这个关系坐实:
>>> p = [[75, 81, 84, 90, 92], [19, 29, 35, 36, 38]]
>>> result = time_per_word(['collar', 'plush', 'blush', 'repute'], p)
>>> result['times']
[[6, 3, 6, 2], [10, 6, 1, 2]]
4 个词,每个玩家 5 个时间戳,输出 4 个时长。玩家 0:81-75=6、84-81=3、90-84=6、92-90=2。对上了。
还有一条隐含要求:'words' 键原样存回去,不做任何加工。这看起来多余,但它让返回值成为一个自洽的数据包——Q10 只需要拿到这一个字典就能干活,不必再要一份 words。这就是数据抽象:把相关的数据打包成一个整体,用键名而不是位置来访问。
怎么想到的
核心是「把累积量转成增量」——数学上就是一阶差分。这个模式在数据处理里到处都是:里程表读数转成每段路程、账户余额转成每笔流水、时间戳转成时长。
写法上有两层循环:外层遍历玩家,内层遍历这个玩家的时间戳。内层是关键,得想清楚下标怎么走。
第一反应可能是:
for i in range(len(timestamps)):
durations.append(timestamps[i + 1] - timestamps[i]) # 越界
最后一轮 i 等于 len - 1,timestamps[i + 1] 越界,报 IndexError。修法有两种:
| 写法 | 循环范围 | 循环体 | 读起来像 |
|---|---|---|---|
| A | range(len(timestamps) - 1) | timestamps[i+1] - timestamps[i] | 「对每个位置,看它和后面那个的差」 |
| B | range(1, len(timestamps)) | timestamps[i] - timestamps[i-1] | 「对每个位置,看它和前面那个的差」 |
两种都对,产生的列表完全相同。本实现选了 B。
因为它和题目的语义更贴:每个时间戳(除了第 0 个)都代表「打完了某个词」,而这个词花的时间就是它减去上一个时间戳。从下标 1 开始遍历,正好对应「跳过那个表示开始时刻的第 0 个」。
写法 A 需要你在脑子里做一次转换:「下标 i 的差分对应第 i 个词」。写法 B 不需要——range(1, ...) 这个 1 就是在说「第 0 个是起始时刻,不参与」,意图写在代码表面上。
外层循环则可以直接遍历元素而不用下标:for timestamps in ts_by_player。因为构造 times 时我是按顺序 append 的,玩家的顺序自动保持,不需要显式的玩家编号。
代码
def time_per_word(words: list[str], timestamps_per_player: list[list[int]]) -> dict:
ts_by_player = timestamps_per_player # A shorter name (for convenience)
# BEGIN PROBLEM 9
times = []
for timestamps in ts_by_player:
# timestamps[0] is when this player started; every later entry is when
# they finished a word, so consecutive differences are the durations.
durations = []
for i in range(1, len(timestamps)):
durations.append(timestamps[i] - timestamps[i - 1])
times.append(durations)
# END PROBLEM 9
return {'words': words, 'times': times}
逐行看:
times = []:新建列表,不复用任何入参。这一点在 Q10 会变成硬性要求,这里先养成习惯。for timestamps in ts_by_player::每次拿到一个玩家的时间戳列表。注意timestamps是对内层列表的引用(alias),不是拷贝——但因为下面只读不写,没有问题。durations = []:必须在外层循环内部。放到外面的话所有玩家的时长会堆进同一个列表,而且times里的每个元素都指向同一个列表对象——这是别名(aliasing)造成的经典事故,输出会变成[[6,3,6,2,10,6,1,2], [6,3,6,2,10,6,1,2]]这种诡异的东西。for i in range(1, len(timestamps))::从 1 开始,跳过起始时刻。循环len(timestamps) - 1次,正好产出len(words)个时长。durations.append(timestamps[i] - timestamps[i - 1]):当前时刻减上一个时刻。题面保证时间戳「cumulative and always increasing」,所以结果必为正,不用取绝对值。times.append(durations):把这个玩家的结果放进总表。放在内层循环外面——放里面会每算一个时长就 append 一次,得到一堆半成品。return {'words': words, 'times': times}:官方给好的。words原样放回。
durations = [] 每次外层迭代都会执行,因此每个玩家拿到一个全新的列表对象。用环境图的方式画:
第 1 轮外层循环结束后:
times ──→ [ ● ]
│
└──→ [6, 3, 6, 2] ← 对象 A
第 2 轮:durations = [] 创建了一个全新对象 B
times ──→ [ ● , ● ]
│ │
│ └──→ [10, 6, 1, 2] ← 对象 B
└──→ [6, 3, 6, 2] ← 对象 A(没被动过)如果把 durations = [] 挪到外层循环之前,就只有一个对象,times 里两个位置指向同一个它:
times ──→ [ ● , ● ]
│ │
└───┴──→ [6, 3, 6, 2, 10, 6, 1, 2] ← 同一个对象!改一个就是改两个——这就是可变性(mutability)带来的麻烦。Lecture 8 讲的那些别名问题,在这一行缩进上具体化了。
验证
追 ok 的第一个用例:time_per_word(['This', 'is', 'fun'], [[1, 4, 6, 7], [0, 4, 6, 9]]),期望 times 是 [[3, 2, 1], [4, 2, 3]]。
外层第 1 轮:timestamps = [1, 4, 6, 7],durations = []
i | timestamps[i] | timestamps[i-1] | 差 | durations |
|---|---|---|---|---|
| 1 | 4 | 1 | 3 | [3] |
| 2 | 6 | 4 | 2 | [3, 2] |
| 3 | 7 | 6 | 1 | [3, 2, 1] |
range(1, 4) 给出 1、2、3,共 3 次——正好等于 len(words)。times = [[3, 2, 1]]。
外层第 2 轮:timestamps = [0, 4, 6, 9],durations = [](新对象)
i | 计算 | durations |
|---|---|---|
| 1 | 4 - 0 = 4 | [4] |
| 2 | 6 - 4 = 2 | [4, 2] |
| 3 | 9 - 6 = 3 | [4, 2, 3] |
times = [[3, 2, 1], [4, 2, 3]]。
返回 {'words': ['This', 'is', 'fun'], 'times': [[3, 2, 1], [4, 2, 3]]}。
读一读这个结果的含义:玩家 0 第 1 秒起步,打 'This' 用 3 秒、'is' 用 2 秒、'fun' 用 1 秒;玩家 1 从 0 秒起步,分别用 4、2、3 秒。虽然玩家 1 更早开始,但他打第一个词更慢——这正是为什么必须用差分而不能直接比时间戳。
误区一:从 0 开始遍历。写成 for i in range(len(timestamps)) 配 timestamps[i] - timestamps[i-1]。i = 0 时 timestamps[-1] 不会报错——Python 的负下标会绕到列表末尾!于是第一个时长变成 1 - 7 = -6,得到 [-6, 3, 2, 1],长度还多了一个。这是本题最阴险的 bug:不崩溃,只是默默算错。
误区二:durations = [] 放到外层循环之前。输出变成 [[3,2,1,4,2,3], [3,2,1,4,2,3]](两个位置指向同一个对象)。ok 报的是「期望 [[3, 2, 1], [4, 2, 3]]」,一眼能看出长度不对。
误区三:以为 words 需要加工。比如去标点、小写。题目要的就是原样。
误区四:返回 times 而不是字典。Q10 会 words_and_times['words'] 这样取值,拿到列表会报 TypeError: list indices must be integers or slices, not str。
10. Problem 10:fastest_words
题目要什么
拿到 Q9 产出的字典,统计每个词是谁打得最快,然后按玩家分组返回。
返回值是「列表的列表」,第 i 个内层列表装的是玩家 i 打得比所有其他人都快的那些词。平局时,下标小的玩家算赢。
>>> p0 = [5, 1, 3]
>>> p1 = [4, 1, 6]
>>> fastest_words({'words': ['Just', 'have', 'fun'], 'times': [p0, p1]})
[['have', 'fun'], ['Just']]
>>> p0 # input lists should not be mutated
[5, 1, 3]
>>> p1
[4, 1, 6]
逐词分析:
| 词 | 玩家 0 用时 | 玩家 1 用时 | 赢家 | 理由 |
|---|---|---|---|---|
'Just' | 5 | 4 | 玩家 1 | 4 < 5 |
'have' | 1 | 1 | 玩家 0 | 平局,下标小的赢 |
'fun' | 3 | 6 | 玩家 0 | 3 < 6 |
所以玩家 0 拿到 ['have', 'fun'],玩家 1 拿到 ['Just']。输出列表里词的顺序,必须和 words 里的顺序一致——'have' 在 'fun' 前面。
三条额外要求,每一条都对应着 ok 里的具体用例:
- 不许修改入参。doctest 在调用之后重新打印
p0和p1来验证。用sort()、pop()、remove()之类的就地方法会当场暴露。 - 玩家数不定。不能假设只有两个人。ok 里有 1 个、2 个、3 个玩家的用例。
- 没有词也要有结构。
fastest_words({'words': [], 'times': [[], [], []]})期望[[], [], []]——三个空列表,不是一个空列表。玩家还在,只是谁也没赢到词。
题面还指定要用官方给的 get_time(times, player_num, word_index) 取值,它内部有断言,越界时会给出可读的错误信息而不是干巴巴的 IndexError。
怎么想到的
第一步:先想清楚循环的方向
输出是「按玩家分组」,很自然会想「外层遍历玩家」:对每个玩家,看看哪些词是他最快的。但这样内层还要再遍历所有词、对每个词再和所有其他玩家比一遍——三层循环。
反过来想:「谁最快」这个问题是按词提出的。每个词有且只有一个赢家。所以外层应该遍历词,对每个词找出赢家,然后把这个词塞进赢家的篮子里。两层循环就够了。
「按词遍历」和「按玩家输出」这两件事怎么调和?答案是预先给每个玩家准备一个空列表,然后边遍历词边往对应的篮子里丢。
fastest = [[] for _ in pl_idxs]
这个模式(先按输出的形状建好空容器,再填内容)在处理「分组」类问题时反复出现。它同时解决了「玩家数不定」和「没有词时也要返回 N 个空列表」两条要求——空列表是在遍历词之前就建好的,词一个都没有也不影响。
这里的列表推导式 [[] for _ in pl_idxs] 有个绝对不能替换的细节:不能写成 [[]] * len(pl_idxs)。
[[]] * n 是个陷阱
[[]] * 3 产生的是「同一个空列表对象的三个引用」,不是三个独立的列表:
>>> a = [[]] * 3
>>> a[0].append('x')
>>> a
[['x'], ['x'], ['x']]
而列表推导式每次迭代都会求值 [],因此得到三个不同的对象:
>>> b = [[] for _ in range(3)]
>>> b[0].append('x')
>>> b
[['x'], [], []]
用了 * 的话,fastest_words 会把所有词都塞进每个玩家的篮子——因为它们本来就是同一个篮子。这和 Q9 里 durations = [] 放错位置是同一类错误:你以为有 N 个对象,实际只有 1 个。
第二步:找赢家
「在若干玩家中找出用时最小的那个的编号」——又是 min 加 key,而且这次要的正是「返回元素本身」这个性质:对玩家编号的序列取 min,key 是「这个玩家在这个词上的用时」,返回的就是赢家的编号。
winner = min(pl_idxs, key=lambda p: get_time(times, p, w))
平局规则又一次白送:min 用严格小于比较,编号小的玩家先入场、后来的等值者顶不掉他。Q5 用过一次,这里再用一次,同一条性质解决了两道题的平局问题。
lambda 里的 w 是外层循环变量。这没问题,因为 lambda 在本次迭代内就被 min 调用完了,用的就是当前的 w。(如果把这些 lambda 存起来留到循环结束后再调用,就会全部拿到最后一个 w——那是另一个经典陷阱,但这里不会发生。)
第三步:确认没碰入参
这个实现只做两件事:get_time(读 times)和 fastest[winner].append(...)(写自己新建的列表)。times 里的那些内层列表从头到尾没被写过。「不许修改入参」这条要求,是靠「压根不写它」来满足的,而不是靠某种保护措施。
代码
def fastest_words(words_and_times: dict) -> list[list[str]]:
check_words_and_times(words_and_times) # verify that the input is properly formed
words, times = words_and_times['words'], words_and_times['times']
pl_idxs = range(len(times)) # contains an *index* for each player
w_idxs = range(len(words)) # contains an *index* for each word
# BEGIN PROBLEM 10
# Build a fresh list of lists so the caller's times are never mutated.
fastest = [[] for _ in pl_idxs]
for w in w_idxs:
# min returns the first player achieving the smallest time, which
# gives the lowest-index player the win on ties.
winner = min(pl_idxs, key=lambda p: get_time(times, p, w))
fastest[winner].append(words[w])
return fastest
# END PROBLEM 10
逐行看:
- 前四行是官方给的。
check_words_and_times会断言字典结构合法(每个玩家的时长数等于词数等等);pl_idxs和w_idxs是两个range,分别是玩家编号和词编号。注意len(times)是玩家数(外层长度),len(words)是词数——这两个别搞反。 fastest = [[] for _ in pl_idxs]:_表示「这个变量我不用,只是要迭代这么多次」。pl_idxs有几个元素就建几个空列表。for w in w_idxs::按词遍历,这是整个设计的枢纽。用下标而不是for word in words,因为get_time需要word_index。winner = min(pl_idxs, key=lambda p: get_time(times, p, w)):对玩家编号取 min。key把编号p映射成用时。返回的winner是编号(整数),不是用时。fastest[winner].append(words[w]):fastest[winner]取出赢家的篮子(这是引用,不是拷贝),append就地把词加进去。这是全函数唯一一处 mutation,改的是自己刚建的对象。因为按w递增的顺序 append,篮子里词的顺序自动和words一致。return fastest。
验证
追 doctest:fastest_words({'words': ['Just', 'have', 'fun'], 'times': [[5, 1, 3], [4, 1, 6]]}),期望 [['have', 'fun'], ['Just']]。
pl_idxs = range(2)(两个玩家),w_idxs = range(3)(三个词)。fastest = [[], []]——两个独立的空列表。w = 0(词 'Just')。min 遍历 p = 0:get_time(times, 0, 0) = 5,当前最优 p=0(值 5)。p = 1:get_time(times, 1, 0) = 4,4 < 5 真 → 更新,最优 p=1。winner = 1。执行 fastest[1].append('Just') → fastest = [[], ['Just']]。w = 1(词 'have')。p = 0:用时 1,最优 p=0。p = 1:用时 1,判断 1 < 1 → 假,不更新。winner = 0。fastest[0].append('have') → fastest = [['have'], ['Just']]。w = 2(词 'fun')。p = 0:用时 3。p = 1:用时 6,6 < 3 假。winner = 0。fastest[0].append('fun') → fastest = [['have', 'fun'], ['Just']]。[['have', 'fun'], ['Just']]。与 doctest 一致。第 2 步是平局规则的现场:两个玩家用时都是 1,min 因为用严格小于而保留了先入场的 p=0。如果 min 内部用的是 <=,赢家会变成 1,输出成 [['fun'], ['Just', 'have']],测试挂。
入参真的没被动过吗
用一张对象图确认。调用前:
Global 帧
p0 ──→ [5, 1, 3] ← 对象 A
p1 ──→ [4, 1, 6] ← 对象 B
传进去的字典:{'words': [...], 'times': [ ●, ● ]}
│ │
│ └──→ B
└──→ A函数体内 times 就指向那个 [A, B] 列表。get_time(times, p, w) 做的是 times[p][w]——纯读取。而 fastest 是全新建的:
fastest ──→ [ ●, ● ]
│ │
│ └──→ ['Just'] ← 新对象 D
└──→ ['have', 'fun'] ← 新对象 C
A 和 B 自始至终没有出现在任何赋值语句的左边。所以 doctest 里 >>> p0 仍然打印 [5, 1, 3]。关键在于:fastest[winner] 取出的是 C 或 D,永远不是 A 或 B。
空词表的情形
fastest_words({'words': [], 'times': [[], [], []]}),期望 [[], [], []]。
len(times) = 3,所以 pl_idxs = range(3),fastest = [[], [], []]。len(words) = 0,所以 w_idxs = range(0),for 循环一次都不执行。直接返回那三个空列表。「玩家数由 times 决定、词数由 words 决定」这个分工,让这个边界情况不需要任何特殊代码。
误区一:fastest = [[]] * len(pl_idxs)。见上文。输出会变成每个玩家都拿到全部的词,比如 [['Just','have','fun'], ['Just','have','fun'], ...]。
误区二:用 times[p].index(min(times[p])) 之类的思路。这是在「每个玩家自己最快的词」,不是「比别人都快的词」。方向完全反了。
误区三:对 times 排序。写成 sorted_times = times[w].sort()——两个问题:sort() 就地修改,违反「不许改入参」,doctest 里 >>> p0 会打印出排好序的 [1, 3, 5];而且 sort() 返回 None,下一行会报 AttributeError: 'NoneType' object has no attribute ...。
误区四:把玩家数写成 len(words)。在「玩家数恰好等于词数」的用例上碰巧能过,其他全挂。check_words_and_times 不会帮你抓这个错,因为它检查的是输入而不是输出。
误区五:假设只有两个玩家。写成 if times[0][w] <= times[1][w]。三个玩家的用例会漏掉第三人,而 fastest 只有两个篮子时还会 IndexError。ok 里 p = [[5], [2], [4]] 期望 [[], ['seeingly'], []] 就是专门测这个的。
11. Problem EC(选做):memo_diff 与装饰器
Phase 4 是官方标注的「Extra Challenge」,不计分。但它把这门课后半程最重要的两个概念——装饰器(decorator)和记忆化(memoization)——放在一个你已经完全熟悉的场景里,非常值得做。本仓库的 1036 个通过用例里包含了这一题。
先过概念题
tests/EC.py 的第一个测试套件是三道选择题('type': 'concept'),跑 python3 ok -q EC -u 时会先问你这三道。它们不计分,但答不对就说明后面的代码你八成写不对。逐题讲清楚为什么是那个答案:
答案:A function that takes another function as an input and returns a new function that extends or modifies the behavior of the original function.
为什么:装饰器就是一个高阶函数,没有任何特殊语法机制在背后。它满足两条:(a) 参数是一个函数;(b) 返回值也是一个函数,而且返回的那个函数通常在调用原函数的前后加了点料。
其他三个选项为什么不对:「a type of design pattern」把装饰器和面向对象里的 Decorator Pattern 混为一谈了——那是用对象包对象,Python 的装饰器是用函数包函数;「a method for declaring class properties」说的是 @property,那只是某一个具体的装饰器,不是装饰器的定义;「a way to loop through an iterable」和装饰器毫无关系。
答案:To add functionality to existing code.
为什么:关键词是「add」和「existing」——不改动原函数的源代码,却让它多出一种能力。本项目就是活例子:minimum_mewtations 的函数体一个字都没改,加上 @memo_diff 之后它就有了缓存;autocorrect 加上 @memo 之后同样的查询第二次就是免费的。
其他选项:「to loop through arrays」「to declare variables」「to check for syntax errors」都是无关的干扰项。
答案:Using the "@decorator_name" syntax above the function definition.
为什么:@ 写在 def 正上方。但更重要的是知道它只是语法糖——题面自己点破了:「we could also directly call the decorator function instead of using the @ notation」。这两段代码完全等价:
@memo_diff
def minimum_mewtations(entered, source, limit):
...
# 等价于
def minimum_mewtations(entered, source, limit):
...
minimum_mewtations = memo_diff(minimum_mewtations)
看懂右边那一版,装饰器就没有神秘感了:先按普通方式创建函数,再把它喂给 memo_diff,最后把返回的新函数绑回同一个名字。cats.py 里 minimum_mewtations = count(minimum_mewtations) 那行用的正是右边这种写法——同一件事的两种写法在同一个文件里都出现了。
两道 What-Would-Python-Display
概念题之后是两个 doctest 形式的求值题。它们在考「@ 到底做了什么」,必须能在脑子里跑出来。
>>> def my_decorator(func):
... def wrapper():
... print("Say Hello")
... func()
... print("Say Goodbye")
... return wrapper
>>> @my_decorator
... def say_hello():
... print("Hello World")
>>> say_hello()
Say Hello
Hello World
Say Goodbye
为什么是这三行:
@my_decorator 让 Python 先正常执行 def say_hello,创建一个「会打印 Hello World」的函数对象(叫它 F)。my_decorator(F)。在 my_decorator 的帧里,func 绑定到 F,wrapper 被创建(它的 parent 就是这一帧,所以 func 对它可见),最后返回 wrapper。wrapper 被绑到全局名字 say_hello 上。原来那个 F 现在只能通过 wrapper 帧链里的 func 访问到,全局已经没有名字指着它了。say_hello() 实际执行的是 wrapper():打印 Say Hello → 调用 func() 也就是 F,打印 Hello World → 打印 Say Goodbye。装饰完成后的绑定:
Global
my_decorator → func my_decorator(func) [parent=Global]
say_hello → func wrapper() [parent=f1] ← 不再是原函数!
f1: my_decorator 帧 [parent=Global]
func → func say_hello() ← 原来那个,只有这里还记得它
wrapper → func wrapper() [parent=f1]题眼:say_hello 这个名字被换掉了。它指向的不再是你写的那个函数,而是 wrapper。这正是装饰器「不改源码却改行为」的实现方式——它改的不是函数,是名字的绑定。
>>> def magic_decorator(func):
... def wrapper(x):
... return func(x * 2)
... return wrapper
>>> @magic_decorator
... def myfunc(x):
... return x * 3
>>> print(myfunc(4))
24
为什么是 24:
myfunc 指向 wrapper,func 指向原来那个「乘 3」的函数。myfunc(4) 即 wrapper(4),x = 4。x * 2 = 8,把 8 传给 func。func(8) 返回 8 * 3 = 24。wrapper 把它 return 出来。print(24) → 24。容易答错成 24 之外的两个数:答 12 的人以为装饰器没起作用(4 * 3);答 8 的人以为 wrapper 只是改参数不调 func。还有一类人漏看了 wrapper 里的 return——如果 wrapper 写成 func(x * 2) 而不 return,输出会是 None。对比 WWPD 1 里的 wrapper:它没有 return,因为它包的是一个只 print 的函数。装饰器要不要 return,取决于被装饰的函数有没有返回值——这也是为什么 memo 和 memo_diff 里每一条路径都必须 return。
题目要什么:memo_diff
官方已经给了通用的 memo:
def memo(f):
"""A general memoization decorator."""
cache = {}
def memoized(*args):
immutable_args = deep_convert_to_tuple(args)
if immutable_args not in cache:
result = f(*immutable_args)
cache[immutable_args] = result
return result
return cache[immutable_args]
return memoized
思路很直接:用一个字典把「参数 → 结果」记下来,见过的参数直接查表。deep_convert_to_tuple 是必要的——字典的键必须可哈希(hashable),而列表不可哈希(因为它可变)。autocorrect 的第二个参数是 word_list,是个列表,不转成元组的话会报 TypeError: unhashable type: 'list'。
你要写的是 memo_diff:一个专门给 diff 函数用的记忆化装饰器。它和 memo 的区别全在 limit 上。
因为 diff 函数的返回值不只取决于两个词,还取决于 limit:
>>> minimum_mewtations("hello", "hasldfasdfsffsfasdf", 100)
17
>>> minimum_mewtations("hello", "hasldfasdfsffsfasdf", 2)
3
同样两个词,limit 不同答案就不同——因为 limit 小的时候函数会提前剪枝,返回的是「反正超了」的近似值 limit + 1。
那把 limit 也放进键里不就行了?可以,但会浪费大量缓存机会。关键洞察是:limit 大的结果包含了 limit 小的信息。如果我用预算 100 算出真实答案 17,那当有人拿预算 2 来问时,我不需要重算——17 > 2,直接把 17 交回去,调用方只会拿它和 2 比大小,结论一样是「超了」。
反过来则不行:用预算 2 算出的 3 是个近似值,当有人拿预算 100 来问时,3 完全可能是错的(真值是 17)。
所以缓存规则是:用 (entered, source) 做键,存 (结果, 当时用的 limit)。查表时若新的 limit <= 缓存的 limit,直接用缓存;否则重算并覆盖。
代码
def memo_diff(diff_function):
"""A memoization function."""
cache = {}
def memoized(entered, source, limit):
# BEGIN PROBLEM EC
key = (entered, source)
if key in cache:
cached_value, cached_limit = cache[key]
# A result computed with a larger budget is at least as informative
# as one computed with a smaller budget, so it is safe to reuse.
if limit <= cached_limit:
return cached_value
value = diff_function(entered, source, limit)
cache[key] = (value, limit)
return value
# END PROBLEM EC
return memoized
逐行看:
cache = {}:定义在memo_diff里、memoized外。这个位置至关重要——它意味着cache属于memo_diff的那一帧,而memoized的 parent 正是那一帧,所以每次调用memoized都访问同一个字典。如果把它挪进memoized里面,每次调用都会新建一个空字典,缓存永远命中不了,性能测试全挂但答案还是对的(最难查的那类 bug)。key = (entered, source):元组,不是列表。题面用 Important 特意强调了这一点。两个字符串都是不可变的,所以由它们组成的元组可哈希,能当字典的键。if key in cache::字典的in查的是键,不是值。cached_value, cached_limit = cache[key]:元组解包(unpacking),一行拆出两个名字。这也是为什么存的时候要存成元组——一个键只能对一个值,把两样东西打包成元组是最省事的做法。if limit <= cached_limit: return cached_value:核心判断。用<=而不是<——limit 完全相同当然可以直接复用,那正是最常见的命中情形。value = diff_function(entered, source, limit):走到这里说明缓存没有、或者缓存的 limit 不够大。老老实实算一遍。注意调的是diff_function(被装饰的原函数),不是memoized自己——写错就是无限递归。cache[key] = (value, limit):覆盖式写入。因为能走到这一行,要么原来没记录,要么原来的 limit 更小、信息更少,新的一定不差。return value:三条路径(缓存命中、缓存失效重算、首次计算)每一条都有return,一条都不能漏。
验证
tests/EC.py 里最能说明问题的是这一串(common_words 从 data/common_words.txt 读入):
>>> minimum_mewtations.call_count = 0
>>> autocorrect("woll", common_words, minimum_mewtations, 4)
'well'
>>> minimum_mewtations.call_count <= 72000
True
>>> minimum_mewtations.call_count = 0
>>> autocorrect("woll", common_words, minimum_mewtations, 4) # identical to the first call
'well'
>>> minimum_mewtations.call_count
0
>>> minimum_mewtations.call_count = 0
>>> autocorrect("woll", common_words, minimum_mewtations, 3)
'well'
>>> minimum_mewtations.call_count < 2500
True
三次调用,三种不同的缓存行为,正好把两个装饰器都验了:
autocorrect 的 memo 缓存里没有这组参数,真的跑了一遍。它对 common_words 里每个词都调一次 minimum_mewtations,每次调用内部又是三路递归。memo_diff 在这个过程中已经在起作用了——大量子问题(比如 ("oll", "ell"))会被不同的候选词反复问到,第二次起就直接查表。最终 call_count 在 72000 以内。autocorrect 头上的 @memo 命中——(entered_word, word_list, diff_function, limit) 这一整组参数上次见过,直接返回缓存的 'well'。minimum_mewtations 一次都没被调用,call_count 恰好是 0。这验证的是 memo(官方给的),顺便说明 deep_convert_to_tuple 把 word_list 转成元组这一步真的生效了。@memo 不命中,autocorrect 得重跑。但这一次 memo_diff 的缓存里已经装满了用 limit=4 算出来的结果。对每个候选词,3 <= 4 成立,于是直接返回缓存值——递归根本不展开。call_count 从七万掉到不足 2500,降了一个数量级以上。第 3 步就是 limit <= cached_limit 这条规则的全部价值所在。如果你把键写成 (entered, source, limit)(也就是照搬 memo 的思路),第三次调用会因为 limit 不同而全部落空,call_count 重新回到几万,测试挂。「什么时候一个旧答案还能用」这个判断,是这道题真正要教的东西。
误区一:cache = {} 写在 memoized 内部。每次调用都重置缓存,等于没有记忆化。答案全对,性能测试全挂。判断方法:cache 必须和 memoized 是「兄弟」,不是「父子」。
误区二:键用列表。key = [entered, source] 会报 TypeError: unhashable type: 'list'。题面专门提醒过。
误区三:判断写成 limit >= cached_limit。方向反了——这会在预算变大时复用一个不可靠的近似值。minimum_mewtations("hello", "hasldfasdfsffsfasdf", 2) 先算出 3 存起来,再问 limit=100 时会返回 3 而不是 17,答案直接错。记法:小预算可以用大预算的答案,反之不行。
误区四:缓存命中时忘了 return。函数会继续往下走重新计算——不崩,只是记忆化白做。
误区五:递归调自己。把 diff_function(...) 写成 memoized(...),第一次未命中就无限递归,RecursionError。
误区六:Q7 的 base case 不够紧。题面在这道题的 Note 里明说了:如果 call_count 测试挂,很可能问题出在 Q7 而不是这里——Q7 的测试不严格,漏掉「长度差剪枝」或「首字符相同时的贪心」照样能过 Q7,但到 EC 就会被逮住。
整份作业回顾
Cats 表面上是十一道零散的题,实际上它把这门课前半程的每一个核心概念都安排了一次「在真实场景里用一遍」的机会。抽掉题目本身,剩下的思维方法是这些:
| 题目 | 核心手法 | 可以迁移到哪里 |
|---|---|---|
Q1 pick | 把「收集全部再取第 k 个」换成「边走边数、命中即返」;用哨兵值 '' 表示「没有了」 | 任何「找第 k 个满足条件的元素」;用返回值而非异常表达「正常的失败」 |
Q2 about | 返回函数以固定住一个参数(闭包);把「子串包含」换成「切分后相等」 | 需要单参数回调却手握多参数逻辑时;所有涉及「整词匹配」的文本处理 |
Q3 accuracy | 用 min(len(a), len(b)) 同时解决「越界」和「多余项算错」两件事 | 任何两个不等长序列的按位比较 |
Q4 wpm | 给中间量起名字,把单位换算显式写出来 | 所有含单位的计算;把公式翻译成代码时 |
Q5 autocorrect | min(seq, key=f)——「返回使 f 最小的元素」,且平局取最靠前 | 所有「按某个指标选最优」的问题;同一手法在 Q10 再用一次 |
Q6 furry_fixes | 字符串切片递归;len(a) + len(b) 一式通吃两个 base case;用局部预算实现全局剪枝 | 所有序列上的递归;任何「超过阈值就没必要精确」的搜索 |
Q7 minimum_mewtations | 三路树形递归;把每种「操作」映射到一种「切片方式」;用问题的数学性质做剪枝 | 编辑距离、最长公共子序列等所有动态规划的递归形态 |
Q8 report_progress | and 的短路保护索引;print 与 return 都要有;把「上报」抽象成参数 | 所有「边界检查 + 内容检查」的循环条件;所有需要与外部系统交互又想被测试的函数 |
Q9 time_per_word | 累积量转增量(一阶差分);range(1, n) 表达「跳过第 0 个」;每轮新建列表避免别名 | 时间序列处理;任何「按组构造嵌套列表」的场景 |
Q10 fastest_words | 先按输出形状建空篮子再填;[[] for _ in ...] 而非 [[]] * n;只读入参 | 所有分组统计;所有对「函数不该有副作用」有要求的接口 |
QEC memo_diff | 装饰器 = 高阶函数 + 名字重绑定;闭包持有可变状态;「什么时候旧答案还能用」 | 缓存、日志、计时、权限检查等所有横切关注点 |
三条贯穿全项目的主线
第一条:函数是值。这个项目里有五处把函数当数据用——pick 收 select、about 返回 select、autocorrect 收 diff_function 并现场造一个 lambda、report_progress 收 upload、memo_diff 收一个函数并返回一个函数。它们的共同目的都是解耦:让一个函数不必知道另一件事怎么做,只需要知道「有人会做」。这个能力直接换来了可测试性——report_progress 之所以能被 doctest 测,就是因为 upload 可以被换成一个只 print 的 lambda。
第二条:递归就是「把问题切小一点,然后信任自己」。Q6 和 Q7 的整个设计过程可以浓缩成三个问题:怎么让问题变小(切掉第一个字符)、什么时候不能再小了(某一边空了)、这一步花了多少钱(0 或 1)。Q7 比 Q6 难,难就难在「变小」有三种方式而不是一种。把每种编辑操作和对应的切片方式一一对上,是整个项目最需要在纸上画一遍的地方。
第三条:可变性要么是工具,要么是事故。Q9 和 Q10 一共设了三个陷阱:durations = [] 的位置、[[]] * n、以及对入参调用 sort()。三个都是同一件事的不同面孔——你以为有 N 个对象,实际只有 1 个;或者你以为在改自己的东西,实际在改别人的。判断方法始终一样:画出对象图,看谁指着谁。
做完 Cats 最该记住的不是编辑距离怎么算,而是这个项目为什么能被拆成十一个互不依赖的纯函数。每个函数的输入输出都由参数和返回值完整描述,不依赖全局状态,于是每个都能用一行 doctest 单独验证——1036 个测试用例之所以跑得动,正是因为这个结构。
反过来说:如果你写的函数很难测,通常不是测试的问题,是这个函数知道得太多了。
动手检查自己是不是真的懂了
1. autocorrect 里为什么不能用 min([(diff(w), w) for w in word_list])?
元组比较是逐项进行的:第一项相等时会去比第二项,也就是字符串的字典序。于是平局规则被悄悄改成了「字母序最小的赢」,而题目要的是「下标最小的赢」。用 autocorrect("bwe", ["bye", "awe"], matching_diff, 10) 可以当场测出来——正确答案 'bye',这种写法给 'awe'。
2. furry_fixes 在 limit < 0 时返回的可能是 0 甚至负数,为什么最终结果还是能大于原来的 limit?
因为这个返回值要沿调用栈回代,一路加上那些累积的 1 +。设原始预算 \(L\),剪枝时已发生 \(k\) 次替换,当前 limit 是 \(L-k\),返回 \(L-k+1\),加回 \(k\) 个 1 之后恰好是 \(L+1\)。每层只管局部预算,全局正确性自动成立。
3. minimum_mewtations 中,如果去掉「首字符相同就直接跳过两边」这个分支会怎样?
答案会错,不只是变慢。因为剩下的 substitute 支路即使在字符相同的情况下也会 1 +,等于给免费的匹配收了费。minimum_mewtations("wind", "wind", 10) 会返回 4 而不是 0。
4. report_progress 的 while 条件如果写成 entered[correct] == source[correct] and correct < len(entered),什么时候才会崩?
只有在用户把 entered 里所有词都敲对时才崩。中途出错的情况下循环会因为右侧条件为假而正常退出,永远走不到越界那一步。所以前几个测试可能都过,直到某个「全对」的用例才报 IndexError。这正是短路求值必须靠正确的顺序来保证安全的原因。
5. memo_diff 的判断为什么是 limit <= cached_limit 而不是相反?
因为 diff 函数在预算充足时给的是精确值,预算不足时给的是近似值 limit + 1。精确值对任何更小的预算都仍然可用(调用方只拿它和预算比大小,结论不变);而近似值对更大的预算不可用,因为真实答案可能远大于它。minimum_mewtations("hello", "hasldfasdfsffsfasdf", ...) 在 limit=100 时是 17、limit=2 时是 3,方向写反就会把 3 当成 17 用。
6. Q3 的分母是 len(entered_words),Q8 的分母是 len(source)。这不矛盾吗?
不矛盾,因为两个指标回答的是不同的问题。accuracy 问「你敲下去的东西里有多大比例是对的」,所以分母是你敲的量;report_progress 问「整篇文章你走了多远」,所以分母是全文长度。看到一个比值,先问清楚「相对于什么」。