CS 61A  /  项目解析
PROJECT 2 · CATS

Cats:打字速度分析ok 1036 项通过

十道必做题加一道选做题,从「按条件挑一段文字」一路做到「递归计算编辑距离」再到「多人对战计分」。这个项目真正在教的,是把一个功能完整的程序拆成一串各自可测的纯函数。

对应讲次:Lecture 7、Lecture 8、Lecture 10 官方题面:cs61a.org/proj/cats 代码:proj/cats/cats.py

0. 这份作业在练什么

Cats 是一个真的能跑起来的打字测速网站的后端。官方把完整版部署在 cats.cs61a.org 上:你在浏览器里看到一段文字,照着敲,页面实时给你算速度、算正确率、自动纠正你刚刚敲错的单词,还能和另一个人同场竞速。这个项目要你亲手写出其中最核心的那部分逻辑。

但你打开 cats.py 会发现一件让人意外的事:没有一行代码和网络、和浏览器、和界面有关。你要填的十个空全都是纯函数——给定输入、返回输出、不碰外部状态(唯一的例外是 report_progress 要调用传进来的 upload,而那个 upload 也是参数,不是全局变量)。GUI、HTTP 服务器、多人对战的服务器通信全都写好了,它们只是在某个时刻调用你的函数。

这件事本身就是这个项目最想教的东西:一个复杂系统之所以能被写出来,是因为它被拆成了一堆各自能独立验证的小函数。「计算正确率」不需要知道文字是从哪来的;「找出最接近的单词」不需要知道用户是不是在打字。每个函数只做一件事,接口用参数说清楚,于是每个函数都能被一行 doctest 单独测。你在 lab 里练的那些「写一个函数」,到这里第一次被组装成一个系统。

四个阶段各练什么

阶段题目真正在练的技能依赖的讲次
Phase 1 TypingQ1 pick、Q2 about、Q3 accuracy、Q4 wpm把函数当参数传(高阶函数)、返回函数(闭包)、列表遍历与索引对齐Lecture 7(序列)
Phase 2 AutocorrectQ5 autocorrect、Q6 furry_fixes、Q7 minimum_mewtationsmin 的 key 参数、字符串切片递归、树形递归与剪枝Lecture 7、递归相关讲次
Phase 3 MultiplayerQ8 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。

逐步推演
迭代paragraphselect(paragraph)进入前 matches_so_far做了什么
1'hi'len('hi')=2 <= 4 → True00 == 1 为假 → 不返回,计数器变 1
2'how are you'len(...)=11 <= 4 → False1整个 if 跳过,计数器不动
3'fine'len('fine')=4 <= 4 → True11 == 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.')。

逐步推演
1 about(['dogs', 'hounds']) 求值:先跑 assert,lower('dogs') == 'dogs' 且 lower('hounds') == 'hounds',通过。然后 def select 在 about 的帧里创建函数值,return select 把它交出去,绑定到全局的 dogs。
2 调用 dogs('"DOGS" stands for ...')。新建帧,parent 指向 about 那一帧,paragraph 绑定到这个字符串。
3 remove_punctuation(paragraph):删掉 " 和 .,得到 'DOGS stands for Department Of Geophysical Science'。
4 lower(...):得到 'dogs stands for department of geophysical science'。
5 split(...):得到 ['dogs', 'stands', 'for', 'department', 'of', 'geophysical', 'science']。
6 循环第一次:word = 'dogs'。查 keywords——本帧没有,去 parent 找到 ['dogs', 'hounds']。'dogs' in ['dogs', 'hounds'] 为 True,返回 True。

与 ok 期望的 True 一致。

再追一遍 dogs("AdogsParagraph"),看「只匹配完整单词」是怎么被自动满足的:

逐步推演
1 remove_punctuation("AdogsParagraph") → 'AdogsParagraph'(没有标点可删)。
2 lower(...) → 'adogsparagraph'。
3 split(...) → ['adogsparagraph'],只有一个元素。
4 循环唯一一次:'adogsparagraph' in ['dogs', 'hounds']。列表的 in 逐个做相等比较:'adogsparagraph' == 'dogs' 假,'adogsparagraph' == 'hounds' 假。结果 False。
5 循环结束,返回 False。

对比一下:如果第 4 步写的是 'dogs' in 'adogsparagraph'(字符串子串判断),结果会是 True,测试就挂了。「切成词再比相等」和「直接搜子串」的区别,全部体现在这一个用例上。

回头看 Q1 + Q2

现在两道题拼起来了。命令行里跑 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.0
  • entered 空、source 非空 → 0.0
  • entered 非空、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。这是最能说明「按位置对齐」有多严格的一个例子。

逐步推演
1 entered_words = split('A Cute Dog!') → ['A', 'Cute', 'Dog!'],长度 3。
2 source_words = split('Cute Dog.') → ['Cute', 'Dog.'],长度 2。
3 两个长度都不是 0,两个 if 都跳过。
4 min(3, 2) = 2,所以 range(2),只比较 i = 0 和 i = 1。
ientered_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。

逐步推演
1 entered_words = ['Cute'](长度 1),source_words = ['Cute', 'Dog.'](长度 2)。
2 都不空,跳过两个 if。
3 min(1, 2) = 1,只比 i = 0:'Cute' == 'Cute' 为真,correct 变 1。
4 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。

逐步推演
1 先数字符。'hello'(5) + ' '(1) + 'friend'(6) + ' '(1) + 'hello'(5) + ' '(1) + 'buddy'(5) + ' '(1) + 'hello'(5) = 30。5 个词 + 4 个空格。
2 assert 15 > 0 通过。
3 words_typed = 30 / 5 = 6.0。折合 6 个标准词——注意真实词数是 5,这两个数不一样,正说明「标准词」是另一套计量。
4 minutes = 15 / 60 = 0.25。
5 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。

Phase 1 到此结束,回头看一眼

四道题写完,一个完整的单机打字测试就能跑了。在 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(容忍上限)。规则三条:

1 如果 entered_word 本来就在 word_list 里,原样返回——用户没打错,不要多事。
2 否则,返回 word_list 中与 entered_word 差异最小的那个词。差异由 diff_function(entered_word, 候选词, limit) 给出。
3 但如果连最小的差异都大于 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」

题面说的是「如果最低差异大于 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) 个位置上有几个字符不同」。

逐步推演
1 'bwe' in ['bye', 'awe']?'bwe' == 'bye' 假,'bwe' == 'awe' 假 → False,不走第一条规则。
2 创建 diff_from_entered,它闭包住 entered_word = 'bwe'、limit = 10、diff_function = matching_diff。
3 min 开始遍历。第一个元素 'bye':matching_diff('bwe', 'bye', 10)——逐位比较 bvsb 同、wvsy 异、evse 同,得 1。当前最优 = 'bye',最优值 = 1。
4 第二个元素 'awe':bvsa 异、wvsw 同、evse 同,得 1。判断 1 < 1 → 假,不更新。最优仍是 'bye'。
5 closest_word = 'bye'。再算一次 diff_from_entered('bye') = 1,判断 1 > 10 → 假。
6 返回 '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) = 11 < 4 真 → 'cult'(1)
3'cultivate'abs(9 - 3) = 66 < 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:] 是去掉第一个字符后的字符串。于是问题变成:

1 看第一对字符 entered[0] 和 source[0]。
2 如果相同,这个位置不花钱,答案就是「剩下部分的差异」,即 furry_fixes(entered[1:], source[1:], limit)。
3 如果不同,这个位置花 1 块钱,答案是 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,最终结果是:

$$k + (L - k + 1) = L + 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。

逐层展开(注意 limit 的下降)
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_fixesminimum_mewtations为什么
"hash" → "ash"41删掉开头的 h 即可
"roses" → "arose"52前面插 a,末尾删 s
"speling" → "spelling"41插一个 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 次编辑,然后把「剩下的」交给递归。问题是:走完这一步之后,「剩下的」分别是什么?

Add

「往 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 掉了头。

Remove

「从 entered 里删掉一个字母」——删哪个?删 entered[0],因为它显然对不上。删完之后 entered 少了第一个字符,source 一个都没少。所以是 mm(entered[1:], source, limit - 1)。

题面例子:从 "scat" 删 "s" 得 "cat"。entered 掉头,source 不动。

Substitute

「把 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 的顺序

三个提前返回的条件写成什么顺序?

1 limit < 0 — 最先。已经超支,别的什么都不用算。
2 entered == '' or source == '' — 第二。一方空了,剩下的只能全靠插入(或全靠删除),代价就是剩余长度,len(entered) + len(source) 一式通吃。放在长度差检查之前更安全:此时答案是精确的,没必要用 limit + 1 这个粗糙值去替代。
3 abs(len(entered) - len(source)) > limit — 第三。走到这里两个串都非空,长度差的剪枝才有意义。
4 entered[0] == source[0] — 第四。前面已经保证非空,取下标安全。
5 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

起始代码里可能带着 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 这一支完整走完,因为它就是最优解所在:

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」那一步。

另外两支的值(自行展开会分别再分叉出去,这里给结果):

支路调用返回值含义
addmm("cats", "cat", 9)1插入 s 之后还需 1 次编辑
removemm("ats", "scat", 9)3删掉 c 是条烂路,后面还要补 3 次
substitutemm("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)。

逐步推演
1 limit = 0,不小于 0,跳过第一个 base case。
2 两串都非空,跳过第二个。
3 abs(5 - 3) = 2,2 > 0 → 命中!立刻返回 0 + 1 = 1。
4 一次递归调用都没发生。

如果没有第三个 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。

函数要做两件事:

1 调用 upload,参数是一个字典 {'id': user_id, 'progress': progress}——两个键,键名必须一字不差。
2 返回 这个 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.6upload(...) 内部的 print 的副作用只剩一行 0.6,ok 报缺少输出
0.6函数的返回值被交互式解释器回显只剩 ID: ... 那行,返回 None
这道题在偷偷考 print 和 return 的区别

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 的。这和 Q3 accuracy 恰好相反,值得停下来想一秒: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右条件结果
100 < 2 真entered[0]='how' vs source[0]='how' → 真进入循环体,correct 变 1
211 < 2 真entered[1]='aree' vs source[1]='are' → 假退出循环
3 correct = 1,len(source) = 5,progress = 1 / 5 = 0.2。
4 upload({'id': 3, 'progress': 0.2})。upload 此刻绑定的是 print_progress,于是打印 ID: 3 Progress: 0.2。这是副作用,发生在 return 之前。
5 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。修法有两种:

写法循环范围循环体读起来像
Arange(len(timestamps) - 1)timestamps[i+1] - timestamps[i]「对每个位置,看它和后面那个的差」
Brange(1, len(timestamps))timestamps[i] - timestamps[i-1]「对每个位置,看它和前面那个的差」

两种都对,产生的列表完全相同。本实现选了 B。

为什么选 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 原样放回。
别名(aliasing)在这里的具体形态

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 = []

itimestamps[i]timestamps[i-1]差durations
1413[3]
2642[3, 2]
3761[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
14 - 0 = 4[4]
26 - 4 = 2[4, 2]
39 - 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'54玩家 14 < 5
'have'11玩家 0平局,下标小的赢
'fun'36玩家 03 < 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']]。

逐步推演
0 pl_idxs = range(2)(两个玩家),w_idxs = range(3)(三个词)。fastest = [[], []]——两个独立的空列表。
1 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']]。
2 w = 1(词 'have')。p = 0:用时 1,最优 p=0。p = 1:用时 1,判断 1 < 1 → 假,不更新。winner = 0。fastest[0].append('have') → fastest = [['have'], ['Just']]。
3 w = 2(词 'fun')。p = 0:用时 3。p = 1:用时 6,6 < 3 假。winner = 0。fastest[0].append('fun') → fastest = [['have', 'fun'], ['Just']]。
4 返回 [['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 时会先问你这三道。它们不计分,但答不对就说明后面的代码你八成写不对。逐题讲清楚为什么是那个答案:

概念题 1:What is a decorator in Python?

答案: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」和装饰器毫无关系。

概念题 2:Why do we use decorators in Python?

答案: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」都是无关的干扰项。

概念题 3:How is a decorator applied to a function?

答案: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 形式的求值题。它们在考「@ 到底做了什么」,必须能在脑子里跑出来。

WWPD 1
>>> 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

为什么是这三行:

1 @my_decorator 让 Python 先正常执行 def say_hello,创建一个「会打印 Hello World」的函数对象(叫它 F)。
2 然后立刻执行 my_decorator(F)。在 my_decorator 的帧里,func 绑定到 F,wrapper 被创建(它的 parent 就是这一帧,所以 func 对它可见),最后返回 wrapper。
3 返回的 wrapper 被绑到全局名字 say_hello 上。原来那个 F 现在只能通过 wrapper 帧链里的 func 访问到,全局已经没有名字指着它了。
4 调用 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。这正是装饰器「不改源码却改行为」的实现方式——它改的不是函数,是名字的绑定。

WWPD 2
>>> 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:

1 装饰后 myfunc 指向 wrapper,func 指向原来那个「乘 3」的函数。
2 myfunc(4) 即 wrapper(4),x = 4。
3 计算 x * 2 = 8,把 8 传给 func。
4 func(8) 返回 8 * 3 = 24。wrapper 把它 return 出来。
5 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 函数不能直接用 memo

因为 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

三次调用,三种不同的缓存行为,正好把两个装饰器都验了:

三次调用分别发生了什么
1 第一次(limit=4):autocorrect 的 memo 缓存里没有这组参数,真的跑了一遍。它对 common_words 里每个词都调一次 minimum_mewtations,每次调用内部又是三路递归。memo_diff 在这个过程中已经在起作用了——大量子问题(比如 ("oll", "ell"))会被不同的候选词反复问到,第二次起就直接查表。最终 call_count 在 72000 以内。
2 第二次(limit=4,参数完全相同):autocorrect 头上的 @memo 命中——(entered_word, word_list, diff_function, limit) 这一整组参数上次见过,直接返回缓存的 'well'。minimum_mewtations 一次都没被调用,call_count 恰好是 0。这验证的是 memo(官方给的),顺便说明 deep_convert_to_tuple 把 word_list 转成元组这一步真的生效了。
3 第三次(limit=3):参数变了,@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 autocorrectmin(seq, key=f)——「返回使 f 最小的元素」,且平局取最靠前所有「按某个指标选最优」的问题;同一手法在 Q10 再用一次
Q6 furry_fixes字符串切片递归;len(a) + len(b) 一式通吃两个 base case;用局部预算实现全局剪枝所有序列上的递归;任何「超过阈值就没必要精确」的搜索
Q7 minimum_mewtations三路树形递归;把每种「操作」映射到一种「切片方式」;用问题的数学性质做剪枝编辑距离、最长公共子序列等所有动态规划的递归形态
Q8 report_progressand 的短路保护索引;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 问「整篇文章你走了多远」,所以分母是全文长度。看到一个比值,先问清楚「相对于什么」。