- 示例工程
【免费下载链接】basic-computer-games
An updated version of the classic "Basic Computer Games" book, with well-written examples in a variety of common MEMORY SAFE, SCRIPTING programming languages. See https://coding-horror.github.io/basic-computer-games/
导读
本文基于 34_Digits/README.md 及其所在仓库的完整源码,深入剖析 DIGITS 这款经典猜数字游戏:玩家随机写下 30 个 0/1/2 数字,计算机则借助三个"记忆矩阵"和一条加权求和方程,实时统计你的数字序列特征并逐位预测下一个数字。你将了解到游戏规则、猜测方程的逐行数学拆解、状态变量的滚动更新机制、原始代码中"神秘常量"与A恒为 0 的历史怪癖,以及同一算法在 BASIC、Python、Java、C#、JavaScript、Perl 中的移植实现差异与运行方式。
一、游戏是什么:规则与玩法
DIGITS 是一个"人机对抗的序列预测"游戏,玩法极其简单:
- 玩家先取一张纸,随机写下 30 个数字,每个数字只能是 0、1 或 2,排成三行、每行 10 个;
- 计算机分三轮向玩家索要数字,每轮 10 个;
- 对每个数字,计算机总是先猜测,再查看玩家给出的真实数字,判断自己是否猜中;
- 连续 30 次预测之后,统计猜中次数,判定胜负。
游戏的核心悬念在于:如果纯粹靠运气(0/1/2 各占 1/3),计算机 30 次预测的期望命中数是10 次(即 1/3)。但正如 README 所述:"It is uncanny how much better it generally does than that!"——由于它会对玩家刚输入的数字序列做统计学习,实际表现往往远超 10 次,这正是本游戏的乐趣所在。
README 同时记载了该程序的出处:程序起源于达特茅斯学院(Dartmouth),原作者不详;它作为《Basic Computer Games》(1978)中的经典程序之一被收录,原版程序由 Vintage Basic 网站流传而来。完整规则说明也原样保留在各移植版的指令文本中,例如 34_Digits/csharp/Resources/Instructions.txt。
二、核心算法:猜测方程的逐行拆解
整个游戏的核心只有一条方程,位于原始 BASIC 源码 34_Digits/digits.bas 的第 700 行:
700 S1=A*K(Z2,J)+B*L(Z1,J)+C*M(Z,J)它对候选数字J(J = 0、1、2)分别计算一个加权得分S1,得分最高的候选即为计算机的猜测。三个加权项分别来自三个"记忆矩阵":
| 项 | 系数 | 矩阵 | 行索引 | 含义 |
|---|---|---|---|---|
A*K(Z2,J) | A = 0 | K(2,2) | Z2 = 上一个真实数字 | 按"上一数字"统计的历史频率 |
B*L(Z1,J) | B = 1 | L(8,2) | Z1 = 最近两位组合编码 | 按"最近两位组合"统计的历史频率 |
C*M(Z,J) | C = 3 | M(26,2) | Z = 两位历史三进制状态 | 按"历史状态"统计的历史频率 |
系数 A = 0、B = 1、C = 3 在 BASIC 中通过DATA 0,1,3读取(digits.bas)。注意:C 的权重(3)远高于 B(1),即"两位历史状态" M 矩阵对猜测的贡献最大,这暗示原作者的意图是捕捉数字序列中的二阶(bigram)统计规律。
猜测的选择逻辑(digits.bas):
690 FOR J=0 TO 2 700 S1=A*K(Z2,J)+B*L(Z1,J)+C*M(Z,J) 710 IF S>S1 THEN 760 720 IF S<S1 THEN 740 730 IF RND(1)<.5 THEN 760 740 S=S1: G=J 760 NEXT J即:初始S=0,遍历 J 后取S1最大的 J 作为猜测G;若两个候选得分相等(平局),则抛一枚硬币(RND(1)<.5,50% 概率)随机改选——这是一个让行为不至于死板的随机扰动机制。所有移植版都忠实保留了这一逻辑,例如 Python 版 34_Digits/python/Digits.py:
if s < s1: s = s1 my_guess = j elif s1 == s and random.random() >= 0.5: my_guess = j三、三个记忆矩阵与状态变量
3.1 矩阵初始化
BASIC 中三个矩阵的初始化(digits.bas):
400 FOR I=0 TO 26: FOR J=0 TO 2: M(I,J)=1: NEXT J: NEXT I ' M 全部填 1 410 FOR I=0 TO 2: FOR J=0 TO 2: K(I,J)=9: NEXT J: NEXT I ' K 全部填 9 420 FOR I=0 TO 8: FOR J=0 TO 2: L(I,J)=3: NEXT J: NEXT I ' L 全部填 3 450 L(0,0)=2: L(4,1)=2: L(8,2)=2 ' 但对角线三项改为 2- M(27×3):27 行对应 27 个"两位历史状态"(下文详述),每行 3 列对应候选数字 0/1/2,初值全部为 1;
- L(9×3):9 行对应两位组合的 9 种编码,初值为 3,但
L(0,0)=L(4,1)=L(8,2)=2三个对角线位置被压低到 2——同样属于 README 所说的"看似随意的神秘常量"; - K(3×3):3 行对应上一数字,初值全部为 9。
3.2 状态变量 Z、Z1、Z2 的滚动更新
三个矩阵的行索引不是固定的,而是随游戏进程滚动变化的:
480 Z=26: Z1=8: Z2=2 ' 初始状态 ... 830 M(Z,N)=M(Z,N)+1 840 L(Z1,N)=L(Z1,N)+1 850 K(Z2,N)=K(Z2,N)+1 860 Z=Z-INT(Z/9)*9 ' 等价于 Z = Z MOD 9 870 Z=3*Z+N(U) ' 三进制左移并入新数字 ... 880 Z1=Z-INT(Z/9)*9 ' Z1 = Z MOD 9 890 Z2=N(U) ' Z2 = 当前真实数字从源码结构可以推断:Z 是一个以 3 为底的"最近两位数字"状态编码。Z = 3*a + b中a对应倒数第二个数字、b对应最近一个数字,取值 0~26;每当猜中并观察到新数字N后,先取Z MOD 9丢弃最高位,再3*Z+N并入新数字,从而滚动记录最新的两位组合。Z1 = Z MOD 9是两位组合的 0~8 编码,用作 L 矩阵的行索引;Z2就是当前真实数字,用作 K 矩阵的行索引。
因此三个矩阵实际构成了不同粒度的"条件计数表":
M[Z][j]:在历史状态 Z(最近两位数字)之后出现 j 的次数(二阶统计);L[Z1][j]:在两位组合 Z1 之后出现 j 的次数(一阶/二阶折中);K[Z2][j]:在数字 Z2 之后出现 j 的次数(一阶统计)。
得分方程S1 = 0*K + 1*L + 3*M实质是对三个粒度的条件频率做加权投票,权重偏向更精细的二阶状态。而权重最高的 M 矩阵之所以初值填 1、L 填 3、K 填 9,是一种"拉普拉斯平滑"式的先验设计——初值越大,冷启动阶段对某种数字的倾向越保守。
3.3 面向对象版:C# 的实现印证
C# 移植版将这套逻辑清晰地对象化,见 34_Digits/csharp/Memory.cs 与 34_Digits/csharp/Matrix.cs:
_matrices = new[] { new Matrix(27, 3, (_, _) => 1), // M:27 行,权重 3,全 1 new Matrix(9, 1, (i, j) => i == 4 * j ? 2 : 3), // L:9 行,权重 1,对角线 2 new Matrix(3, 0, (_, _) => 9) // K:3 行,权重 0,全 9 };C# 版把原来散落在 BASIC 全局变量里的系数 A/B/C内嵌为 Matrix 对象的weight字段(3、1、0 分别对应 M、L、K),每次猜测时对三个矩阵的加权值求和(34_Digits/csharp/Guesser.cs),学习更新则集中在ObserveDigit中:三个矩阵各自IncrementValue,并同步滚动Index(对应 BASIC 的 Z/Z1/Z2)。这与 BASIC 原版的逐行逻辑一一对应,可以作为理解原始方程的最佳"带注释版本"。
四、输入校验与游戏流程
每轮输入 10 个数字时,程序会强制校验取值范围。BASIC 的写法相当精妙(digits.bas):
570 W=N(I)-1 580 IF W=SGN(W) THEN 620 ' 只有 N=0、1、2 时 W 才等于 SGN(W) 590 PRINT "ONLY USE THE DIGITS '0', '1', OR '2'." 600 PRINT "LET'S TRY AGAIN.":GOTO 530即对每个输入数字减 1 后与符号函数比较,巧妙地排除了 0/1/2 以外的任何整数;非法的整轮输入都会被驳回重输。移植版则改用更直白的范围判断,如 Python 版 34_Digits/python/Digits.py 的if number < 0 or number > 2,并在读取时对非数字输入提示!NUMBER EXPECTED - RETRY INPUT LINE。
三轮共 30 个数字猜测完毕后,进入胜负判定(digits.bas):
- X > 10:
I GUESSED MORE THAN 1/3 OF YOUR NUMBERS. I WIN.(计算机赢,BASIC 还会连响 10 声铃CHR$(7)); - X == 10:
I GUESSED EXACTLY 1/3 OF YOUR NUMBERS. IT'S A TIE GAME.(平局,恰等于纯随机的期望值); - X < 10:
I GUESSED LESS THAN 1/3 OF YOUR NUMBERS. YOU BEAT ME. CONGRATULATIONS *****(玩家赢)。
随后询问是否再来一局(DO YOU WANT TO TRY AGAIN (1 FOR YES, 0 FOR NO)),选择继续则重新初始化三个矩阵和状态变量(回到第 400 行),保证每局之间互不影响。整个游戏主循环在 C# 版中被拆分为GameSeries.Play()与Game.Play()(34_Digits/csharp/Game.cs),GameSeries负责"介绍 → 指令 → 循环开局 → 告别",Game负责单局的三轮预测,结构比原始 BASIC 的 GOTO 网清晰得多。
五、移植注意事项:神秘常量与 A=0 的怪癖
README 的 Porting Notes 部分给出了两个重要的移植警示,这两点在源码中都能得到印证:
- The program contains a lot of mysterious and seemingly arbitrary constants. It's not clear there is any logic or rationality behind it.
- The key equation involved in the guess (line 700) involves a factor of
A, butAis always 0, making that term meaningless. As a result, all the work to build and update array K and value Z2 appear to be meaningless, too.
其一,神秘常量遍地:矩阵初值 1/9/3、L 的三个对角线值 2、Z/Z1/Z2 的初始 26/8/2、系数 0/1/3……这些数值从算法层面难以完全解释其设计动机,移植时照搬即可,不必强行赋予意义。
其二,A 恒为 0 导致 K 矩阵与 Z2 的更新成为"死代码":猜测方程第 700 行S1=A*K(Z2,J)+B*L(Z1,J)+C*M(Z,J)中,A通过DATA 0,1,3恒定为 0,因此A*K(Z2,J)这一项对最终得分永远没有贡献。相应地,第 850 行K(Z2,N)=K(Z2,N)+1对 K 矩阵的更新、以及第 890 行对 Z2 的维护,从结果上看都属于无用功。Python 移植版的作者在源码中直接以注释标注了这一发现(34_Digits/python/Digits.py):
# What did the original author have in mind ? # The first expression always results in 0 because a is always 0有趣的是,C# 版把 A=0 这个怪癖"固化"进了设计:K 矩阵的weight参数正是 0(34_Digits/csharp/Memory.cs),同时在ObserveDigit中仍然忠实执行 K 矩阵的更新与 Z2 的滚动——即移植版完整保留了原始代码的结构与行为,包括其"无效"的部分,这对理解原始程序、保持跨语言行为一致而言,反而是值得肯定的移植态度。这也解释了为何 README 建议移植者"照原样翻译",而不是"自作聪明地删掉 K 矩阵"。
六、多语言移植与运行方式
DIGITS 是该仓库中移植语言最丰富的游戏之一,除原始 BASIC 外至少包含 6 种实现,且各版本逻辑逐行对应:
| 语言 | 文件 | 特点 |
|---|---|---|
| BASIC | 34_Digits/digits.bas | 原始版,GOTO 结构,RND/CHR$(7)等经典语法 |
| Python | 34_Digits/python/Digits.py | 函数化拆分,附"原作者意图"注释 |
| Java | 34_Digits/java/Digits.java | 单文件Digits类,printf对齐表格输出 |
| C# | 34_Digits/csharp/Program.cs | OO 重构,Guesser/Memory/Matrix分层,文本资源外置 |
| JavaScript | 34_Digits/javascript/digits.js | 浏览器交互版,async/await模拟输入 |
| Perl | 34_Digits/perl/digits.pl | 1-based 数组技巧("0 " . $Answer占位) |
各版本的运行方式:
- BASIC:使用 Vintage BASIC 等兼容解释器加载 34_Digits/digits.bas 后
RUN; - Python:
python3 34_Digits/python/Digits.py(需要 Python 3,使用了typing与random标准库); - Java:编译并运行 34_Digits/java/Digits.java(
javac+java Digits,依赖java.util.Scanner); - C#:
dotnet run --project 34_Digits/csharp/Digits.csproj,该项目面向 .NET 6.0(见 34_Digits/csharp/Digits.csproj),并引用仓库公共库00_Common/dotnet/Games.Common; - JavaScript:直接用浏览器打开 34_Digits/javascript/digits.html,在页面中交互;
- Perl:
perl 34_Digits/perl/digits.pl(依赖use strict; use warnings;)。
七、小结
DIGITS 是一个"以简驭繁"的经典案例:30 行规则 + 1 条加权方程 + 3 个计数矩阵,就让一台 1978 年的计算机具备了超越纯随机的"模式识别"能力。透过 34_Digits/README.md 的规则说明与移植笔记,再对照 34_Digits/digits.bas 及各语言移植版,可以清晰看到:
- 猜测的本质是对一阶/二阶条件频率的加权投票,系数 0/1/3 与矩阵初值构成隐式的先验平滑;
Z/Z1/Z2是一个滚动更新的三进制历史状态机,是整条算法的"记忆指针";- 原版代码中
A恒为 0、K 矩阵与 Z2 更新无意义,属于忠实移植时必须知晓的历史遗留怪癖; - 各移植版(尤其 C# 的对象化重构)为理解这套算法提供了极佳的"可读性增强版本"。
如果你对"让计算机学会猜你"的统计游戏感兴趣,DIGITS 是一个小而完整的入门样例——既适合作为学习马尔可夫式条件计数思想的练手项目,也适合作为跨语言移植对比的基准程序。
- 示例工程
【免费下载链接】basic-computer-games
An updated version of the classic "Basic Computer Games" book, with well-written examples in a variety of common MEMORY SAFE, SCRIPTING programming languages. See https://coding-horror.github.io/basic-computer-games/
相关推荐
Archon Monorepo 上下文预热指南:用 Prime 命令为 AI Agent 建立完整的代码库认知
Archon Monorepo 上下文预热指南:用 Prime 命令为 AI Agent 建立完整的代码库认知 本文围绕 Archon 仓库中面向编码 Agen
示例工程basic-computer-games 的 Bombardment 替代语言移植:从 1978 年 BASIC 到 Go 与 MiniScript 的实战剖析
basic computer games 的 Bombardment 替代语言移植:从 1978 年 BASIC 到 Go 与 MiniScript 的实战剖析
示例工程Basic Computer Games 之 Tower:从汉诺塔传说看 1978 年经典 BASIC 游戏的多语言移植
Basic Computer Games 之 Tower:从汉诺塔传说看 1978 年经典 BASIC 游戏的多语言移植 导读 本文围绕经典书籍 Basic C
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考