CS 61A  /  作业解析
PROJECT 1

项目一 Hog:掷骰游戏ok 267 项通过

十三道题,从「把规则翻译成 if 和 while」一路走到「用函数生产函数、用函数评价函数」。这是控制流与高阶函数第一次被放在同一张桌子上。

对应讲次:Lecture 2、Lecture 3、Lecture 4 官方题面:cs61a.org/proj/hog 代码:proj/hog/hog.py 验证:本仓库 python3 ok --local 输出「267 test cases passed! No cases failed.」

0. 这份作业在练什么

Hog 是 CS 61A 的第一个项目。它的题面很长,规则读起来像一份桌游说明书,很容易让人以为「这是一道阅读理解题」。它确实需要认真读题——但真正被考的东西只有两样,而且这两样正好对应课程前四讲:

第一样是控制流(control flow)。Phase 1 的五道题(Problem 1–5)要求你把三条自然语言写的游戏规则,一字不差地翻译成 if、while 和 return。这里的难点不在语法,而在「翻译时不许偷工减料」:题面说「即使中途掷出 1 也要把骰子掷完」,你就不能在发现 1 之后立刻 return;题面说「分数可能超过 100」,你就不能用「除以 10 取余」这种只对两位数成立的写法。这门课的第一个真实教训是:一个只在你想到的输入上正确的程序,不叫正确的程序。

第二样是高阶函数(higher-order function)。Phase 2 的七道题(Problem 6–12)几乎每一道都在做同一件事的不同变奏:函数是值。

  • 函数可以被当作参数传进去——play 收下两个策略函数和一个 update 函数;make_averaged 收下任意一个函数。
  • 函数可以被当作返回值造出来——always_roll(5) 现场生产一个策略函数,make_averaged(roll_dice, 40) 现场生产一个「平均版 roll_dice」。
  • 函数可以被当作数据去检验——is_always_roll 把一个策略当成研究对象,穷举它的所有可能输入,判断它是不是常函数。

把这两样放在一起,你就得到了这个项目真正的形状:先用普通的控制流把「一局游戏」写成一个函数 play,然后把这个函数当成一台实验设备,往里面塞不同的策略,用统计手段比较哪个策略更好。这是从「写代码」跨到「用代码做实验」的第一步。

本次要点
  • 非纯函数(non-pure function):dice() 每次调用返回值不同,调用它本身是一种副作用。谁调用、调用几次,都是有后果的。
  • 循环不变量:roll_dice 必须恰好调用 dice() num_rolls 次,这条约束决定了「先记账、后结算」的写法。
  • 用算术而非字符串取数位:n % 10 取个位,(n // 10) % 10 取十位。后者的 % 10 不是多余的。
  • 函数抽象:take_turn 调用 roll_dice 和 boar_brawl,sus_update 调用 take_turn 和 sus_points。规则改了只需要改一处。
  • 闭包(closure):always_roll(n) 返回的函数记得 n,因为它的父帧是 always_roll 的帧,而不是全局帧。
  • *args:让一个函数「原样转发」任意个参数给另一个函数。

做之前该掌握什么

你需要已经会在哪一讲本项目哪里用
def、参数、return、默认参数值Lecture 2每一道题;dice=six_sided 就是默认参数
if / while / 布尔运算 / assertLecture 3Problem 1–5、7、9
环境图、局部帧、父帧、名字查找顺序Lecture 3Problem 6、8 的闭包
函数作为参数与返回值、lambdaLecture 4Problem 5–12

如果你对「函数名和函数值的区别」还不踏实——即 six_sided 和 six_sided() 到底差在哪——那正好,Problem 0 就是专门为此设置的。

1. 热身:dice.py 与非纯函数(Problem 0)

题目要什么

Problem 0 不需要写任何代码,它只要求你解锁一组测试,确认你读懂了 dice.py。但它考的东西贯穿整个项目,跳过它你会在 Problem 1 和 Problem 9 上栽跟头。

dice.py 提供两类骰子。第一类是公平骰子(fair dice):

def make_fair_dice(sides):
    """Return a die that returns 1 to SIDES with equal chance."""
    assert type(sides) == int and sides >= 1, 'Illegal value for sides'
    def dice():
        return randint(1,sides)
    return dice

four_sided = make_fair_dice(4)
six_sided = make_fair_dice(6)

第二类是测试骰子(test dice),它不随机,而是按给定序列循环:

def make_test_dice(*outcomes):
    """Return a die that cycles deterministically through OUTCOMES."""
    assert len(outcomes) > 0, 'You must supply outcomes to make_test_dice'
    for o in outcomes:
        assert type(o) == int and o >= 1, 'Outcome is not a positive integer'
    index = len(outcomes) - 1
    def dice():
        nonlocal index
        index = (index + 1) % len(outcomes)
        return outcomes[index]
    return dice

官方测试里的第一组题是:

>>> test_dice = make_test_dice(4, 1, 2)
>>> test_dice()
4
>>> test_dice() # Second call
1
>>> test_dice() # Third call
2
>>> test_dice() # Fourth call
4

为什么是这个输出

关键在 index 这个名字。make_test_dice(4, 1, 2) 被调用时,会创建一个属于 make_test_dice 的局部帧,帧里有 outcomes = (4, 1, 2) 和 index = 2(即 len(outcomes) - 1)。函数 dice 在这个帧里被 def 出来,所以它的父帧是这个帧,不是全局帧。make_test_dice 返回之后,它的帧并不消失——dice 还指着它。

全局帧 Global
    make_test_dice  -> func make_test_dice(*outcomes)
    test_dice       -> func dice()   [parent = f1]

f1: make_test_dice   [parent = Global]
    outcomes = (4, 1, 2)
    index    = 2      <- 这个数字会被后续每次调用改写

每次调用 test_dice(),都会开一个新的 dice 帧,但 nonlocal index 声明使得帧里的赋值不是在 dice 帧里新建一个 index,而是回到 f1 去改那个已有的 index。

逐步推演
1 第一次调用:index = (2 + 1) % 3 = 0,f1 里的 index 变成 0,返回 outcomes[0] 即 4。
2 第二次调用:index = (0 + 1) % 3 = 1,返回 outcomes[1] 即 1。
3 第三次调用:index = (1 + 1) % 3 = 2,返回 outcomes[2] 即 2。
4 第四次调用:index = (2 + 1) % 3 = 0,绕回开头,返回 4。

为什么 index 的初值要写成 len(outcomes) - 1 而不是 0?因为自增写在返回之前。若初值为 0,第一次调用就会先变成 1,第一个返回的是 outcomes[1]——序列整体错位一格。把初值设成「最后一个下标」,加 1 取模后正好绕回 0,第一次调用返回第一个元素。这是一个很典型的「off-by-one 用初值补偿」技巧。

概念题:怎样才算「掷一次骰子」

解锁测试里的选择题(tests/00.py,答案已随本仓库解锁):

Question 0 · 概念题

问:Which of the following is the correct way to "roll" a fair, six-sided die?

选项:make_test_dice(6) / make_fair_dice(6) / six_sided / six_sided() / six_sided(1) / six_sided(6)

看答案

正确答案是 six_sided()。逐个排除:

  • make_test_dice(6):返回一个永远返回 6 的测试骰子函数,它本身不是一次投掷,而且也不公平。
  • make_fair_dice(6):返回一个新的六面骰函数。这是「造一个骰子」,不是「掷一次」。
  • six_sided:这只是那个函数对象本身。求值它得到 <function dice>,不会产生任何点数。
  • six_sided():调用它,得到 1–6 中的一个整数。这才是「掷一次」。
  • six_sided(1) / six_sided(6):dice 的形参列表是空的,传参会得到 TypeError: dice() takes 0 positional arguments but 1 was given。骰子函数的签名是零参数,「几面」这个信息在造它的时候就已经被闭包记住了。

为什么这件事重要

dice 是一个非纯函数(non-pure function):同样的调用表达式 dice(),前后两次的返回值不同。用课程的说法,调用它会产生副作用(side effect)——它改掉了 index,从而改变了「下一次调用会返回什么」。

注意

这条性质会在项目里反复咬人。因为对非纯函数来说,调用次数本身就是程序行为的一部分:

  • Problem 1 里,如果你在掷出 1 之后提前 return,剩下的 dice() 没被调用,测试骰子的指针就停在了错误的位置,下一轮的结果全错。tests/01.py 里有一条专门抓这个的用例。
  • Problem 9 里,如果你为每个 num_rolls 重新调用一次 make_averaged,或者多掷了几次骰子,指针位置一样会错位。tests/09.py 里那条注释写着「test that you are not rolling the dice more than necessary」的用例就是为此而设。

换句话说:在这个项目里,「多调用一次 dice()」不是无害的浪费,而是一个 bug。

还有一点值得先记下:make_fair_dice 与 make_test_dice 是这个项目里第一批「返回函数的函数」。你在 Problem 6 和 Problem 8 要亲手写的东西,形状和它们一模一样。项目开头这份「读别人写的高阶函数」,其实是在给结尾的「自己写高阶函数」做示范。

2. Problem 1:roll_dice

题目要什么

实现 roll_dice(num_rolls, dice=six_sided):掷 num_rolls 次骰子,返回这一回合得到的分数。规则只有一条例外——

Sow Sad. If any of the dice outcomes is a 1, the current player's score for the turn is 1, regardless of the other values rolled.

翻译成人话:把所有点数加起来;但只要出现过哪怕一个 1,整回合就只得 1 分,其他点数一概作废。

题面另外压了一条硬约束,它比规则本身更容易被忽略:

You should call dice() exactly num_rolls times in the body of roll_dice. Remember to call dice() exactly num_rolls times even if Sow Sad happens in the middle of rolling.

官方测试里有一条用例专门抓这一点,注释写得很直白:

>>> counted_dice = make_test_dice(4, 1, 2, 6)
>>> roll_dice(3, counted_dice)
1
>>> # Make sure you call dice exactly num_rolls times!
>>> # Note that a return statement within a loop ends the loop
>>> roll_dice(1, counted_dice)
6

边界情况清单:

情况应该返回为什么容易错
没有出现 1,例如 roll_dice(2, make_test_dice(4, 6, 1))10只掷了 2 次,序列里的那个 1 还没轮到
中途出现 1,例如 roll_dice(3, make_test_dice(4, 6, 1))1不是 4+6+1=11,也不是 4+6=10
第一次就出现 1,例如 roll_dice(2, make_test_dice(1))1剩下那次骰子仍然必须掷
骰子循环长度小于 num_rolls,例如 roll_dice(4, make_test_dice(2, 2, 3))9序列绕回来:2+2+3+2=9
num_rolls 为 0不会发生函数顶部 assert num_rolls > 0;0 由 take_turn 单独处理

怎么想到的

第一反应几乎所有人都一样:「边加边看,遇到 1 就直接返回 1」。写出来是这样:

# 这是错的,别抄
def roll_dice(num_rolls, dice=six_sided):
    total = 0
    k = 0
    while k < num_rolls:
        outcome = dice()
        if outcome == 1:
            return 1          # <-- 问题出在这里
        total = total + outcome
        k = k + 1
    return total

它对不对?就单次调用的返回值而言,完全正确。roll_dice(3, make_test_dice(4, 6, 1)) 照样返回 1。所以很多人写到这里跑了两个测试通过就走了,然后在上面那条 counted_dice 用例上翻车。

翻车的原因是 Problem 0 里说过的那件事:dice 是非纯函数,调用次数会改变世界的状态。return 1 会立刻结束整个函数,当然也就结束了循环——第 3 次的 dice() 根本没被调用。于是:

逐步推演:错误版本为什么会污染下一次调用

测试骰子的循环是 4, 1, 2, 6,指针初始停在最后一格。

1 roll_dice(3, counted_dice):第 1 次取到 4,第 2 次取到 1 —— 错误版本此时 return 1,第 3 次没掷。指针停在「1」这一格。
2 roll_dice(1, counted_dice):下一格是 2,于是返回 2。而正确答案是 6。
3 正确版本:第 1 步会把 4、1、2 三次全掷完,指针停在「2」这一格;第 2 步取到 6。✓

知道了病根,改法就清楚了:循环内不许 return,只许记账;结算放在循环之后。用一个布尔变量记住「这一回合到底有没有出现过 1」。

关键一步

把「一边扫描一边输出结论」改成「先扫完,再下结论」。这是循环题里非常常用的一次思路转向。它的代价是多一个变量,收益是循环的行为变得整齐——无论输入是什么,循环体都恰好执行 num_rolls 次,不多不少。

还有一个次要选择:既然一旦 Sow Sad 就返回 1,那 total 在这种情况下是多少根本无所谓。所以「发现 1 之后还继续累加」不是错误,只是没用。写成 total = total + outcome 不加判断,比写成「只在没 Sow Sad 时才累加」更短,也更少出错。

代码

def roll_dice(num_rolls, dice=six_sided):
    """Simulate rolling the DICE exactly NUM_ROLLS > 0 times. Return the sum of
    the outcomes unless any of the outcomes is 1. In that case, return 1.

    num_rolls:  The number of dice rolls that will be made.
    dice:       A function that simulates a single dice roll outcome. Defaults to the six sided dice.
    """
    # These assert statements ensure that num_rolls is a positive integer.
    assert type(num_rolls) == int, "num_rolls must be an integer."
    assert num_rolls > 0, "Must roll at least once."
    # BEGIN PROBLEM 1
    total = 0
    sow_sad = False  # True as soon as any roll comes up 1
    k = 0
    # Keep rolling even after a 1 appears, so the dice function is called
    # exactly num_rolls times (the test dice and the UI both rely on this).
    while k < num_rolls:
        outcome = dice()
        if outcome == 1:
            sow_sad = True
        total = total + outcome
        k = k + 1
    if sow_sad:
        return 1
    return total
    # END PROBLEM 1

逐行说明:

  • total = 0:累加器的初值必须是 0,因为 0 是加法的单位元——「还没掷任何骰子时的总分」就是 0。
  • sow_sad = False:这是一个标志变量(flag)。初值 False 表示「到目前为止还没见过 1」。它只会从 False 变成 True,绝不会反向变回去——这正是我们要的语义:只要出现过一次 1 就算数。
  • k = 0 配合 while k < num_rolls:这是最朴素的计数循环。用 k 从 0 数到 num_rolls - 1,循环体恰好执行 num_rolls 次。(Lecture 3 之后你会想用 for k in range(num_rolls),那也对;while 版本的好处是把「循环恰好跑几次」这件事摊开在眼前。)
  • outcome = dice():必须先存进变量再用。如果写成 if dice() == 1: ... total = total + dice(),那就调用了两次 dice(),一次掷骰变成了两次掷骰,整个测试全崩。这是本题第二常见的错误。
  • if outcome == 1: sow_sad = True:注意这里没有 else。不需要写 else: sow_sad = False——那会把之前记住的 1 抹掉,等于只看最后一次掷的结果。
  • total = total + outcome:无条件累加。Sow Sad 时这个和用不上,但也不碍事。
  • k = k + 1:循环变量的推进。忘了写这一行就是死循环,表现为运行 ok 时程序卡住不动。
  • 循环之后的 if sow_sad: return 1 / return total:唯一的两个出口,都在循环外面。这保证了「循环执行次数」与「返回什么值」这两件事被彻底解耦。

验证

手动追踪 roll_dice(4, make_test_dice(2, 2, 3)),doctest 期望 9。测试骰子按 2, 2, 3, 2, 2, 3, … 循环。

轮次 kdice() 返回outcome == 1?sow_sadtotal
进入循环前——False0
02否False2
12否False4
23否False7
32(绕回序列开头)否False9
4—k < 4 为假,退出循环

sow_sad 是 False,跳过第一个 return,执行 return total,得到 9。✓

再追一个带 Sow Sad 的:roll_dice(7, make_test_dice(2, 2, 2, 2, 2, 2, 1)),期望 1。前 6 轮各取到 2,total 累到 12,sow_sad 一直是 False;第 7 轮(k = 6)取到 1,sow_sad 变 True,total 变成 13。循环结束,sow_sad 为真,return 1。注意 total 最终是 13 这个值被完全丢弃了——这正是「先记账后结算」的样子。✓

最后验证那条最刁钻的连续调用用例,它同时检查返回值和骰子指针:

>>> dice = make_test_dice(5, 4, 3, 2, 1)
>>> roll_dice(1, dice)    # Roll 1 (5)
5
>>> roll_dice(4, dice)    # Reset (4, 3, 2, 1)
1
>>> roll_dice(2, dice)    # Roll 2 (5, 4)
9

第一次调用消耗掉 5;第二次调用消耗掉 4、3、2、1 —— 因为一次不落地掷完了 4 次,指针正好回到序列开头,第三次才能拿到 5 和 4 得到 9。如果第二次调用在遇到 1 时提前返回,那第二次只会消耗 4、3、2 三次(末尾的 1 还没掷到),第三次拿到的就是 1 和 5,返回 1 而不是 9。这条用例的设计非常精准:它用「下一次调用的返回值」来测量「上一次调用掷了几次骰子」。

常见误区
  • 在循环里 return 1:单看返回值全对,连续调用时全错。这是本题挂得最多的写法。
  • 把 dice 当值用:写成 outcome = dice(漏掉括号),后面 total + outcome 会报 TypeError: unsupported operand type(s) for +: 'int' and 'function'。
  • 一轮里调用两次 dice():比如 if dice() == 1 和 total += dice() 分开写。测试会给出各种莫名其妙的偏差值,很难看出病因。
  • 忘记 k = k + 1:ok 会挂在那里不返回。看到测试卡住超过几秒,先怀疑死循环。
  • 用 print 代替 return:tests/01.py 里专门有一条 a = roll_dice(4, make_test_dice(1, 2, 3)) 然后检查 a 的用例,注释就是「check that the value is being returned, not printed」。用 print 的话 a 会是 None。

3. Problem 2:boar_brawl

题目要什么

实现 boar_brawl(player_score, opponent_score),返回玩家选择「掷 0 个骰子」时按 Boar Brawl 规则得到的分数。

Boar Brawl. A player who chooses to roll zero dice scores three times the absolute difference between the tens digit of the opponent's score and the ones digit of the current player's score, or 1, whichever is greater.

把这句英文拆成四步,就是这道题的全部:

1 取对手分数的十位数字。
2 取自己分数的个位数字。
3 两者相减取绝对值,再乘 3。
4 与 1 取较大者("or 1, whichever is greater")。

注意第 1、2 步取的东西是交叉的:十位取对手的,个位取自己的。读题时看反了会得到一个到处差一点点的函数。题面还补了两条约束:

If a player's score is a single digit (less than 10), the tens digit of that player's score is 0.

Don't assume that scores are below 100. Write your boar_brawl function so that it works correctly for any non-negative score.

Important: Your implementation should not use str, lists, or contain square brackets [ ]. The test cases will check if those have been used.

最后这条不是建议,是会被机器检查的。tests/02.py 的最后一条用例长这样:

>>> boar_brawl(727, 939)
12
>>> # ban str and indexing (lists)
>>> test.check('hog.py', 'boar_brawl', ['Slice', 'List', 'ListComp', 'Index', 'Subscript', 'For'])
True

它会去解析你的源码语法树,一旦发现方括号取下标、列表、列表推导式或 for 循环就判失败。也就是说,「把数字转成字符串再取字符」这条路被彻底封死了。

怎么想到的

没学过位运算和取模技巧的人,第一反应通常是:

# 被明令禁止的写法
def boar_brawl(player_score, opponent_score):
    opponent_tens = int(str(opponent_score)[-2])   # 用了 str 和 [ ]
    ...

这个写法除了违规,本身还有两个 bug:分数是个位数(如 5)时 str(5)[-2] 直接 IndexError: string index out of range;而且它对负下标的理解要非常小心。既然被禁,就得回到算术。

取个位很容易,几乎人人都知道:n % 10。因为 n = 10 * q + r,余数 r 就是个位。

取十位呢?自然的想法是「先把个位切掉,再取新的个位」。「切掉个位」就是整除 10:n // 10。于是十位是 (n // 10) % 10。

但很多人会写成 n // 10 就收手。对不对?对两位数是对的,对三位数就错了。比如 n = 115:115 // 10 = 11,而十位应该是 1。缺的正是外面那层 % 10——它把「十位以上的所有位」再截掉。这就是题面反复强调「Don't assume that scores are below 100」的原因,官方测试也确实给了 boar_brawl(82, 115)、boar_brawl(99, 121)、boar_brawl(109, 99) 这三条专门打脸的用例。

直觉

把「取某一位数字」记成一个通用公式:第 k 位(个位记作 k = 0)等于 (n // 10**k) % 10。// 10**k 负责把这一位挪到个位上,% 10 负责把左边所有位削掉。个位是 (n // 1) % 10 = n % 10,十位是 (n // 10) % 10。两个操作缺一不可,缺了 % 就会带上高位的尾巴。

再看「个位数分数的十位是 0」这条特殊说明。需要写一个 if score < 10: tens = 0 吗?不需要——公式已经自动满足它了:(5 // 10) % 10 = 0 % 10 = 0。题面特意点出的「特殊情况」,在正确的实现下往往根本不特殊。这是判断自己写法是否自然的一个好信号:如果你为它写了 if,多半说明主路径的写法不够干净。

最后是「or 1, whichever is greater」。两种写法都行:

points = 3 * abs(opponent_tens - player_ones)
if points < 1:
    return 1
return points

或者直接用内置的 max。后者短且更贴近题面的措辞——题面说的就是「取较大者」。

代码

def boar_brawl(player_score, opponent_score):
    """Return the points scored when the current player rolls 0 dice according to Boar Brawl.

    player_score:     The total score of the current player.
    opponent_score:   The total score of the other player.

    """
    # BEGIN PROBLEM 2
    # Digits are extracted with arithmetic only (no str / indexing allowed).
    opponent_tens = (opponent_score // 10) % 10
    player_ones = player_score % 10
    return max(3 * abs(opponent_tens - player_ones), 1)
    # END PROBLEM 2

逐行说明:

  • opponent_tens = (opponent_score // 10) % 10:opponent 的十位。// 10 把个位丢掉,% 10 把百位及以上丢掉。两个括号不能省:opponent_score // 10 % 10 虽然因为 // 和 % 同优先级、从左到右结合而恰好等价,但写上括号能让「先整除后取余」的意图一眼可见。
  • player_ones = player_score % 10:player 的个位。这里不需要 //,因为个位本来就在最右边。
  • 把两个中间量各取一个名字,而不是塞进一个大表达式里,是有意为之:这两个名字直接对应题面里的 "the tens digit of the opponent's score" 和 "the ones digit of the current player's score"。名字是防止读反题目的最好办法。
  • abs(...):题面说的是 absolute difference。少了它,当自己的个位比对手的十位大时会算出负数,最后被 max(..., 1) 一律压成 1,症状是「有时候莫名其妙只得 1 分」。
  • 3 * ...:题面的 "three times"。
  • max(..., 1):实现 "or 1, whichever is greater"。它同时兜住了差为 0 的情况(题面 Example 2)。
  • 整个函数只有三行、没有循环、没有分支。当题面描述里有一堆「如果……那么……」而你的代码里一个 if 都不需要时,通常说明你找对了表达方式。

验证

逐条追踪官方用例,看每一步取到的数字:

调用对手十位自己个位3 * abs(差)max(·, 1)
boar_brawl(21, 46)(46//10)%10 = 421%10 = 13*3 = 99
boar_brawl(0, 0)0001
boar_brawl(5, 0)053*5 = 1515
boar_brawl(0, 5)(5//10)%10 = 0001
boar_brawl(72, 29)2201
boar_brawl(82, 115)(115//10)%10 = 11%10 = 123*1 = 33
boar_brawl(99, 121)(121//10)%10 = 12%10 = 293*7 = 2121
boar_brawl(109, 99)9109%10 = 901

特别看 boar_brawl(82, 115) 这一行:如果十位写成 opponent_score // 10,会得到 11,算出 3 * abs(11 - 2) = 27,而正确答案是 3。这一条用例的存在就是为了区分「写了外层 % 10」和「没写」的两种实现。

再看 boar_brawl(5, 0) 与 boar_brawl(0, 5) 这一对:参数只是交换了位置,结果一个是 15 一个是 1。如果你把十位和个位取反了,这两条用例的答案会正好互换——这是检查自己有没有读反题的最快办法。

常见误区
  • 十位漏掉外层 % 10:两位数全对,三位数全错。只跑前几条测试完全发现不了。
  • 把两个分数取反:算成「自己的十位」和「对手的个位」。boar_brawl(21, 46) 会得到 3*abs(2-6) = 12 而不是 9。
  • 忘了 abs:差为负时被 max 兜成 1。表现为「玩家分数高的时候 Boar Brawl 总是只给 1 分」。
  • 用 str 绕路:就算逻辑写对了,test.check 那一条会直接判失败,而且报错信息只说检测到了被禁的语法结构,不会告诉你哪里逻辑不对——很容易误以为是别的问题。
  • 把 max 写成 min:所有结果都变成 1(因为 3*abs(...) 只要不为 0 就 ≥ 3)。
核心结论

取数位不要碰字符串。n % 10 取个位、(n // 10) % 10 取十位、(n // 10**k) % 10 取第 k 位——这三个式子在后面的 lab 和考试里还会反复出现。

4. Problem 3:take_turn

题目要什么

实现 take_turn(num_rolls, player_score, opponent_score, dice=six_sided):返回玩家这一回合得到的分数(不是新的总分)。题面只有两句要求:

Implement the take_turn function, which returns the number of points scored for a turn by rolling the given dice num_rolls times.

Your implementation of take_turn should call both the roll_dice and boar_brawl functions rather than repeating their implementations.

逻辑其实前两题已经写完了,这道题要做的只是把两条规则接起来:掷 0 个骰子走 Boar Brawl,掷 1 个以上走 Sow Sad 求和。函数顶部的 assert 已经把合法范围划好了:

assert type(num_rolls) == int, "num_rolls must be an integer."
assert num_rolls >= 0, "Cannot roll a negative number of dice in take_turn."
assert num_rolls <= 10, "Cannot roll more than 10 dice."

对比 roll_dice 里的 assert num_rolls > 0:take_turn 允许 0,roll_dice 不允许。这个差别不是随意的,它正是本题的全部内容——0 这个值必须在到达 roll_dice 之前就被拦下来,否则会撞上断言错误 AssertionError: Must roll at least once.。

边界情况:

调用期望走哪条路
take_turn(2, 7, 27, make_test_dice(4, 5, 1))9掷 2 次得 4+5=9,没出现 1
take_turn(3, 15, 9, make_test_dice(4, 6, 1))1Sow Sad
take_turn(0, 12, 41)6Boar Brawl,一次骰子都不掷
take_turn(0, 37, 15)18Boar Brawl

注意后两条根本没传 dice 参数——因为掷 0 个骰子时压根不需要骰子。这也从侧面说明了:Boar Brawl 分支里绝对不能调用 dice()。

怎么想到的

这道题的「想」不在算法,在抽象的边界该划在哪。

一个很自然但错误的冲动是:既然 roll_dice 已经会掷骰子了,那 take_turn 干脆把 Boar Brawl 的算式也抄进来算了,反正就三行。为什么不行?

  • 规则一改就要改两处。假如助教把 Boar Brawl 从「乘 3」改成「乘 4」,你得同时改 boar_brawl 和 take_turn,漏一处就是 bug。
  • Problem 10 会直接调用 boar_brawl。如果逻辑被复制到两个地方,两处将来一定会不一致。
  • 官方会检查你有没有真的调用 roll_dice。tests/03.py 的第二个 suite 玩了一个很有意思的把戏:
>>> import hog
>>> def roll_dice(num_rolls, dice):
...     print("Called roll dice!")
...     return 9002
...
>>> hog.roll_dice, old_roll_dice = roll_dice, hog.roll_dice
>>> hog.take_turn(5, 0, 0) # Make sure you call roll_dice!
Called roll dice!
9002

它把 hog 模块里的 roll_dice 这个名字重新绑定到一个假函数上,然后调用 take_turn。如果你的 take_turn 真的是通过 roll_dice 这个名字去查找函数的,它就会调到假货,打印出 "Called roll dice!" 并返回 9002。如果你把求和逻辑抄进了 take_turn,这条测试会失败。

核心结论

这条测试顺带演示了 Python 的一个根本事实:take_turn 内部的 roll_dice(...) 不是「指向那个函数对象的硬链接」,而是「运行时去全局帧查名字 roll_dice」。名字在调用发生的那一刻才被解析,所以中途换掉全局帧里的绑定,函数的行为就跟着变。这就是 Lecture 3 讲的名字查找规则在起作用——也是为什么改一个全局函数会影响所有调用它的地方。

确定了要调用两个已有函数之后,剩下的只是选择分支写法。两种都可以:

# 写法 A:早返回
if num_rolls == 0:
    return boar_brawl(player_score, opponent_score)
return roll_dice(num_rolls, dice)

# 写法 B:if/else 对称
if num_rolls == 0:
    return boar_brawl(player_score, opponent_score)
else:
    return roll_dice(num_rolls, dice)

两者行为完全一致。写法 A 少一层缩进,且强调了「0 是需要特殊处理的例外情况,处理掉就走主路径」这个含义。

代码

def take_turn(num_rolls, player_score, opponent_score, dice=six_sided):
    """Return the points scored on a turn rolling NUM_ROLLS dice when the
    current player has PLAYER_SCORE points and the opponent has OPPONENT_SCORE points.

    num_rolls:       The number of dice rolls that will be made.
    player_score:    The total score of the current player.
    opponent_score:  The total score of the other player.
    dice:            A function that simulates a single dice roll outcome.
    """
    # Leave these assert statements here; they help check for errors.
    assert type(num_rolls) == int, "num_rolls must be an integer."
    assert num_rolls >= 0, "Cannot roll a negative number of dice in take_turn."
    assert num_rolls <= 10, "Cannot roll more than 10 dice."
    # BEGIN PROBLEM 3
    if num_rolls == 0:
        return boar_brawl(player_score, opponent_score)
    return roll_dice(num_rolls, dice)
    # END PROBLEM 3

逐行说明:

  • if num_rolls == 0:唯一的判断。用 == 0 而不是 < 1 或 not num_rolls——前者精确表达题意,后者虽然等价但把「数值 0」和「假值」混为一谈,可读性差。
  • return boar_brawl(player_score, opponent_score):参数顺序是「先自己后对手」,和 boar_brawl 的形参顺序一致。这里传反了不会报错——两个参数都是整数,Python 照单全收——只会算出错误的分数。take_turn(0, 12, 41) 传反后会得到 3*abs(1-1) = 0 → max(0,1) = 1 而不是 6。
  • return roll_dice(num_rolls, dice):把 dice 原样往下传。这一步很容易漏:如果写成 return roll_dice(num_rolls),那么 roll_dice 会用自己的默认参数 six_sided,测试传进来的测试骰子被无声地忽略掉,结果变成真随机——症状是「测试结果每次都不一样,而且都不对」。
  • 返回的是本回合得分,不是总分。simple_update 里那句 score = player_score + take_turn(...) 说明了这个约定:加总分是 update 函数的责任,不是 take_turn 的。

验证

追踪 take_turn(0, 37, 15),doctest 期望 18。

逐步推演
1 三条 assert 依次通过:0 是 int,0 >= 0,0 <= 10。
2 num_rolls == 0 为真,进入 Boar Brawl 分支。dice 参数从头到尾没被碰过——调用时也没传,用的是默认值 six_sided,但因为不会被调用,随机数生成器根本没动。
3 调用 boar_brawl(37, 15):对手十位 (15 // 10) % 10 = 1,自己个位 37 % 10 = 7。
4 3 * abs(1 - 7) = 3 * 6 = 18,max(18, 1) = 18。
5 take_turn 把这个 18 原样 return 出去。✓

再追一个走另一条路的:take_turn(2, 7, 27, make_test_dice(4, 5, 1)),期望 9。num_rolls 是 2,不等于 0,于是调用 roll_dice(2, dice)。roll_dice 里循环两次,取到 4 和 5,sow_sad 保持 False,返回 9。注意 player_score = 7 和 opponent_score = 27 这两个参数在这条路径上完全没被用到——掷骰子时双方的分数是无关的。这解释了一个初学者常有的困惑:「为什么 take_turn 要收下两个用不上的参数?」因为它可能用得上(num_rolls == 0 时就用上了),函数签名必须覆盖所有分支的需要。

还可以观察一个有意思的现象:take_turn(0, ...) 的返回值是确定的,完全由两个分数决定,没有任何随机性。这一点会在 Problem 10 和 11 里被策略函数利用——策略可以在真正掷骰之前,先算出「如果掷 0 个能得多少分」,再决定要不要这么做。掷骰子的结果没法预知,但 Boar Brawl 的结果可以。

常见误区
  • 忘了传 dice:roll_dice(num_rolls) 会静默改用真随机六面骰。测试报错信息只会说期望 9 得到 14,看不出是参数漏传。
  • 把 boar_brawl 的两个参数写反:不报错,只是分数错。上面已经给了具体数字。
  • 把 Boar Brawl 的算式抄进来:tests/03.py 的第二个 suite 会因为你没调用 roll_dice 而失败;同样的道理,重复实现 boar_brawl 也违反题面明确的要求。
  • 没有拦住 0 就调 roll_dice:报 AssertionError: Must roll at least once.。这个报错很友好,一看就知道 0 没被处理。
  • 返回总分而不是回合得分:写成 return player_score + roll_dice(...)。take_turn 的测试会立刻挂掉,而且后面 simple_update 会再加一次分数,变成双重计分。

5. Problem 4:num_factors、sus_points 与 sus_update

题目要什么

这一题一次要写三个函数,它们层层叠上去:

函数输入 → 输出依赖谁
num_factors(n)正整数 → 它的因数个数(含 1 和 n 本身)无
sus_points(score)分数 → 应用 Sus Fuss 规则后的分数num_factors、is_prime
sus_update(num_rolls, player_score, opponent_score, dice)一回合的全部信息 → 回合结束后的总分take_turn、sus_points

规则是:

Sus Fuss. We call a number sus if it has exactly 3 or 4 factors, including 1 and the number itself. If, after rolling, the current player's score is a sus number, their score instantly increases to the closest prime number greater than their current score.

三个要点必须抠准:

1 「恰好 3 个或 4 个因数」——不是 3 个以上,也不是「3 或 4 的倍数」。5 个因数不算,2 个(质数)也不算。
2 触发后跳到「严格大于当前分数的最小质数」。是严格大于,不是「大于等于」。
3 判定发生在加完这回合得分之后,判定对象是总分,不是回合得分。

题面给的三个例子正好覆盖了「触发 / 不触发(因数太多)/ 不触发(因数太少)」:

新总分因数个数sus?结果
211, 3, 7, 214是跳到 23(22 不是质数,23 是)
641, 2, 4, 8, 16, 32, 647否保持 64
671, 672否保持 67

官方另外给的关键用例:sus_points(1) 返回 1(1 只有一个因数)、sus_points(100) 返回 100(100 有 9 个因数)、sus_points(86) 返回 89(86 = 1·2·43·86 共 4 个因数,往上找质数:87 = 3×29 不是,88 不是,89 是)。

怎么想到的

num_factors:把定义直接翻译成循环

「n 有几个因数」这个问题,定义本身就是算法:从 1 数到 n,每个数试一下能不能整除 n,能就计数加一。没有任何技巧。

确实有更快的写法——只需要枚举到 sqrt(n),每找到一个因数 k 就同时记下 n // k。但这需要处理「完全平方数时 k 和 n // k 重复」的边界,而项目里 n 最多也就一两百,朴素写法完全够用。在还没被效率逼到墙角之前,选那个「一眼就能看出正确」的写法。(真正需要关心增长阶的时候会到来——那是 HW 5 的内容。)

唯一要小心的是循环边界:必须写 while k <= n,不是 k < n。因为 n 本身也是自己的因数。写成 < 会让每个数的因数个数都少 1,于是「4 个因数」的数被误判成「3 个」——恰好还是落在 sus 范围里,所以 sus_points 可能碰巧还对,但 num_factors(1) 会返回 0 而不是 1,num_factors(2) 返回 1 而不是 2。官方专门给了 num_factors(1) 这条用例来抓它。

sus_points:先判定,再往上找

拆成两个独立的问题:

1 判定:num_factors(score) 是不是 3 或 4?
2 寻找:从 score + 1 开始逐个往上试,第一个满足 is_prime 的就是答案。

第 2 步为什么从 score + 1 而不是 score 开始?因为题面说 "the closest prime number greater than their current score"。这里有个微妙之处值得想一想:如果从 score 开始会怎样?会不会出现「score 本身就是质数,于是原地不动」的情况?不会——因为能进到这个分支的数一定有 3 或 4 个因数,而质数只有 2 个因数,二者互斥。所以即使写成从 score 开始,结果也碰巧一样。但这属于「碰巧对」,不是「按题意对」,还是照题面写 score + 1 更稳妥。

这个「往上找」的循环会不会永远找不到而死循环?不会——质数有无穷多个,欧几里得两千年前就证明了。任何有限的起点往上走,总会撞到质数。

关键一步

while not is_prime(next_score): next_score = next_score + 1 这个模式叫做线性搜索(linear search):从一个起点开始逐个试,遇到第一个满足条件的就停。它的通用形状是「while 条件不满足:往前挪一格」,循环退出时 next_score 恰好停在第一个满足条件的位置上。注意条件里的 not——循环的意思是「只要还不是质数就继续找」。

sus_update:抄 simple_update 再加一层

题面直接给了提示:

You can look at the implementation of simple_update provided in hog.py and use that as a starting point for your sus_update function.

Recall that take_turn already took the Boar Brawl rule into consideration!

simple_update 是现成的:

def simple_update(num_rolls, player_score, opponent_score, dice=six_sided):
    """Return the total score of a player who starts their turn with
    PLAYER_SCORE and then rolls NUM_ROLLS DICE, ignoring Sus Fuss.
    """
    score = player_score + take_turn(num_rolls, player_score, opponent_score, dice)
    return score

那 sus_update 就是同一句话,只是最后 return sus_points(score) 而不是 return score。第二条提示则提醒你不要重复劳动:Sow Sad 和 Boar Brawl 已经在 take_turn 里处理完了,sus_update 只需要在上面加 Sus Fuss 这最后一层。

顺序绝对不能颠倒。必须是「先算完这回合的总分,再对总分做 Sus Fuss 判定」。如果你写成 player_score + sus_points(take_turn(...)),那就是拿回合得分去判 sus,完全是另一个游戏。举例:sus_update(0, 15, 37) 的回合得分是 6,而 6 的因数是 1、2、3、6 共 4 个,sus_points(6) 会把它顶到 7,最后算出 15 + 7 = 22,而正确答案是 23。这种错误产生的数字看起来「差不多对」,最难查。

代码

先看题目提供的、不用改的 is_prime:

def is_prime(n):
    """Return whether N is prime."""
    if n == 1:
        return False
    k = 2
    while k < n:
        if n % k == 0:
            return False
        k += 1
    return True

它单独把 1 挑出来判 False(1 不是质数),然后用 2 到 n−1 逐个试除。注意 n = 2 时循环一次都不执行,直接返回 True——这正好是对的。

我们要写的三个函数:

def num_factors(n):
    """Return the number of factors of N, including 1 and N itself."""
    # BEGIN PROBLEM 4
    count = 0
    k = 1
    while k <= n:
        if n % k == 0:
            count = count + 1
        k = k + 1
    return count
    # END PROBLEM 4


def sus_points(score):
    """Return the new score of a player taking into account the Sus Fuss rule."""
    # BEGIN PROBLEM 4
    # A score is "sus" when it has exactly 3 or 4 factors.
    factors = num_factors(score)
    if factors == 3 or factors == 4:
        # Jump up to the smallest prime strictly greater than the score.
        next_score = score + 1
        while not is_prime(next_score):
            next_score = next_score + 1
        return next_score
    return score
    # END PROBLEM 4


def sus_update(num_rolls, player_score, opponent_score, dice=six_sided):
    """Return the total score of a player who starts their turn with
    PLAYER_SCORE and then rolls NUM_ROLLS DICE, *including* Sus Fuss.
    """
    # BEGIN PROBLEM 4
    # take_turn already handles Sow Sad and Boar Brawl; Sus Fuss applies after.
    score = player_score + take_turn(num_rolls, player_score, opponent_score, dice)
    return sus_points(score)
    # END PROBLEM 4

逐行说明:

  • num_factors 里 k 从 1 起(不是 2),因为 1 是因数;while k <= n 用 <=(不是 <),因为 n 也是因数。这两个边界都写对,num_factors(1) 才会返回 1。
  • if n % k == 0:整除判定。n % k 是余数,为 0 就说明 k 整除 n。
  • factors = num_factors(score):只调用一次并存起来。如果写成 if num_factors(score) == 3 or num_factors(score) == 4,同样的循环会被跑两遍。这在这道题里只是浪费,但养成「结果存变量」的习惯,在 Problem 11 里会救你一命——那里被调用的函数是有副作用的。
  • factors == 3 or factors == 4:不能写成 3 <= factors <= 4 之外的任何近似形式。特别不能写成 factors == 3 or 4——这在 Python 里合法但意思完全不同:or 的左边 factors == 3 若为假,整个表达式的值就是 4,而 4 是真值,于是所有分数都会被判成 sus。这是 Python 初学者最经典的陷阱之一,而且不报错。
  • next_score = score + 1:搜索起点,严格大于当前分数。
  • while not is_prime(next_score):只要还不是质数就继续加 1。退出时 next_score 就是答案。
  • return next_score 在 if 内部,return score 在外部:不是 sus 的分数原样返回。题面强调「even if the score remains unchanged」——这个函数总要返回一个分数,不能在非 sus 时什么都不返回(那会隐式返回 None,后面 None < goal 会报 TypeError)。
  • sus_update 里 score = player_score + take_turn(...):先得到新总分。
  • return sus_points(score):再对新总分应用 Sus Fuss。这两步的顺序就是这道题的全部。

验证

追踪官方用例 sus_update(0, 15, 37),期望 23;同一组还给了 simple_update(0, 15, 37) 期望 21,正好可以对照。

逐步推演
1 take_turn(0, 15, 37, six_sided):num_rolls 是 0,走 Boar Brawl。对手十位 (37 // 10) % 10 = 3,自己个位 15 % 10 = 5,得 max(3 * abs(3 - 5), 1) = max(6, 1) = 6。
2 score = 15 + 6 = 21。这就是 simple_update 的答案 21。✓
3 sus_points(21):先算 num_factors(21)。k 从 1 扫到 21,能整除的有 1、3、7、21,count = 4。
4 factors == 4 成立,进入搜索。next_score = 22:is_prime(22) 中 k = 2 时 22 % 2 == 0,返回 False,所以循环继续。
5 next_score = 23:is_prime(23) 中 k 从 2 试到 22,没有一个能整除 23,返回 True。not True 为假,循环结束。
6 返回 23。✓

再追一个不触发的:sus_update(2, 5, 7, make_test_dice(2, 4)),期望 11。take_turn(2, 5, 7, dice) → roll_dice(2, dice) 取到 2 和 4,和为 6。score = 5 + 6 = 11。num_factors(11):11 是质数,因数只有 1 和 11,共 2 个。2 不在 {3, 4} 里,sus_points 直接 return score。答案 11,和 simple_update 一致。✓

还有一条很有教育意义的:sus_points(64) 期望 64。64 = 2⁶,因数是 1、2、4、8、16、32、64 共 7 个。7 不是 3 也不是 4,不触发。这条用例专治「以为因数多就一定 sus」的误解——sus 的定义是恰好 3 或 4 个,是一个很窄的区间。

直觉:什么样的数是 sus 的

恰好 3 个因数的数一定是质数的平方,100 以内只有 4、9、25、49 四个。恰好 4 个因数的数要么是两个不同质数的乘积(6、10、14、15、21、22、26、33…),要么是质数的立方(8 = 1·2·4·8,27 = 1·3·9·27),100 以内有 32 个。两类合起来,100 以内共 36 个 sus 数——超过三分之一的分数落在这个集合里,游戏中被触发的机会相当大。这正是 Problem 11 的策略能挖到便宜的原因。

常见误区
  • num_factors 的循环写成 k < n:每个数少数一个因数。num_factors(1) 返回 0,官方第一条用例就挂。
  • k 从 2 开始:漏掉因数 1,同样每个数少一个。
  • 写成 factors == 3 or 4:永远为真,所有分数都被当成 sus。不报错,只是分数全乱。
  • 顺序颠倒:对回合得分而不是总分调用 sus_points。sus_update(0, 15, 37) 会返回 22。
  • sus_update 里重新实现了 Boar Brawl:题面明确要求复用 take_turn。
  • sus_points 非 sus 分支忘了 return:返回 None。后续在 play 里做 score0 < goal 比较时报 TypeError: '<' not supported between instances of 'NoneType' and 'int'。

6. Problem 5:play

题目要什么

把前面四道题拼成一局完整的游戏。play 的签名很长,但每个参数的含义都很干净:

def play(strategy0, strategy1, update, score0=0, score1=0, dice=six_sided, goal=GOAL):
参数是什么怎么用
strategy0 / strategy1函数:收下(自己分数, 对手分数),返回要掷几个骰子每回合调用一次,只调当前玩家的那个
update函数:收下 (num_rolls, 自己分数, 对手分数, dice),返回回合后的新总分每回合调用一次,两个玩家共用同一个
score0 / score1两位玩家的起始分循环里被不断改写
dice骰子函数原样传给 update
goal获胜分数线,默认 100循环的终止条件

行为要求:两位玩家从 Player 0 开始轮流行动,直到某一方的分数达到或超过 goal,然后返回 (score0, score1) 这个二元组。题面还压了一条硬要求:

Important: For the user interface to work, a strategy function should be called only once per turn. Only call strategy0 when it is Player 0's turn and only call strategy1 when it is Player 1's turn.

这条要求和 Problem 1 的「恰好掷 num_rolls 次」是同一类约束:策略函数可能是非纯的(hog_ui.py 里的 printing_strategy 会打印,interactive_strategy 会等用户输入),多调一次就多打印一行、多要一次输入。

四道概念题

tests/05.py 的第一个 suite 是四道选择题。它们不是走过场——每一道都对应一个具体的实现陷阱。

概念题 1 · 循环该在什么条件下继续

问:The variables score0 and score1 are the scores for Player 0 and Player 1, respectively. Under what conditions should the game continue?

选项:(A)While score0 and score1 are both less than goal (B)While at least one of score0 or score1 is less than goal (C)While score0 is less than goal (D)While score1 is less than goal

看答案

答案是 (A),写成代码是 while score0 < goal and score1 < goal:。

为什么不是(B)?把「游戏结束」的条件写清楚:只要有一个人到线,游戏就结束,即 score0 >= goal or score1 >= goal。循环继续的条件是它的否定。根据德摩根定律(De Morgan's law),not (A or B) 等于 (not A) and (not B),于是继续条件是 score0 < goal and score1 < goal——or 取反变成了 and。

选(B)的后果是游戏在一方到线后还会继续跑,直到两个人都到线,返回的分数会明显偏大。选(C)(D)则是只看一个人,另一个人再高分也不算赢。

概念题 2 · 什么是 strategy

问:What is a strategy in the context of this game?

选项:(A)The number of dice a player will roll (B)A function that returns the number of dice a player will roll (C)A player's desired turn outcome

看答案

答案是 (B):一个函数,不是一个数字。

这个区分是整个 Phase 2 的地基。如果 strategy 只是个数字,那玩家每回合都掷一样多的骰子,游戏毫无策略可言。正因为它是函数,它才能「看着当前比分做决定」——catch_up 落后时多掷一个,boar_strategy 在 Boar Brawl 划算时掷 0 个。

落到代码上,这意味着 play 里必须写 strategy0(score0, score1) 而不是直接用 strategy0。少写一对括号,num_rolls 就会是一个函数对象,接着在 take_turn 顶部撞上 AssertionError: num_rolls must be an integer.。

概念题 3 · 参数顺序

问:If strategy1 is Player 1's strategy function, score0 is Player 0's current score, and score1 is Player 1's current score, then which of the following demonstrates correct usage of strategy1?

选项:(A)strategy1(score1, score0) (B)strategy1(score0, score1) (C)strategy1(score1) (D)strategy1(score0)

看答案

答案是 (A)strategy1(score1, score0)。

策略函数的形参是 (score, opponent_score)——永远是「我的分数在前,对手的分数在后」,它不知道自己被谁使用。所以对 Player 1 来说,「我的」是 score1,「对手的」是 score0,顺序必须反过来写。

这一点是 play 里最容易写错的地方,而且写错了不会报错,只会让 Player 1 的策略基于错误的信息做决定。同样的道理也适用于 update:Player 1 的回合要写 update(num_rolls, score1, score0, dice)。

这种「视角切换」是一个值得单独记住的设计模式:写一个函数时,让它只从「当前主角」的视角看世界,由调用方负责把参数摆成主角的视角。这样一份代码就能同时服务两个玩家。

概念题 4 · 一个 lambda 求值

问:Player 0 has a score of 55, Player 1 has a score of 22, and Player 0's strategy is given by lambda x, y: ((y % 10) * (x % 10)) % 10. How many dice will Player 0 roll on their turn?

选项:0 / 1 / 5 / 10

看答案

答案是 0。这道题在考两件事:参数怎么绑定,以及表达式怎么一步步求值。

Player 0 的回合,调用形式是 strategy0(score0, score1),即 strategy0(55, 22)。所以 x 绑定 55(自己的分数),y 绑定 22(对手的分数)。

逐步推演
((y % 10) * (x % 10)) % 10
= ((22 % 10) * (55 % 10)) % 10
= (2 * 5) % 10
= 10 % 10
= 0

顺带一个提醒:这个策略返回 0,意味着这一回合走 Boar Brawl。而 take_turn 的 assert 只允许 0 到 10,所以任何策略都必须把返回值控制在这个范围内——这也是 Problem 12 会被单独检查的原因。

怎么想到的

先把要素列清楚,会发现 play 需要维护三样状态:两个分数,加上「现在轮到谁」。前两个已经是参数了,第三个需要一个新变量——起手代码里已经给好了:

who = 0  # Who is about to take a turn, 0 (first) or 1 (second)

然后是循环骨架。循环条件由概念题 1 定下:while score0 < goal and score1 < goal。循环体做四件事:

1 问当前玩家的策略要掷几个骰子(只问一次)。
2 调 update 算出他的新总分。
3 把新总分写回对应的变量。
4 换人。

第 4 步的换人,题面给了个漂亮的提示:who = 1 - who。因为 1 - 0 = 1,1 - 1 = 0,一个算术式就实现了在两个值之间来回切换,比 if who == 0: who = 1 else: who = 0 短得多。这个技巧在任何「两方轮流」的场景里都能用。

剩下的问题是:怎么处理「当前玩家是谁」带来的分支?两个玩家的代码结构完全一样,只是变量名和参数顺序对调。有人会想写得更「聪明」,比如把两个分数放进一个列表用 who 做下标——但这道题在课程的第 4 讲,列表还没教,而且那样做反而会让参数顺序更难看清。老老实实写一个 if/else,两个分支各四行,是最好的选择。

注意:一个很有诱惑力的错误写法

为了避免重复,有人会写成这样:

# 错的
num_rolls0 = strategy0(score0, score1)
num_rolls1 = strategy1(score1, score0)
if who == 0:
    score0 = update(num_rolls0, score0, score1, dice)
else:
    score1 = update(num_rolls1, score1, score0, dice)

它「看起来」只是提前算了一下,实际上每回合把两个策略都调用了一次,直接违反题面的 "a strategy function should be called only once per turn"。跑 hog_ui.py 时你会看到每回合打印两行「rolls N dice...」,跑交互模式时会要你输入两次。策略函数是非纯的,「提前算好备用」这种对纯函数无害的优化,在这里是错的。

代码

def play(strategy0, strategy1, update, score0=0, score1=0, dice=six_sided, goal=GOAL):
    """Simulate a game and return the final scores of both players, with
    Player 0's score first and Player 1's score second.

    E.g., play(always_roll_5, always_roll_5, sus_update) simulates a game in
    which both players always choose to roll 5 dice on every turn and the Sus
    Fuss rule is in effect.

    A strategy function, such as always_roll_5, takes the current player's
    score and their opponent's score and returns the number of dice the current
    player chooses to roll.

    An update function, such as sus_update or simple_update, takes the number
    of dice to roll, the current player's score, the opponent's score, and the
    dice function used to simulate rolling dice. It returns the updated score
    of the current player after they take their turn.

    strategy0: The strategy for player0.
    strategy1: The strategy for player1.
    update:    The update function (used for both players).
    score0:    Starting score for Player 0
    score1:    Starting score for Player 1
    dice:      A function of zero arguments that simulates a dice roll.
    goal:      The game ends and someone wins when this score is reached.
    """
    who = 0  # Who is about to take a turn, 0 (first) or 1 (second)
    # BEGIN PROBLEM 5
    while score0 < goal and score1 < goal:
        if who == 0:
            # Ask the current player's strategy exactly once per turn.
            num_rolls = strategy0(score0, score1)
            score0 = update(num_rolls, score0, score1, dice)
        else:
            num_rolls = strategy1(score1, score0)
            score1 = update(num_rolls, score1, score0, dice)
        who = 1 - who  # Hand the turn to the other player.
    # END PROBLEM 5
    return score0, score1

逐行说明:

  • while score0 < goal and score1 < goal:and 而不是 or(见概念题 1)。用 < goal 而不是 <= goal,因为题面说的是「达到或超过 goal 就结束」,所以「继续」的条件是严格小于。
  • if who == 0: / else::两个对称的分支。who 只可能是 0 或 1,所以 else 就是 Player 1。
  • num_rolls = strategy0(score0, score1):Player 0 视角,自己在前。结果存进变量再用——这不只是风格问题,它保证了策略函数恰好被调用一次。如果写成 score0 = update(strategy0(score0, score1), score0, score1, dice),虽然也只调了一次,但可读性差,而且很容易在改代码时不小心变成两次。
  • score0 = update(num_rolls, score0, score1, dice):赋值给 score0。update 返回的是新总分(不是增量),所以是直接覆盖,不是 score0 += ...。写成 += 会让分数以平方级别爆炸。
  • Player 1 分支的两行,两处参数顺序都要反过来:strategy1(score1, score0) 和 update(num_rolls, score1, score0, dice)。前者见概念题 3;后者同理,update 的第二个形参叫 player_score,对 Player 1 来说就是 score1。
  • dice 必须原样传下去。漏了会静默改用默认的真随机六面骰,所有确定性测试全挂。
  • who = 1 - who 放在 if/else 外面,缩进与 if 齐平。放进任一分支里就只有一个玩家会换手,导致同一个人一直行动。
  • return score0, score1 在循环外面。放进循环里会让游戏只跑一回合就返回。返回的是一个元组(tuple),Python 的 a, b = play(...) 可以直接拆包。

验证

手动跑一局。为了能笔算,用固定骰子:play(always_roll_5, always_roll_5, simple_update, dice=make_test_dice(3), goal=20)。这个骰子永远返回 3,两个玩家都固定掷 5 个。

回合who循环条件策略返回update 算什么回合末 (score0, score1)
—00<20 and 0<20 ✓——(0, 0)
10—strategy0(0, 0) = 50 + roll_dice(5) = 0 + 15(15, 0)
2115<20 and 0<20 ✓strategy1(0, 15) = 50 + 15(15, 15)
3015<20 and 15<20 ✓strategy0(15, 15) = 515 + 15 = 30(30, 15)
—130<20 ✗ 退出——返回 (30, 15)

实际运行 python3 -i hog.py 验证,输出正是 (30, 15)。注意第 3 回合结束时 who 仍然会被翻成 1,但循环条件已经不成立,Player 1 没有「补一回合」的机会——先到线者立刻终止游戏,这正是题面要的行为。

把 update 换成 sus_update,同样的骰子和 goal,结果会变成 (32, 17)。为什么?

逐步推演:Sus Fuss 怎么改变结局
1 回合 1:Player 0 得 15 分。num_factors(15):因数 1、3、5、15,共 4 个 → sus!往上找质数:16、17,is_prime(17) 为真 → score0 = 17。
2 回合 2:Player 1 同理得 15 → 也被顶到 17。分数变成 (17, 17)。
3 回合 3:Player 0 得 17 + 15 = 32。num_factors(32):1、2、4、8、16、32,共 6 个 → 不是 sus,保持 32。
4 32 >= 20,循环退出,返回 (32, 17)。✓

这个对照很能说明 play 的设计价值:游戏主循环一个字都没改,只是换了一个传进来的 update 函数,规则集就整个变了。这就是把函数当参数的威力——play 不知道也不需要知道 Sus Fuss 是什么,它只负责「轮流、更新、判胜负」这个骨架。同一个 play 之后还会被 hog_ui.py 拿去做打印版、被 hog_gui.py 拿去做图形版、被 winner 拿去做策略对战实验。

常见误区
  • 循环条件用 or:游戏不肯结束,直到两人都到线。
  • Player 1 分支参数没反过来:不报错,但 Player 1 的策略拿到的是对手的分数。用 catch_up 这种「看比分做决定」的策略才会暴露。
  • who = 1 - who 写进了 if 分支里:只有一方换手,游戏变成一个人独角戏,或者死循环。
  • 用 score0 += update(...):update 返回的已经是总分,再加一次等于把分数翻倍再累加。
  • 每回合调用两个策略:见上面的「注意」框。ok 的自动测试用 describe_game 追踪每一步的骰子序列,多调一次策略会导致骰子指针错位,整局的数字全对不上。
  • return 写在循环里:只跑一回合就返回,返回值大概率是 (15, 0) 这种一方还是 0 的分数。

7. Problem 6:always_roll

题目要什么

Implement always_roll, a higher-order function that takes a number of dice n and returns a strategy function that always rolls n dice. Thus, always_roll(5) would be equivalent to always_roll_5.

注意这句话里出现了两个函数:always_roll 本身,和它造出来的那个策略函数。docstring 的 doctest 把这个两步过程写得很清楚:

>>> strategy = always_roll(3)
>>> strategy(0, 0)
3
>>> strategy(99, 99)
3

第一行调用 always_roll,得到的不是数字,是函数;第二、三行才是调用那个函数。测试里也有紧凑写法 always_roll(3)(10, 20)——两对括号,两次调用,从左到右依次发生。

参照物已经写在 hog.py 里:

def always_roll_5(score, opponent_score):
    """A strategy of always rolling 5 dice, regardless of the player's score or
    the opponent's score.
    """
    return 5

我们要做的,就是把这个函数里写死的 5 变成可配置的 n——但不是通过给它加一个参数(那会破坏策略函数「必须恰好收两个参数」的约定),而是通过造一个工厂。

怎么想到的

这里的思维卡点非常具体:策略函数的签名被 play 锁死了。play 里写的是 strategy0(score0, score1),两个参数,不多不少。所以「掷几个」这个信息不可能作为第三个参数传进来。

那 n 从哪来?三个候选答案,前两个都不行:

方案写法为什么不行 / 行
加第三个参数def strategy(score, opp, n)play 只传两个参数,调用时报 TypeError: strategy() missing 1 required positional argument
用全局变量N = 5 然后 return N全局只有一个,没法同时存在 always_roll(3) 和 always_roll(6) 两个不同的策略
用闭包在 always_roll 内部 def strategy,直接引用 n✓ 每次调用 always_roll 都创建一个新的帧,各自记住各自的 n

第三个方案就是 Lecture 3 讲的环境模型给出的答案:函数被 def 出来时,会记住「自己是在哪个帧里被定义的」(这个帧成为它的父帧)。以后在它体内查找一个名字,先看自己的局部帧,找不到就去父帧找,再找不到去父帧的父帧……直到全局帧。

n 是 always_roll 的形参,住在 always_roll 的帧里。strategy 的父帧正是这个帧,所以 strategy 体内写 return n 能找到它。

核心结论

「函数返回函数」的意义,是把参数分两批交付。第一批(n)在造函数时交,被闭包记住;第二批(score、opponent_score)在真正使用时交。make_fair_dice(sides) 是同一个模式:sides 早早交付,之后每次 dice() 都不用再说一遍「我是六面的」。这个模式在函数式编程里叫柯里化(currying)的近亲,你在 Lecture 4 里已经见过它的名字。

代码

def always_roll(n):
    """Return a player strategy that always rolls N dice.

    A player strategy is a function that takes two total scores as arguments
    (the current player's score, and the opponent's score), and returns a
    number of dice that the current player will roll this turn.

    >>> strategy = always_roll(3)
    >>> strategy(0, 0)
    3
    >>> strategy(99, 99)
    3
    """
    assert n >= 0 and n <= 10

    # BEGIN PROBLEM 6
    def strategy(score, opponent_score):
        return n  # n is looked up in the enclosing always_roll frame

    return strategy
    # END PROBLEM 6

逐行说明:

  • def strategy(score, opponent_score)::形参名字叫什么无所谓(叫 a, b 也能过测试),但个数必须是 2,因为 play 会传两个进来。这两个参数在函数体里一次都没被用到——这是完全正常的:always_roll 造出来的策略就是「无论比分如何都掷 n 个」,它必须接受这两个参数只是为了满足接口约定。
  • return n:注意不是 return score,也不是 return 5。n 在这个函数的局部帧里找不到,Python 于是去它的父帧(always_roll 的帧)里找,找到了。
  • return strategy:没有括号。这是整道题唯一真正的语法陷阱。写成 return strategy() 会立刻调用它——但此时没有传参,报 TypeError: strategy() missing 2 required positional arguments: 'score' and 'opponent_score'。always_roll 要返回的是「函数这个东西本身」,不是「调用它的结果」。
  • return strategy 的缩进:与 def strategy 齐平,在 always_roll 的函数体里,不在 strategy 里面。多缩进一级的话,always_roll 就没有 return 了,返回 None。
  • assert n >= 0 and n <= 10 是题目给的,注意它在造策略的时候就检查,而不是在策略被调用的时候。这是个不错的设计:错误尽早暴露。

验证

用环境图追踪 strategy = always_roll(3) 然后 strategy(0, 0)。

逐步推演
1 求值 always_roll(3):先求算子 always_roll 得到函数对象,再求算子数 3。然后创建帧 f1,父帧是 Global(因为 always_roll 是在全局帧里 def 的),把形参 n 绑定到 3。
2 在 f1 里执行 assert(通过),然后执行 def strategy(...):创建一个函数对象,它的父帧记为 f1,并把名字 strategy 绑定到 f1 里。
3 执行 return strategy:在 f1 里查到这个函数对象,返回它。f1 不会消失,因为返回的函数还指着它。
4 全局帧里 strategy 被绑定到这个函数对象上。
全局帧 Global
    always_roll -> func always_roll(n)          [parent = Global]
    strategy    -> func strategy(score, opponent_score)  [parent = f1]

f1: always_roll   [parent = Global]
    n        = 3
    strategy -> func strategy(score, opponent_score)  [parent = f1]
    返回值    -> 上面那个函数对象

f2: strategy      [parent = f1]        <- 调用 strategy(0, 0) 时创建
    score          = 0
    opponent_score = 0
    执行 return n:f2 里没有 n
                 -> 去父帧 f1 找:n = 3 ✓
    返回值    = 3

关键在第 4 帧(f2)里的那条查找路径。strategy 的父帧是 f1,不是 Global——这正是它能记住 n = 3 的原因。如果 strategy 是在全局帧里定义的(比如你把它写在 always_roll 外面),那 return n 会去 Global 找 n,找不到就报 NameError: name 'n' is not defined。

再看为什么两个不同的策略互不干扰:

>>> three = always_roll(3)
>>> six = always_roll(6)
>>> three(50, 50)
3
>>> six(50, 50)
6

两次调用 always_roll 创建了两个不同的帧,一个里面 n = 3,另一个里面 n = 6。three 和 six 各自指向不同的父帧,所以查到的 n 不同。这正是「用全局变量」方案做不到的事——全局只有一份,两个策略会打架。

最后验证官方测试的两条:always_roll(3)(10, 20) —— 先调 always_roll(3) 造出策略,再立刻用 (10, 20) 调它,返回 3 ✓。always_roll(0)(99, 99) —— n = 0 通过 assert(0 >= 0 成立),返回 0 ✓。返回 0 是合法的:它表示「这个策略永远选择 Boar Brawl」。

常见误区
  • return strategy():多写一对括号。报 TypeError: strategy() missing 2 required positional arguments。
  • 忘了 return strategy:always_roll(3) 返回 None,接着 None(0, 0) 报 TypeError: 'NoneType' object is not callable。
  • 内层函数只写一个参数:def strategy(score)。造的时候没事,被 play 调用时报参数个数不对。
  • 内层写成 return n(score, opponent_score):把 n 当函数用了。报 TypeError: 'int' object is not callable。
  • 用 lambda 写但漏了参数:return lambda: n 少了两个形参。lambda score, opponent_score: n 才是等价写法——顺带一提,这个 lambda 版本完全正确,只是 def 版本更容易看清它是个策略函数。

8. Problem 7:is_always_roll

题目要什么

Implement is_always_roll, which takes a strategy and returns whether that strategy always rolls the same number of dice for every possible argument combination, where each score is up to goal points.

换句话说:给你一个策略函数,判断它是不是常函数(constant function)。

>>> is_always_roll(always_roll_5)
True
>>> is_always_roll(always_roll(3))
True
>>> is_always_roll(catch_up)
False

「every possible argument combination」的范围由题面精确划定:

in a game with a goal of 100, there are only 100 possible score values (0-99) and 100 possible opponent_score values (0-99), resulting in 10,000 possible argument combinations

Reminder: The game continues until one player reaches goal points. Ensure your solution considers every possible combination of score and opponent_score for the specified goal.

为什么上限是 goal - 1 而不是 goal?因为一旦有人达到 goal,游戏就结束了,策略函数不会再被调用。分数为 goal 的局面在真实游戏里不存在,不需要检查。所以两层循环都是 0 到 goal - 1,共 goal² 个组合。

关键边界:goal 不一定是 100。官方测试里有一条明晃晃写着注释的用例:

>>> def s(x, y):
...    if x == 150 and y == 125:
...        return 0
...    else:
...        return 1
>>> is_always_roll(s, 200) # GOAL is not always 100!
False

如果你把上限写死成 100,这条测试会返回 True(因为 150 和 125 都超出了你的检查范围),直接挂掉。

怎么想到的

这道题的形状是「穷举验证一个全称命题」:命题是「对所有 (score, opponent_score),策略返回同一个值」。

验证全称命题只有一条通用路子:逐个检查,一旦找到反例立刻宣告为假;全部查完没找到反例,才宣告为真。这个「找反例」的结构决定了代码的骨架——

1 先确定一个基准值:策略在某个已知输入上返回什么。
2 双层循环遍历所有 (score, opponent_score)。
3 任何一个组合的返回值 ≠ 基准值 → 立刻 return False。
4 循环完整跑完 → return True。

基准值取哪个?strategy(0, 0) 是最自然的选择——它一定在遍历范围内(因为 goal > 0)。也可以不预先取基准,改成「记住第一次见到的返回值」,但那要多一个「是不是第一次」的标志变量,不如直接调一次 strategy(0, 0) 干脆。

关键一步:return False 必须在循环里面,return True 必须在循环外面

这是「找反例」型循环的标准形状,也是最容易写反的地方。想清楚这件事的方法是问自己:「什么时候我才有把握下结论?」

  • 只要看到一个反例,我立刻就有把握说「不是常函数」——所以 return False 可以(也应该)在循环里,看到就跑。
  • 但要说「是常函数」,我必须把一万个组合全看完才有把握——所以 return True 只能在循环彻底结束之后。

如果把 return True 写进循环里(比如写成 if ... != first: return False else: return True),函数在检查完第一个组合 (0, 0) 时就返回了,等于只查了一个点,几乎所有非常函数都会被误判成常函数。

接下来是「双层循环怎么写」。两个变量各自从 0 走到 goal - 1,用 while 写就是嵌套的两个计数循环。这里有一个每次都会被踩的坑:内层循环变量的重置必须放在外层循环体的开头。如果把 opponent_score = 0 写在两个循环之外,那么外层第二次迭代时 opponent_score 已经是 goal 了,内层循环一次都不会执行——结果只检查了 score = 0 那一行的 100 个组合。

顺带说一句效率:这个函数会调用策略 10001 次(1 次基准 + 10000 次遍历)。对本项目这个规模完全无所谓,一秒都不到。但值得注意的是,如果传进来的策略是非纯的(比如会打印、会掷骰子),这一万次调用会产生一万次副作用——这也是为什么策略函数应该被写成纯函数。

代码

def is_always_roll(strategy, goal=GOAL):
    """Return whether STRATEGY always chooses the same number of dice to roll
    for every possible combination of score and opponent_score
    given a game that goes to GOAL points.

    >>> is_always_roll(always_roll_5)
    True
    >>> is_always_roll(always_roll(3))
    True
    >>> is_always_roll(catch_up)
    False
    """
    # BEGIN PROBLEM 7
    # Compare every reachable (score, opponent_score) pair against the choice
    # the strategy makes at (0, 0).
    first_choice = strategy(0, 0)
    score = 0
    while score < goal:
        opponent_score = 0
        while opponent_score < goal:
            if strategy(score, opponent_score) != first_choice:
                return False
            opponent_score = opponent_score + 1
        score = score + 1
    return True
    # END PROBLEM 7

逐行说明:

  • first_choice = strategy(0, 0):基准值。只算一次并存起来——如果写成在循环里每次都调 strategy(0, 0) 做比较,会白白多出一万次调用。
  • score = 0 / while score < goal:外层循环,score 取 0 到 goal - 1。用 < 而不是 <=,正是因为分数等于 goal 的局面在游戏中不存在。
  • opponent_score = 0 在外层循环体内:每次换一个 score,内层都要从 0 重新走一遍。这一行的位置是本题最容易错的地方。
  • while opponent_score < goal:内层循环。
  • if strategy(score, opponent_score) != first_choice: return False:找到反例立刻退出。这个 return 会同时跳出两层循环并结束整个函数——return 比 break 强的地方就在这里,break 只能跳出一层。
  • opponent_score = opponent_score + 1 和 score = score + 1:两个循环变量各自的推进。内层的推进必须在内层循环体末尾,缩进错一级就是死循环。
  • return True 在两层循环之外,与最外层 while 齐平。只有一万个组合全部通过检查才会执行到这里。
  • 参数 goal=GOAL 是题目给的默认值。函数体里必须用 goal 而不是 GOAL——用后者的话,is_always_roll(s, 200) 传进来的 200 就被忽略了。

验证

先追一个返回 False 的:is_always_roll(catch_up)。catch_up 的定义是:

def catch_up(score, opponent_score):
    if score < opponent_score:
        return 6  # Roll one more to catch up
    else:
        return 5
逐步推演
1 first_choice = catch_up(0, 0):0 < 0 为假,走 else,返回 5。
2 外层 score = 0,内层 opponent_score = 0:catch_up(0, 0) = 5,等于基准,继续。
3 内层 opponent_score = 1:catch_up(0, 1) 中 0 < 1 为真,返回 6。6 != 5 → return False。
4 整个函数在第 2 次内层迭代就结束了,共调用 catch_up 三次。✓

再追一个返回 True 的:is_always_roll(always_roll(3))。always_roll(3) 造出的策略无论收到什么参数都返回 3(见上一题的环境图),所以 first_choice = 3,接下来一万次比较全部是 3 != 3 为假,一次都不触发 return False。两层循环跑完,执行 return True。✓

最有价值的是那几条「针尖式反例」用例。它们各自把唯一的不同点藏在遍历范围的不同角落,正好把常见的边界错误全都覆盖了:

反例藏在能抓出什么错
x == 0 and y == 0基准值取自 (0, 0) 时,这个点和基准相同,反而永远不会被判为不同——但因为其他 9999 个点返回 1 而基准是 0,仍能测出 False。这条用例在考「你有没有真的遍历」。
x == 60 and y == 0内层循环范围写错(比如只查 opponent_score = score 的对角线)会漏掉
x == 0 and y == 60内层循环变量没重置,导致只有 score = 0 时内层跑过一次——这条反而能被抓到;但如果外层内层写反了就会漏
x == 99 and y == 99循环上限写成 < goal - 1 会漏掉这个最角落的点
x == 150 and y == 125(goal = 200)上限写死 100 会漏掉

第一条特别值得展开。策略是「(0,0) 返回 0,其余返回 1」,first_choice = strategy(0, 0) = 0。遍历到 (0, 0) 时 0 != 0 为假,通过;到 (0, 1) 时 1 != 0 为真,return False ✓。基准点自己永远不会成为反例,所以判定完全依赖「除基准点外还有别的点」——这就是为什么 goal 至少是 1 时也不会出问题,以及为什么不能只检查基准点。

注意:starter 文件里的一个小瑕疵

hog.py 里 catch_up 的 docstring 是这样的:

>>> catch_up(9, 4)
5
>>> strategy(17, 18)
6

第二行写的是 strategy(17, 18) 而不是 catch_up(17, 18)——这是官方 starter 文件里的一处笔误,直接跑这个 doctest 会因为找不到名字 strategy 而报 NameError。ok 不会运行这个 doctest,所以不影响评分,我们也不该去改它(题面要求不要改动指定范围之外的代码)。指出来只是提醒你:官方给的注释也可能有错,最终以你自己的推演为准。

常见误区
  • 内层循环变量在两层循环外初始化:只有第一轮外层循环时内层才跑,实际只检查了 100 个组合。用「反例藏在 (60, 0)」那条用例能抓出来。
  • return True 写进循环里:只检查第一个组合就返回。is_always_roll(catch_up) 会错误地返回 True。
  • 用 GOAL 而不是形参 goal:goal = 200 那条用例挂掉。
  • 上限用 <= goal:多查了一行一列。这倒不会导致测试失败(多查的点上策略照样返回同一个值),但语义上不对——分数等于 goal 时游戏已经结束。
  • 只沿对角线检查:写成一层循环 strategy(i, i)。省事但漏掉绝大多数组合。
  • 忘了推进循环变量:死循环,ok 卡住。

9. Problem 8:make_averaged

题目要什么

Implement make_averaged, which is a higher-order function that takes a function original_function as an argument. The return value of make_averaged is a function that takes in the same arguments as original_function. When called with specific arguments, this function should repeatedly call original_function on those same arguments times_called times, and return the average of the results.

这是整个项目最抽象的一道题。它的输入是一个任意函数,输出是「同一个函数的平均版」。docstring 的 doctest 是理解它的钥匙:

>>> dice = make_test_dice(4, 2, 5, 1)
>>> averaged_dice = make_averaged(roll_dice, 40)
>>> averaged_dice(1, dice)  # The avg of 10 4's, 10 2's, 10 5's, and 10 1's
3.0

读懂这三行需要把「谁是谁」摆清楚:

名字是什么
original_function就是 roll_dice
times_called40
averaged_dicemake_averaged 返回的那个新函数
(1, dice)传给 averaged_dice 的参数,它要原封不动转发给 roll_dice
返回值 3.040 次 roll_dice(1, dice) 的平均值,是浮点数

难点在于:make_averaged 不知道 original_function 需要几个参数。roll_dice 要两个;但测试里还会传一个零参数的骰子函数进来:

>>> dice = make_test_dice(3, 1, 5, 6)
>>> averaged_dice = make_averaged(dice, 1000)
>>> averaged_dice()
3.75

还会传 winner(两个参数,都是函数):average_win_rate 里写着 make_averaged(winner)(strategy, baseline)。所以内层函数必须能吞下任意个参数。

两道概念题

概念题 1 · 为什么它是高阶函数

问:What is one reason that make_averaged is a higher order function?

选项:(A)It contains a nested function (B)It calls a function that is not itself (C)It takes in a function as an argument (D)It uses the *args keyword

看答案

答案是 (C):它接受一个函数作为参数。

高阶函数的定义只有两条:接受函数作为参数,或把函数作为返回值。make_averaged 两条都占(它还返回 averaged),但题目问的是「one reason」,而选项里只有(C)命中了定义。

逐个排除其余选项:(A)「内部有嵌套函数」——嵌套函数本身不构成高阶,比如一个内部定义了辅助函数但从不返回它、也不接受函数参数的函数就不是高阶函数。(B)「它调用了别的函数」——几乎所有函数都调用别的函数,这句话说了等于没说。(D)「用了 *args」——*args 只是一种参数语法,跟高阶不高阶毫无关系。

概念题 2 · 被传进来的函数有几个参数

问:How many arguments does the function passed into make_averaged take?

选项:(A)None (B)Two (C)An arbitrary amount, which is why we need to use *args to call it

看答案

答案是 (C):任意个。

这道题直接告诉了你实现方案。项目里 make_averaged 会被喂进三种不同签名的函数:

  • roll_dice:两个参数 (num_rolls, dice)
  • 测试骰子函数 dice:零个参数
  • winner:两个参数,但都是函数(strategy0, strategy1)

一个能同时服务这三者的实现,只可能靠 *args。这就是题面专门花一整段介绍 *args 语法的原因。

怎么想到的

先假装参数个数是已知的,把主干写出来。如果我只需要支持 roll_dice,代码是这样:

# 只能用于两个参数的函数
def make_averaged(original_function, times_called=1000):
    def averaged(num_rolls, dice):
        total = 0
        k = 0
        while k < times_called:
            total = total + original_function(num_rolls, dice)
            k = k + 1
        return total / times_called
    return averaged

骨架完全正确:累加器 + 计数循环 + 除以次数。剩下的唯一问题是把 (num_rolls, dice) 换成「不管来几个都行」。

题面给了答案,而且给了一个可以直接照着改的例子:

>>> def printed(f):
...     def print_and_return(*args):
...         result = f(*args)
...         print('Result:', result)
...         return result
...     return print_and_return
>>> printed_pow = printed(pow)
>>> printed_pow(2, 8)  # *args represents the arguments (2, 8)
Result: 256
256
>>> printed_abs = printed(abs)
>>> printed_abs(-10)  # *args represents one argument (-10)
Result: 10
10

*args 在两个位置出现,含义是对称的两半:

位置写法做什么
形参表里def averaged(*args):打包:把调用时给的所有位置参数收进一个元组 args。调用 averaged(1, dice) 时 args = (1, dice);调用 averaged() 时 args = ()。
调用表达式里original_function(*args)拆包:把元组 args 里的元素重新摊开成一个个独立的参数。args = (1, dice) 时,这句等价于 original_function(1, dice)。
注意:两处的 * 都不能省

如果调用时写成 original_function(args)(漏掉星号),那就是把整个元组当成一个参数传过去。roll_dice((1, dice)) 会在 assert type(num_rolls) == int 处报 AssertionError: num_rolls must be an integer.——因为 num_rolls 被绑成了一个元组。

最后一个细节:返回值必须是浮点数。测试期望的是 3.0 而不是 3,还专门加了注释「Enter a float (e.g. 1.0) instead of an integer」。用 /(真除法)而不是 //(地板除)就自动满足了——Python 3 里 120 / 40 得到 3.0,而 120 // 40 得到 3。ok 的 doctest 比较的是字符串表示,3 和 3.0 不相等。

代码

def make_averaged(original_function, times_called=1000):
    """Return a function that returns the average value of ORIGINAL_FUNCTION
    called TIMES_CALLED times.

    To implement this function, you will have to use *args syntax.

    >>> dice = make_test_dice(4, 2, 5, 1)
    >>> averaged_dice = make_averaged(roll_dice, 40)
    >>> averaged_dice(1, dice)  # The avg of 10 4's, 10 2's, 10 5's, and 10 1's
    3.0
    """

    # BEGIN PROBLEM 8
    def averaged(*args):
        """Call ORIGINAL_FUNCTION on ARGS repeatedly and average the results."""
        total = 0
        k = 0
        while k < times_called:
            total = total + original_function(*args)
            k = k + 1
        return total / times_called

    return averaged
    # END PROBLEM 8

逐行说明:

  • def averaged(*args)::内层函数。形参表里只有 *args,没有别的——因为它不知道也不需要知道 original_function 要什么。
  • total = 0 / k = 0:累加器和计数器,和 roll_dice 里是同一个模式。
  • while k < times_called:循环恰好 times_called 次。times_called 是 make_averaged 的形参,averaged 通过闭包在父帧里找到它——和 always_roll 里的 n 完全同理。original_function 也一样。
  • total = total + original_function(*args):每次循环都重新调用一次。这一点至关重要——如果你写成在循环外先算一次 result = original_function(*args),然后循环里累加 result,那就等于把同一个值加了 times_called 遍,平均值恒等于那一次的结果。对纯函数来说这两种写法结果一样,但 roll_dice 是非纯的,平均的意义正来自于每次调用结果不同。
  • return total / times_called:真除法,得到浮点数。这一行在循环外面,写进去的话第一次循环就返回了。
  • return averaged:返回函数本身,不带括号——和 Problem 6 一样。

验证

手动追踪 docstring 里的 doctest:make_averaged(roll_dice, 40) 然后 averaged_dice(1, dice),其中 dice = make_test_dice(4, 2, 5, 1)。

全局帧 Global
    dice          -> func dice()      [闭包记着 outcomes = (4, 2, 5, 1)]
    averaged_dice -> func averaged(*args)  [parent = f1]

f1: make_averaged   [parent = Global]
    original_function -> func roll_dice(num_rolls, dice=six_sided)
    times_called      = 40
    averaged          -> func averaged(*args)   [parent = f1]

f2: averaged        [parent = f1]     <- 调用 averaged_dice(1, dice) 时创建
    args  = (1, dice)                 <- 打包
    total, k 在循环中不断改写
    执行 original_function(*args):
        original_function 在 f2 里没有 -> 去 f1 找到 roll_dice ✓
        times_called      在 f2 里没有 -> 去 f1 找到 40 ✓
        *args 拆包成 roll_dice(1, dice)
逐步推演:为什么平均值是 3.0

每次循环都调用 roll_dice(1, dice),即「掷 1 次」。测试骰子按 4, 2, 5, 1 循环,所以 40 次调用依次取到:

第 1 次: dice() = 4 -> 没有 1,roll_dice 返回 4
第 2 次: dice() = 2 -> 返回 2
第 3 次: dice() = 5 -> 返回 5
第 4 次: dice() = 1 -> Sow Sad!roll_dice 返回 1
第 5 次: dice() = 4 -> 返回 4   (序列绕回开头)
...

40 次正好是 4 的 10 倍,所以 4、2、5、1 各出现 10 次。

total = 10 × (4 + 2 + 5 + 1) = 10 × 12 = 120

return 120 / 40 = 3.0 ✓

注意第 4 次那个 1:roll_dice(1, dice) 掷一次得到 1,触发 Sow Sad,返回 1。碰巧点数和回合分都是 1,所以看不出差别。docstring 的注释「The avg of 10 4's, 10 2's, 10 5's, and 10 1's」说的正是这四个返回值。

再验证一条能真正区分 Sow Sad 的用例:

>>> dice = make_test_dice(3, 1, 5, 6)
>>> averaged_roll_dice = make_averaged(roll_dice, 1000)
>>> averaged_roll_dice(2, dice)
6.0

这次每轮掷 2 次。骰子序列 3, 1, 5, 6 循环,每次 roll_dice(2, dice) 消耗两个数:

第几次调用 roll_dice消耗的点数有 1 吗roll_dice 返回
13, 1有1(Sow Sad)
25, 6无11
33, 1有1
45, 6无11
…1 和 11 交替出现,1000 次里各 500 次

total = 500 × 1 + 500 × 11 = 6000,6000 / 1000 = 6.0 ✓。同一组测试还有一条 make_averaged(roll_dice, 5),只调 5 次:返回值序列是 1, 11, 1, 11, 1,total = 25,25 / 5 = 5.0 ✓——这条用例专门检查你有没有真的用上 times_called,而不是写死 1000。

最后是零参数的那条:make_averaged(dice, 1000) 然后 averaged_dice()。这次 original_function 就是骰子函数本身,args = (),original_function(*args) 拆包成 dice()。1000 次调用里 3、1、5、6 各出现 250 次,total = 250 × 15 = 3750,3750 / 1000 = 3.75 ✓。注意这里没有 Sow Sad——因为调的是 dice 而不是 roll_dice,那个 1 就老老实实按 1 计入平均。这条用例正好证明了 make_averaged 对传进来的函数一无所知,也不需要知道。

核心结论

make_averaged 是一个装饰器(decorator)形状的函数:它包住原函数,在调用前后加上自己的逻辑(这里是「重复调用并求平均」),同时保持对外的调用方式完全不变。hog_ui.py 里的 printing_strategy、printing_dice、sus_update_and_print 全都是同一个形状——它们包住原函数并加上打印。这就是题面说的「我们可以在几乎不改动原代码的前提下,给模拟器加上界面」。

常见误区
  • 循环外先算一次再循环累加:平均值恒等于某一次的结果。这个 bug 在真随机骰子下表现为「平均分总是整数且波动巨大」。
  • 调用时漏掉星号:original_function(args) 把元组当单个参数传。报 AssertionError: num_rolls must be an integer.。
  • 形参写成 args 而不是 *args:averaged(1, dice) 会报 TypeError: averaged() takes 1 positional argument but 2 were given。
  • 用 // 求平均:返回整数 3 而不是 3.0,doctest 判失败,报错看起来像「期望 3.0 得到 3」,很多人会盯着数字看半天没意识到是类型问题。
  • times_called 写死:忽略参数,用 1000 次。make_averaged(roll_dice, 5) 那条用例会挂。
  • return averaged():多了括号,会立刻执行 1000 次然后返回一个数,而不是返回函数。

10. Problem 9:max_scoring_num_rolls

题目要什么

Implement max_scoring_num_rolls, which runs an experiment using a die with a fixed number of sides to determine the number of rolls (from 1 to 10) that gives the maximum average score for a turn. Your implementation should use make_averaged and roll_dice.

If two numbers of rolls are tied for the maximum average score, return the lower number.

Important: In order to pass all of our tests, please make sure that you are testing dice rolls starting from 1 going up to 10, rather than from 10 to 1.

这是项目里第一道用程序做实验的题:把 1 到 10 每个掷法都试很多次,看哪个的平均回合分最高。返回的是掷几个骰子(1–10 的整数),不是那个平均分。

>>> dice = make_test_dice(1, 6)
>>> max_scoring_num_rolls(dice)
1

两条边界规则要抠准:

1 平局取小。3 和 6 打成平手就返回 3。
2 必须从 1 升序扫到 10。题面明说了这一点,原因见下文——它和测试骰子的非纯性有关。
概念题 · 平局怎么办

问:If multiple num_rolls are tied for the highest scoring average, which should you return?

选项:(A)The lowest num_rolls (B)The highest num_rolls (C)A random num_rolls

看答案

答案是 (A):最小的那个。

实现上,这条规则决定了比较运算符必须用严格大于:if average > best_average。配合「从小到大扫描」,遇到平局时新值不会 > 旧值,于是保留先记录的(也就是更小的)那个 num_rolls。如果写成 >=,平局时会被后来的更大的数字覆盖掉,正好违反规则。

这是一个值得单独记住的对应关系:「升序扫描 + 严格大于」= 取第一个最大值;「升序扫描 + 大于等于」= 取最后一个最大值。

怎么想到的

算法本身是最基础的「打擂台求最大值」:设一个当前冠军,依次比较,谁大谁上位。真正需要想的是三件容易被忽略的事。

第一件:make_averaged 应该在循环外还是循环里调用?

两种写法在纯函数意义下等价,但在这里不等价:

# 写法 A(正确):造一次,反复用
averaged_roll_dice = make_averaged(roll_dice, times_called)
while num_rolls <= 10:
    average = averaged_roll_dice(num_rolls, dice)
    ...

# 写法 B:每轮都重新造
while num_rolls <= 10:
    average = make_averaged(roll_dice, times_called)(num_rolls, dice)
    ...

写法 B 每轮新建一个 averaged 函数。这本身不算错——新函数的行为完全一样,dice 的指针也不会被重置(指针存在 dice 自己的闭包里,跟 make_averaged 无关)。所以 B 其实也能过测试,只是每次重建一个闭包纯属浪费。写法 A 更好的理由是意图更清楚:「平均版的 roll_dice」是一个固定的工具,造一次就够了。

第二件:骰子调用次数是全局共享的

这条比上一条重要得多。看这个专门设计的测试用例:

>>> dice = make_test_dice(*([2] * 55 + [1, 2] * 500)) # test that you are not rolling the dice more than necessary
>>> max_scoring_num_rolls(dice, times_called=1) # dice is 2 for the first 55 rolls, then is 1 followed by 2 for 1000 rolls
10

这个骰子的前 55 次调用全返回 2,之后开始出现 1。为什么恰好是 55?因为 times_called = 1 时,整个 max_scoring_num_rolls 一共需要掷:

$$1 + 2 + 3 + \cdots + 10 = \frac{10 \times 11}{2} = 55 \text{ 次}$$

也就是说,如果你的实现一次骰子都不多掷,那么这 55 次全部取到 2,于是 num_rolls = n 的平均分正好是 2n,单调递增,最大值在 10。答案就是 10。

但只要你多掷了一次(比如为了「热身」先调了一下,或者在某处重复计算了平均值),第 56 次开始就会摸到 1,触发 Sow Sad,后面的平均分全乱,答案不再是 10。这条用例用「刚好够用的 55」把「有没有多掷」变成了一个可观测的现象。这也是为什么题面强调必须从 1 升序扫到 10——降序扫描消耗骰子的顺序不同,同样过不了这条测试。

第三件:擂台的初值设成多少?

best_average 的初值必须小于任何可能的实际平均分,否则第一个候选就上不了台。题面提示「Assume that the dice always return positive outcomes」——点数至少是 1,所以任何平均分都 > 0。把初值设成 0 就够了。

best_num_rolls 的初值设成 1:万一所有平均分都是 0(实际不会发生),也能返回一个合法的值,而不是 None。

代码

def max_scoring_num_rolls(dice=six_sided, times_called=1000):
    """Return the number of dice (1 to 10) that gives the maximum average score for a turn.
    Assume that the dice always return positive outcomes.

    >>> dice = make_test_dice(1, 6)
    >>> max_scoring_num_rolls(dice)
    1
    """
    # BEGIN PROBLEM 9
    averaged_roll_dice = make_averaged(roll_dice, times_called)
    best_num_rolls, best_average = 1, 0
    num_rolls = 1
    # Scan 1 through 10 in increasing order so ties keep the lower number.
    while num_rolls <= 10:
        average = averaged_roll_dice(num_rolls, dice)
        if average > best_average:
            best_num_rolls, best_average = num_rolls, average
        num_rolls = num_rolls + 1
    return best_num_rolls
    # END PROBLEM 9

逐行说明:

  • averaged_roll_dice = make_averaged(roll_dice, times_called):造一次工具。注意 times_called 是形参,必须原样传进去,写死 1000 会让那条 times_called=1 的用例挂掉。
  • best_num_rolls, best_average = 1, 0:多重赋值(multiple assignment),一行初始化两个擂台变量。Python 会先把右边两个表达式都求值完,再同时绑定左边两个名字。
  • num_rolls = 1 与 while num_rolls <= 10:从 1 扫到 10(含 10)。<= 不能写成 <,否则漏掉 10——而 10 恰恰经常是答案。
  • average = averaged_roll_dice(num_rolls, dice):把结果存进变量。这一步不只是风格:每调用一次就会真的掷 times_called × num_rolls 次骰子。如果写成 if averaged_roll_dice(num_rolls, dice) > best_average: best_average = averaged_roll_dice(num_rolls, dice),同一轮里调了两次,骰子被多消耗一倍,那条 55 次的用例必挂。
  • if average > best_average:严格大于,实现「平局取小」。
  • best_num_rolls, best_average = num_rolls, average:新冠军上位,两个变量必须同时更新。分成两行写也可以,但一定要两个都写——只更新 best_average 忘了 best_num_rolls 是很常见的手滑。
  • num_rolls = num_rolls + 1 在 if 外面。写进 if 里的话,一旦某轮没刷新纪录就不再推进,死循环。
  • return best_num_rolls:返回掷几个,不是 best_average。这个混淆很常见,报错会是「期望 1 得到 3.5」这类。

验证

手动追踪 docstring 的 doctest:dice = make_test_dice(1, 6),max_scoring_num_rolls(dice),times_called 用默认的 1000,期望返回 1。

这个骰子交替返回 1, 6, 1, 6, …。关键观察:只要一轮里掷了 2 次或更多,就必然会摸到至少一个 1(因为序列里每两个数就有一个 1),必定 Sow Sad。

num_rolls每次 roll_dice 的结果1000 次的平均擂台变化
1交替得到 1 和 6(500×1 + 500×6)/1000 = 3.53.5 > 0 → 冠军 = 1
2每轮两个数里必有 1 → 恒为 11.01.0 > 3.5 为假,不换
3恒为 11.0不换
…4 到 10 同理,全部是 1.0
循环结束,return best_num_rolls = 1 ✓

再追一条能考出「平局取小」的:

>>> dice = make_test_dice(1, 2, 3, 4, 5)  # dice sweeps from 1 through 5
>>> max_scoring_num_rolls(dice, times_called=1000)
3

以及一条考「times_called 有没有真的用上」的:

>>> dice = make_test_dice(6, 5, 4, 3, 2, 1)  # dice sweeps from 1 through 6
>>> max_scoring_num_rolls(dice, times_called=1) # ensure times_called is being used
4

第二条可以完全笔算。times_called = 1 意味着每个 num_rolls 只试一轮,骰子按 6, 5, 4, 3, 2, 1 循环,从头开始一路消耗下去:

num_rolls这一轮消耗的点数roll_dice 返回擂台
1666 > 0 → 冠军 = 1,纪录 6.0
25, 499 > 6 → 冠军 = 2,纪录 9.0
33, 2, 11(Sow Sad)不换
46, 5, 4, 31818 > 9 → 冠军 = 4,纪录 18.0
52, 1, 6, 5, 41不换
63, 2, 1, 6, 5, 41不换
73, 2, 1, 6, 5, 4, 31不换
82, 1, 6, 5, 4, 3, 2, 11不换
96, 5, 4, 3, 2, 1, 6, 5, 41不换
103, 2, 1, 6, 5, 4, 3, 2, 1, 61不换
返回 4 ✓

这张表把整道题的所有要点都摊开了:骰子的指针在十轮之间是连续的(第 2 行结束在「4」,第 3 行紧接着从「3」开始),所以每一行消耗多少、下一行从哪开始,完全由前面的行决定。任何一次多余的调用都会让整张表往后错位。这道题真正在考的,不是求最大值,而是「你有没有把每一次副作用都算清楚」。

注意:用真骰子跑出来是多少

题面说 "You will likely find that rolling 6 dice maximizes the result of roll_dice using six-sided dice"。可以自己验证:python3 hog.py -r 会调用 run_experiments(),其中第一行就打印 max_scoring_num_rolls(six_sided)。

直觉上为什么是 6?多掷一个骰子,期望多加约 3.5 分(不含 1 时是 2 到 6 的平均 4);但同时 Sow Sad 的概率也在涨——掷 n 个六面骰全都不出 1 的概率是 (5/6)^n。收益线性增长、幸存概率指数衰减,两者的乘积在某个点达到峰值,实测这个点在 6 附近。这就是「用实验回答理论问题」的价值:不用推导,跑一遍就知道。

常见误区
  • 用 >= 比较:平局时取大的,违反规则。
  • 从 10 倒着扫到 1:就算平局逻辑对了,骰子消耗顺序不同,那条 55 次的用例会挂。题面已经明确警告过。
  • 循环上限写成 < 10:漏掉 10。用真六面骰时不一定看得出来(答案通常是 6),但测试里好几条的答案就是 10。
  • 同一轮里调用两次 averaged_roll_dice:骰子被多消耗一倍。
  • 返回 best_average:返回了平均分而不是掷法。
  • times_called 写死 1000:忽略了形参,times_called=1 的几条用例全挂。
  • 忘记更新 best_num_rolls:只更新了纪录没换冠军,返回值永远是 1。

11. Problem 10:boar_strategy

题目要什么

A strategy can try to take advantage of the Boar Brawl rule by rolling 0 when it is most beneficial to do so. Implement boar_strategy, which returns 0 whenever rolling 0 would give at least threshold points and returns num_rolls otherwise. This strategy should not also take into account the Sus Fuss rule.

签名是 boar_strategy(score, opponent_score, threshold=11, num_rolls=6)。虽然多了两个参数,但它仍然是一个合法的策略函数——因为后两个有默认值,play 用两个参数调用它时照样能工作。这是默认参数的一个漂亮用法:让同一个函数既能被自动调用(用默认配置),又能被人工调参(做实验)。

翻译成人话,就一句判断:如果掷 0 个骰子能拿到不少于 threshold 分,就掷 0 个;否则掷 num_rolls 个。

题面的提示直接给出了实现路径:「You can use the boar_brawl function you defined in Problem 2.」

「at least threshold」是大于等于,不是大于。这个边界被官方测试盯得很死,每个 threshold 都成对出现:

>>> boar_strategy(40, 51, threshold=15, num_rolls=7)
0
>>> boar_strategy(40, 51, threshold=16, num_rolls=7)
7

boar_brawl(40, 51) 恰好是 15。threshold = 15 时因为「至少」包含等于,所以掷 0;threshold = 16 时 15 不够,掷 7。写成 > 的话,第一条会返回 7 而不是 0。

怎么想到的

这道题的思维价值不在代码(只有三行),而在为什么这个策略会赢。

先看基线:always_roll(6) 每回合的期望得分大约是 8 分左右(六面骰掷 6 个,不出 1 的概率是 (5/6)^6 ≈ 0.335,此时期望和是 6 × 4 = 24;出 1 时只得 1 分。粗算 0.335 × 24 + 0.665 × 1 ≈ 8.7)。

再看 Boar Brawl:它的取值范围是 1 到 3 × 9 = 27。而且它是完全确定的——不掷骰子,没有 Sow Sad 风险。当 boar_brawl 算出来的分数超过 8 或 9 时,掷 0 个骰子就是稳赚不赔的买卖。

核心结论

threshold 的默认值 11 就是这么来的:它比掷 6 个骰子的期望得分(实测约 8.69)高一截,确保只在明显划算时才放弃掷骰。题面说这个策略的胜率会接近 66–67%——比五五开的基线高出十几个百分点,仅仅因为它学会了「什么时候不掷骰子」。

这就是这道题真正教的东西:一个策略的价值,往往来自于识别出「例外情况」并在例外情况下换一种做法。

实现上唯一需要想清楚的是:怎么在不掷骰子的情况下知道掷 0 个能拿多少分?答案在 Problem 3 的验证里已经点过了——boar_brawl 是纯函数,输入两个分数就能算出确定的结果,不需要掷任何骰子。所以策略可以「先算一遍看看划不划算,再决定」。这在掷 1 个以上骰子的情况下是做不到的:你没法预知随机骰子的结果。

注意:不要在这里考虑 Sus Fuss

题面明确写了 "This strategy should not also take into account the Sus Fuss rule."。所以这里只能调 boar_brawl,不能调 sus_update。把 Sus Fuss 也算进来是下一题(Problem 11)的事,两道题的区别正是这个。如果这道题就用了 sus_update,那 boar_strategy(53, 60, threshold=14, ...) 这类用例的答案会和标准答案不一致。

代码

def boar_strategy(score, opponent_score, threshold=11, num_rolls=6):
    """This strategy returns 0 dice if Boar Brawl gives at least THRESHOLD
    points, and returns NUM_ROLLS otherwise. Ignore the Sus Fuss rule.
    """
    # BEGIN PROBLEM 10
    if boar_brawl(score, opponent_score) >= threshold:
        return 0
    return num_rolls
    # END PROBLEM 10

逐行说明:

  • boar_brawl(score, opponent_score):参数顺序仍然是「自己在前,对手在后」,和 boar_brawl 的形参一致。策略函数的 score 就是当前玩家自己的分数(回忆 Problem 5 的概念题 3:play 负责把参数摆成主角视角)。
  • >= threshold:大于等于,对应题面的 "at least"。
  • return 0:掷 0 个骰子,即主动选择 Boar Brawl。
  • return num_rolls:否则按配置的数量掷。注意返回的是形参 num_rolls,不是写死的 6——测试会传各种值进来(num_rolls=7、num_rolls=2、num_rolls=5、num_rolls=3)。
  • 返回值必须是 int:0 到 10 之间。这两个返回值都满足。

验证

把官方测试逐条算一遍。先把每组分数对应的 boar_brawl 值算出来,剩下的就是比大小:

调用对手十位 / 自己个位boar_brawlthreshold判断返回
boar_strategy(40, 51, 7, 2)5 / 03×5 = 15715 >= 7 ✓0
boar_strategy(40, 51, 15, 7)5 / 0151515 >= 15 ✓0
boar_strategy(40, 51, 16, 7)5 / 0151615 >= 16 ✗7
boar_strategy(44, 53, 3, 2)5 / 43×1 = 333 >= 3 ✓0
boar_strategy(44, 53, 4, 2)5 / 4343 >= 4 ✗2
boar_strategy(40, 31, 9, 5)3 / 03×3 = 999 >= 9 ✓0
boar_strategy(40, 31, 10, 5)3 / 09109 >= 10 ✗5

注意这些用例的组织方式:每一对都把 threshold 卡在 boar_brawl 的值上下各一格。这是测试边界条件的标准做法——只要你把 >= 写成 >,每一对里的第二条(临界那条)就会翻车。

最后一条测试更狠,它用循环覆盖了对手的所有可能分数:

>>> s = 0
>>> while s < 100:
...     if boar_brawl(90, s) >= 10:
...         assert boar_strategy(90, s, threshold=10, num_rolls=3) == 0
...     else:
...         assert boar_strategy(90, s, threshold=10, num_rolls=3) == 3
...     s += 1

这条测试等于直接把「策略必须与 boar_brawl 的判断一致」写成了断言,对 100 个对手分数逐一验证。它无法被投机取巧地通过——除非你的实现真的就是「算 boar_brawl,和 threshold 比大小」。

拿一个具体的 s 手算:s = 7 时,boar_brawl(90, 7) 中对手十位是 (7 // 10) % 10 = 0,自己个位是 90 % 10 = 0,得 max(3 × abs(0 - 0), 1) = 1。1 >= 10 为假,所以断言要求返回 3。我们的实现走 return num_rolls,返回 3 ✓。再看 s = 45:对手十位 4,自己个位 0,得 12。12 >= 10 为真,两边都要求 0 ✓。

常见误区
  • 用 > 而不是 >=:临界用例全挂。这是本题唯一的实质性陷阱。
  • 返回写死的 6:忽略了 num_rolls 形参。
  • 把 boar_brawl 的参数写反:boar_strategy(40, 51, ...) 会算成对手十位 4、自己个位 1,得 9 而不是 15,判断整个反过来。
  • 用了 sus_update:题面明令这道题忽略 Sus Fuss。
  • 返回布尔值:写成 return boar_brawl(...) >= threshold。虽然 True 在 Python 里等于 1、False 等于 0,比较时甚至可能碰巧通过某些测试,但语义完全错了(应该在划算时返回 0,不划算时返回 num_rolls,而这个写法正好反过来)。

12. Problem 11:sus_strategy

题目要什么

A better strategy would take advantage of both Boar Brawl and Sus Fuss in combination. For example, if a player has 53 points and their opponent has 60, rolling 0 would bring them to 62, which is a sus number, and so they would end the turn with 67 points: a gain of 67 − 53 = 14!

The sus_strategy returns 0 whenever rolling 0 would result in a score that is at least threshold points more than the player's score at the start of turn.

和上一题只差一个词,但这个词改变了计算方式:上一题看的是「Boar Brawl 直接给多少分」,这一题看的是「回合结束时总分比开局时多了多少」——中间可能被 Sus Fuss 白送一大截。

题面给的提示是「You can use the sus_update function you defined in Problem 4.」

怎么想到的

先把上一题的例子改造一下,看看差别有多大。score = 53,opponent_score = 60:

逐步推演:Boar Brawl 之后还能白捡 5 分
1 boar_brawl(53, 60):对手十位 (60 // 10) % 10 = 6,自己个位 53 % 10 = 3,得 max(3 × abs(6 - 3), 1) = 9。上一题的策略看到的就是这个 9。
2 新总分 53 + 9 = 62。
3 num_factors(62):62 = 2 × 31,因数是 1、2、31、62,共 4 个 → sus!
4 往上找质数:63 = 7×9 不是,64 不是,65 = 5×13 不是,66 不是,67 是。总分跳到 67。
5 这回合实际净赚 67 − 53 = 14 分,比 Boar Brawl 显示的 9 分多了 5 分。

所以判断依据必须换成「净增量」。怎么算净增量?不用自己重算一遍规则——sus_update 已经把整条链路(Boar Brawl → 加总分 → Sus Fuss)走完了。它返回的是回合结束时的总分,减去开局的 score 就是净增量:

gain = sus_update(0, score, opponent_score) - score

第一个参数写 0,因为我们要评估的正是「掷 0 个骰子会怎样」。

关键一步:为什么可以「先算一遍再决定」

这看起来像作弊——策略函数居然能提前知道结果?能,而且完全合法,因为 num_rolls = 0 这条路径上不存在任何随机性。追一下调用链就明白了:

sus_update(0, score, opponent_score)
  -> take_turn(0, score, opponent_score, dice)
       -> num_rolls == 0,走 boar_brawl 分支,dice 完全没被碰
  -> sus_points(score + boar_brawl(...))
       -> 纯算术,无随机

整条链上没有一次 dice() 调用,所以策略可以放心地「预演」一遍。换成 sus_update(6, ...) 就不行了——那会真的掷 6 次骰子,不但结果不可预测,还会把骰子指针推进 6 格,污染真正的那一回合。

注意 sus_update 的第四个参数 dice 我们没传,用的是默认值 six_sided。这不要紧:这条路径上它根本不会被调用。

代码

def sus_strategy(score, opponent_score, threshold=11, num_rolls=6):
    """This strategy returns 0 dice when rolling 0 increases the score by at least
    THRESHOLD points, and returns NUM_ROLLS otherwise. Consider both the Boar Brawl and
    Suss Fuss rules."""
    # BEGIN PROBLEM 11
    # sus_update(0, ...) is the score we would end the turn with after both
    # Boar Brawl and Sus Fuss are applied.
    if sus_update(0, score, opponent_score) - score >= threshold:
        return 0
    return num_rolls
    # END PROBLEM 11

逐行说明:

  • sus_update(0, score, opponent_score):第一个参数是 0(掷 0 个骰子),后两个是当前比分。得到的是回合结束后的总分。
  • - score:减去开局分数,得到净增量。这是本题与上一题唯一的结构性差异,也是最容易漏的一步——忘了减就是拿「总分」跟 threshold 比,几乎所有用例都会返回 0。
  • >= threshold:仍然是「at least」,大于等于。
  • return 0 / return num_rolls:和上一题一样。
  • 整个判断写成一个表达式而没有拆成中间变量,是因为这条路径无副作用、只调用一次。但如果你想拆成 gain = sus_update(...) - score 再判断,那也完全可以,可读性还更好一点。唯一要避免的是在 if 和 return 里各调一次 sus_update——虽然这里无害(纯函数),但养成坏习惯迟早出事。

验证

逐条追踪官方用例。每一条都要走完「Boar Brawl → 加分 → 判 sus → 可能跳质数 → 算增量」这条完整链路:

调用boar_brawl加分后因数个数 → sus?sus_update增量返回
sus_strategy(31, 21, 10, 2)对手十位 2,自己个位 1 → 3341,2,17,34 → 4 个,是跳到 3766 >= 10 ✗ → 2
sus_strategy(30, 41, 10, 2)对手十位 4,自己个位 0 → 12428 个 → 否421212 >= 10 ✓ → 0
sus_strategy(53, 60, 14, 2)对手十位 6,自己个位 3 → 9621,2,31,62 → 4 个,是跳到 671414 >= 14 ✓ → 0
sus_strategy(53, 60, 15, 2)同上62是671414 >= 15 ✗ → 2
sus_strategy(23, 54, 4, 2)对手十位 5,自己个位 3 → 629质数,2 个 → 否2966 >= 4 ✓ → 0
sus_strategy(14, 21, 8, 2)对手十位 2,自己个位 4 → 6206 个 → 否2066 >= 8 ✗ → 2
sus_strategy(14, 21, 12, 5)同上20否2066 >= 12 ✗ → 5

第一行最有教育意义:Boar Brawl 只给了 3 分,但 Sus Fuss 又送了 3 分,净增量翻倍到 6。如果这道题错误地用了 boar_brawl(也就是抄了上一题),算出来的是 3 而不是 6——虽然这一行的答案碰巧还是 2(因为 3 和 6 都够不到 10),但换个 threshold 就会露馅。第三、四行那对卡在 14 上的用例则是把 >= 和净增量算法一起考了。

最后同样有一条循环式的全覆盖断言:

>>> s = 0
>>> while s < 100:
...     if sus_update(0, 20, s) - 20 >= 10:
...         assert sus_strategy(20, s, threshold=10, num_rolls=3) == 0
...     else:
...         assert sus_strategy(20, s, threshold=10, num_rolls=3) == 3
...     s += 1

它把「策略必须与 sus_update(0, ...) - score 的判断一致」变成了 100 条断言。注意它连计算式都和标准实现一模一样——这条测试实际上是在说:「这道题的唯一正确解法就是这个式子」。

核心结论

Problem 10 和 11 放在一起,演示了一个反复出现的设计原则:要评估「做某个决定的后果」,最可靠的办法是直接调用那个真正会执行这个决定的函数,而不是重新推导一遍它的逻辑。sus_strategy 不需要知道什么是 sus 数、下一个质数怎么找——它只需要问 sus_update:「如果我掷 0 个,我会变成多少分?」这样一来,即使规则将来改了,策略也自动跟着改。

题面说这个策略的胜率接近 67–69%,比只看 Boar Brawl 的版本又高了一两个百分点。多出来的那点胜率,全部来自「顺手把 Sus Fuss 的白送也算进来」。

常见误区
  • 忘了 - score:拿总分和 threshold 比。sus_strategy(30, 41, 10, 2) 会拿 42 去比 10,永远返回 0。这是本题头号错误。
  • 第一个参数传错:写成 sus_update(num_rolls, score, opponent_score)。这不只是逻辑错——它会真的掷 num_rolls 次骰子,把骰子指针推进,污染整局游戏。
  • 用了 boar_brawl 而不是 sus_update:漏掉 Sus Fuss 的加成。卡在 14 那对用例会挂。
  • 用 simple_update:同样漏掉 Sus Fuss。
  • 把 score 和 opponent_score 传反:整条链路的数字全错,而且不报错。

13. Problem 12(选做):final_strategy

题目要什么

Implement final_strategy, which combines these ideas and any other ideas you have to achieve a high win rate against the baseline strategy. Some suggestions:

  • If you know the goal score (by default it is 100), there's no benefit to scoring more than the goal. Check whether you can win by rolling 0, 1 or 2 dice. If you are in the lead, you might decide to take fewer risks.
  • Instead of using a threshold, roll 0 whenever it would give you more points on average than rolling 6.

这道题是选做,ok 也不给分(tests/12.py 的 points 是 0)。它的评分方式和前面所有题都不一样:没有标准答案,只检查合法性。

>>> def check_strategy(strat):
...     for score in range(100):
...         for opp in range(100):
...             num_rolls = strat(score, opp)
...             if not isinstance(num_rolls, int):
...                 raise ValueError("final_strategy({0}, {1}) returned {2}, not an int.".format(score, opp, num_rolls))
>>> def max_scoring_num_rolls(dice=lambda: 1):
...     raise RuntimeError("Your final strategy should not call max_scoring_num_rolls.")
>>> old_max_scoring_num_rolls = hog.max_scoring_num_rolls
>>> hog.max_scoring_num_rolls = max_scoring_num_rolls
>>> check_strategy(hog.final_strategy)

两条约束:

1 对所有 10000 个 (score, opponent_score) 组合,返回值必须是 int。(浮点数不行,None 不行。)
2 不许调用 max_scoring_num_rolls。测试用 Problem 3 里见过的同一招把它换成了一个会抛异常的假函数。

第 2 条为什么要禁?因为 max_scoring_num_rolls 每次调用要掷上万次骰子。策略函数每回合都被调用一次,如果它内部去跑一遍实验,一局游戏要掷几十万次骰子——评测会慢到超时。这是「不要在热路径上做昂贵计算」的一个具体教训。

怎么想到的

题面已经给了两个方向的提示,把它们排成一条优先级链就是本实现的思路:

1 能白赢就白赢。如果掷 0 个就能达到或超过 GOAL,那没有任何理由去掷骰子冒 Sow Sad 的险。这是收益无限大的情况。
2 掷 0 明显比掷 6 划算就掷 0。掷 6 个的期望得分实测约 8.69 分,所以门槛设在 9:净增量 ≥ 9 就选 Boar Brawl。这比 sus_strategy 默认的 11 更激进一点,因为 11 那个门槛把 9、10 分的机会白白放过了。
3 快到终点时少掷几个。只差 5 分却掷 6 个骰子,多出来的分数一文不值,反而白白承担了 Sow Sad 的风险(掷 6 个六面骰有约 67% 的概率只得 1 分)。掷 1 个的话,Sow Sad 概率降到 1/6,而期望得分 3.5 分对「只差几分」的局面已经够用。
4 其余情况掷 6 个——Problem 9 实验出来的最优掷法。

第 3 条的分档怎么定的?纯粹是用「期望得分够不够填上缺口」倒推的:

掷几个不出 1 的概率 (5/6)^n不出 1 时的期望和 4n适合填多大的缺口
1≈ 83%4≤ 6 分
2≈ 69%8≤ 12 分
6≈ 33%24其余

核心权衡是:掷得越多,期望分越高,但「整回合只得 1 分」的概率也越高。当你只需要几分就能赢时,「稳」比「多」重要得多——掷 1 个有 83% 的概率拿到 2–6 分,而掷 6 个有 67% 的概率只拿到 1 分。终局阶段应该切换到低方差模式。

注意:这里用了 GOAL 而不是参数

final_strategy 的签名只有两个参数,拿不到 play 的 goal。所以代码里直接引用全局的 GOAL = 100。这在 goal 不是 100 的游戏里会算错「还差多少分」——但策略仍然合法(返回值仍是 0–10 的整数),只是不再最优。官方的 average_win_rate 用的正是默认 goal,所以不影响评测。这是一个「为了简单而接受的已知局限」,把它说清楚比假装没有这回事好。

代码

def final_strategy(score, opponent_score):
    """Roll 0 dice whenever that is enough to win or gains a lot of points,
    and otherwise roll 6 dice (the number that maximizes the average turn
    score with six-sided dice), rolling fewer dice when we only need a few
    more points to reach the goal.
    """
    # BEGIN PROBLEM 12
    # 1. If rolling 0 already wins the game, take the free win.
    if sus_update(0, score, opponent_score) >= GOAL:
        return 0
    # 2. Rolling 0 is worth it when Boar Brawl + Sus Fuss beat the ~8 points
    #    that 6 dice earn on average.
    if sus_update(0, score, opponent_score) - score >= 9:
        return 0
    # 3. Near the goal, roll just enough dice to get there and no more, which
    #    lowers the chance of Sow Sad ruining the turn.
    points_needed = GOAL - score
    if points_needed <= 6:
        return 1
    if points_needed <= 12:
        return 2
    return 6
    # END PROBLEM 12

逐行说明:

  • if sus_update(0, score, opponent_score) >= GOAL: return 0:比较的是总分,不减 score。这一条问的是「掷 0 个之后我的总分够不够 100」,和第二条问的「涨了多少」是两个不同的问题,所以一个减 score 一个不减。
  • 第二条 - score >= 9:净增量判断,形状和 sus_strategy 完全一样,只是门槛从 11 降到 9。这个数字是可调的——把它调到 8 或 10 也能跑,胜率差别在噪声范围内。
  • points_needed = GOAL - score:还差多少分。取名比直接写 100 - score 清楚得多。
  • if points_needed <= 6: return 1 / <= 12: return 2:两级降档。顺序不能反——如果先判 <= 12,那么 points_needed = 3 也会走到 return 2,第一档形同虚设。「从最窄的条件开始判」是写多分支的通用规则。
  • return 6:兜底。每条路径都有 return,且返回的都是整数——这正是 check_strategy 要检查的。
  • 没有调用 max_scoring_num_rolls:6 这个数字是写死的,来自 Problem 9 的实验结论,而不是运行时算出来的。这就是那条禁令想让你做的事——实验在开发时跑一次,结论固化进代码。
  • 这个策略调用了两次 sus_update(0, ...),重复计算了一遍。因为这条路径是纯的(不掷骰子),重复调用无副作用,只是略微浪费。想优化的话可以存成一个变量 zero_score = sus_update(0, score, opponent_score) 再用两次。

验证

先用几个具体局面手动追一遍决策:

局面 (score, opp)sus_update(0, ...)条件 1条件 2(增量)points_needed返回
(53, 60)6767 >= 100 ✗14 >= 9 ✓—0(拿下 14 分)
(95, 40)boar_brawl(95,40)=max(3·|4−5|,1)=3 → 98,因数 6 个非 sus → 9898 >= 100 ✗3 >= 9 ✗51(求稳收官)
(90, 71)boar_brawl(90,71)=max(3·|7−0|,1)=21 → 111,因数 1,3,37,111 共 4 个 是 sus → 113113 >= 100 ✓——0(直接赢)
(20, 20)boar_brawl(20,20)=max(3·|2−0|,1)=6 → 26,因数 4 个 是 sus → 29✗9 >= 9 ✓—0
(0, 0)boar_brawl(0,0)=1 → 1,1 个因数非 sus → 1✗1 >= 9 ✗1006(开局正常掷)

第四行值得多看一眼:(20, 20) 这个局面下,Boar Brawl 只给 6 分,够不到门槛 9;但加完变成 26 之后触发 Sus Fuss 跳到 29,净增量正好是 9,刚刚好卡在门槛上,于是掷 0。如果这里错用了 boar_brawl 而不是 sus_update,就会看到 6 分而放弃这次机会。

实测胜率

hog.py 里的 run_experiments() 会拿各种策略去和 always_roll(6) 对打。在本仓库跑(每个策略作为先手和后手各打 4000 局,共 8000 局)得到:

策略对 always_roll(6) 的胜率说明
always_roll(6)0.493自己打自己,理论值 0.5,偏差是采样噪声
catch_up0.506「落后时多掷一个」几乎没用
boar_strategy0.678只靠「该掷 0 时掷 0」就跳到近七成
sus_strategy0.679加上 Sus Fuss 的加成,与上一行差别在噪声内
final_strategy0.697再加上「白赢就赢」和「终局降档」
注意:这些数字有多可信

8000 局的采样标准误差大约是 sqrt(0.25 / 8000) ≈ 0.6%,所以两个策略相差不到约 1.5 个百分点时,你无法断言谁更强。boar_strategy 和 sus_strategy 的 0.678 与 0.679 就属于这种情况——用更少的试验次数跑,两者的先后顺序完全可能颠倒。本文档更早的一次 400 局试验里,final_strategy 的胜率甚至一度低于 sus_strategy。

题面给的参考值(boar 约 66–67%、sus 约 67–69%)与实测吻合。这本身就是这道题最重要的一课:当你的评价指标带随机性时,「A 比 B 好」是一个需要统计证据的断言,不是跑一次就能下的结论。想提高置信度只有一个办法——加大 times_called,用更多局数换更小的误差。

常见误区
  • 返回非整数:比如写了 return points_needed / 6。check_strategy 会报 ValueError: final_strategy(x, y) returned z, not an int.。
  • 返回值超出 0–10:虽然 check_strategy 只查类型,但 take_turn 的 assert 会在真正玩游戏时炸掉。
  • 调用 max_scoring_num_rolls:报 RuntimeError: Your final strategy should not call max_scoring_num_rolls.。
  • 分档条件顺序写反:先判宽的再判窄的,窄的那档永远走不到。
  • 某条分支漏了 return:返回 None,check_strategy 立刻报错;在真实游戏里则是 take_turn 的 assert type(num_rolls) == int 失败。
  • 在策略里掷骰子:比如调用 sus_update(6, ...) 来「预演掷 6 个的结果」。这会真的消耗骰子,把游戏搞乱。只有 num_rolls = 0 的预演是安全的。

整份作业回顾

如果只从这个项目里带走一样东西,应该是这句话:先把「规则」写成一堆互不知情的小函数,再用另一层函数把它们组装成实验设备。

回头看这十三道题的依赖图,它是一棵很整齐的树:

play(strategy0, strategy1, update, ..., dice, goal)
 ├── strategy0 / strategy1   <- 由 always_roll / boar_strategy / sus_strategy / final_strategy 提供
 └── update                  <- simple_update 或 sus_update
       └── take_turn
             ├── roll_dice   <- Sow Sad
             │     └── dice()
             └── boar_brawl  <- Boar Brawl
       └── sus_points        <- Sus Fuss
             ├── num_factors
             └── is_prime

评价层(把 play 当成实验设备):
    average_win_rate -> make_averaged(winner) -> play
    max_scoring_num_rolls -> make_averaged(roll_dice) -> roll_dice

每一层都只知道它下面那一层的接口,不知道实现。play 不知道 Sus Fuss 存在;make_averaged 不知道它在平均什么;sus_strategy 不知道下一个质数怎么找。正因为如此,换掉任何一块都不需要改其他地方——这就是「函数抽象」这四个字的实际含义。

题目核心手法迁移到哪里
Problem 0非纯函数:调用次数本身有后果后面所有涉及可变状态、迭代器、生成器的地方
Problem 1 roll_dice循环内只记账、循环外才结算;用标志变量记住「曾经发生过」任何「扫描一遍再下结论」的题;return 会终止循环这件事
Problem 2 boar_brawl用 // 和 % 取数位,不碰字符串数位相关的所有题(HW 1 的 digit、期中考的经典题型)
Problem 3 take_turn复用而非复制;名字在调用时才被解析理解为什么 monkey-patch 能生效;模块化设计
Problem 4 num_factors 等把定义直译成循环;线性搜索「第一个满足条件的」is_prime、找最小满足某性质的数、后面的效率分析
Problem 5 play状态机主循环;who = 1 - who;and/or 取反任何回合制模拟;德摩根定律的实际应用
Problem 6 always_roll闭包:把参数分两批交付Lecture 4 之后的一切;装饰器、柯里化、Cats 项目的 make_*
Problem 7 is_always_roll穷举验证全称命题:return False 在里、return True 在外所有「判断某性质是否对所有输入成立」的题
Problem 8 make_averaged*args 打包与拆包;装饰器形状Lecture 上的 trace、memo;hog_ui.py 的打印包装
Problem 9 max_scoring_num_rolls打擂台求最大值;「升序 + 严格大于」= 平局取小所有极值搜索;注意副作用次数的计算
Problem 10–12 策略调用真正的执行函数来预演决策,而不是重推逻辑博弈搜索、启发式设计;「昂贵计算不放热路径」

三个母题在这个项目里的答案

(a) 一个表达式被求值时到底发生了什么?Problem 0 的 test_dice() 给了最直白的演示:同一个表达式,第一次求值得到 4,第二次得到 1。因为求值不只是「查出一个值」,它还可能改变环境。Problem 3 的那条 monkey-patch 测试则展示了另一半:roll_dice(...) 里的 roll_dice 是在调用发生的那一刻才去查的名字,不是写代码时就钉死的。

(b) 名字和值怎么绑定?作用域为什么长这样?Problem 6 和 8 是标准答案。always_roll(3) 返回的函数之所以记得 3,是因为它的父帧是 always_roll 的那个帧,而那个帧因为被引用着所以不会消失。两次调用 always_roll 产生两个独立的帧,于是两个策略互不干扰——这是「用全局变量」永远做不到的。

(c) 怎么把大问题拆成同构的小问题?这个项目的答案不是递归,而是分层:把「一局游戏」拆成「一个回合」,把「一个回合」拆成「掷骰 + 规则修正」,再把「掷骰」拆成「重复调用骰子函数」。每一层都只处理自己那一层的事。递归会在下一个阶段(链表、树)成为主角,但分层抽象的思维在这里就已经完整登场了。

验证状态

本仓库 proj/hog/hog.py 的实现在 python3 ok --local 下输出:267 test cases passed! No cases failed. Problem 0 到 Problem 12 全部通过,包括 Problem 12 的合法性检查。本文中所有手动追踪的数值都在实际代码上复算过。