CS 61A  /  作业解析
HOMEWORK 2

HW 2:递归、树递归、序列与列表ok 6 项通过

从「把大问题交给更小的自己」开始,一路练到树递归的分叉计数,和会就地改写嵌套列表的可变性递归。

对应讲次:Lecture 5、Lecture 6 官方题面:cs61a.org/hw/hw02 教材:1.7、2.3 代码:hw/hw02/hw02.py

0. 这份作业在练什么

HW 2 一共六道必做题(Q1–Q6),外加一道选做的 Q7。本仓库里 python3 ok --local 的结果是 6 个测试用例全部通过,也就是说下面贴出来的每一段代码都真的跑通过官方评分器,不是我凭印象敲的。 (Q7 是 optional,评分器不计入这 6 项,但我也一并做了并在文末讲。)

这份作业的主线只有一句话:把一个问题,表达成「同一个问题的更小版本」加上「一点点收尾工作」。 这句话听上去像废话,真正难的是三件具体的事:

  1. 选什么当「更小」。对整数来说,「更小」通常是 num // 10(砍掉最后一位); 对列表来说,「更小」是去掉第一个元素、或者是「某个更内层的子列表」;对找零钱来说,「更小」有两个维度 ——剩余金额变小,或者可用面额变少。选错了维度,递归就永远停不下来。
  2. base case 到底停在哪。停早了会漏算,停晚了会算重,停错了会无限递归直接 RecursionError: maximum recursion depth exceeded。
  3. 递归调用返回来的那个值,语义是什么。这是最关键的一点:你必须在写 return 之前, 先用一句话说清楚「f(更小的输入) 给我的是什么」,然后才知道该怎么把它拼成答案。 CS 61A 管这个叫 recursive leap of faith(递归的信心之跃)——假设更小的那次调用已经对了, 你只需要写对「从小到大」这一步。
本次要点
  • 线性递归(Q1 num_eights、Q2 digit_distance):每层只调用自己一次, 形状和 while 循环一一对应。核心手法是整数的拆位:num % 10 取末位, num // 10 去掉末位。
  • 带辅助函数的递归(Q3 interleaved_sum):当递归需要携带的信息比原函数的参数多时, 就在函数内部再定义一个函数。内层函数通过词法作用域直接读到外层的 num、f_odd、f_even,不必一路当参数传下去。
  • 树递归(Q4 count_dollars、Q7 count_dollars_upward):每层调用自己两次, 对应「用/不用这张面额」的二选一。这是 count_partitions 的直接变体。
  • 序列操作(Q5 shuffle):切片、索引、append,以及「返回新列表 vs. 修改原列表」的区别。
  • 可变性 + 递归(Q6 deep_map):在嵌套列表上就地(in place)递归改写, 函数返回 None,效果全靠副作用。必须搞清楚谁指向谁,才能保证不新建列表。

做之前该会什么

前置知识具体要会在哪学的
整数除法与取余2638 // 10 == 263,2638 % 10 == 8Lecture 1–2
函数作为值函数可以当参数传(f_odd)、可以在函数里定义函数Lecture 4
lambdalambda x: x * x 就是一个没名字的一参函数Lecture 4
环境与作用域内层函数的 parent 帧是定义它的那一帧,因此能读到外层的名字Lecture 4
递归的三步法base case / 递归调用 / 用返回值拼答案Lecture 5
树递归模板count_partitions 的「用它 / 不用它」分叉Lecture 6
列表基础len、索引、切片、append、type(x) == listLecture 7
注意:这次作业有「语法禁令」

HW 2 里好几道题的 doctest 末尾有这样几行:

>>> from construct_check import check
>>> check(SOURCE_FILE, 'num_eights',
...       ['Assign', 'AnnAssign', 'AugAssign', 'NamedExpr', 'For', 'While'])
True

construct_check.check 会去读你的源文件,把指定函数解析成抽象语法树(AST), 然后检查里面有没有出现被禁的语法节点。所以这不是「建议你用递归」,而是硬性检查: Q1 里你连 total = 0 这种赋值语句都不能写(Assign 被禁), Q3 里你连 % 都不能用(Mod 被禁)。 写完自己觉得对了却过不了,多半是踩了这个。禁令清单每题不同,动手前先把 doctest 最后几行读一遍。

1. Num Eights:数一个整数里有几个 8

题目要什么

写一个递归函数 num_eights(num),输入是一个正整数,返回数字 8 在它的十进制表示里出现了几次。

>>> num_eights(3)
0
>>> num_eights(8)
1
>>> num_eights(88888888)
8
>>> num_eights(2638)
1
>>> num_eights(86380)
2
>>> num_eights(12345)
0
>>> num_eights(8782089)
3

翻译成人话:把 num 当成一串数字符号看,数里面有几个 8。 num_eights(86380) 是 2,因为 8、6、3、8、0 里有两个 8——注意末尾的 0 一点也不特殊, 它只是「不是 8」而已。

边界情况有两个值得先想清楚:

  • 一位数:num_eights(3) 要返回 0,num_eights(8) 要返回 1。 这是递归的终点,必须单独处理。
  • 末位是 0:86380 // 10 == 8638,去掉末位不会有任何麻烦。 但如果你写的是「把数转成字符串再数」——题目允许吗?允许,但那样就不是在练递归了, 而且这题的 check 禁了赋值语句,字符串解法通常也写不下去。
这题的禁令特别狠

['Assign', 'AnnAssign', 'AugAssign', 'NamedExpr', 'For', 'While'] —— 四种赋值全禁, 两种循环全禁。也就是说函数体里一个等号都不能有(比较用的 == 不算赋值,那是 Compare 节点,没被禁)。 连 last = num % 10 这种为了可读性起的临时名字都不许写。 这逼着你把整个计算写成纯粹的 if / return 表达式。

怎么想到的

第一步,先别管递归,问自己:如果允许用循环,我会怎么写?

total = 0
while num > 0:
    if num % 10 == 8:
        total = total + 1
    num = num // 10
return total

这个版本谁都会写:每轮看一眼末位是不是 8,是就计数加一,然后把末位砍掉,直到数被砍空。 它揭示了两个关键操作:num % 10 拿到末位,num // 10 得到「去掉末位后的那个数」。

第二步,把循环翻译成递归。翻译的秘诀是问:「去掉末位后剩下的那个数」里有几个 8,谁来数? 答案是——让 num_eights 自己去数。这就是信心之跃:我假设 num_eights(263) 已经能正确返回 0,那么 num_eights(2638) 就等于 「263 里的 8 的个数」加上「末位 8 是不是 8」= 0 + 1 = 1。

关键一步:给递归调用一个「语义句子」

写递归时,先把这句话写在纸上:
「num_eights(num // 10) 返回的是:num 去掉最后一位之后,剩下部分里 8 的个数。」
只要这句话是对的,剩下的工作就只有一件:把最后一位补上去。 最后一位要么是 8(补 1),要么不是(补 0)。整个函数就写完了。

第三步,定 base case。递归每次把 num 缩小 10 倍,迟早会变成一位数。 一位数的时候不能再砍了(8 // 10 == 0,再往下就是 num_eights(0), 虽然也能靠 num == 0 收住,但题目说输入是正整数,用 0 当 base case 语义上有点绕)。 所以我选 num < 10:这时候整个数就是一位,它是 8 就返回 1,否则返回 0。

这里我走过一条弯路,值得写下来。我最初写的 base case 是:

if num == 0:
    return 0

然后主体统一写 return (1 if num % 10 == 8 else 0) + num_eights(num // 10)。 这版其实也是对的,逻辑上更整齐。但它有个隐患:num_eights(0) 会返回 0, 而如果有人真的传了 0 进来(题目保证不会,但你的心里模型要清楚), 「0 这个数里有几个 8」的答案是 0——恰好蒙对。可换成 num_eights(80) 走这条路: 80 → 8 → 0,三层,末层返回 0,中间层看到 8 % 10 == 8 补 1,结果是 1。正确。 两版都能过。我最终选了 num < 10 那版,因为它在一位数就停, 递归深度少一层,而且「一位数是不是 8」这句话读起来就是人话。

代码

def num_eights(num: int) -> int:
    # The last digit contributes 1 if it is an 8, and we recurse on the rest.
    if num < 10:
        return 1 if num == 8 else 0
    elif num % 10 == 8:
        return 1 + num_eights(num // 10)
    else:
        return num_eights(num // 10)

逐行讲:

1 if num < 10: —— base case。为什么用 < 10 而不是 == 8 或 <= 9? <= 9 完全等价,只是 < 10 更直白地表达「只剩一位」。 写成 == 8 是错的:那样 num_eights(3) 会掉进递归分支, 算 num_eights(0),再算 num_eights(0)……0 // 10 还是 0, 无限递归,直接 RecursionError。
2 return 1 if num == 8 else 0 —— 条件表达式(conditional expression), 不是条件语句。写成 if num == 8: return 1 / else: return 0 也对, 但条件表达式在这里更紧凑,而且提醒你:这一层返回的是「这一位贡献了多少」。 注意它不是赋值,所以不会触发 Assign 禁令。
3 elif num % 10 == 8: —— 走到这里说明 num 至少两位。 num % 10 是末位。为什么是 elif 而不是新的 if? 其实这里换成 if 也一样,因为上一个分支必定 return。用 elif 是为了让 「三个分支互斥、恰好覆盖所有情况」这件事在视觉上一目了然。
4 return 1 + num_eights(num // 10) —— 末位是 8,所以答案 = 1 +(剩下部分里 8 的个数)。 这里的 1 + 就是「一点点收尾工作」,递归调用负责的是「更小的同一个问题」。
5 else: return num_eights(num // 10) —— 末位不是 8,这一位不贡献, 答案原封不动等于剩下部分的答案。注意不能写成 return 0 + ... 然后不递归—— 那样就把整个高位部分都扔了。
核心结论

「函数体里一个赋值都不能有」并不是刁难。它逼你写出纯粹的递归形状: 每个分支都是一个 return,返回值 = 常数 + 递归调用。 一旦你能自然地写出这种形状,说明你不再是「把循环改写成递归」, 而是真的在用递归的方式思考了。

验证:手动追踪 num_eights(8782089)

doctest 说它应该返回 3(8、8、8 三个八:8782089 的数字依次是 8,7,8,2,0,8,9)。 把调用栈真的展开——注意递归是从末位往前处理的:

逐步推演:往下展开
num_eights(8782089)
  8782089 >= 10, 8782089 % 10 == 9 != 8  → 走 else
  = num_eights(878208)
      878208 % 10 == 8  → 走 elif
      = 1 + num_eights(87820)
            87820 % 10 == 0 != 8 → else
            = num_eights(8782)
                  8782 % 10 == 2 != 8 → else
                  = num_eights(878)
                        878 % 10 == 8 → elif
                        = 1 + num_eights(87)
                              87 % 10 == 7 != 8 → else
                              = num_eights(8)
                                    8 < 10  → base case
                                    = 1
逐步推演:逐层回代
num_eights(8)       = 1
num_eights(87)      = num_eights(8)        = 1
num_eights(878)     = 1 + num_eights(87)   = 1 + 1 = 2
num_eights(8782)    = num_eights(878)      = 2
num_eights(87820)   = num_eights(8782)     = 2
num_eights(878208)  = 1 + num_eights(87820) = 1 + 2 = 3
num_eights(8782089) = num_eights(878208)   = 3   ✓

七层调用,其中三层执行了 1 +,正好对应三个 8。用表格再看一遍每一层在干什么:

层numnum % 10走哪个分支本层贡献返回值
187820899else03
28782088elif+13
3878200else02
487822else02
58788elif+12
6877else01
78—base case11

再快速验一个:num_eights(12345)。12345 → 1234 → 123 → 12 → 1, 每一位都不是 8,五层全走 else,最底层 1 < 10 且 1 != 8 返回 0, 一路原样传回来,结果 0。✓

常见误区
  • base case 写成 num == 8:num_eights(3) 会无限递归, 报 RecursionError: maximum recursion depth exceeded。 根源是没意识到 base case 必须覆盖所有「不能再缩小」的输入,而不只是「答案好算」的那一个。
  • 写成 return num_eights(num // 10) + num % 10 == 8: Python 的运算符优先级里 + 高于 ==,这句实际上是 (num_eights(num//10) + num % 10) == 8,返回一个布尔值,结果完全错乱。 真要用布尔当数字,得写成 (num % 10 == 8) + num_eights(num // 10),加括号。
  • 忍不住写 total = 0:ok 会报 found forbidden construct Assign,测试直接判错,哪怕答案数值全对。
  • 只 return 了一半:写成
    if num % 10 == 8:
        return 1 + num_eights(num // 10)
    num_eights(num // 10)
    
    最后一行忘了 return,函数返回 None, 接着上一层做 1 + None,报 TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'。 这是递归题最常见的 bug,看到这个报错第一反应就该去找漏掉的 return。

2. Digit Distance:相邻数位之差的绝对值之和

题目要什么

一个整数的 digit distance 定义为:把它的十进制数位排成一排, 把每一对相邻数位的差取绝对值,再全部加起来。

  • 61 的 digit distance 是 5,因为 abs(6 - 1) == 5。
  • 71253 是 12,因为 abs(7-1) + abs(1-2) + abs(2-5) + abs(5-3) = 6 + 1 + 3 + 2 = 12。
  • 6 是 0,因为一位数根本没有相邻数位对。
>>> digit_distance(3)
0
>>> digit_distance(777) # 0 + 0
0
>>> digit_distance(314) # 2 + 3
5
>>> digit_distance(31415926535) # 2 + 3 + 3 + 4 + ... + 2
32
>>> digit_distance(3464660003)  # 1 + 2 + 2 + 2 + ... + 3
16

禁令是 ['For', 'While']——这次允许赋值,只是不许循环。 比 Q1 宽松,正好可以用局部名字把代码写清楚。

要点住的边界情况:

  • 一位数返回 0。注意「0 对相邻数位」和「相邻数位差都是 0」是两回事, 但结果都是 0,所以 base case 写 return 0 天然正确。
  • n 位数有 n-1 对。314 有 3 位、2 对(3-1 和 1-4)。 如果你算出 3 对,说明多算了一对,多半是 base case 停晚了。
  • 必须取绝对值。314 的第二对是 abs(1-4) == 3 而不是 -3。 少了 abs,digit_distance(314) 会得到 2 + (-3) = -1。

怎么想到的

Q1 建立的直觉是「砍末位、递归、补一点」。这题的第一个坑就在这个「补一点」上: Q1 里每一位独立贡献,这题的贡献属于「一对」,不属于任何单独一位。 所以第一个问题必须是:递归的每一层,负责的是哪一对?

我最开始的错误想法是:既然要比较相邻两位,那就一次砍掉两位吧—— digit_distance(num // 100)。写出来立刻发现不对: 314 砍两位变成 3,可 3 和 1 那一对我算了, 1 和 4 那一对呢?它跨在被我砍掉的两位里。更根本的问题是: 相邻对是「重叠」的——3-1 和 1-4 共用了中间那个 1。 一次砍两位会把共用的那一位弄丢,所以只能一次砍一位。

关键一步:让每一层只负责「最后那一对」

对 num = 314:最后一对是末位 4 和倒数第二位 1,贡献 abs(4-1) = 3。 剩下的所有对,全都落在 31 里面——也就是 num // 10。
所以:digit_distance(num) = abs(末位 - 倒数第二位) + digit_distance(num // 10)。
关键在于砍掉末位之后,倒数第二位还留着,它会在下一层充当新的末位, 继续和更前面那一位配对。重叠的问题就这样自动解决了。

怎么拿到倒数第二位?末位是 num % 10。 倒数第二位 = 「去掉末位之后的那个数」的末位 = (num // 10) % 10。 拿 314 验一下:314 % 10 == 4,(314 // 10) % 10 == 31 % 10 == 1。对。

base case 呢?递归每次少一位,什么时候该停? 当只剩一位时,没有「倒数第二位」可配对了,贡献为 0,直接返回 0。 这就是 if num < 10: return 0。

顺带验证一下配对数量对不对:314 三位 → 第一层算一对(4 与 1), 递归到 31 两位 → 算一对(1 与 3),递归到 3 一位 → base case 返回 0。 一共 2 对 = 3 - 1。✓

如果 base case 写成 num == 0 会怎样

假设你写 if num == 0: return 0,主体照旧。 那么 digit_distance(31) 会算 abs(1 - 3) + digit_distance(3), 而 digit_distance(3) 又会算 abs(3 - 0) + digit_distance(0) = 3—— 它把不存在的「前导 0」当成了一个真实数位,凭空多算了一对。 最终 digit_distance(314) 会得到 3 + 2 + 3 = 8 而不是 5。
教训:base case 的位置直接决定「算了几对」。改 base case 之前, 先数一遍它会让递归多走/少走几层。

代码

def digit_distance(num: int) -> int:
    # A single digit has no pair of consecutive digits, so distance is 0.
    if num < 10:
        return 0
    last_digit = num % 10
    second_to_last_digit = (num // 10) % 10
    return abs(last_digit - second_to_last_digit) + digit_distance(num // 10)
1 if num < 10: return 0 —— 一位数,没有相邻对。 这一行必须放在最前面:后面的 (num // 10) % 10 对一位数会算出 0 (比如 3 // 10 == 0),那是个不存在的数位,用它配对就会多算。
2 last_digit = num % 10 —— 这次允许赋值,就用名字把意图写出来。 你完全可以把整个函数压成一行 return abs(num % 10 - (num // 10) % 10) + digit_distance(num // 10), 它一样能过测试,但半年后你自己都看不懂 (num // 10) % 10 是什么。 清晰度值这两行。
3 second_to_last_digit = (num // 10) % 10 —— 括号不能省。 虽然 Python 里 // 和 % 优先级相同、从左往右结合, 所以 num // 10 % 10 恰好等价,但显式括号让「先砍末位、再取新末位」这两步读得出来。
4 abs(last_digit - second_to_last_digit) —— abs 是内置函数, 把负数变正。顺序无所谓:abs(4-1) 和 abs(1-4) 都是 3, 这正是取绝对值的意义。
5 + digit_distance(num // 10) —— 剩余所有对交给更小的自己。 注意参数是 num // 10 不是 num // 100, 因为 second_to_last_digit 还要在下一层继续参与配对。

验证:手动追踪 digit_distance(3464660003)

doctest 说结果是 16。这个数的数位是 3,4,6,4,6,6,0,0,0,3,一共 10 位、9 对。 先按定义横着算一遍:

相邻对3,44,66,44,66,66,00,00,00,3合计
差的绝对值12220600316

现在看递归是怎么把这 9 项凑出来的。注意递归是从右往左处理的, 所以它先算出的是最后那一对 0,3:

逐步推演:往下展开
digit_distance(3464660003)
  末位 3, 次末位 0 → abs(3-0)=3
  = 3 + digit_distance(346466000)
        末位 0, 次末位 0 → abs(0-0)=0
        = 0 + digit_distance(34646600)
              末位 0, 次末位 0 → 0
              = 0 + digit_distance(3464660)
                    末位 0, 次末位 6 → abs(0-6)=6
                    = 6 + digit_distance(346466)
                          末位 6, 次末位 6 → 0
                          = 0 + digit_distance(34646)
                                末位 6, 次末位 4 → 2
                                = 2 + digit_distance(3464)
                                      末位 4, 次末位 6 → 2
                                      = 2 + digit_distance(346)
                                            末位 6, 次末位 4 → 2
                                            = 2 + digit_distance(34)
                                                  末位 4, 次末位 3 → 1
                                                  = 1 + digit_distance(3)
                                                        3 < 10 → 0
逐步推演:逐层回代
digit_distance(3)          = 0
digit_distance(34)         = 1 + 0  = 1
digit_distance(346)        = 2 + 1  = 3
digit_distance(3464)       = 2 + 3  = 5
digit_distance(34646)      = 2 + 5  = 7
digit_distance(346466)     = 0 + 7  = 7
digit_distance(3464660)    = 6 + 7  = 13
digit_distance(34646600)   = 0 + 13 = 13
digit_distance(346466000)  = 0 + 13 = 13
digit_distance(3464660003) = 3 + 13 = 16   ✓

递归产生的贡献序列是 3, 0, 0, 6, 0, 2, 2, 2, 1, 正是横着算那一行 1, 2, 2, 2, 0, 6, 0, 0, 3 倒过来。 这不是巧合:递归从末位往前走,自然是逆序遍历所有相邻对。加法可交换,所以总和一样。

再验一个短的:digit_distance(777)。 末位 7、次末位 7 → 0,加 digit_distance(77); 77 的末位 7、次末位 7 → 0,加 digit_distance(7); 7 是一位数 → 0。总和 0 + 0 + 0 = 0。✓ 这个例子还顺便说明:贡献为 0 不等于该停, 停的条件只看位数,不看数值。

常见误区
  • 忘了 abs:digit_distance(314) 返回 -1。 不会报错,只是答案错,所以特别难发现。看到「结果偏小甚至是负数」,第一个去检查 abs。
  • 一次砍两位:digit_distance(num // 100)。 digit_distance(31415926535) 只会算到 6 对而不是 10 对,结果偏小。 根源是没看出相邻对是重叠的。
  • base case 用 num == 0:多算一对「首位与虚构的 0」, digit_distance(3) 会返回 3 而不是 0。
  • 把 num 转成字符串再遍历:for c in str(num) 会被 check(SOURCE_FILE, 'digit_distance', ['For', 'While']) 拦下, 报 found forbidden construct For。
  • 在递归里改了 num 却没用返回值:比如写 digit_distance(num // 10) 单独成一行不加到结果里, 函数只会返回最后一对的差,digit_distance(314) 得 3。 递归调用的返回值必须被用上,这是和「循环里修改变量」最不一样的地方。
核心结论

Q1 和 Q2 的骨架完全一样:if 只剩一位: return 基础值 + return 本层贡献 + f(num // 10)。 差别只在「本层贡献」是什么。
把它抽象出来:线性递归 = 把序列的最后一个元素单独拿出来处理,其余交给自己。 后面遇到「数位求和」「判断回文」「找最大数位」,全是同一个模子换一个贡献函数。

3. Interleaved Sum:奇偶交错求和

题目要什么

interleaved_sum(num, f_odd, f_even) 接收一个整数 num 和两个单参数函数, 返回把 f_odd 作用在 1 到 num 之间所有奇数上、 把 f_even 作用在所有偶数上,然后全部加起来的结果。num 本身包含在内。

>>> identity = lambda x: x
>>> square = lambda x: x * x
>>> triple = lambda x: x * 3
>>> interleaved_sum(5, identity, square) # 1   + 2*2 + 3   + 4*4 + 5
29
>>> interleaved_sum(5, square, identity) # 1*1 + 2   + 3*3 + 4   + 5*5
41
>>> interleaved_sum(4, triple, square)   # 1*3 + 2*2 + 3*3 + 4*4
32
>>> interleaved_sum(4, square, triple)   # 1*1 + 2*3 + 3*3 + 4*3
28

禁令有两条:

>>> check(SOURCE_FILE, 'interleaved_sum', ['While', 'For', 'Mod']) # ban loops and %
True
>>> check(SOURCE_FILE, 'interleaved_sum', ['BitAnd', 'BitOr', 'BitXor'])
True

这才是本题真正的难点。不许循环好说,关键是不许用 %—— 也就是说你不能写 if k % 2 == 0 来判断奇偶。位运算 k & 1 也被堵死了。 题面里那句提示说得很明白:

Instead of directly checking whether a number is even or odd, start with 1, which you know is an odd number.

边界情况:

  • num 是奇数时(如 5),最后一项 5 要用 f_odd。
  • num 是偶数时(如 4),最后一项 4 要用 f_even, 而且不能多加一个 5。
  • 如果 num 小于 1(题目没给这种测试,但你的 base case 应该经得起推敲), 和应该是 0。

怎么想到的

先把「如果能用 %」的版本写出来,看清楚我们到底损失了什么:

# 假想版本,本题不允许这么写
def interleaved_sum(num, f_odd, f_even):
    total, k = 0, 1
    while k <= num:
        if k % 2 == 1:
            total = total + f_odd(k)
        else:
            total = total + f_even(k)
        k = k + 1
    return total

这个版本的核心是:每走一步,都要重新问一遍「我现在这个 k 是奇是偶」。 禁掉 % 之后,这个「问一遍」的能力没了。那怎么办?

关键一步:把奇偶信息编码进「步长」,而不是每次去测

你不需要知道 k 是奇是偶——你只需要让它永远是奇数。
从 1 出发(1 一定是奇数),每次 +2,得到的永远是奇数:1, 3, 5, 7…
而每个奇数 k 的下一个数 k+1 一定是偶数。
于是把整个求和两个两个地打包: 每一层处理 f_odd(k) + f_even(k+1) 这一对,然后跳到 k+2。
奇偶性从「运行时判断」变成了「结构上的不变量(invariant)」。

用 num = 5 走一遍这个打包:(1,2)、(3,4)、然后 5 落单。 用 num = 4:(1,2)、(3,4),正好配完。 这就出现了两种收尾情况,正好对应两个 base case。

第二个问题:递归函数需要携带哪些信息? 显然需要当前的 k。但 interleaved_sum 的签名是固定的三个参数, 没有 k 的位置。我一开始想改成 interleaved_sum(k, num, f_odd, f_even), 但那会改掉函数签名,doctest 直接调用 interleaved_sum(5, identity, square) 就会崩。

解决办法就是题面的第二条提示:定义一个内层辅助函数。 内层函数 sum_from(k) 只带一个参数 k, 而 num、f_odd、f_even 通过词法作用域直接从外层帧读到—— 这正是 Lecture 4 讲的:内层函数的 parent 是它被定义时所在的那一帧。 不必把它们一路当参数传下去,代码干净得多。

推导 sum_from 的三个分支

先定义清楚语义:sum_from(k) = 从奇数 k 开始,一直到 num(含)的交错和。 注意「k 是奇数」是这个函数的前置条件,由调用方保证。

情况 A: k > num
    区间是空的,没有任何项要加  → return 0

情况 B: k == num
    k 是最后一项,而且我们知道 k 是奇数 → return f_odd(k)
    (不能再加 f_even(k+1),因为 k+1 已经超过 num 了)

情况 C: k < num
    k 是奇数,在范围内            → 加 f_odd(k)
    k+1 <= num,且是偶数          → 加 f_even(k+1)
    剩下从 k+2 开始,k+2 仍是奇数  → 加 sum_from(k+2)
    → return f_odd(k) + f_even(k + 1) + sum_from(k + 2)

情况 C 里那个 k + 1 <= num 的推理值得停一下:既然 k < num 且两者都是整数, 那么 k + 1 <= num 必然成立,所以 f_even(k+1) 这一项一定在范围内, 不需要额外判断。这是 A、B、C 三分而不是两分的全部理由: 如果只写 if k > num: return 0 一个 base case, 那么当 k == num(num 为奇数)时会走进 C, 多加一个 f_even(num + 1)——超界了。 拿 interleaved_sum(5, identity, square) 试: 会变成 1 + 4 + 3 + 16 + 5 + 36 = 65,而不是 29。

另一条路:相互递归(mutual recursion)

题面还提到可以用 mutual recursion——写两个函数, odd_sum(k) 处理奇数位置并调用 even_sum(k+1), even_sum(k) 处理偶数位置并调用 odd_sum(k+1)。 奇偶性同样是靠「你在哪个函数里」编码的,而不是靠计算。 这条路也对,但要写两个函数、两套 base case。我选了单函数跳两格的版本, 因为它只有一处 base case 逻辑,更不容易写错。

代码

def interleaved_sum(num: int, f_odd, f_even) -> int:
    def sum_from(k):
        """Interleaved sum from the odd number k up to num."""
        if k > num:
            return 0
        elif k == num:
            # k is the last term, and we know it is odd.
            return f_odd(k)
        else:
            # k is odd, so k + 1 is even; then jump to the next odd number.
            return f_odd(k) + f_even(k + 1) + sum_from(k + 2)

    return sum_from(1)
1 def sum_from(k): —— 定义在 interleaved_sum 内部。 为什么必须在内部? 因为它要用到 num、f_odd、f_even。 写在模块顶层的话,这三个名字在它的作用域里查不到, 调用时报 NameError: name 'num' is not defined。
2 if k > num: return 0 —— 空区间。num = 4 时递归会走到 sum_from(5), 靠这一条收住。为什么返回 0 而不是别的? 因为 0 是加法的单位元, 加上它不改变结果。
3 elif k == num: return f_odd(k) —— num 为奇数时的收尾。 为什么敢直接用 f_odd 而不判断奇偶? 因为 sum_from 的前置条件保证了 k 一定是奇数: 入口是 sum_from(1),之后每次 +2。 这是全题最精妙的一点——奇偶性由调用链的结构保证,不需要计算。
4 return f_odd(k) + f_even(k + 1) + sum_from(k + 2) —— 一次吃掉一对。 为什么 f_even(k+1) 不用先检查 k+1 <= num? 因为进入这个分支说明 k < num,整数世界里这就等价于 k + 1 <= num。 为什么递归传 k + 2 而不是 k + 1? 传 k+1 就破坏了「k 是奇数」这个不变量, 下一层会把偶数当奇数处理,全乱套。
5 return sum_from(1) —— 从 1 启动。这一行是整个设计的地基: 1 是我们唯一「白送」的奇偶信息,后面所有的奇偶判断都是从它推出来的。

验证:手动追踪两个例子

例一:interleaved_sum(5, identity, square),期望 29。 这里 f_odd = identity(原样返回),f_even = square(平方)。 注意 num = 5 是奇数,会走到 k == num 那条。

逐步推演
sum_from(1)
  1 < 5 → 分支 C
  = identity(1) + square(2) + sum_from(3)
  = 1          + 4         + sum_from(3)

      sum_from(3)
        3 < 5 → 分支 C
        = identity(3) + square(4) + sum_from(5)
        = 3          + 16        + sum_from(5)

            sum_from(5)
              5 == num → 分支 B
              = identity(5) = 5

回代:
sum_from(5) = 5
sum_from(3) = 3 + 16 + 5  = 24
sum_from(1) = 1 + 4  + 24 = 29   ✓

对照注释里的 1 + 2*2 + 3 + 4*4 + 5 = 1 + 4 + 3 + 16 + 5 = 29。 项数、项的归属完全对上。

例二:interleaved_sum(4, triple, square),期望 32。 f_odd = triple(乘 3),f_even = square。 num = 4 是偶数,会走到 k > num 那条。

逐步推演
sum_from(1)
  1 < 4 → 分支 C
  = triple(1) + square(2) + sum_from(3)
  = 3        + 4         + sum_from(3)

      sum_from(3)
        3 < 4 → 分支 C
        = triple(3) + square(4) + sum_from(5)
        = 9        + 16        + sum_from(5)

            sum_from(5)
              5 > 4 → 分支 A
              = 0

回代:
sum_from(5) = 0
sum_from(3) = 9 + 16 + 0  = 25
sum_from(1) = 3 + 4  + 25 = 32   ✓

对照注释 1*3 + 2*2 + 3*3 + 4*4 = 3 + 4 + 9 + 16 = 32。✓

环境图:内层函数怎么读到 num

这是本题最值得画的东西。调用 interleaved_sum(4, triple, square) 时:

Global 帧
    interleaved_sum  ──→ func interleaved_sum(num, f_odd, f_even) [parent=Global]
    triple           ──→ func λ(x) [parent=Global]
    square           ──→ func λ(x) [parent=Global]

f1: interleaved_sum   [parent=Global]
    num     4
    f_odd   ──→ triple 那个 λ
    f_even  ──→ square 那个 λ
    sum_from ──→ func sum_from(k) [parent=f1]     ← 注意 parent 是 f1,不是 Global
    return value  32

f2: sum_from   [parent=f1]        ← 每次递归调用都新建一帧,parent 都是 f1
    k  1
    正在算: f_odd(1) + f_even(2) + sum_from(3)
    查 f_odd  → f2 里没有 → 去 parent f1 → 找到 triple ✓
    查 num    → f2 里没有 → 去 parent f1 → 找到 4      ✓
    return value  32

f3: sum_from   [parent=f1]
    k  3
    return value  25

f4: sum_from   [parent=f1]
    k  5
    5 > num(4) → return 0
核心结论

f2、f3、f4 是三个不同的帧,各有各的 k,互不干扰——这就是为什么递归不需要 「把变量存哪儿」这种烦恼。而它们的 parent 都是 f1(sum_from 被定义的那一帧), 不是调用它的那一帧。所以每一层查 num 都直接跳到 f1 拿到同一个 4。
「parent 由定义位置决定,不由调用位置决定」——这就是词法作用域(lexical scoping), 也是 61A 反复要考的点。

常见误区
  • 只写一个 base case if k > num: return 0: num 为奇数时会多加一项 f_even(num + 1)。 interleaved_sum(5, identity, square) 会返回 65 而不是 29。 症状是「偶数 num 全对、奇数 num 全大」——看到这种规律就该去查 base case。
  • 递归写成 sum_from(k + 1):不变量被破坏, 第二层的 k = 2 会被当奇数用 f_odd, interleaved_sum(4, triple, square) 得到的是一堆错配的项。
  • 忍不住写 if k % 2:ok 报 found forbidden construct Mod。注意 Mod 指的是 % 运算符节点, 所以连字符串格式化的 "%d" % k 都会被误伤(虽然你不会这么写)。
  • 把 sum_from 定义在模块顶层:NameError: name 'num' is not defined。
  • 忘了最后那行 return sum_from(1): interleaved_sum 只定义了一个内层函数就结束了,返回 None, doctest 报 Expected: 29 / Got: None。 「定义函数」和「调用函数」是两件事,别漏了后者。

4. Count Dollars:有多少种凑钱的方法

题目要什么

美元纸币面额是 1、5、10、20、50、100。给定正整数 sum_needed, 问:有多少种不同的纸币组合,面值之和恰好等于 sum_needed? 纸币数量不限,同一面额可以用任意多张。

题面给了 15 的全部 6 种:

#$1$5$10合计
115 张——15
210 张1 张—15
35 张2 张—15
45 张—1 张15
5—3 张—15
6—1 张1 张15
>>> count_dollars(15)
6
>>> count_dollars(10)
4
>>> count_dollars(20)
10
>>> count_dollars(45)
44
>>> count_dollars(100)
344
>>> count_dollars(200)
3274

最重要的一句话在这张表里:这是「组合」不是「排列」。 「1 张 $5 + 1 张 $10」和「1 张 $10 + 1 张 $5」算同一种,只数一次。 你的算法必须内建这个「不重复计数」的机制,否则 count_dollars(15) 会得到远大于 6 的数。

题目还要求必须用 next_smaller_dollar:

def next_smaller_dollar(bill: int) -> int:
    """Returns the next smaller bill in order."""
    if bill == 100:
        return 50
    if bill == 50:
        return 20
    if bill == 20:
        return 10
    elif bill == 10:
        return 5
    elif bill == 5:
        return 1

注意最后:next_smaller_dollar(1) 什么都不返回—— 所有 if 都不成立,函数走到末尾,Python 自动返回 None。 这个 None 不是 bug,它是「没有更小的面额了」的信号, 你的 base case 必须接住它。

怎么想到的

先说为什么「直接递归」在这题会失败。最朴素的想法是: 「凑 15 的方法数 = 用一张 $1 之后凑 14 的方法数 + 用一张 $5 之后凑 10 的方法数 + 用一张 $10 之后凑 5 的方法数 + …」 写出来是这样:

# 错误示范
def count_dollars(sum_needed):
    if sum_needed == 0:
        return 1
    if sum_needed < 0:
        return 0
    return (count_dollars(sum_needed - 1) + count_dollars(sum_needed - 5)
            + count_dollars(sum_needed - 10) + ...)

这个版本数的是排列:凑 6 元时,「先 $1 再 $5」和「先 $5 再 $1」被算成两条不同的路径。 count_dollars(15) 会返回一个大得离谱的数。问题的根源是: 这个递归没有记住「我已经决定不再用某些面额了」,导致同一个组合被以不同顺序数了很多遍。

关键一步:强制一个顺序,把「组合」变成「有序的决策序列」

要让每个组合只被数一次,就规定一个考察面额的固定顺序: 永远从大到小(100 → 50 → 20 → 10 → 5 → 1)。
然后在每一步只问一个是非题:「这张面额,我还要不要再用一张?」
· 要 → 金额减掉它,面额上限不变(还可以再用同面额)
· 不要 → 金额不变,面额上限降到下一档(此后永远不再考虑这一档)
这两条路互斥、互不重叠,所以不会重复计数。这就是 Lecture 6 里 count_partitions 的完全同一个套路。

但这里立刻出现一个技术问题:递归需要携带两个信息——还差多少钱,以及当前允许的最大面额。 可 count_dollars 的签名只有一个参数。这就是题面提示说的 「If you need to keep track of more than one value across recursive calls, consider writing a helper function」。 和 Q3 一样,写内层辅助函数:

给辅助函数一个精确的语义句子

count_using(remaining, largest_bill) = 用面额不超过 largest_bill 的纸币, 凑出 remaining 元的方法数。

有了这句话,三个 base case 全都能直接推出来:

remaining == 0
    → 一分不差,当前这套组合成立了。这本身就是「一种方法」  → return 1

remaining < 0
    → 上一步用的那张纸币太大,超支了。这条路走不通       → return 0

largest_bill is None
    → 已经把 1 元也排除掉了,没有面额可用;而 remaining 还 > 0
      (因为 remaining == 0 已经先被拦下了)              → return 0

递归步:

with_bill    = count_using(remaining - largest_bill, largest_bill)
               # 再用一张 largest_bill,上限不降
without_bill = count_using(remaining, next_smaller_dollar(largest_bill))
               # 这档不再用了,换下一档
return with_bill + without_bill

这里有个我一开始搞错的细节:remaining == 0 必须排在 remaining < 0 前面吗? 其实这两个条件互斥,顺序无所谓。但 remaining == 0 必须排在 largest_bill is None 前面——这一条至关重要。 考虑「用 15 张 $1 凑 15」这条路:最后一次 count_using(0, 1), remaining 是 0 但 largest_bill 还是 1,没问题。 可如果顺序反了,某些路径会在还剩 0 元、面额已耗尽时被判成 0,漏算。 更直接的理由是:remaining == 0 是成功,不管还有没有面额可用,都该算一种。

最后一个决定:从哪个面额起步?count_using(sum_needed, 100)。 从最大的 100 开始,逐级往下。如果从 1 起步会怎样? next_smaller_dollar(1) 是 None,第一步就把面额用光了, 只能得到「全用 1 元」这一种(或者 0 种)。方向必须和 next_smaller_dollar 一致。 (选做的 Q7 正是把方向反过来,用 next_larger_dollar,见第 7 节。)

代码

def count_dollars(sum_needed: int) -> int:
    def count_using(remaining, largest_bill):
        """Ways to make `remaining` using bills no larger than largest_bill."""
        if remaining == 0:
            return 1  # Found one complete way to make change.
        elif remaining < 0 or largest_bill is None:
            return 0  # Overshot, or ran out of bill values to try.
        # Either use one more `largest_bill`, or stop using it entirely.
        with_bill = count_using(remaining - largest_bill, largest_bill)
        without_bill = count_using(remaining, next_smaller_dollar(largest_bill))
        return with_bill + without_bill

    return count_using(sum_needed, 100)
1 if remaining == 0: return 1 —— 「1」代表什么? 不是「一块钱」,而是「一种成功的凑法」。 递归树的每一片「返回 1 的叶子」,就对应一种具体的组合。 最终答案 = 这样的叶子有多少片。想清楚这一点,整棵树就活了。
2 elif remaining < 0 or largest_bill is None: return 0 —— 两种失败合并成一条。为什么用 is None 而不是 == None? None 是单例,is 判断的是「同一个对象」, 这是 Python 的惯例写法,也更快。为什么不能写 if not largest_bill? 那样 largest_bill == 0 也会被当成 None——虽然本题面额不会是 0, 但把「空值」和「假值」混为一谈是个坏习惯。
3 with_bill = count_using(remaining - largest_bill, largest_bill) —— 第二个参数不变,这是允许「同一面额用多张」的全部机制。 如果这里写成 next_smaller_dollar(largest_bill), 每种面额就最多只能用一张,count_dollars(15) 会返回 2 而不是 6。
4 without_bill = count_using(remaining, next_smaller_dollar(largest_bill)) —— 第一个参数不变(没花钱),第二个参数降级。 「降级」是不可逆的,这就是防止重复计数的关键: 一旦决定不用 $10,往后就再也不会考虑 $10。
5 return with_bill + without_bill —— 两条路互斥且穷尽, 所以直接相加。这就是「树递归」的名字来源:每次调用分出两个叉。
6 return count_using(sum_needed, 100) —— 从最大面额启动。 注意这一行在内层函数外面,和 Q3 的 return sum_from(1) 同一个道理。

验证:把 count_dollars(10) 的整棵树画出来

doctest 说是 4。10 元的四种凑法是:10 张 $1、5 张 $1 + 1 张 $5、2 张 $5、1 张 $10。 我们来看递归树怎么正好数出这 4 片叶子。

入口是 count_using(10, 100)。前面几层没有悬念: 100 和 50 都比 10 大,用一张就超支;20 也一样。所以:

逐步推演:前三层的「降级链」
count_using(10, 100)
  with_bill    = count_using(-90, 100)  → remaining < 0 → 0
  without_bill = count_using(10, 50)
  = 0 + count_using(10, 50)

count_using(10, 50)
  with_bill    = count_using(-40, 50)   → 0
  without_bill = count_using(10, 20)
  = count_using(10, 20)

count_using(10, 20)
  with_bill    = count_using(-10, 20)   → 0
  without_bill = count_using(10, 10)
  = count_using(10, 10)

真正的分叉从 count_using(10, 10) 开始:

逐步推演:主干
count_using(10, 10)
├─ with_bill    = count_using(0, 10)  → remaining == 0 → 1   ★叶子1:一张 $10
└─ without_bill = count_using(10, 5)

   count_using(10, 5)
   ├─ with_bill    = count_using(5, 5)
   │  ├─ with_bill    = count_using(0, 5) → 1                ★叶子2:两张 $5
   │  └─ without_bill = count_using(5, 1)
   │     ├─ with = count_using(4, 1)
   │     │  └─ ... 一路 -1 ... count_using(0, 1) → 1         ★叶子3:一张$5 + 五张$1
   │     └─ without = count_using(5, None) → None → 0
   │     count_using(5, 1) = 1 + 0 = 1
   │  count_using(5, 5) = 1 + 1 = 2
   └─ without_bill = count_using(10, 1)
      ├─ with = count_using(9, 1) → ... → count_using(0,1) → 1  ★叶子4:十张 $1
      └─ without = count_using(10, None) → 0
      count_using(10, 1) = 1 + 0 = 1

   count_using(10, 5) = 2 + 1 = 3
count_using(10, 10)   = 1 + 3 = 4   ✓

四片「返回 1 的叶子」,正好是四种凑法,一一对应。 注意 count_using(x, 1) 这一支永远返回 1(只要 x >= 0)—— 道理很简单:用 1 元凑任何非负整数,方法唯一,就是全用 1 元。

把关键中间值列成表,方便对照:

count_using(r, b)值含义
(0, 任意)1凑齐了,一种方法
(5, 1)1五张 $1
(10, 1)1十张 $1
(5, 5)2一张 $5;五张 $1
(10, 5)3两张 $5;一张$5+五张$1;十张$1
(10, 10)4上面三种,再加「一张 $10」
(10, 20)、(10, 50)、(10, 100)4大面额一张都用不上,值不变

同样的方法验 15:count_using(15, 1) = 1, count_using(15, 5) = 4(三张$5;两张$5+五张$1;一张$5+十张$1;十五张$1), count_using(15, 10) = count_using(5, 10) + count_using(15, 5) = 2 + 4 = 6。✓ 而 count_using(5, 10) = count_using(-5, 10) + count_using(5, 5) = 0 + 2 = 2。 20、50、100 都太大,值一路保持 6。最终 count_dollars(15) == 6。

核心结论:树递归的通用模板

凡是「有多少种方式做某件事」,且每一步都能拆成一个二选一的决策, 就套这个模板:

def count(状态, 当前可选项):
    if 成功:        return 1
    if 失败/耗尽:   return 0
    return count(用了当前选项后的状态, 当前可选项)      # 选项可重复用
         + count(状态不变,           下一个选项)      # 放弃当前选项

「选项只能降不能升」是防止重复计数的机关。 count_partitions、找零钱、子集和、背包计数,全是这一个模板。

常见误区
  • 不写辅助函数,直接对 sum_needed 递归:数出来的是排列不是组合, count_dollars(15) 会返回 42 而不是 6。
  • with_bill 那一支也降级了:每种面额最多用一张, count_dollars(15) 返回 1(只剩「一张$5 + 一张$10」这一种), count_dollars(10) 也返回 1。症状是「答案严重偏小」。
  • 忘了 largest_bill is None 这个 base case: 递归走到 count_using(remaining, None) 后会去算 remaining - None, 报 TypeError: unsupported operand type(s) for -: 'int' and 'NoneType'。 看到这个报错就去检查「面额用完了怎么办」。
  • 把 remaining == 0 判断放在 largest_bill is None 之后: 某些成功路径会被误判成失败,答案偏小。成功优先于耗尽。
  • 从 count_using(sum_needed, 1) 起步:只会数出「全 1 元」一种, count_dollars(15) 返回 1。方向搞反了。
  • 写循环遍历面额列表:check(SOURCE_FILE, 'count_dollars', ['While', 'For']) 会报 found forbidden construct For。

5. Shuffle:把序列的两半交错洗牌

题目要什么

shuffle(s) 接收一个元素个数为偶数的序列(sequence,可以是 list,也可以是 range), 把它前一半和后一半的元素交错排列,返回一个新列表。 不修改 s。

「交错」的定义:新列表依次是 s0 的第 0 个、s1 的第 0 个、s0 的第 1 个、s1 的第 1 个…… 其中 s0 是前半,s1 是后半。

>>> shuffle(range(6))
[0, 3, 1, 4, 2, 5]
>>> letters = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
>>> shuffle(letters)
['a', 'e', 'b', 'f', 'c', 'g', 'd', 'h']
>>> shuffle(shuffle(letters))
['a', 'c', 'e', 'g', 'b', 'd', 'f', 'h']
>>> letters  # Original list should not be modified
['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']

这个洗牌方式在扑克里叫 riffle shuffle(鸽尾式交错洗牌): 把牌分成两摞,然后左一张右一张地交叉插进去。

要注意的几点:

  • 输入可能是 range 而不是 list。第一个 doctest 就是 shuffle(range(6))。 所以你只能用「序列都支持的操作」:len()、s[i] 索引、切片。 不能用 s.append()、s.pop()——range 对象没有这些方法 (AttributeError: 'range' object has no attribute 'append')。
  • 必须返回新列表,不能改 s。最后一个 doctest (>>> letters 应仍是原样)就是专门测这一点的。
  • 返回值必须是 list,不能是 range 或 tuple。 shuffle(shuffle(letters)) 这个嵌套调用说明返回值还要能再喂给 shuffle。
  • 第一行 assert len(s) % 2 == 0 是题目给好的,不用动,它保证了「恰好能对半分」。

怎么想到的

这题没有递归,是纯粹的索引练习,但索引算错是最容易翻车的地方。 所以第一件事不是写代码,是把下标关系写在纸上。

拿 s = ['a','b','c','d','e','f','g','h'],len(s) == 8,half = 4:

结果位置01234567
值aebfcgdh
来自 s 的下标04152637
属于哪一半前后前后前后前后

规律一眼就出来了:成对出现——第 i 对是 (s[i], s[half + i]), i 从 0 走到 half - 1。

关键一步:按「对」循环,而不是按「结果位置」循环

你也可以写成遍历结果的 8 个位置,然后用 i // 2 和 i % 2 反推来源下标—— 但那个式子容易写错,而且读起来完全看不出意图。
按对循环只需要 half 轮,每轮 append 两个元素: 先 s[i](前半的第 i 个),再 s[half + i](后半的第 i 个)。 下标关系简单到不可能写错。

第二个问题:half 怎么算?len(s) // 2。 为什么是 // 而不是 /? 因为 / 返回浮点数,8 / 2 == 4.0, 拿它当下标会报 TypeError: list indices must be integers or slices, not float。 虽然 assert 已经保证长度是偶数、除得尽,但类型仍然是 float。这是新手最常踩的坑之一。

第三个问题:怎么保证不修改 s? 答案是根本不碰 s——只读它(s[i] 是读), 所有写操作都发生在一个全新的空列表 shuffled = [] 上。 这里我走过一个弯路:一开始想写 shuffled = s 再原地调整, 但 shuffled = s 根本不复制,只是让两个名字指向同一个列表对象 (这叫 aliasing,别名)。往 shuffled 里 append, letters 也会跟着变长,最后一个 doctest 立刻失败。 要复制得写 list(s) 或 s[:];但这题连复制都不需要,从空表建起最干净。

代码

def shuffle(s: list) -> list:
    assert len(s) % 2 == 0, 'len(seq) must be even'
    half = len(s) // 2
    shuffled = []
    for i in range(half):
        shuffled.append(s[i])
        shuffled.append(s[half + i])
    return shuffled
1 assert len(s) % 2 == 0, 'len(seq) must be even' —— 题目自带。 assert 条件, 消息:条件为假就抛 AssertionError: len(seq) must be even。 它把「长度必须是偶数」这个前置条件写进了代码, 后面所有下标运算都可以放心地假设它成立。
2 half = len(s) // 2 —— 分界点。 注意 half 同时是两个含义:它既是「前半的长度」, 也是「后半第一个元素的下标」。s[0:half] 是前半,s[half:] 是后半, 两者不重不漏。这种「长度即起始下标」的巧合在 Python 的半开区间约定下总是成立,用熟了很省脑子。
3 shuffled = [] —— 建一个全新的空列表。 这一行是「不修改 s」和「返回 list(而不是 range)」两个要求的共同保证。
4 for i in range(half): —— 循环 half 次,i 从 0 到 half-1。 为什么不是 range(len(s))? 因为每轮产出两个元素, 只需要 len(s) / 2 轮。写成 range(len(s)) 会产出 16 个元素, 而且 s[half + i] 在 i >= half 时越界, 报 IndexError: list index out of range。
5 shuffled.append(s[i]) 然后 shuffled.append(s[half + i]) —— 顺序不能颠倒。题目要求「先 s0 的第一个,再 s1 的第一个」, 颠倒过来 shuffle(range(6)) 会得到 [3, 0, 4, 1, 5, 2]。 append 是就地修改 shuffled、返回 None 的方法—— 千万别写 shuffled = shuffled.append(...),那会把 shuffled 变成 None。
6 return shuffled —— 缩进在 for 外面。 缩进到里面的话第一轮就返回了,shuffle(range(6)) 只会得到 [0, 3]。 这是「return 写在循环里」的经典 bug。

验证一:shuffle(range(6))

range(6) 的元素是 0,1,2,3,4,5,len == 6, 6 % 2 == 0 通过 assert,half = 3。 前半是 0,1,2(下标 0,1,2),后半是 3,4,5(下标 3,4,5)。

is[i]half + is[half+i]本轮 append 后 shuffled
循环前———[]
0033[0, 3]
1144[0, 3, 1, 4]
2255[0, 3, 1, 4, 2, 5]

range(3) 到此耗尽,返回 [0, 3, 1, 4, 2, 5]。✓ 顺带注意:返回的是 list,不是 range, 所以 doctest 显示的是 [0, 3, 1, 4, 2, 5] 而不是 range(...)。

验证二:shuffle(shuffle(letters))

这是最能暴露理解深度的一个 doctest。先算内层:

逐步推演
letters   = ['a','b','c','d','e','f','g','h']     half = 4
  前半 = a b c d (下标 0..3)   后半 = e f g h (下标 4..7)
  i=0: append a, append e   →  [a, e]
  i=1: append b, append f   →  [a, e, b, f]
  i=2: append c, append g   →  [a, e, b, f, c, g]
  i=3: append d, append h   →  [a, e, b, f, c, g, d, h]

内层结果 t = ['a','e','b','f','c','g','d','h']

再对 t 洗一次:                                    half = 4
  前半 = a e b f (下标 0..3)   后半 = c g d h (下标 4..7)
  i=0: append t[0]=a, append t[4]=c  →  [a, c]
  i=1: append t[1]=e, append t[5]=g  →  [a, c, e, g]
  i=2: append t[2]=b, append t[6]=d  →  [a, c, e, g, b, d]
  i=3: append t[3]=f, append t[7]=h  →  [a, c, e, g, b, d, f, h]

结果 ['a','c','e','g','b','d','f','h']   ✓

然后 doctest 检查 letters 仍然是 ['a','b','c','d','e','f','g','h']。 为什么它没变?因为两次 shuffle 调用都只从 s 里读 (s[i]、s[half+i]、len(s)),所有 append 都作用在函数内部新建的 shuffled 上。

Global 帧
    letters ───→ ┌──────────────────────────────┐
                 │ list1: a b c d e f g h        │   ← 从头到尾没被改过
                 └──────────────────────────────┘

第一次调用 shuffle(letters):
f1: shuffle [parent=Global]
    s        ───→ list1        (只读)
    half     4
    shuffled ───→ ┌──────────────────────┐
                  │ list2: a e b f c g d h │   ← 新对象
                  └──────────────────────┘
    return value ───→ list2

第二次调用 shuffle(list2):
f2: shuffle [parent=Global]
    s        ───→ list2        (只读)
    shuffled ───→ ┌──────────────────────┐
                  │ list3: a c e g b d f h │   ← 又一个新对象
                  └──────────────────────┘
    return value ───→ list3

list1、list2、list3 是三个互不相干的对象。
核心结论:返回新对象 vs. 就地修改

同样是「处理一个列表」,有两种截然不同的风格:

返回新对象(本题 shuffle)就地修改(下一题 deep_map)
原对象不变被改
返回值新列表None
怎么用t = shuffle(s)deep_map(f, s) 然后直接看 s
典型内置sorted(s)、s + t、s[:]s.sort()、s.append(x)、s[i] = v
误用症状忘了接返回值 → 什么都没发生接了返回值 → 拿到 None

Python 标准库刻意用这个约定:就地修改的函数一律返回 None, 好让你写 x = s.sort() 时立刻发现错了。HW 2 把两种风格前后排在一起(Q5 和 Q6), 就是要你把这条界线画清楚。

常见误区
  • half = len(s) / 2:得到 4.0, s[half + i] 报 TypeError: list indices must be integers or slices, not float。
  • shuffled = s 然后往里 append:这是别名不是复制, letters 会被改成 16 个元素,最后一个 doctest 失败。
  • 对 range 调 .append: AttributeError: 'range' object has no attribute 'append'。 提醒你 s 不一定是 list。
  • return 缩进进 for 里:只返回前两个元素。
  • 循环写成 range(len(s)): i = 4 时 s[4 + 4] 越界,IndexError: list index out of range。
  • shuffled = shuffled.append(s[i]): append 返回 None,下一行立刻 AttributeError: 'NoneType' object has no attribute 'append'。
  • 用 s[:half] 和 s[half:] 先切两半再交错: 这本身没问题(切片对 range 也可以,返回 range), 但如果你接着写 s0 + s1 那是拼接不是交错,结果就是原序列。

6. Deep Map:就地改写嵌套列表

题目要什么

先看定义。嵌套数字列表(nested list of numbers):一个列表, 它的元素要么是数字,要么还是嵌套数字列表。 [1, [2, [3]], 4]、[1, 2, 3]、[[1, 2], [3, 4]] 都算。

deep_map(f, s) 要就地(in place)把 s 里每一个数字 (不管埋在多深)替换成 f(那个数字)。

>>> six = [1, 2, [3, [4], 5], 6]
>>> deep_map(lambda x: x * x, six)
>>> six
[1, 4, [9, [16], 25], 36]

注意第二行调用之后什么都没打印——因为 deep_map 返回 None, 而交互式解释器不显示 None。效果全在 six 上,所以第三行才要重新求值 six。

题面里有两条硬约束:「deep_map returns None and should not create any new lists.」 后半句由这段 doctest 强制检查:

>>> s = [3, [1, [4, [1]]]]
>>> s1 = s[1]
>>> s2 = s1[1]
>>> s3 = s2[1]
>>> deep_map(lambda x: x + 1, s)
>>> s
[4, [2, [5, [2]]]]
>>> s1 is s[1]
True
>>> s2 is s1[1]
True
>>> s3 is s2[1]
True

这四行是全题的灵魂,必须逐字读懂。s1 = s[1] 让 s1 指向 s 里那个子列表对象本身(不是副本)。 调用 deep_map 之后再问 s1 is s[1]—— is 检查的是「是不是同一个对象」,不是「值是否相等」。 它为 True,就说明你没有把 s[1] 换成一个新造的列表, 而是钻进原来那个列表里把元素一个个改掉了。

is 和 == 的区别,这题必须分清
a == ba is b
问什么值相等吗是同一个对象吗
[1,2] == [1,2]True[1,2] is [1,2] → False
能被「造新列表」的解法骗过能不能

所以题目故意用 is 来测。如果你写了个「返回新嵌套列表」的版本, s 的值可能对,但 s1 is s[1] 会是 False,测试挂掉。

还有一条提示:type(a) == list 在 a 是列表时为 True。 这是本题唯一需要的类型判断手段。

怎么想到的

第一反应多半是这样写:

# 错误示范 1
def deep_map(f, s):
    result = []
    for x in s:
        if type(x) == list:
            result.append(deep_map(f, x))
        else:
            result.append(f(x))
    return result

这是最自然的「map」写法,但它造了新列表, 既违反「returns None」也违反「should not create any new lists」。 s1 is s[1] 会是 False。

第二反应:那就 for x in s: x = f(x) 吧?

# 错误示范 2
def deep_map(f, s):
    for x in s:
        if type(x) == list:
            deep_map(f, x)
        else:
            x = f(x)

这个版本什么都不会发生,而且不报错,最阴险。 原因:for x in s 里的 x 只是一个局部名字, 每轮被重新绑定到列表里的下一个元素。 x = f(x) 只是把这个局部名字重新指向一个新数字, 列表本身的第 i 个槽位(slot)没被碰过。 循环一结束 x 就没了,改动随之蒸发。

关键一步:要改列表,必须通过「列表 + 下标」去写

x = f(x) 改的是名字的绑定;
s[i] = f(s[i]) 改的是列表对象内部第 i 个位置存的引用。
只有后者才是「就地修改」。
所以循环必须写成 for i in range(len(s)),拿到下标, 而不是 for x in s 拿元素。

有了这一点,剩下的就是递归的老三步:

分情况讨论 s[i]
对每个下标 i,看 s[i] 是什么:

情况 1: s[i] 是一个列表
    → 它内部可能还埋着数字,需要同样的处理
    → 递归:deep_map(f, s[i])
    → 注意:不接返回值!deep_map 靠副作用工作,
      它会钻进 s[i] 这个对象里把元素改掉,
      而 s[i] 这个槽位存的引用【一动不动】——这正是 `is` 测试要的。

情况 2: s[i] 是一个数字
    → 直接替换:s[i] = f(s[i])

base case 在哪?没有显式的 base case—— 当 s 是空列表时,range(0) 是空的,循环一轮都不跑,函数直接结束。 更常见的情况是:当某个 s[i] 全是数字时,情况 1 一次都不触发,递归自然停住。 「循环体一次都不执行」本身就是 base case,这是列表递归常见的写法。

还有一个我一开始没想明白的点:为什么递归调用不写 s[i] = deep_map(f, s[i])? 因为 deep_map 返回 None,那样写会把子列表整个替换成 None, 结果变成 [4, None]。就地修改型的函数,调用它就完事了,不要接返回值。 这和 Q5 的 shuffle 恰好相反,两题放在一起就是要练这个对比。

代码

def deep_map(f, s: list) -> list:
    for i in range(len(s)):
        if type(s[i]) == list:
            deep_map(f, s[i])  # Recurse into the sublist, mutating it in place.
        else:
            s[i] = f(s[i])
1 for i in range(len(s)): —— 遍历下标不是元素。 这是全题最关键的一行。len(s) 只在循环开始时求值一次, 但我们不改变列表长度,所以没有「边遍历边改长度」的风险。
2 if type(s[i]) == list: —— 判断当前元素是不是子列表。 为什么不用 isinstance(s[i], list)? 两者在本题等价,isinstance 通常更 Pythonic(它认子类), 但题面明确提示用 type(a) == list,跟着题面走最稳。
3 deep_map(f, s[i]) —— 递归钻进子列表。 三个「不要」:不要接返回值(返回 None); 不要写成 deep_map(f, s)(无限递归,RecursionError); 不要在这里给 s[i] 重新赋值(那就造新对象了)。 注意参数顺序是 (f, s),函数在前—— 写反了会得到 TypeError: 'list' object is not callable。
4 else: s[i] = f(s[i]) —— 元素是数字,直接改槽位。 先读出 s[i]、喂给 f、把结果写回同一个槽位。 写 s[i] 改的是列表对象,所有指向这个列表的名字都会看到变化。
5 没有 return —— 函数走到末尾,Python 隐式返回 None。 这正是题目要的。写 return s 会让第一个 doctest 失败: >>> deep_map(lambda x: x * x, six) 那行本该不输出任何东西, 返回 s 会让解释器打印出整个列表, 测试报 Expected nothing / Got: [1, 4, [9, [16], 25], 36]。

验证:追踪 deep_map(lambda x: x + 1, s),s = [3, [1, [4, [1]]]]

先把对象关系画清楚。调用之前:

Global 帧
    s  ──→ ┌───────────────────┐
           │ listA: [ 3 , • ]  │
           └───────────┬───────┘
                       ↓
    s1 ──→ ┌───────────────────┐
           │ listB: [ 1 , • ]  │
           └───────────┬───────┘
                       ↓
    s2 ──→ ┌───────────────────┐
           │ listC: [ 4 , • ]  │
           └───────────┬───────┘
                       ↓
    s3 ──→ ┌───────────┐
           │ listD: [1]│
           └───────────┘

s1 和 listA 的第 1 槽指向【同一个】listB。
s2 和 listB 的第 1 槽指向【同一个】listC。
s3 和 listC 的第 1 槽指向【同一个】listD。

现在跑递归。f = lambda x: x + 1:

逐步推演
deep_map(f, listA)                      len = 2, i 取 0, 1
  i=0: listA[0] 是 3,不是 list
       → listA[0] = f(3) = 4            listA 变成 [4, •→listB]
  i=1: listA[1] 是 listB,是 list
       → deep_map(f, listB)   ← 递归,【不】给 listA[1] 赋值

     deep_map(f, listB)                 len = 2
       i=0: listB[0] 是 1 → listB[0] = 2      listB 变成 [2, •→listC]
       i=1: listB[1] 是 listC → deep_map(f, listC)

          deep_map(f, listC)            len = 2
            i=0: listC[0] 是 4 → listC[0] = 5   listC 变成 [5, •→listD]
            i=1: listC[1] 是 listD → deep_map(f, listD)

               deep_map(f, listD)       len = 1
                 i=0: listD[0] 是 1 → listD[0] = 2   listD 变成 [2]
                 循环结束 → 隐式 return None
            循环结束 → return None
       循环结束 → return None
  循环结束 → return None

调用之后的对象图——箭头一根都没变,只有方框里的数字变了:

Global 帧
    s  ──→ ┌───────────────────┐
           │ listA: [ 4 , • ]  │        3 → 4
           └───────────┬───────┘
                       ↓  (还是原来那根箭头)
    s1 ──→ ┌───────────────────┐
           │ listB: [ 2 , • ]  │        1 → 2
           └───────────┬───────┘
                       ↓
    s2 ──→ ┌───────────────────┐
           │ listC: [ 5 , • ]  │        4 → 5
           └───────────┬───────┘
                       ↓
    s3 ──→ ┌───────────┐
           │ listD: [2]│                1 → 2
           └───────────┘

于是四个 doctest 全部满足:

表达式为什么结果
slistA 的内容变成了 [4, listB],递归打印展开[4, [2, [5, [2]]]] ✓
s1 is s[1]s[1] 仍是 listB,s1 也是 listBTrue ✓
s2 is s1[1]s1[1] 仍是 listCTrue ✓
s3 is s2[1]s2[1] 仍是 listDTrue ✓

再快速验第一个 doctest:six = [1, 2, [3, [4], 5], 6],f = lambda x: x * x。 外层 len == 4:i=0 把 1 变 1;i=1 把 2 变 4; i=2 是列表 [3, [4], 5],递归进去把 3 变 9、 再往里递归把 4 变 16、回来把 5 变 25,得到 [9, [16], 25]; i=3 把 6 变 36。最终 six 是 [1, 4, [9, [16], 25], 36]。✓

核心结论:三种「递归形状」在这份作业里都出现了
形状每层调用自己几次本次作业的例子怎么收敛
线性递归1 次num_eights、digit_distance、sum_from参数单调变小
树递归2 次(固定)count_using两个维度轮流变小
结构递归看数据有多少层/多少个子列表deep_map嵌套深度有限,钻到底就是数字

deep_map 的递归次数不是由「数值」决定的,而是由数据本身的结构决定的—— 数据里有几个子列表,就递归几次。这是后面学「树」和「链表」的直接铺垫。

常见误区
  • for x in s: x = f(x):不报错,但 s 纹丝不动。 doctest 报 Expected: [1, 4, [9, [16], 25], 36] / Got: [1, 2, [3, [4], 5], 6]。 根源:重新绑定局部名字 ≠ 修改列表槽位。
  • 造新列表 return [f(x) for x in s]: 值可能对,但 s1 is s[1] 是 False,而且返回值不是 None, 第一个 doctest 就会因为多打印了东西而失败。
  • s[i] = deep_map(f, s[i]): deep_map 返回 None,子列表被替换成 None, s 变成 [4, None]。
  • 最后写了 return s: >>> deep_map(lambda x: x * x, six) 那一行本该无输出, 现在打印出列表,报 Expected nothing。
  • 忘了判断类型,直接 s[i] = f(s[i]): f 是 lambda x: x * x 时,[3] * [3] 报 TypeError: can't multiply sequence by non-int of type 'list'。 (如果 f 是 lambda x: x + 1 则报 TypeError: can only concatenate list (not "int") to list。)
  • 递归写成 deep_map(f, s)(漏了 [i]): 无限递归,RecursionError: maximum recursion depth exceeded。
  • 参数顺序写反 deep_map(s[i], f): 内层会把 f 当列表去 len(),报 TypeError: object of type 'function' has no len()。

7. Count Dollars Upward(选做):把方向反过来

这题是 optional

Q7 不计入本次作业的 2 分,也不在 ok --local 那 6 项里。 但它是理解 Q4 的最好方式:同一个问题,把递归方向反过来, 你会立刻发现自己当初到底有没有想明白「为什么不重复计数」。

题目要什么

和 count_dollars 完全一样的问题,唯一的区别是必须用 next_larger_dollar:

def next_larger_dollar(bill: int) -> int:
    """Returns the next larger bill in order."""
    if bill == 1:
        return 5
    elif bill == 5:
        return 10
    elif bill == 10:
        return 20
    elif bill == 20:
        return 50
    elif bill == 50:
        return 100

同样地,next_larger_dollar(100) 返回 None——没有更大的面额了。 doctest 的期望值和 Q4 一模一样:15 → 6,10 → 4,20 → 10,45 → 44,100 → 344,200 → 3274。

怎么想到的

这题真正在问的是:Q4 里「从大到小」这个方向,是本质的还是任意的?

答案是:任意的。防止重复计数的机关不是「大到小」,而是 「面额的考察顺序被固定住了,而且只能单向前进」。 方向朝上朝下都行,只要一致。

关键一步:把辅助函数的语义句子也反过来

Q4:count_using(remaining, largest_bill) = 用面额不超过 largest_bill 的纸币凑 remaining 的方法数。
Q7:count_using(remaining, smallest_bill) = 用面额不小于 smallest_bill 的纸币凑 remaining 的方法数。
把这句话写对,代码就是照抄: 「再用一张当前面额」→ 面额不变;「不再用当前面额」→ 换更大的一档。 启动改成 count_using(sum_needed, 1),从最小的 1 元开始。

三个 base case 一个字都不用改,因为它们的含义与方向无关: remaining == 0 是成功、remaining < 0 是超支、 面额变成 None 是选项耗尽。

代码

def count_dollars_upward(sum_needed: int) -> int:
    def count_using(remaining, smallest_bill):
        """Ways to make `remaining` using bills no smaller than smallest_bill."""
        if remaining == 0:
            return 1
        elif remaining < 0 or smallest_bill is None:
            return 0
        with_bill = count_using(remaining - smallest_bill, smallest_bill)
        without_bill = count_using(remaining, next_larger_dollar(smallest_bill))
        return with_bill + without_bill

    return count_using(sum_needed, 1)

和 Q4 逐行对照,只有三处不同:

Q4 count_dollarsQ7 count_dollars_upward
参数名(语义)largest_bill,「上限」smallest_bill,「下限」
降级函数next_smaller_dollarnext_larger_dollar
启动值100(最大面额)1(最小面额)
三个 base case完全相同
with_bill 是否改面额都不改

验证:count_dollars_upward(10)

期望仍是 4。但这次树的形状完全不同——先决定用几张 $1,再往上走。

逐步推演
count_using(10, 1)
├─ with_bill    = count_using(9, 1)
│                 ├─ count_using(8, 1) ... 一路 -1 ...
│                 │   最终 count_using(0, 1) → 1        ★十张 $1
│                 └─ 每一层的 without 分支都是 count_using(k, 5),见下
└─ without_bill = count_using(10, 5)     ← 一张 $1 都不用

count_using(10, 5)
├─ with_bill    = count_using(5, 5)
│                 ├─ with    = count_using(0, 5) → 1     ★两张 $5
│                 └─ without = count_using(5, 10)
│                              ├─ with    = count_using(-5,10) → 0
│                              └─ without = count_using(5, 20) → ... 一路 <0 ... → 0
│                              = 0
│                 count_using(5, 5) = 1
└─ without_bill = count_using(10, 10)
                  ├─ with    = count_using(0, 10) → 1    ★一张 $10
                  └─ without = count_using(10, 20)
                               ├─ with = count_using(-10, 20) → 0
                               └─ without = count_using(10, 50) → ... → 0
                               = 0
                  count_using(10, 10) = 1
count_using(10, 5) = 1 + 1 = 2

那 count_using(10, 1) 呢?沿着 with_bill 一路减 1, 每减一次都会分出一个 count_using(剩余, 5) 的分支:

kcount_using(k, 5)对应的凑法(含已用掉的 10-k 张 $1)
102两张$5;一张$10
909 元凑不出(只能用 ≥5 的面额)
80—
70—
60—
51五张$1 + 一张$5
40—
30—
20—
10—
0—remaining == 0 直接返回 1:十张$1

把这一列加起来:2 + 1 + 1(最后那个 count_using(0, 1))= 4。✓ 四种凑法和 Q4 数出来的完全一样,只是被发现的顺序不同。

核心结论

Q4 和 Q7 数的是同一个集合,只是遍历这个集合的顺序不同。 两棵递归树形状完全不一样,叶子数量却分毫不差。
这就是树递归最值得记住的一点:你在设计的不是「计算过程」,而是「一个不重不漏的枚举方案」。 只要枚举方案是双射的,怎么走都对。

8. 整份作业回顾

六道必做题看着散,其实是一条严密的递进线。

题目核心手法真正在教什么迁移到哪里
Q1 num_eightsnum // 10 拆位 + 线性递归递归的信心之跃:假设小的那次已经对了数位求和、判断回文、逆序整数
Q2 digit_distance同上,但贡献属于「一对」base case 的位置决定算了几项任何「相邻元素两两处理」的问题
Q3 interleaved_sum内层辅助函数 + 不变量把要判断的性质编码进结构,而不是运行时测状态机、奇偶/相位分离、mutual recursion
Q4 count_dollars树递归「用它 / 不用它」用「单向的选项顺序」保证不重复计数子集和、背包计数、count_partitions
Q5 shuffle下标算术 + 新建列表「返回新对象」这一派的写法任何 map/zip/transpose 式的序列变换
Q6 deep_map结构递归 + 就地修改名字绑定 vs. 对象修改;is vs. ==树的遍历、链表操作、深拷贝/浅拷贝
Q7(选做)把 Q4 的方向反过来递归方向是任意的,单向性才是本质验证自己是否真的理解 Q4

三个可以带走的思维工具

1 写递归之前,先用一句话说清楚「递归调用返回的是什么」。 这句话必须精确到能当 docstring 用。
「num_eights(num // 10) 是:去掉末位之后剩下部分里 8 的个数。」
「sum_from(k) 是:从奇数 k 到 num 的交错和。」
「count_using(r, b) 是:用面额不超过 b 的纸币凑 r 元的方法数。」
句子写不出来,代码一定写不对;句子写对了,代码基本是照抄。
2 base case 不是「答案好算的那个输入」,而是「不能再缩小的所有输入」。 Q1 写 num == 8 会无限递归、Q2 写 num == 0 会多算一对、 Q3 少一个 k == num 会越界多加一项——三次翻车都是同一个原因。 每写完一个 base case,问自己:还有哪种输入会绕过它继续往下走?
3 「参数需要携带的信息比函数签名多」→ 写内层辅助函数。 Q3 需要 k、Q4 需要 largest_bill,签名里都没位置。 内层函数靠词法作用域白拿外层的所有名字,比把一堆参数一路传下去干净得多。 这是 61A 后面会反复用的手法。

本次作业的报错速查

报错 / 症状最可能的原因
RecursionError: maximum recursion depth exceededbase case 漏了某类输入;或递归调用的参数没变小(deep_map(f, s) 漏了 [i])
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'某个分支漏写 return
TypeError: unsupported operand type(s) for -: 'int' and 'NoneType'Q4/Q7 没处理面额耗尽(bill is None)
TypeError: list indices must be integers or slices, not floatQ5 用了 / 而不是 //
AttributeError: 'range' object has no attribute 'append'Q5 忘了输入可能是 range,要新建 list
found forbidden construct Assign / For / While / Mod踩了该题的 construct_check 禁令
doctest 说 Expected nothing 却打印了列表Q6 多写了 return s
结果对但 is 测试为 FalseQ6 造了新列表,没有就地修改
不报错但列表没变Q6 写成 for x in s: x = f(x)
Q4 答案巨大(15 得 42)数成了排列:没有固定面额顺序
Q4 答案过小(15 得 1)with_bill 那支也降级了,每种面额只能用一张
验证状态(如实记录)

本仓库在 hw/hw02/ 下运行 python3 ok --local, 结果是 6 个测试用例全部通过,本次作业的必做题(Q1–Q6)全部通过。 上面每一段贴出的代码都逐字取自 hw/hw02/hw02.py,未作任何改写。 Q7 是官方标注的 optional 题,不在这 6 项统计内,其代码同样位于该文件中。