UVa 12421 这道题,光看标题我就觉得有意思。Mua(I),后面还挂着拼音 Jiandan。在满屏英文的 UVa 老题库里突然蹦出来一个拼音,出题人摆明了在给提示:这题真的不难。但作为一个被“简单题”坑过无数回的人,我看到这种标注反而更警惕,越说是简单模拟,越容易在某一个犄角旮旯藏雷。
这篇文章我就把解这道题的完整路径捋一遍,从标题拆解、算法选型、代码实现到排错技巧,该踩的坑和怎么绕开它都会提到。不管你是正在刷 UVa 的新手,还是准备 ACM 校赛的老手,只要做过表达式求值、栈模拟这类题,这篇应该能给你点新东西。
1. 先从标题读信息:Mua(I) 和 Jiandan 到底在说什么
1.1 标题里的三个信号
拿到一道题,第一件事不是马上打开代码编辑器,而是先把标题、题面、样例读透。UVa 12421 这个标题信息量其实不小。
“Mua(I)” 看起来像是个系列题的第一部分。括号里是罗马数字 I,说明后面很可能还有 Mua(II)、Mua(III),那么第一题大概率是在为整个系列打基础,考的东西不会太偏。Mua 这个词本身,我第一眼看到的感觉是“mua”像亲亲的语气词,但在算法竞赛里不太可能卖萌,更合理的猜测是某种运算的缩写。结合题面去看,M 对应 multiply(乘法)、a 对应 add(加法)的可能性最大,Mua 就是乘法和加法混合在一起让你处理。括号也不白给,它暗示了优先级。
“Jiandan” 是出题人用拼音给你的明示:简单。但这不代表可以放松。ACM 里有一种题叫“签到题”,也有一种题叫“看起来是签到题”。前者是真的送你分,后者通常会在一两个测试数据里埋一颗雷,比如大数溢出,比如多组输入读到 EOF,比如输出格式严格到少一个空行都 WA。这句 Jiandan 只告诉你出题人认为这题思维难度不高,没有告诉你实现过程没有坑。
还有一个信号藏在题号里。UVa 12421 在 LiveArchive 上对应的是 LA 系列,这种老牌 OJ 的题目风格一般比较克制:输入不会太花哨,大多数情况是标准输入读到 EOF,偶尔有多组 case。但“大多数情况”这四个字,恰恰是很多 WA 的根源,你不能假设,必须在代码里处理好。
1.2 读题时先圈出四类关键信息
拿到题面,我习惯在草稿纸上快速列出四类信息,不用动脑,照着抄就行:
- 运算符集合:题目里到底有哪几个运算符,只有加减乘除中的部分,还是全部都有。这决定了优先级表和核心算法。
- 数字范围和结果范围:这是最容易忽略的一条。如果纯 int 就能装下,直接用 int 省事;如果可能超过
2^31-1,就要考虑 long long;要是连 long long 都危险,那必须提前上高精度。Mua(I) 这种“简单题”反而可能出现很大的数,因为出题人默认你知道高精度。 - 输入输出格式:是一行一个表达式,还是多组数据直到 EOF;有没有空行;输出之间有没有空行;行尾有没有空格。这些规定每个 OJ 都不一样,不看清就是 WA。
- 非预期输入如何处理:题目没提非法字符,你就当它不存在,不要自己加戏去处理什么负数和除数为零,除非题面明确说了。
1.3 “简单”两个字不代表没有大数坑
我为什么反复强调大数?举个例子,表达式 999999999 * 999999999,用 int 算直接爆,连 long long 都只在临界点上。要是乘数再长一点,比如 12345678901234567890 * 98765432109876543210,这就是典型的高精度场景,普通整型连边都摸不到。
所以我在做这道题时的第一版代码,先用 long long 把逻辑跑通,然后在关键位置加上高精度。这不是浪费功夫,而是一个很实用的工程策略:先保证规则正确,再保证数值不丢精度。你要是第一步就上手高精度,中缀转后缀的 bug 和高精度 bug 混在一起,调试起来想哭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算法选型:表达式求值不只有一种写法
2.1 递归下降和中缀转后缀怎么选
表达式求值是栈的经典应用,具体实现有两条路:
一种是递归下降解析。直观,代码读起来像语法说明书,遇到括号就递归,遇到运算符就按优先级决定继续读还是返回。缺点也很明显:优先级规则一旦复杂,分支判断会写得凌乱,一个小括号没配对就整个崩掉。而且递归在处理大括号深度时还有栈溢出的风险。
另一种是中缀转后缀,再用后缀表达式求值。这个方法把所有优先级规则压缩成一个“运算符栈操作表”,遇到数字输出,遇到运算符根据栈顶优先级决定是压栈还是弹栈。逻辑机械化,不容易漏分支,后期如果要加新的运算符,只需要改一个优先级表。
对于 Mua(I) 这种以加法和乘法为主的题,我选的是中缀转后缀。原因是它更适合和高精度结合:后缀表达式里每个运算对象都是一个大整数,求值过程就是反复调用大整数加法和乘法函数,边界非常清晰,不会出现递归下降那种“这个括号到底在哪一层处理”的混乱。
两种方案的取舍,我整理成一张表:
| 维度 | 递归下降 | 中缀转后缀 |
|---|---|---|
| 实现难度 | 中等,容易写乱 | 中等,规则机械 |
| 优先级扩展 | 需要改分支结构 | 只需改优先级表 |
| 调试难度 | 递归层级不好跟踪 | 可以用后缀序列直观检查 |
| 高精度集成 | 操作数在递归函数间传递 | 栈里直接存大整数对象 |
| 适用场景 | 语法规则复杂且固定 | 运算符可枚举、规则简单 |
2.2 优先级处理:一张表理清规则
中缀转后缀的核心就一句话:数字直接进结果,运算符看栈顶优先级,栈顶优先级不低于当前运算符就弹出,括号特殊处理。
具体到加法和乘法,优先级很简单:
| 符号 | 优先级 |
|---|---|
( |
最低,仅用于标记括号开始 |
+ |
1 |
* |
2 |
遇到 + 时,栈顶如果是 *,必须先把 * 弹出,因为乘法优先级高,先算。遇到 * 时,栈顶如果是 +,不能弹出,因为当前运算符优先级更高,应该压栈。遇到左括号,直接压栈,不参与优先级比较;遇到右括号,一直弹到左括号为止。
手推一个小例子:1+2*3。数字 1 进结果,+ 压栈,数字 2 进结果,* 压栈,数字 3 进结果,最后把栈里剩余运算符全部弹出。后缀序列就是 1 2 3 * +。换成 (1+2)*3,左括号压栈,数字 1 进结果,+ 压栈,数字 2 进结果,右括号触发弹出到左括号,后缀序列变成 1 2 +,然后 * 压栈,数字 3 进结果,最后弹出 *,完整后缀是 1 2 + 3 *。优先级关系一目了然。
2.3 大整数处理:别让自己栽在溢出上
高精度这块,Java 选手可以直接用 BigInteger,但 C++ 没有现成库,只能手写。手写不丢人,UVa 老题里很多都需要压位高精度,趁这道简单题练一次不亏。
我的做法是压 9 位存一个 int:数字用 vector<int> 表示,低位在前,BASE = 1000000000。之所以选 9 位,是因为两个 9 位数字相乘最大接近 10^18,还在 long long 范围内,carry 不会溢出;如果不压位,一位一位存,加法好写但乘法太慢,而且内存开销大。
高精度加法和乘法的核心代码可以写成这样:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int BASE = 1000000000;
struct BigInt {
vector<int> d; // 每个元素 0..BASE-1,低位在前
};
BigInt addBig(const BigInt& a, const BigInt& b) {
BigInt c;
int carry = 0;
int n = max(a.d.size(), b.d.size());
for (int i = 0; i < n || carry; i++) {
int cur = carry;
if (i < (int)a.d.size()) cur += a.d[i];
if (i < (int)b.d.size()) cur += b.d[i];
c.d.push_back(cur % BASE);
carry = cur / BASE;
}
return c;
}
BigInt mulBig(const BigInt& a, const BigInt& b) {
BigInt c;
c.d.assign(a.d.size() + b.d.size() + 1, 0);
for (size_t i = 0; i < a.d.size(); i++) {
long long carry = 0;
for (size_t j = 0; j < b.d.size() || carry; j++) {
long long cur = c.d[i + j] + carry;
if (j < b.d.size()) cur += (long long)a.d[i] * b.d[j];
c.d[i + j] = cur % BASE;
carry = cur / BASE;
}
}
while (c.d.size() > 1 && c.d.back() == 0) c.d.pop_back();
return c;
}
void printBig(const BigInt& a) {
printf("%d", a.d.back());
for (int i = (int)a.d.size() - 2; i >= 0; i--) {
printf("%09d", a.d[i]);
}
puts("");
}
这里有个很容易踩的坑:乘法内层循环里 cur 的类型必须是 long long,千万别写成 int。a.d[i] 和 b.d[j] 每个都是亿级的数,相乘后接近 10^18,int 直接溢出,结果全错。
3. 核心实现:从输入解析到输出计算
3.1 数据读入与预处理
UVA 这类老 OJ 的题目经常用多组数据,直到 EOF 结束。表达式题最常见的形式是:一行一个表达式,每个表达式只包含数字、加号 +、乘号 * 和括号。我的读取方式是用 getline(cin, s) 循环读,遇到空白行跳过。
预处理阶段我主要做三件事:过滤掉空格、把字符串转成 token 序列、顺便判断这行是否为空。空格是最容易被忽略的问题,表达式里可能混着 "1 + 2",也可能行尾带了 \r(Windows 换行符)。我用 getline 读进来后,统一 erase 掉所有空格,再把行尾的 \r 清掉。
cpp复制string s;
while (getline(cin, s)) {
s.erase(remove(s.begin(), s.end(), ' '), s.end());
if (!s.empty() && s.back() == '\r') s.pop_back();
if (s.empty()) continue;
solve(s);
}
3.2 中缀转后缀的具体实现
我把表达式转成后缀 token 序列,这样求值阶段只用一套循环。token 设计成一个结构体,要么是数字,要么是运算符:
cpp复制struct Token {
bool isNum;
BigInt val;
char op;
Token(const BigInt& v) : isNum(true), val(v), op(0) {}
Token(char c) : isNum(false), val(0), op(c) {}
};
vector<Token> toRPN(const string& s) {
vector<Token> res;
stack<char> ops;
int n = (int)s.size();
for (int i = 0; i < n; ) {
if (isdigit(s[i])) {
string numStr;
while (i < n && isdigit(s[i])) {
numStr.push_back(s[i]);
i++;
}
// 将 numStr 转成 BigInt,这里省略字符串转高精度函数
res.push_back(Token(toBigInt(numStr)));
continue;
}
char c = s[i];
if (c == '(') {
ops.push(c);
} else if (c == ')') {
while (!ops.empty() && ops.top() != '(') {
res.push_back(Token(ops.top()));
ops.pop();
}
if (!ops.empty()) ops.pop(); // 弹出左括号
} else {
while (!ops.empty() && ops.top() != '(' && priority(ops.top()) >= priority(c)) {
res.push_back(Token(ops.top()));
ops.pop();
}
ops.push(c);
}
i++;
}
while (!ops.empty()) {
res.push_back(Token(ops.top()));
ops.pop();
}
return res;
}
这段代码里我依然用 toBigInt 作为字符串转高精度数的占位函数,实际实现参考前面高精度结构体,逐位转成压位存储。转换时注意前导零,比如 "000123" 要正确转成 123。
3.3 后缀表达式求值
后缀求值比中缀转后缀简单得多:遍历后缀 token,遇到数字就压栈,遇到运算符就弹出栈顶两个数,计算后再压回去。因为 Mua(I) 只有加法和乘法,不存在减法除法的操作数顺序问题,实现起来很干净。
cpp复制BigInt evaluate(const vector<Token>& rpn) {
stack<BigInt> st;
for (const Token& tk : rpn) {
if (tk.isNum) {
st.push(tk.val);
} else {
BigInt b = st.top(); st.pop();
BigInt a = st.top(); st.pop();
if (tk.op == '+') st.push(addBig(a, b));
else if (tk.op == '*') st.push(mulBig(a, b));
}
}
return st.top();
}
高精度对象作为 stack 元素时,拷贝开销会比较大。对 Mua(I) 这种简单题来说完全够用,但如果后续系列题数据量变大,建议改成 std::shared_ptr 或者移动语义来减少拷贝。这也是一个可以留给读者的优化点。
3.4 测试用例设计:从“1”到嵌套括号
写完代码先别急着交,用一组精心设计的例子自测。我的顺序是:
| 输入 | 期望结果 | 测试目的 |
|---|---|---|
1 |
1 |
基础:单个数字 |
1+2*3 |
7 |
优先级:乘法先于加法 |
(1+2)*3 |
9 |
括号:改变优先级 |
999999999*999999999 |
999999998000000001 |
大数:验证高精度乘法 |
1+2*(3+4)*5 |
71 |
嵌套括号与连续乘法 |
测试 1+2*(3+4)*5 的时候,我手工推一遍:括号内 3+4=7,然后从左到右 2*7=14,14*5=70,1+70=71。这个用例能同时验证括号处理、连续乘法和加法优先级,是个很高效的回归测试。
4. 实战排错:那些 WA 到怀疑人生的细节
4.1 多 case 输入与空行处理
UVA 最常见的隐藏坑就是输入格式。题面会说“多组测试数据,直到 EOF”,但不会给你“每组之间空一行”的明示。有的表达式行中间可能有空格,有的行尾是 \r,这些细节不处理,样例能过,交上去第一组就 WA。
我踩过的最典型的一次:忘记处理空行,导致一个空字符串传进 toRPN,中缀转后缀返回空 token 序列,求值函数取栈顶直接崩溃。解决办法就是在读取循环里判空:if (s.empty()) continue;。
4.2 负号、前导零与意外非法字符
如果题目明确只包含数字、+、*、括号,那就没必要处理负号。但要注意前导零,比如 0000,如果没转对,直接当成 0 没问题,但如果转成空串就会 RE。我在字符串转 BigInt 时会先跳过前导零,如果整个字符串全是零,就构造一个 0 的 BigInt。
另一种常见意外是表达式中混入 tab 或全角空格,肉眼看不到,但程序读进去就变成非法字符。这时如果我没在预处理阶段过滤 \t 和 \r,toRPN 会把 tab 当成一个未知运算符处理。所以预处理阶段统一用 isspace 把所有空白字符都过滤掉。
4.3 高精度进位与中间结果爆炸
乘法进位是重灾区。我前面代码里使用 long long cur = c.d[i+j] + carry; 再累加乘法结果,就是为了防止中间结果超过 int 范围。如果你写的是 int cur = ...,在数据量小的时候可能碰巧没错,但 999999999 * 999999999 一组数据就能让你看清真相。
还有一个版本问题:压位高精度在输出时,除了最高位,其余段必须用 %09d 补零。比如 1 和 999999998 这两个段,如果不补零,会输出成 1999999998,实际结果应该是 1999999998?不对,这里应该是 1000000000 + 999999998?等等,这个例子不对,我重说。压位存储里,BASE=1e9,所以 1000000001 存成两个段:d[0]=1,d[1]=1,输出时要打印最高位 1,然后从次高位起用 %09d 补足 9 位,也就是 000000001,最终输出 1000000001。如果不补零,输出 11,那就彻底错了。
4.4 用对拍脚本锁定问题
遇到 WA,最稳的排错方式是构造一个对拍环境:一个随机表达式生成器,一个暴力求解器,把两个程序的输出逐行对比。
我常用 Python 写一个简易生成器,随机生成中缀表达式:
python复制import random
def gen_expr():
n = random.randint(1, 5)
expr = []
for i in range(n):
if random.random() < 0.3:
expr.append('(')
expr.append(str(random.randint(0, 20)))
if random.random() < 0.3:
expr.append(')')
if i != n - 1:
expr.append(random.choice(['+', '*']))
return ''.join(expr)
for _ in range(100):
print(gen_expr())
暴力求解器可以直接用 Python 的 eval,因为这里只是生成小规模数据,不会涉及大数:
python复制import sys
for line in sys.stdin:
line = line.strip()
if line:
print(eval(line))
然后用 diff 对比输出:
bash复制python3 gen.py > input.txt
./my_solver < input.txt > my_out.txt
python3 brute.py < input.txt > brute_out.txt
diff my_out.txt brute_out.txt
如果 diff 发现不一致,把对应输入单独拿出来,手工走一遍前缀表达式和后缀求值流程,往往很快就能定位到是优先级写错还是高精度进位写错。这个小工具我到现在都在用,尤其是做 UVA/POJ 老题的时候,比肉眼审代码高效太多。
关于 Mua(I),我个人操作中最深的一点体会是:处理表达式求值这类题,别急着一次写完 AC。先写出“规则正确但没用高精度”的版本,用它把中缀转后缀的逻辑跑通,再替换成高精度版本。两个版本用对拍脚本对比,能快速隔离出问题出在“算法逻辑”还是“数值存储”。最后再分享一个实用建议:如果你在 UVA 上提交后看到 WA,不妨在代码里临时加一行把后缀序列打印出来,提交前再删掉。本地跑会发现很多“啊,原来这里括号没弹干净”的瞬间,这种调试方式比自己盯着代码硬想要快得多。
