表达式求值这道题,我估计不少准备算法机试或者面试的朋友都刷过。网上流传最广的解法就是一个基于双栈的经典教学版本:一个数字栈、一个运算符栈,外加一张运算符优先级表,从左到右扫一遍中缀表达式,就能把加减乘除和括号混合的算式直接算出来。它看透了“中缀表达式要转后缀再计算”的本质,又不显式生成后缀序列,纯粹靠栈的压入和弹出实时完成求值。这篇文章不打算把代码贴给你就完事,而是把为什么要这么写、每一步在干什么、哪些地方最容易翻车,都拆开讲清楚。适合刚学完栈想做练习的初学者,也适合想彻底吃透这套模板的备赛选手。
1. 把表达式求值拆开看:双栈法到底解决了一个什么问题
1.1 人眼和机器的差异,才是这道题的核心
先想想人在算混合表达式时靠的是什么。比如 3 + 2 * 4 - 5,我们扫一眼就知道要先算乘法,再算加减。这种“扫一眼”的能力,来自对整条式子的全局感知:我们能看到乘号在后面,也看得到加减号是平级的。但计算机从左往右读的时候,看到的只有当前字符,它不知道后面还会不会冒出更高优先级的运算符。
这就是中缀表达式的难点所在:它天然依赖“优先级”和“括号”来改变计算顺序,而这两样东西都需要跨过很长一段距离才能确定一个局部算式能不能先算。机器没有全局视野,只有“当前看到什么”和“之前记住什么”。于是栈就派上用场了。双栈法的核心思想特别直白:看到一个数字,先别急着算,放数字栈里存起来;看到一个运算符,也别急着算,因为后面可能有更高优先级的运算符先到。只有当信息足够多、可以确定某个运算符该执行时,才从栈里把两个操作数捞出来,运算完再把结果压回数字栈。
这个思路可以类比成手头攒了一堆临时任务:有些任务明明可以做,但你先放着,等更重要的任务做完再回来做。括号和优先级就是给这些临时任务排序的规则。双栈法本质上就是用栈模拟“人眼扫整体”的能力,只不过它靠的是“延迟计算”加“局部触发”。
1.2 优先级表怎么设计,为什么左括号排最低
双栈法里最核心的规则是:当前扫描到运算符 A 时,如果栈顶也存在运算符 B,需要比较 A 和 B 的优先级。如果栈顶 B 的优先级大于等于 A,那么就先算 B;如果栈顶 B 优先级小于 A,才把 A 压入运算符栈。
常见的优先级设计如下:
| 运算符 | 优先级 | 说明 |
|---|---|---|
+ - |
1 | 低优先级,左结合 |
* / |
2 | 高优先级,左结合 |
( |
0 | 最低,用来挡住普通运算符 |
左括号的优先级必须低于所有普通运算符,这非常关键。一旦左括号入栈,它就像一个屏障,保证括号内部的运算符之间正常比较,但括号内部的运算符永远没法“穿透”左括号去触发括号外的运算符。比如 (1 + 2) * 3,左括号入栈后,内部的 + 和栈顶左括号比较,因为左括号优先级最低,+ 才允许入栈。等右括号到来,系统就知道要把左括号和右括号之间的所有运算符结算掉,然后弹出左括号,让括号外的 * 能继续和括号内算完的结果打交道。
为什么比较条件用“栈顶优先级大于等于当前运算符优先级”而不是“严格大于”?因为运算符都是左结合的,同级的运算要从左往右算。比如 8 / 4 * 2,如果同级不先算,就可能先做 4 * 2 再除,结果是 1,但正确结果是 4。所以只要栈顶运算符和当前运算符同级,就必须先把栈顶的旧运算符算掉,这就是“大于等于”的由来。
1.3 边界情况决定了代码的复杂度
单纯设计完优先级表还不够,真正考验细节的是三类边界情况:多位数、右括号后的弹栈、表达式收尾。
多位数很容易被遗漏。读到 1 就入栈,然后读到 2 又入栈,等于把 12 拆成了两个数字。正确做法是遇到数字字符时,用一层循环连续读完整段数字再整体入栈。这个细节在短表达式里可能看不出问题,但一旦跑 12 + 3 这种用例,立刻翻车。
右括号的处理也比较容易写错。读到右括号时,需要不断弹出运算符栈栈顶并执行一次计算,直到遇到左括号。这里有两个隐藏要求:第一,左括号本身不能参与计算,要在循环结束后单独弹出;第二,右括号本身也不入栈,它只是一个“结算信号”。不少初学版本在写完 while 循环后忘了弹出左括号,导致收尾时把左括号也拿出来当运算符算,输出的对错就完全随机了。
最后是表达式收尾。扫描过程中,有些运算符会因为后续运算符优先级低而被触发计算,但最后一个运算符往往没有机会被触发。比如 1 + 2,扫描到 2 时结束,运算符栈里还留着 +,这个 + 如果没人处理,答案就停在 2。所以整个表达式扫描完之后,必须额外循环,把运算符栈里剩余的运算符全部出栈并执行计算。最后留在数字栈顶的,才是最终结果。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 完整代码的施工过程:每一行都有它存在的理由
2.1 数据结构准备:不只是栈这么简单
代码实现上,两个栈是最基本的骨架。数字栈我用 vector<int> 或 vector<long long> 而不是直接用 stack<int>,原因是这类题读入的表达式长度通常已知,用 vector 模拟栈能减少很多边界判断,而且性能上也有优势。运算符栈同理,用 vector<char> 存。
优先级表用 map<char, int> 存起来,好处是后面想扩展取模 %、幂运算 ^ 时,只需要往表里加一行,不用动其他逻辑。这是教学版里很漂亮的一个设计:数据和逻辑分离。如果把优先级写到分支判断里,扩展起来就会很痛苦。
还有一个容易被忽略的设计:计算函数 calc() 里弹栈的顺序。做减法或除法时,操作数顺序不能搞反。比如 5 - 2,先从数字栈弹出的是 2,再弹出的是 5,所以要用先弹出的数当右操作数,后弹出的数当左操作数。写成伪代码就是:
text复制num1 = st_top(); pop
num2 = st_top(); pop
st_push(num2 运算符 num1)
这个顺序一旦写反,5 - 2 会算成 2 - 5,8 / 2 会算成 2 / 8。代码语法完全正确,但结果全错,而且样例越短越难发现。本质上就是“后进先出”和“先入先出”的区别:后压栈的数字在表达式右侧,反而先被弹出。
2.2 数字解析:读取完整数字再入栈
核心的扫描循环里,第一步就是判断当前字符是不是数字。如果用 isdigit(s[i]) 判断为真,就用一个 while 循环把连续的数字字符组合成一个整数,然后整体压入数字栈。这个位置必须用循环,不能用一次 s[i] - '0' 就结束。
cpp复制if (isdigit(s[i])) {
long long x = 0;
while (i < s.size() && isdigit(s[i])) {
x = x * 10 + s[i] - '0';
i++;
}
st_num.push_back(x);
i--; // 因为外层循环末尾会 i++,这里要回退一位
continue;
}
这里最后那个 i-- 是很多初学的人反复调试才明白的点。外层 for 循环自带 i++,如果内层 while 已经移动到下一位非数字字符,外层再 i++ 就会跳过一个字符。所以在内层循环结束后回退一步,再让外层统一前进,这样每个字符恰好被访问一次。这种“统一外层推进、内层手动回退”的标准写法,比内外层各管各的要稳得多。
2.3 运算符处理:入栈前的优先级博弈
当扫描字符是四则运算符时,就用优先级表和栈顶运算符做博弈。核心分支如下:
cpp复制while (!st_op.empty() && prio[s[i]] <= prio[st_op.back()]) {
calc();
}
st_op.push_back(s[i]);
这个 while 的成立条件是“当前运算符优先级不高于栈顶运算符优先级”。一旦成立,就先把栈顶运算算掉。算完一个,新的栈顶又可能满足条件,所以要用 while 而不是 if,一直算到当前运算符能够顺利入栈为止。
举个例子,表达式 1 * 2 + 3。扫描到 * 时,栈顶是 +,因为乘号优先级更高,所以乘号入栈。等到扫描 + 时,栈顶是 *,于是触发 1 * 2,结果 2 入数字栈,然后 + 才入栈。这正好实现了“先乘除后加减”的规则。
再比如 1 + 2 * 3。扫描到 + 时,运算符栈为空,直接入栈。扫描到 * 时,栈顶是 +,当前 * 优先级更高,不触发计算,直接入栈。这样两个运算符都留在栈里,扫描结束后,统一收尾时先弹出 *,再弹出 +,也就是先算乘法再算加法。两种常见表达式,一套逻辑全部覆盖,关键就在于优先级表加这个 while 条件。
2.4 右括号与收尾:最后一步往往决定成败
右括号的分支相对简单:
cpp复制if (s[i] == ')') {
while (st_op.back() != '(') {
calc();
}
st_op.pop_back(); // 弹出左括号
}
这个逻辑极其直接:右括号不参与入栈,只是触发一轮结算。结算的终止条件是遇到左括号,然后左括号被弹出。这里最值得提的就是“弹出左括号”这一步。如果少写这一行,左括号会一直留在运算符栈里,等到收尾循环时,它会被当成普通运算符参与计算,而它的优先级是 0,会导致一系列意想不到的结果。
所有字符处理完毕后,还需要对剩余运算符做一次清尾:
cpp复制while (!st_op.empty()) {
calc();
}
数字栈里最后剩下的那个数就是最终结果。这时候整个表达式已经被拆解成多次“弹两个数、做一个运算、压回一个数”的过程,每个操作都对应原始算式的一个有效局部计算。
3. 可运行的参考实现与测试记录
3.1 一份可以直接跑的C++代码
下面这份代码是我平时最常用的版本,所有细节都按上面说的来:
cpp复制#include <iostream>
#include <vector>
#include <map>
using namespace std;
map<char, int> prio = {
{'+', 1},
{'-', 1},
{'*', 2},
{'/', 2}
};
vector<long long> nums;
vector<char> ops;
void calc() {
long long right = nums.back(); nums.pop_back();
long long left = nums.back(); nums.pop_back();
char op = ops.back(); ops.pop_back();
long long result = 0;
switch (op) {
case '+': result = left + right; break;
case '-': result = left - right; break;
case '*': result = left * right; break;
case '/': result = left / right; break;
}
nums.push_back(result);
}
long long solve(const string& s) {
nums.clear();
ops.clear();
for (int i = 0; i < s.size(); i++) {
if (isdigit(s[i])) {
long long x = 0;
while (i < s.size() && isdigit(s[i])) {
x = x * 10 + s[i] - '0';
i++;
}
nums.push_back(x);
i--;
} else if (s[i] == '(') {
ops.push_back(s[i]);
} else if (s[i] == ')') {
while (ops.back() != '(') {
calc();
}
ops.pop_back();
} else {
while (!ops.empty() && prio[s[i]] <= prio[ops.back()]) {
calc();
}
ops.push_back(s[i]);
}
}
while (!ops.empty()) {
calc();
}
return nums.back();
}
int main() {
string s;
cin >> s;
cout << solve(s) << endl;
return 0;
}
可以直接拿去跑,不需要额外依赖。这里用 long long 而不是 int,主要是怕乘法和长表达式累加成 int 溢出。实际机试时,表达式的数字范围往往没给得很清楚,用大宽度类型更稳妥。
3.2 测试用例设计:不要只依赖题目样例
我把常用的测试用例整理一下,方便验证自己写的版本是否完整。
| 输入表达式 | 预期结果 | 验证点 |
|---|---|---|
1+2*3 |
7 | 乘法的优先级高于加法 |
1*2+3 |
5 | 同优先级先算前面的乘法 |
8/4*2 |
4 | 同级运算符左结合 |
(1+2)*3 |
9 | 括号改变计算顺序 |
((1+2)*3)/2 |
4 | 嵌套括号 |
10-3+2 |
9 | 同级从左到右 |
12+34 |
46 | 多位数解析 |
7-(2+3) |
2 | 右括号后仍有减法 |
尤其值得关注的是 8/4*2 和 10-3+2。8/4*2 如果处理不好优先级,容易得到 1;10-3+2 如果处理成从右往左,容易得到 5。这两个用例能快速检验“左结合”和“栈顶优先级大于等于当前运算符就触发计算”的规则是否写对。
3.3 复杂度与性能表现
整个算法对表达式中的每个字符只处理常数次,数字压栈、运算符压栈、计算弹栈,整体是严格的 O(n),其中 n 是表达式长度。空间上,最坏情况下所有数字和运算符都积压着,也是 O(n)。
实际跑一个几十万字符的长表达式也不会卡,因为每个字符的入栈出栈次数有上界,不存在一个字符被反复处理的情况。如果一不留神把 while 里的计算逻辑写成了对整条表达式重新扫描,复杂度就会退化成 O(n^2),在数据量大的题目里直接超时。这也是为什么理解“为什么每个运算符最多出栈一次”很重要:它保证了双栈法的线性复杂度。
4. 常见翻车现场与扩展玩法
4.1 高频错误速查表
我在实际调试里见过不少初学同学卡在同样几个位置上,集中整理成一张表:
| 现象 | 原因 | 解决办法 |
|---|---|---|
12+3 结果不对 |
数字被拆成 1、2 分别入栈 |
数字解析用 while 连续读取 |
| 减法或除法结果符号相反 | calc() 里操作数顺序反了 |
先弹出的数作为右操作数 |
| 程序死循环 | 运算符栈 while 条件中缺少栈空判断 | 循环条件里加 !ops.empty() |
| 有括号的表达式算完不弹出左括号 | 右括号分支漏了 pop_back() |
结算到左括号后必须弹栈 |
| 答案比预期大或溢出 | 用 int 存累乘和累加结果 |
改用 long long |
| 除法出现奇异结果 | 没考虑除法和减法的左结合,同级运算符从左到右 | 检查 <= 而不是 < |
<= 和 < 的差异是这里面最容易忽视的。如果写成 prio[s[i]] < prio[ops.top()],同级运算符就不会触发计算,1-2+3 会变成先算 2+3 再算 1-5,结果就是 -4,而正确结果是 2。所以这个“等于也要触发计算”的条件,本质上是给“左结合”规则兜底。
4.2 调试技巧:把栈打出来看
写这种栈模拟题,最有效的调试手段就是把每一步执行前后两个栈的内容打印出来。我一般会在 calc() 函数里临时加一段输出,或者直接在扫描循环里打印:
cpp复制void debug() {
for (long long x : nums) cout << x << " ";
cout << "| ";
for (char op : ops) cout << op << " ";
cout << endl;
}
在每次压栈、弹栈操作后调用一下,就能看到 1*2+3 的完整执行轨迹。比如扫描到 * 入栈后,两个栈分别是 [1] 和 [+] [*];扫描到 + 时,因为栈顶 * 优先级更高,触发一次 calc(),数字栈变成 [2],运算符栈变成 [+],然后 + 入栈。这个过程一旦打印出来,任何逻辑错误都能被直观定位。所谓“查错先查状态”,就是这个道理。
4.3 扩展玩法:支持一元负号、取模、幂运算
题目原版通常只要求处理正整数的四则运算和括号,但实际考试或练习中经常出现变体,我这里给几个常见的扩展思路。
一元负号,比如 -3+5 或 2*(-3),最大的问题是负号可能出现在表达式开头或者左括号之后。一个通用的处理方法是:如果在 - 前面没有任何字符,或者前一个字符是 (、+、-、*、/,就认为这是一元负号,此时先往数字栈里压一个 0,再把 - 当作普通运算符入栈。这样 -3 就会变成 0-3,逻辑完全复用现有的二元运算符流程,不需要新写规则。
取模运算更简单,只要在优先级表里加入 %,并让它的优先级和 *、/ 相同即可:
cpp复制prio['%'] = 2;
然后在 calc() 的 switch 里补充 case '%'。当然取模要求操作数是整数,所以用了 long long 的情况下完全兼容。
更进阶的是幂运算 ^。它有两点特殊:一是优先级高于乘除,二是有结合性。数学上 2^3^2 通常理解为 2^(3^2),也就是右结合。这意味着同为 ^ 时,不能像加减乘除那样“同级先算栈顶”,而要等右边的幂先算。解决办法是把优先级判断从 <= 改成 < 只对幂运算符生效,或者在优先级表里额外记录结合性字段。这个细节能帮你理解为什么不能永远只用一张优先级表就把所有运算符都塞进去,运算符的结合性同样影响出栈时机。
4.4 从表达式求值到更广阔的地盘
双栈法不只是某道算法题的标准答案,它背后是“调度场算法”的一次具体实践。如果你以后接触编译原理,会看到表达式语法分析、逆波兰表达式转换,本质上都在做同一件事:把带优先级的线性输入序列,转成可执行的计算顺序。理解了双栈的“延迟入栈、符号触发、栈顶博弈”之后,再看后缀表达式求值、递归下降分析,都会觉得很多套路是相通的。
我个人在讲这道题时最常用的比喻是:双栈法相当于给运算符建了一个“等待区”。高优先级的运算符可以插队,低优先级的得等前面的先处理完。整个过程中没有任何奇技淫巧,全靠“你是谁、你前面是谁、你俩谁大”这三个问题。能把这三个问题答清楚,代码自然水到渠成。
最后分享一个实操建议:如果你现在还在背代码阶段,先不要急着抄,尝试自己写出数字解析和右括号处理这两段;写完再对照参考代码,看看漏了什么。踩过一次“忘记弹左括号”的坑,比看十遍别人的解释都记得牢。表达式求值不难,难的是把每个边界都想清楚。等你把一元负号和幂运算也能用这套框架实现出来,就真的可以从“会做题”跨到“懂设计”了。
