作为常年混迹各类 OJ 的人,我第一次在训练题单里看到 UVA11956 这道题时,第一反应是:写个 Brainfuck 解释器?那不是几分钟的事。可真到动手提交才发现,这类标着【模拟】的题,坑全埋在看似理所当然的细节里——内存到底多大、指针越界怎么处理、字节溢出往哪回绕、十六进制输出是大写还是小写、行尾要不要多一个空格。任何一个点没跟题面对齐,就是一记 WA。这篇文章就把我从读题到 AC 的完整思路、可提交代码,以及实际踩过的坑全部拆开讲,适合刚开始刷模拟题的选手,也适合想快速找回状态的老手。
先交代清楚问题本质:UVA11956 要求实现一个简化版 Brainfuck 虚拟机的执行模拟,程序执行完毕后,把全部 100 个内存单元的十六进制值依次输出。它没有复杂的搜索、没有数学推导、没有高深的算法,唯一的任务就是把语言定义翻译成代码逻辑,并把所有边界条件想清楚。这也是我把它视为"状态模拟入门第一课"的原因。
1. 读懂题面:这不是你熟悉的那个 Brainfuck
1.1 题面里最容易被忽略的三个约束
UVA11956 的题面很短,短到很多人扫一眼就去写,结果一交就错。我先把里面的硬约束列出来:
- 内存单元个数是100 个,不是经典 Brainfuck 里的 30000 个。指针在 99 的位置再执行
>会回到 0,在 0 的位置执行<会跳到 99。也就是说,内存是一条首尾相接的环形带子。 - 每个单元是一个8 位无符号整数,取值 0 到 255。对 255 执行
+会回到 0,对 0 执行-会变成 255。 - 程序由且仅由
> < + - .这五种指令组成,其中.在本题里不影响最终内存状态——它的本意是输出当前单元,但本题要求的是执行结束后统一输出整个内存,所以模拟时可以直接跳过。
这三条看起来简单,但每条都是 WA 的高发区。尤其是第一点:把 100 写成 30000 的人不在少数。这不只是数组大小的问题,还直接改变指针回绕的位置——你在 100 上取模,别人在 30000 上取模,程序一长,执行结果完全不一样。
1.2 为什么出题人选中这门"玩具语言"来考模拟
Brainfuck 是 1993 年出现的一门极小化语言,一共只有 8 条指令,却能图灵完备,意思是理论上它能算出任何可计算函数。出题人选它,绝不是想让选手研究冷门语言,而是想用一套极其干净的状态转移规则来考察基本功:你能不能把一个明确给出的状态机翻译成代码,并正确处理所有边界。
模拟类题目的本质就是维护状态、执行转移、输出结果。Brainfuck 的状态恰好由两部分组成:指针位置和内存内容。每执行一条指令,就是一次确定性的转移。这类题在面试里也经常以"手写一个简易解释器"的形式出现,所以吃透这一道,收益远不止一个 AC。
1.3 手算样例:把状态变化摊在纸面上
我拿到题目时做的第一件事,不是马上写代码,而是找一段小程序,把指针和内存的每一步变化都写在纸上。比如程序是++>+++<--,初始时 100 个单元全为 0,指针 p = 0:
| 步骤 | 当前指令 | p | mem[0] | mem[1] |
|---|---|---|---|---|
| 初始 | — | 0 | 00 | 00 |
| 1 | + | 0 | 01 | 00 |
| 2 | + | 0 | 02 | 00 |
| 3 | > | 1 | 02 | 00 |
| 4 | + | 1 | 02 | 01 |
| 5 | + | 1 | 02 | 02 |
| 6 | + | 1 | 02 | 03 |
| 7 | < | 0 | 02 | 03 |
| 8 | - | 0 | 01 | 03 |
| 9 | - | 0 | 00 | 03 |
执行完之后,mem[0] = 00,mem[1] = 03,其余 98 个单元保持 00。所以输出应当是:
Case 1: 00 03 00 00 ... (后面 98 个 00)这个手算过程看起来很笨,但它逼着你把"指针回绕、字节回绕、输出格式"三个关键点全部过一遍。当年我偷懒跳过这一步,直接写代码,结果样例输出跟手算对不上,回头排查反而花了更多时间。
2. 模拟器的核心状态:指针、内存与回绕逻辑
2.1 指针移动的本质是取模运算
先看指针。指针是一个 0 到 99 的整数,初始为 0。>让指针加 1,<让指针减 1,但都不是普通的加减,因为边界要环绕。换句话讲,指针的合法范围是一个长度为 100 的环,写成公式就非常干净:
- 执行
>:p = (p + 1) % 100 - 执行
<:p = (p - 1 + 100) % 100
这个(p - 1 + 100) % 100是很多人第一次看会愣一下的写法。它的来源是(p - 1) % 100,但 C/C++ 里负数取模的结果可能是负数,比如(-1) % 100在多数编译器里结果是 -1,而不是 99,直接导致数组越界。加上 100 再取模,就是为了保证被除数非负。也有人写成p--; if (p < 0) p = 99;,同样没问题,但取模写法更统一、更不容易漏,尤其是当你把内存长度定义成常量宏时,改一个SIZE就能适配题目。
实际效果可以用一张表看明白:
| 操作 | 执行前 p | 执行后 p |
|---|---|---|
| > | 99 | 0 |
| > | 0 | 1 |
| < | 0 | 99 |
| < | 99 | 98 |
举个例子,程序是<<,初始 p = 0:第一次<之后 p 变成 99,第二次<之后 p 变成 98。如果不做加 100 的保护,第一次就炸了。
2.2 字节增量溢出:unsigned char 帮你自动回绕
内存单元是 8 位无符号数,所以+是加 1 后对 256 取模,-是减 1 后对 256 取模。这里最省事的做法是直接使用unsigned char类型的数组:
unsigned char mem[100] = {0}; mem[p]++; // 255 之后再加 1,自动变成 0 mem[p]--; // 0 之后再减 1,自动变成 255unsigned char的算术运算天然遵循"对 256 取模"的规则,所以完全符合题目要求。
| 操作 | 执行前 | 执行后 |
|---|---|---|
| ++ | 255 | 0 |
| ++ | 254 | 255 |
| -- | 0 | 255 |
| -- | 1 | 0 |
这里有一个我当年踩得很惨的坑:如果图省事把数组声明成char,在有符号平台上char的范围是 -128 到 127,0执行--会变成 -1,状态直接坏掉。就算你运气好没走到负值,最后用%02X输出时,char被提升成int后如果是负数,会输出FFFFFFFF这种 8 位十六进制,WA 得莫名其妙。所以我的建议是:能用无符号就用无符号,写死了就不会错。
如果你用的是 Java,就得手动取模:mem[p] = (mem[p] + 1) & 255,或者干脆% 256;Python 下直接% 256,道理完全一样。C 系选手在这里占了"自动回绕"的便宜,但也正因太自动,换个语言就容易忘了这层逻辑。
3. 从指令到代码:完整实现与格式化输出
3.1 主循环五条分支怎么写最干净
明确了状态模型,代码其实就是一层薄薄的翻译。我直接给一版最终提交时用的 C++ 代码:
#include <cstdio> #include <cstring> int main() { int T; scanf("%d", &T); getchar(); // 吃掉 T 后面的换行符 for (int cas = 1; cas <= T; cas++) { char s[100005]; fgets(s, sizeof(s), stdin); unsigned char mem[100] = {0}; int p = 0; for (int i = 0; s[i] != '\0'; i++) { if (s[i] == '>') p = (p + 1) % 100; else if (s[i] == '<') p = (p + 99) % 100; else if (s[i] == '+') mem[p]++; else if (s[i] == '-') mem[p]--; // '.' 对最终内存无影响,直接跳过 } printf("Case %d:", cas); for (int i = 0; i < 100; i++) { printf(" %02X", mem[i]); } putchar('\n'); } return 0; }几个值得注意的写法细节:
- 指针减一写成
(p + 99) % 100,是因为 99 和 -1 在模 100 意义下等价,少一步加法,代码更紧凑。可读性稍差,所以我一般会在注释里说明等价关系。 - 主循环用
if-else if串起来就够了,不需要switch。实际评测数据里,程序串行尾必然带着换行符,fgets会把\n也读进数组,但五个分支都不命中,自然跳过,效果等同于"忽略无效字符"。 - 如果用
int mem[100],就必须显式取模:mem[p] = (mem[p] + 1) % 256;和mem[p] = (mem[p] - 1 + 256) % 256;。哪种风格都行,关键是心里清楚自己正在处理哪一层回绕,不要指望编译器替你兜底。
复杂度方面,主循环每个字符处理一次,最后输出 100 个单元,总体是 O(程序长度 + 100)。程序最长可以到十万字符量级,数组开 100005 足够;如果题目约束改大,记得按实际范围调整缓冲区。
3.2 十六进制输出:%02X 的正确打开方式
输出格式是这道题最容易踩的第二个坑。题目要求每个单元输出两位十六进制,不足两位补前导零:十进制 2 要输出02,十进制 255 要输出FF。C 的printf里%02X正好一步到位:0表示补零,2表示最小宽度两位,X表示大写十六进制。
我写printf(" %02X", mem[i])而不是printf("%02X ", mem[i]),是因为前者在最后一个单元后面不会留下尾随空格,每行输出就是标准的Case 1: 02 03 00 ... 00。如果写成后者,多余的空格在某些严格判空的评测环境下会判成 PE。虽然老 OJ 对 PE 的处理有时很宽松,但更稳妥的做法是直接按题面来,别给自己制造无谓的风险。
另外强调一点:%02X要求参数被提升为int后是非负值。这也是上面反复强调必须用unsigned char的原因。要是数组类型是char,值为 255 时提升成 -1,printf会输出FFFFFFFF,那就从"范围问题"升级成"格式错误"了。
如果你偏好 C++ 流式输出,可以这样写:
cout << "Case " << cas << ":"; for (int i = 0; i < 100; i++) { cout << ' ' << hex << uppercase << setw(2) << setfill('0') << (int)mem[i]; } cout << dec << '\n';注意这里必须把mem[i]强转成int,否则unsigned char会被当成字符输出。流式写法每次都要手动setfill('0')和uppercase,而setw只对下一次输出生效,所以每轮都要写一遍。相比之下printf的%02X一行到位,我个人在 OJ 上更推荐它。
4. 实测中的坑:读入方式、空行与多余字符
4.1 getline 吃回车:读入缓冲区的老朋友
很多第一次做 UVA 系题目的选手,会在读入这里卡一下。题面第一行是整数 T,接下来是 T 行,每行一个 Brainfuck 程序。经典错误是这样写的:
int T; cin >> T; string s; getline(cin, s); // 第一次 getline 读到的是空串!原因很简单:cin >> T只读走整数,换行符还留在缓冲区里,紧接着的getline把这个换行符当成一行的结束,于是读进来一个空串,第一个测试用例就"消失"了。
解决办法有几种,任选其一:
cin >> T; cin.ignore();之后再用getline;- C 风格写法
scanf("%d", &T); getchar();; - 或者先用
fgets读一行,再用sscanf解析 T。
上面给的代码用的是 C 风格那套,因为整个输出都走printf,混着用最顺手。但无论选哪种,请记住:读完整数后,缓冲区里的回车不会自己消失。这个坑在几乎所有"第一行 T、后面 T 行字符串"的题里都存在,属于模拟题读入的第一课。
4.2 程序里混入未知字符时,答案应该怎么处理
Brainfuck 语言的设计哲学是"任何非指令字符都是注释"。所以严格来说,程序串里可能包含空格、换行、甚至随意的标点,一个完备的模拟器应该忽略它们,而不是报错。UVA11956 的评测数据里程序主体虽然都是指令字符,但行尾换行符必然存在,所以你的循环必须容忍非指令字符。
我的建议是:不需要单独的兜底分支,就让它静静地不匹配任何分支,自动跳过。这个设计恰好也符合 Brainfuck"非指令即注释"的思想。如果你用fgets读入,行尾的\n已经躺在数组里,跳过逻辑正好用上;如果你用cin >> s读程序串,反而会把程序里的空格或者换行切断,遇到特殊输入就出错。
之前有位队友问我,为什么他cin >> s读程序,样例能过,一提交就 WA。我让他把输入数据打印出来看,才发现数据里确实有程序中途换行的情况,>>只读了第一段。换成getline之后立即 AC。所以读入方式在本题不是小事,它直接决定你能不能拿到完整的一整段程序。
4.3 本地自测:三组极端数据绕不开
提交之前,我强烈建议本地构造三组极端数据,它们分别测三个最容易出错的点:
- 单个
<:程序只有一个左移指令,用来验证指针从 0 绕回 99,第一个单元必须是 00,第 100 个单元必须是 00,其余不受影响。 - 单个
-:程序只有一个减一指令,验证 0 减一变成 255,输出里第一个单元必须是FF。 - 连续 100 个
>:指针绕行一圈回到原点,再配合一个+,最终应该只有 mem[0] 变成 01,其余全 00。
这三组数据全部输出符合预期,再提交基本就是一遍过。我这些年做任何模拟题都会先问自己:边界数据构造了吗?这个习惯帮我省下了大量 WA 之后的排查时间。
5. 跳出 AC:把模拟器扩展成完整的 Brainfuck 解释器
5.1 加入 [ ] 循环后的执行策略
AC 之后,如果对这类模拟题还有兴趣,我建议把它扩展成支持全部 8 条指令的完整解释器。最核心的增量是处理[和]这对循环指令。教科书式做法是:预处理阶段用一个栈做括号匹配,把每个[对应的]位置、每个]对应的[位置记录在两个数组里,执行时遇到[且当前内存单元为 0,就跳到匹配的]之后;遇到]且当前内存单元不为 0,就跳回匹配的[之后。
int jump[1000005]; // jump[i] 记录与位置 i 匹配的括号位置 std::stack<int> st; for (int i = 0; i < len; i++) { if (s[i] == '[') st.push(i); else if (s[i] == ']') { int j = st.top(); st.pop(); jump[i] = j; jump[j] = i; } } // 执行时 if (s[i] == '[' && mem[p] == 0) i = jump[i]; else if (s[i] == ']' && mem[p] != 0) i = jump[i];注意主循环末尾的i++会让指针在跳转后再往后挪一位,所以jump里记录的是"匹配括号的下标",执行跳转后自然落在正确位置。这个扩展做完,你就拥有一个能真正跑任何 Brainfuck 程序的解释器了。当然,内存大小、,和.的行为仍需按目标环境补齐。
5.2 性能优化与更大内存的适配思路
如果完整解释器要在性能要求更高的场景里运行,还有几个点值得想:
- 指令折叠:程序里连续出现大量
+或-时,可以合并成一次操作,比如++++++直接mem[p] += 6,大幅减少循环次数。 - 指针移动合并:连续的
>或<先算出净位移再一次性移动,省去中间每一步的取模运算。 - 动态内存:经典 Brainfuck 内存是 30000 个单元,但有些程序会连续写
>上万次,用std::vector按需扩容更稳妥,不要一上来开一个超大的定长数组。 - 输入输出语义:
,需要从标准输入读一个字节,.输出一个字节。在字符流环境下注意 EOF 的处理,很多解释器在 EOF 时会把内存单元置为 0 或 -1,行为差异很大,得先定好语义。
我个人做完 UVA11956 后,顺手把它扩成了全指令版本,还写了一个经典测试程序:用[]实现从 0 累加到 10 再逐项输出。当看到那串循环真正在我写的解释器里跑出 0 到 10 的时候,那种"我懂了这个语言"的满足感,比 AC 一道题来得更扎实。如果你是算法竞赛新人,我非常建议做一次这种"从 AC 到理解"的延伸,它能把模拟题的价值吃到最大化。
最后再分享一个小习惯:我现在遇到任何"模拟某某语言"的题,都会先花十分钟把语言规范里的回绕规则全部列出来,再动手写循环。UVA11956 教会我的不是怎么解析五个字符,而是永远先问一句——这个状态机的边界到底长什么样。你把边界画清楚了,代码怎么写都是顺理成章的事。