Hog掷骰子游戏:函数抽象与高阶函数的经典CS项目实战
很多第一次接触 Hog 项目的人看到题目描述第一反应都是这不就是个掷骰子游戏吗玩家轮流掷骰子先到 100 分谁赢规则再特殊一点也就是多几个加分条件感觉半天就能写完。真正动手做起来才发现这个项目的难点从来不在“掷骰子”而在于把游戏规则翻译成一层一层的函数抽象再通过测试驱动自己一步步把逻辑补全。它来自某门经典 CS 入门课 2020 年秋季的版本属于课程里第一个正经的大型项目重点考查函数、高阶函数、随机模拟和系统化调试能力。这篇文章我会按规则理解、项目骨架、核心实现、调试方法、策略实验、常见坑这样一个顺序把我做完这个项目的完整思路复述一遍无论你是正在被它折磨还是想搞清楚这类骰子游戏项目到底在练什么应该都能找到有用的内容。1. 项目规则拆解这真的只是一个掷骰子游戏吗1.1 基础计分与 Pig Out风险的第一步先看最核心的机制。两名玩家交替行动每回合从 0 到 10 之间选择一个数字代表本回合要掷几个骰子。如果所有骰子都没有出现 1那么本回合得分就是骰子点数之和只要任何一个骰子点数是 1本回合只能拿 1 分这个规则叫 Pig Out。举例来说你掷 3 个骰子结果是 2、5、2那么回合得分是 9但如果结果是 2、1、4因为出现了 1你的得分直接变成 1。这个规则很朴素但它是整个游戏风险收益的起点。骰子数量越多期望点数越高但同时至少出现一个 1 的概率也在飞速上升。掷 1 个骰子时 Pig Out 概率只有 1/6掷到 6 个骰子时这个概率已经超过 66%。项目的所有后续特殊规则都是在“基础得分 风险惩罚”这个框架上做文章。1.2 零骰子与 Sow Sad最容易被忽视的翻盘机制如果玩家选择 0 个骰子那就不调用骰子函数而是套用一个固定公式得分等于 3 乘以对手分数除以 8 后向下取整的平方。即score 3 * (opponent_score // 8) ** 2这条规则在 2020 秋季版本里被称为 Sow Sad。我刚开始看的时候觉得它很怪为什么掷 0 个骰子还能得分后来算了几个数就明白了。假设对手分数是 4242 // 8 5平方后是 25再乘 3你白拿 75 分直接逼近胜利。但如果对手分数只有 77 // 8 0你拿 0 分等于白送一回合。所以 Sow Sad 不是“躺平拿低保”而是一个高风险的翻盘按钮。它鼓励玩家在落后很多、常规掷骰子追赶无望的时候赌一把对手的高分区间用一次 0 骰子直接抹平差距。1.3 Swine Swap 与 Feral Hogs让最优策略失效的规则2020 版的 Hog 项目还有两条特殊规则这俩才是项目真正烧脑的地方。第一条叫 Swine Swap。每次回合结束后计算两名玩家当前总分之差如果这个差的绝对值是一个平方数比如 1、4、9、16那么两名玩家的总分直接交换。举个例子当前比分是 5 比 5你本回合掷骰子拿到 4 分比分变成 9 比 5分差是 44 是 2 的平方于是触发交换比分瞬间变成 5 比 9原本得分的你反而落后了。这个规则让“领先越多越安全”的想法彻底失效因为一旦分差落到平方数上领先优势会被直接送给对手。第二条叫 Feral Hogs。玩家本回合的得分不包含 Feral Hogs 奖励本身与掷骰子数之间的绝对差如果正好等于 3就额外加 3 分。举个例子你掷 4 个骰子点数之和是 7|7 - 4| 3那么最终回合得分是 7 3 10。又比如你掷 5 个骰子但因为 Pig Out 得了 1 分|1 - 5| 4不满足条件所以不触发额外加分。这两条规则放在一起游戏的“最优策略”就不再是一个可以用直觉拍出来的答案而是需要靠大量模拟实验去摸索。我后面专门用了一节讲这件事。2. 项目骨架拆解官方给了你哪些函数2.1 为什么项目要拆成这么多小函数很多人第一次看到 Hog 项目的代码框架第一反应是规则我都懂但为什么要拆成七八个函数其实这正是项目的教学目标。如果全部逻辑写在一个大循环里你很难定位“到底是骰子模拟错了还是交换分数判断错了”。拆成小函数之后每个函数只负责一件明确的事谬误可以被精确隔离。这也是为什么课程会配套一堆针对单一函数的测试用例。2.2 各个阶段的关卡列表以我复现的 2020 秋季版本为例项目的函数大致可以分成以下几个阶段第一阶段是骰子与回合计算核心函数是roll_dice和take_turn。第二阶段是特殊交换规则核心函数是is_swap用来判断当前总分差是否为平方数。第三阶段是策略构造核心函数是always_roll、make_averaged和max_scoring_num_rolls。第四阶段是对局主循环核心函数是play把前面所有函数串起来跑完整游戏。第五阶段是可选的可视化与解说属于加分性质。不同年份的 Hog 项目细节会有差异比如规则的名称或计算顺序可能微调但整体骨架基本一致。2.3 每个函数的输入输出边界做这个项目最忌讳的是“知道大概要写什么但搞不清每个函数的输入输出到底是什么”。我在这里列一份我自己实现的边界笔记你可以把它当作字典来查。roll_dice(num_rolls, dicesix_sided)输入骰子数量和骰子函数返回本回合的基础得分。需要自己处理 Pig Out且要保证每次调用都会消耗对应次数的随机骰子。take_turn(num_rolls, opponent_score, dicesix_sided)输入骰子数量、对手分数和骰子函数返回回合最终得分。需要处理 0 骰子的 Sow Sad 规则和 Feral Hogs 奖励。is_swap(score0, score1)输入双方总分返回布尔值表示是否触发交换。always_roll(n)返回一个新的“策略函数”。这个策略函数接收两个分数永远返回 n。make_averaged(function, total_samples)接收原始函数和采样次数返回一个新函数。新函数每次被调用时都会执行原始函数 total_samples 次然后返回平均值。max_scoring_num_rolls(dice, total_samples)返回一个骰子数量使得在当前规则下平均得分最高。play(strategy0, strategy1, ...)模拟整场对局返回最终双方分数。我建议刚开始动手前先把这个列表抄在纸上每写完一个函数就在旁边打个勾。项目看着函数多实际上每个函数都能独立测试心态会稳定很多。3. 核心函数的实现思路规则如何变成代码3.1 roll_dice 的随机性陷阱roll_dice看起来很简单就是循环掷骰子但有一个特别容易踩的坑当你发现某个骰子是 1 时不能立刻return 1。你需要先记录“本回合出现了 1”然后把剩余骰子数量全部掷完最后再返回 1。为什么要这么做因为骰子函数不是普通随机数它依赖内部状态。如果你提前返回后续调用骰子函数的次数就变少了整个随机序列会错位。后面所有模拟实验的结果都会跟着偏移而且这种错误非常隐蔽表面上看每个测试都能过但在完整对局模拟时容易出现“同样的策略不同运行结果差异巨大”的诡异现象。我当时写了一个简化版本的实现思路def roll_dice(num_rolls, dicesix_sided): total 0 pig_out False for _ in range(num_rolls): outcome dice() if outcome 1: pig_out True else: total outcome return 1 if pig_out else total注意这里即使在pig_out True之后循环依然会继续执行这样才能保证随机函数被完整调用。3.2 take_turn 里的规则顺序take_turn负责把单回合规则完整计算出来。它要先判断num_rolls是否为 0是则走 Sow Sad 公式否则先调用roll_dice拿到基础分再判断是否触发 Feral Hogs 的额外 3 分。需要特别注意 Feral Hogs 的判定时机它看的是“还没加额外奖励之前的回合得分”与掷骰子数的绝对差。所以代码里面第一步拿到基础分之后先别急着返回先做一次绝对差判断。def take_turn(num_rolls, opponent_score, dicesix_sided): if num_rolls 0: return 3 * (opponent_score // 8) ** 2 turn_total roll_dice(num_rolls, dice) if abs(turn_total - num_rolls) 3: turn_total 3 return turn_total这个顺序看起来不起眼但错一步全盘皆输。我见过有同学先加 Feral 分再去判断结果把很多本来不满足条件的回合也算进去了。规则翻译成代码最关键的不是语法而是时机。3.3 make_averaged 的高阶函数写法这个函数是项目里最有“CS 味儿”的部分。make_averaged接收一个原始函数返回一个新的函数这个新函数会在内部反复调用原始函数并返回平均结果。def make_averaged(original_function, total_samples10000): def averaged_function(*args): total 0 for _ in range(total_samples): total original_function(*args) return total / total_samples return averaged_function这里最核心的概念是“函数可以作为参数传递也可以作为返回值返回”。always_roll返回一个函数make_averaged也返回一个函数这就是高阶函数。很多同学一开始不习惯这种写法总觉得“函数返回函数”很绕。我的理解方式很简单你可以把函数想象成一台机器高阶函数就是“制造机器的机器”。它的存在价值在于你可以动态地给同一套逻辑注入不同的行为。比如说你想知道“每次都掷 4 个骰子”的平均得分就可以先构造一个策略再把它包进make_averaged。这样你不需要手写一万次循环只需要把原始函数交给包装器即可。3.4 play 主循环与交换分数的胜负判定play是唯一一个把所有规则串起来的长函数。它内部有一个 while 循环每次处理一个玩家的回合。回合结束之后先更新当前玩家分数然后调用is_swap判断是否触发交换。这里有一个非常关键的地方胜负判定必须放在交换判断之后。举个例子当前比分是 A 玩家 96 分B 玩家 95 分。A 本回合得到 3 分变成 99 比 95分差是 4触发 Swine Swap于是比分变成 95 比 99。此时 B 是 99 分离目标只差 1A 反而变成 95 分。如果你在更新分数后立刻判断“A 到没到 100”就会判定错误因为在 exchange 之后领先者已经换了人。正确的简化逻辑是def play(strategy0, strategy1, score00, score10, dicesix_sided, goal100): player 0 while score0 goal and score1 goal: if player 0: rolls strategy0(score0, score1) score0 take_turn(rolls, score1, dice) else: rolls strategy1(score1, score0) score1 take_turn(rolls, score0, dice) if is_swap(score0, score1): score0, score1 score1, score0 player 1 - player return score0, score1我这里为了展示结构省略了say之类的解说参数实际项目里还会有一些界面相关的东西但核心循环的骨架就是这样。4. 调试与测试我踩过的坑和确定 bug 的方法4.1 随机种子是调试的定海神针Hog 项目最大的调试难点在于“随机”。每次运行结果都不同你很难判断一个 bug 是代码问题还是随机波动。这时候一定要学会固定随机种子。import random random.seed(0)在测试脚本或调试代码开头加上这一行之后你每次运行遇到的骰子序列就都是固定的。尤其是当你怀疑“Pig Out 提前返回导致随机序列错位”的时候固定种子能让你精确对比两次不同实现之间的差异到底出在哪一步。但要注意一点课程自带的测试框架通常会用自己的方式控制随机性你自己调试时加的random.seed只是辅助工具千万不要把它写进最终提交的核心逻辑里。4.2 三个最容易写错的位置我前前后后做完整个项目复盘时发现最容易错的位置就三个。第一个是roll_dice的 Pig Out 提前返回。很多人一看到骰子出现 1 就立刻return 1导致剩余骰子没有被消费随机序列错位。第二个是针对 Feral Hogs 的判定时机。必须先拿到基础回合得分再判断绝对差最后把奖励加上去。奖励不能参与自身触发条件的判断。第三个是 Swine Swap 与胜负判定的顺序。前面已经提过交换发生在每个回合结束后胜负判断要放在交换之后做否则领先者可能被“偷”走胜利。这些位置之所以容易错是因为它们都涉及“规则的时序”而人脑阅读规则时往往只关注“做什么”不关注“先后”。4.3 手算用例怎么构造在依赖课程测试之前我自己会先构造几个能心算的小用例不断验证函数逻辑。这里有个很实用的技巧写一个固定返回某个值的骰子函数。def make_fake_dice(value): def fake_dice(): return value return fake_dice假设你调用roll_dice(3, make_fake_dice(4))结果必然是 12调用roll_dice(3, make_fake_dice(1))结果必然是 1。这样的手算用例可以快速排除骰子模拟的错误。计算 Sow Sad 时也一样。对手分数是 42手算出 75 分对手分数是 7手算出 0 分。如果代码结果和手算不一致那一定是公式写错了。这种“先手算再写断言”的习惯能让你在大量测试用例之前就解决掉 80% 的问题。5. 策略优化找最优掷骰子数的完整实验5.1 为什么不能用直觉定策略很多人会凭感觉说“多掷几个骰子平均分高呗但掷多了容易出 1那大概掷 3 个最好。”这种直觉有一定道理不过在 Feral Hogs 和 Swine Swap 存在的情况下单凭直觉很容易被误导。比如 0 骰子的得分高度依赖对手分数在对手领先时它可能是收益最高的选择而某些骰子数恰好因为 Feral Hogs 的“差值 3”规则而收益偏高。正确的做法是把每个候选骰子数都放进模拟器里跑大量样本用平均得分说话。5.2 实验设计分区间、大批量、看期望我的实验思路分三步。第一步固定骰子数 n从 0 到 10 逐个测试。第二步对于 n 大于 0 的情况直接调take_turn(n, opponent_score)模拟大量次数求平均对于 n 等于 0 的情况因为得分完全取决于对手分数我会把对手分数分成几个区间分别测试。第三步把每种骰子数得到的平均得分列成表选出每个区间下的最优值。用课程提供的高阶函数来做这个实验非常顺手。比如我想知道掷 4 个骰子在对手分数 50 时的平均得分可以这样构造ave4 make_averaged(lambda: take_turn(4, 50)) print(ave4())注意这里我用take_turn的参数传递让每次模拟都固定使用相同的对手分这样比较的才是“同一条件下的策略收益”。5.3 实验结果与启发式解释我当时跑出来的粗略规律是这样的骰子数至少出现 1 的概率大致平均得分1约 16.7%3.5 左右2约 30.6%5.2 左右3约 42.1%6.3 左右4约 51.8%6.1 左右5约 59.8%5.3 左右6约 66.5%4.0 左右7 及以上超过 72%明显下降这个表只是粗略估算因为没有计入 Feral Hogs 的奖励变化但规律已经很明显最优区间基本落在 3 到 4 个骰子之间。再往后Pig Out 惩罚的概率太大期望收益会被明显拉低。更有意思的是 0 骰子策略。当对手分数在 8 到 15 之间时0 骰子只能拿 3 分完全不值得但当对手分数超过 40 时0 骰子一次可以拿 75 分甚至更多远超普通掷骰子的单回合收益。所以最优策略不是“永远掷 X 个骰子”而是“根据当前比分动态调整”。5.4 用胜率验证策略而不是单凭期望单回合期望得分高不代表整局胜率一定高因为回合是交互的。你可能单回合拿到高分却也把双方分差推到了平方数上触发 Swine Swap反而送掉优势。所以我建议做完期望分析后再用play函数做完整的“策略对战”验证。我当时的做法是写一个循环让策略 A 和策略 B 对战一万局统计 A 的胜率。这种胜率实验比单回合期望更接近真实结果也能顺便验证play的逻辑是否正确。在跑胜率实验时我注意到一个小细节很多看起来“数学期望很高”的策略实战胜率反而不如预期因为它们更容易把分差送到平方数上。Swine Swap 的破坏力真的只有跑完完整对局才能感受到。6. 做完这个项目我觉得最值得保留的习惯6.1 常见错误的提醒清单现在回头看我踩过的坑差不多可以浓缩成一份提醒清单。如果你正在写这个项目建议每完成一个函数就对照一下roll_dice必须在出现 1 后继续掷完剩余骰子。take_turn要先算基础分再判 Feral Hogs。is_swap检查的是当前总分差的绝对值是否为平方数而不是某一方得分是否为平方数。play的胜负判断必须在交换分数之后。make_averaged返回的是新函数不是数值调用时别忘记加括号执行。0 骰子策略的收益完全依赖对手分数不要和固定骰子数的平均值混在一起比较。这六条里面任意一条错了都可能导致测试随机失败而且非常难排查。6.2 我自己真正学到的三件事第一件事是“先把规则读满三遍再动手”。看着 Hog 项目规则不长但 Swine Swap 和 Feral Hogs 这种“反直觉规则”特别容易在实现时被简化或漏掉。我后来养成了用表格把每条规则、触发时机、影响变量列出来的习惯写代码时对照表格基本不会再出现顺序错误。第二件事是“测试能帮你定位但真正的手算是你的底气”。课程自带的测试当然重要但在测试失败时你如果手上没有手算用例就只能瞎猜。我的建议是先给自己造几个固定骰子、固定比分的小场景把结果算清楚再去看哪些测试没过。第三件事是“高阶函数值得花时间慢慢体会”。make_averaged这种返回函数的写法第一次遇到会觉得别扭但一旦习惯了你会发现它让代码的复用性提升了不止一个档次。后面很多更复杂的项目都用的是同一套思想把行为作为参数传递把函数作为返回值组合。Hog 项目等于是在一个游戏里把这种思想练到了极致。如果让我用一句话总结这个项目的价值我会说它让你在写一个看起来很好玩的游戏时被迫把函数、随机、规则时序、实验设计这些基本功全部过了一遍。做完之后你再看其他复杂项目的函数抽象基本不会再发怵。最后再分享一个我的小习惯遇到 bug 卡了三十分钟以上不要继续盯着代码改先把游戏规则文本打印出来对着规则逐行读自己的代码大部分问题都出在“规则时序”和“代码顺序”没有一一对应上。