1. 从一个“人人抄过”的模板说起
表达式求值,准确说是“中缀表达式求值”,是栈这个数据结构最经典的落地场景之一。算法学习圈里流传最广的一份代码,不是某个花里胡哨的大项目,而是某算法基础课上那个用两个栈、几十行就写完整套逻辑的模板。我当年第一次见到这段代码的时候,第一反应是“就这?”,第二反应是“原来括号还能这么处理”。
这题解决的是个很实际的问题:给你一串字符串,比如 2+3*4+(5*6+7),要直接算出最终结果。人眼扫过去会觉得很简单,但是要让程序理解“先乘除、后加减”“括号优先”这些运算规则,就没那么直觉了。解决思路基本分成两类:一类是把中缀表达式先转成后缀表达式(逆波兰式),然后再用栈求值;另一类是直接用双栈模拟,边扫描边算。我们今天聊的就是后者,也是那个流传模板的核心思路。
这篇内容适合哪些人?刚学完栈、链表这些基础数据结构,想找一个“学完马上能用”的例子的初学者;正在准备算法笔试、面试,想快速掌握高频考点的求职者;以及那些背过代码但没真正搞懂里面每个 while 条件为什么这么写的同学。看完之后你不仅能跑通模板,还能自己讲清楚每一步的来龙去脉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体思路:两个栈怎么就把表达式算明白了
2.1 为什么必须用两个栈
先想一个问题:表达式求值的过程,本质上是个“延迟计算”的过程。扫到 2 的时候,你不知道后面是 +2 还是 *10,所以不能急着算;扫到 3 的时候,你知道 2+3 可以算了,但也要确认后面是不是有更高优先级的 *。也就是说,计算时机完全取决于“后面出现的运算符”。
那用什么来承载这种“先放着、等条件满足再算”的状态?栈是最自然的结构。一个栈存数字,一个栈存运算符,需要的时候从两个栈里各取东西算一笔,再把结果放回数字栈。这就是双栈算法的全部动机。
形象点说,这就好比你在厨房备菜。数字栈是你的操作台,运算符栈是待办清单。看到数字就放到操作台上,看到运算符先别急着动手,把这件事记在待办清单上,等发现新来的运算符优先级不如清单上的事重要时,才回头把之前记的事处理掉。
2.2 优先级表:算法的裁判
整个算法的核心,其实就一件事:判断“新来的运算符”和“栈顶运算符”谁更优先。
规则很简单,也很符合直觉:
- 如果栈顶运算符的优先级大于等于新来的运算符,说明栈顶那个运算符可以先算了,因为后面这个操作符不会影响它的运算顺序;
- 如果栈顶运算符优先级小于新来的运算符,比如栈顶是
+,新来的是*,那就得把*压栈,等后面更高的优先级被处理完再说; - 括号是个特殊情况,后面单独讲。
这里有个关键细节:为什么判断条件是“大于等于”而不是“大于”?因为运算结合性。2-3+4 这种从左往右算的式子,扫到 + 时,栈顶是 -,两者优先级相同。如果条件用 >,那 - 就一直留在栈里,到最后才一起算,结果就变成了 2-(3+4),直接算错。所以必须用 >=,保证优先级相同的运算符按照从左到右的顺序依次计算。
2.3 括号的特殊身份
括号在优先级表里没有位置,因为它的逻辑和其他运算符完全不一样。它不是一个真正参与计算的运算符,更像一个“分组标记”。
左括号出现时,直接无条件压栈,它不跟别的运算符比优先级;右括号出现时,反复执行“把栈顶运算符拿出来算”,直到遇到左括号为止,然后把这个左括号弹出栈。这样做的效果是:一对括号内部的所有运算,都会在碰到右括号的瞬间被完整清算,清算完之后括号本身消失,不会影响外层表达式。
你可以把括号理解成一个“结界”。结界里面怎么算都行,碰到右括号这个结界就解除,里面被延迟的所有计算一起做完。
3. 核心代码逐段拆解:每一行都不能白写
先给一份完整的代码,后面再逐段拆。这里的实现用 C++,是竞赛和面试里最常见的版本。
cpp复制#include <iostream>
#include <stack>
#include <string>
#include <unordered_map>
using namespace std;
stack<int> num;
stack<char> op;
unordered_map<char, int> pr = {
{'+', 1}, {'-', 1},
{'*', 2}, {'/', 2}
};
void eval() {
int b = num.top(); num.pop();
int a = num.top(); num.pop();
char c = op.top(); op.pop();
int res;
if (c == '+') res = a + b;
else if (c == '-') res = a - b;
else if (c == '*') res = a * b;
else res = a / b;
num.push(res);
}
int main() {
string s;
cin >> s;
for (int i = 0; i < s.size(); i++) {
char c = s[i];
if (isdigit(c)) {
int x = 0;
int j = i;
while (j < s.size() && isdigit(s[j])) {
x = x * 10 + (s[j] - '0');
j++;
}
i = j - 1;
num.push(x);
} else if (c == '(') {
op.push(c);
} else if (c == ')') {
while (op.top() != '(') eval();
op.pop();
} else {
while (op.size() && op.top() != '(' && pr[op.top()] >= pr[c]) eval();
op.push(c);
}
}
while (op.size()) eval();
cout << num.top() << endl;
return 0;
}
3.1 eval 函数:最容易算错的顺序问题
eval 是整个算法的“执行单元”,职责是:从数字栈里取两个数,从运算符栈里取一个运算符,做一次运算,再把结果塞回数字栈。
这里有一个绝大多数初学者第一次写都会踩的坑:取数的顺序。先取出来的是 b,后取出来的是 a,运算必须是 a 运算符 b。
为什么?因为栈是后进先出。表达式 8-3 扫描顺序是 8 先入栈、3 后入栈,所以用 num.top() 取到的是 3,也就是右操作数 b。如果代码写成 int a = num.top(); int b = ...,也就是先取的叫 a、后取的叫 b,那减法就变成了 3-8,除法也会颠倒,整个结果全错。
我见过不少同学在 eval 里搞反顺序后,测试 8-3 得到 -5,百思不得其解,甚至怀疑是不是算法思路有问题。本质上就是一个命名习惯的问题:先弹出来的数一定要赋给右操作数。不少模板直接写成 int b = num.top(); num.pop(); int a = num.top(); num.pop(); 是有讲究的,照着写就是安全的。
另外还要注意:eval 函数本身不关心优先级,它只负责“算一笔”。该不该算、什么时候算,由调用它的 while 条件决定。这个职责划分很干净,也是这段代码好读的一个重要原因。
3.2 读取数字:一次循环吃掉完整的整数
扫描字符串的过程中,碰到一个数字字符怎么办?不能只读一位。例如 123+45,如果只把 1 入栈,那整个表达式就废了。所以要用一个 while 循环,从当前位置开始一直往后读,直到碰到非数字字符为止,把连续的一串数字字符转换成整数。
这段代码里有个很实用的手法:
cpp复制int j = i;
while (j < s.size() && isdigit(s[j])) {
x = x * 10 + (s[j] - '0');
j++;
}
i = j - 1;
用一个临时变量 j 去扫描,扫描结束后把 i 直接跳到 j-1 的位置,因为循环结束后外层 for 会执行 i++。这样 i 恰好变成连续数字的最后一位。读入 x 之后入栈。
有个细节值得说:isdigit 判断的是字符是不是 0 到 9,如果表达式里混入了空格,这段逻辑会在空格处停下来,不会把数字破坏。但注意,主循环里碰到空格会被当作普通运算符处理,会出问题,后面讲踩坑时会提到。
3.3 主循环:四种情况的完整处理逻辑
主循环是整个算法的调度中心,对每个字符分四类处理。
第一类,数字字符:按上一节的方式读完整数字并入数字栈。
第二类,左括号:直接无条件压入运算符栈。这里不比较优先级,因为左括号不是真正要参与计算的运算符。如果这里也去做优先级比较,括号的语义就会被破坏。
第三类,右括号:循环执行 eval(),直到运算符栈顶是左括号为止。这个 while 条件的含义是:把括号内部所有已经排好序、等着计算的运算符全部算完。算完之后左括号还在栈里,把它弹掉。这一步结束后,括号内部的表达式整个变成了一个数字,重新回到数字栈里,完美地实现了嵌套。
第四类,普通运算符 + - * /:这是整个算法的核心决策点。逻辑是:
只要运算符栈不为空、栈顶不是左括号、并且栈顶运算符的优先级不低于当前运算符,就持续执行
eval(),把所有该先算的都算完,最后把当前运算符压栈。
三条件缺一不可。op.size() 防止空栈访问;op.top() != '(' 防止把括号左边界也算掉;pr[op.top()] >= pr[c] 保证优先级高的先算、优先级相同的从左往右算。
扫完整个字符串后,如果运算符栈还有残留,说明还剩一些延迟计算的运算没有收尾,所以最后用一个 while (op.size()) eval(); 把剩下的全部算干净。此刻数字栈栈顶就是整个表达式的最终结果。
4. 一份可以直接跑通的代码与验证过程
上一节已经把代码里的逻辑拆得很细了,这里给一个完整的测试流程。假设输入是:
code复制2+3*4+(5*6+7)
手动推导一遍执行过程,能帮你把整个算法真正串起来。
扫描 2,入数字栈。扫描 +,运算符栈为空,直接入栈。扫描 3,入数字栈。扫描 *,栈顶是 +,优先级 1 小于 2,不满足弹出条件,所以 * 入栈。扫描 4,入数字栈。扫描 +,此时栈顶是 *,优先级 2 大于 1,触发 eval():先算 3*4 得 12 入数字栈。继续看栈顶是 +,优先级 1 不小于 1,再次触发 eval():算 2+12 得 14 入数字栈。此时运算符栈空了,当前 + 入栈。扫描 (,直接入栈。扫描 5 入栈,扫描 *,栈顶是 (,不满足弹出条件,* 入栈。扫描 6 入栈。扫描 +,栈顶是 *,优先级 2 大于 1,触发 eval():算 5*6 得 30。此时栈顶是 (,循环停止,+ 入栈。扫描 7 入栈。扫描 ),执行 eval(),算 30+7 得 37,再弹出左括号。扫描结束,最后 eval() 算 14+37 得 51。输出 51,和手算结果一致。
实际跑一遍,这段代码对绝大多数合法中缀表达式都能给出正确结果。下面是一些用来做自测的用例:
| 输入 | 期望输出 | 说明 |
|---|---|---|
1+2 |
3 | 最简情形 |
2+3*4 |
14 | 乘法优先于加法 |
(2+3)*4 |
20 | 括号改变运算顺序 |
1-2-3 |
-4 | 左结合性的关键测试 |
100/10/2 |
5 | 除法同级从左往右 |
(1+(2+3)) |
6 | 嵌套括号 |
123+456 |
579 | 多位数读取 |
其中 1-2-3 这个用例特别关键。如果优先级比较时写的是 > 而不是 >=,结果会变成 1-(2-3)=2,错误非常隐蔽,因为 1-2+3 这类用例未必会暴露问题。所以验证代码时请一定把 1-2-3、1-2+3、100/10/2 这三个用例加进去。
为了让代码更接近竞赛环境,main 里读入用的是 cin >> s,会忽略前面空白符,但对字符串中间的字符不设防。如果题目会输入多组表达式,可以在外面再套一层 while (cin >> s) 循环,注意每组测试前要清空两个栈。
5. 实战中绕不开的坑与模板的改进方向
模板能解决“标准题目”,但真正用到项目里,或者遇到变种题目,有几个问题需要额外处理。
5.1 负数怎么办
经典模板处理不了以负号开头的表达式,比如 -3+5,也处理不了括号后紧跟负号的情况,比如 2*(-3)。原因很简单:负号和减号用的是同一个字符 -,程序分不清这是一个单目运算符还是一个双目减法运算符。
常见的解法有两种。第一种是在表达式预处理阶段,把开头的负号改写成 0-,比如把 -3+5 转成 0-3+5,本质是给缺失的左操作数补一个 0。第二种是在扫描时判断负号前一个字符,如果前一个字符是左括号或表达式开头,就认定这是负号,把它转换成 0 入数字栈后,再按普通 - 处理。两种思路都可行,第二种更通用,但实现时需要注意边界判断。
我的建议是:先老老实实把基础版的题目逻辑吃透,然后立刻给自己加练一道“带负数版本”的题,把这两种解的代码都写一遍。这一步做完,你对这个算法的理解会完全不一样。
5.2 空格、空串和非法输入
模板化的 cin >> s 不会读入空格,所以如果题目给的是 "1 + 2 * 3" 这种带空格的表达式,读入就会出问题。改成 getline(cin, s) 后空格进来了,但主循环里空格会被当作普通运算符处理,触发优先级比较,然后陷入逻辑混乱。
处理方式是在循环开头加一句:
cpp复制if (c == ' ') continue;
一句话的事,但做项目时最容易漏。空串的问题也一样,输入为空时,最后 num.top() 会访问空栈,直接崩溃。工程化一点的写法是求值前先判空,输出前也判一下数字栈是否为空,但竞赛里通常默认输入合法,这些保护可以不写。
实际上真正在比赛里出问题的,往往不是算法思路,而是这种“输入没按理想情况来”的细节。
5.3 从 int 到更大范围的扩展
模板里数字栈用的是 int。如果题目改成表达式里数字很大,乘起来可能溢出,这时候有两个选择:把 stack<int> 改成 stack<long long>;或者上大数模板。大多数情况下,改成 long long 就够用。
如果你用其他语言刷题,思路完全一致,只是语法细节不同。Python 的话,数字栈直接用列表模拟栈即可,由于数值类型本身不限制大小,甚至不需要考虑溢出;Java 的话用 Deque 实现栈,注意 pop 和 peek 的区别,这个模板的坑主要在类型和空栈,其他部分都一样。
还有一个小改进点:运算符优先级表用 unordered_map 已经够用,但如果追求极致性能,可以直接用 switch 返回一个整数。不过说实话,在表达式求值这个规模的问题上,unordered_map 的额外开销完全可以忽略,不用为了几微秒牺牲可读性。
5.4 表达式树:往后深挖的一条路
这个双栈算法还有个很漂亮的兄弟版——把表达式建成树。每次 eval() 相当于把两个子树的根节点和一个运算符合成一个新节点。如果你之后学编译原理,会发现“算符优先分析”和这个双栈算法有千丝万缕的联系。表达式求值并没有在“求出数字”那一刻就结束,它后面连着语法分析、AST 构建、代码生成一整条链路。先把双栈这个地基打牢,后面学编译原理会轻松很多。
模板的价值从来不在“背下来”,而在于让你看到:一个看似复杂的计算问题,如何被拆成“延迟计算 + 优先级排序”两个简单模型,然后用栈这个基础数据结构优雅地解决。
6. 最后的建议:怎么写自己的版本
不少人背下模板就以为会了,等过两天再写时,发现卡在细节上。我的经验是,至少要自己手写三遍。第一遍照着敲,让自己感受代码的节奏;第二遍盖上代码,靠理解自己写出完整实现,卡壳的地方就是你还没懂的地方;第三遍在不看任何资料的前提下,写成原题的标准答案,并且把每一行的“为什么”都能说清楚。
三个常见错误再强调一遍:eval 里取数的顺序不能错,减法除法全靠它;优先级比较要用 >=,不然同级从左往右的结合性会失效;右括号处理时弹出左括号用的是 op.pop(),不要把它也当成普通运算符参与 eval。这三个坑每一个我都亲眼见过别人踩,也都自己踩过。
如果你已经能熟练写出这个模板,可以试着给自己加点难度:支持幂运算符 ^,支持取模 %,或者把单目正负号加进去。每加一种运算符,你都要思考它在优先级表里的位置、结合性方向,以及会不会和括号交互。这个过程比刷十道模板题都锻炼人。
表达式求值这道题,说难不难,说简单也绝不简单。它考察的不只是栈的API调用,而是你能不能把“运算顺序”这个抽象的规则,翻译成一套具体的、可执行的判定逻辑。想清楚这一点,哪怕换个语言、换个题目变体,你也能写得出来。
