1. 引言
四则运算表达式是我们从小学就开始接触的数学工具,比如 3+(4*5-2)/2。对人来说,一眼就能看出先算括号里的 4*5,再算 20-2,然后除以 2,最后加上 3。但计算机面对同样的表达式却一头雾水------它不知道运算符的优先级,也不理解括号的嵌套关系。
《大话数据结构》第 4.9 节用栈这一数据结构优雅地解决了四则运算问题。核心思路分两步:先把中缀表达式转成后缀表达式(逆波兰表示法),再对后缀表达式求值。这篇博客给出完整、可处理多位数和括号的 C++ 实现,并对算法细节做了深入剖析。
2. 核心概念:三种表达式
在正式写代码之前,先理清三种表达式的区别:
| 表达式类型 | 示例 | 特点 |
|---|---|---|
| 中缀表达式 | 3 + 4 * 5 |
运算符在两个操作数中间,符合人类习惯,但有优先级和括号问题 |
| 前缀表达式(波兰式) | + 3 * 4 5 |
运算符在操作数前面,无需括号即可表达优先级 |
| 后缀表达式(逆波兰式) | 3 4 5 * + |
运算符在操作数后面,计算机求值最方便,无需括号 |
后缀表达式之所以适合计算机处理,是因为它天然消除了优先级和括号:遇到数字就压栈,遇到运算符就弹出两个操作数计算,结果再压回去。整个过程不需要前瞻或回溯。我们要做的,就是把中缀表达式转换成这种结构。
3. 中缀转后缀算法详解
3.1 算法核心思想
中缀转后缀的核心是用栈管理运算符。遍历中缀表达式的每个字符,按以下规则处理:
- 遇到数字:直接输出到后缀表达式(注意处理多位数)。
- 遇到左括号
(:直接压入运算符栈。 - 遇到右括号
):不断弹出栈顶运算符并输出,直到遇到左括号为止,最后把左括号弹出丢弃。 - 遇到运算符 :和栈顶运算符比较优先级。如果栈顶运算符优先级大于等于当前运算符,就弹出栈顶并输出,直到栈为空或栈顶优先级更低,然后把当前运算符压栈。
- 遍历结束后:把栈中剩余的运算符全部弹出输出。
下面用一个实例走一遍:中缀表达式 3+(4*5-2)/2 的转换过程。
| 步骤 | 当前字符 | 运算符栈 | 后缀输出 | 说明 |
|---|---|---|---|---|
| 1 | 3 |
空 | 3 |
数字直接输出 |
| 2 | + |
+ |
3 |
栈空,+ 直接压栈 |
| 3 | ( |
+ ( |
3 |
左括号直接压栈 |
| 4 | 4 |
+ ( |
3 4 |
数字直接输出 |
| 5 | * |
+ ( * |
3 4 |
栈顶是 (,* 直接压栈 |
| 6 | 5 |
+ ( * |
3 4 5 |
数字直接输出 |
| 7 | - |
+ ( - |
3 4 5 * |
- 优先级低于 *,弹出 * 后压入 - |
| 8 | 2 |
+ ( - |
3 4 5 * 2 |
数字直接输出 |
| 9 | ) |
+ |
3 4 5 * 2 - |
弹出 -,再弹出 ( 丢弃 |
| 10 | / |
+ / |
3 4 5 * 2 - |
/ 优先级高于 +,直接压栈 |
| 11 | 2 |
+ / |
3 4 5 * 2 - 2 |
数字直接输出 |
| 12 | 结束 | 空 | 3 4 5 * 2 - 2 / + |
弹出剩余运算符 / 和 + |
最终后缀表达式为 3 4 5 * 2 - 2 / +,这正是我们期望的结果。
3.2 优先级函数
优先级判断是整个算法的基石。乘除的优先级高于加减,括号本身不参与优先级比较:
cpp
int priority(char op) {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0; // 括号返回 0,保证不会错误弹出
}
这里把 ( 的优先级设为 0 是关键设计:当栈顶是左括号时,任何运算符都不会因为优先级比较而被错误弹出,因为左括号的优先级最低。
3.3 完整转换代码
cpp
string infixToPostfix(const string& infix) {
stack<char> opStack;
string postfix;
for (size_t i = 0; i < infix.size(); ++i) {
char c = infix[i];
if (isdigit(c)) {
// 处理多位数:连续读取直到非数字
while (i < infix.size() && isdigit(infix[i])) {
postfix += infix[i++];
}
postfix += ' '; // 用空格分隔操作数
--i; // 回退,外层 for 会 ++i
}
else if (c == '(') {
opStack.push(c);
}
else if (c == ')') {
// 弹出直到遇到左括号
while (!opStack.empty() && opStack.top() != '(') {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
opStack.pop(); // 弹出 '(' 并丢弃
}
else if (c == '+' || c == '-' || c == '*' || c == '/') {
// 栈顶优先级大于等于当前运算符时弹出
while (!opStack.empty() && priority(opStack.top()) >= priority(c)) {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
opStack.push(c);
}
}
// 弹出栈中剩余运算符
while (!opStack.empty()) {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
return postfix;
}
这段代码有两个容易忽略的细节:
- 多位数处理 :内层
while循环连续读取数字字符,保证像12、345这样的多位数被完整输出为一个操作数。 - 空格分隔 :每个操作数和运算符后面都追加一个空格,这是为了让后缀表达式求值阶段能够用
stringstream按空格切分 token。
4. 后缀表达式求值
4.1 算法思想
拿到后缀表达式后,求值反而简单了------只需要一个操作数栈。遍历后缀表达式中的每个 token:
- 如果是数字,压入操作数栈。
- 如果是运算符 ,从栈中弹出两个操作数(先弹出的是右操作数
b,后弹出的是左操作数a),计算a op b,把结果压回栈中。
遍历结束后,栈中剩下的唯一元素就是最终结果。
以 3 4 5 * 2 - 2 / + 为例:
| 步骤 | 当前 token | 操作数栈 | 操作 |
|---|---|---|---|
| 1 | 3 |
[3] |
数字入栈 |
| 2 | 4 |
[3, 4] |
数字入栈 |
| 3 | 5 |
[3, 4, 5] |
数字入栈 |
| 4 | * |
[3, 20] |
弹出 5 和 4,计算 4*5=20,压回 |
| 5 | 2 |
[3, 20, 2] |
数字入栈 |
| 6 | - |
[3, 18] |
弹出 2 和 20,计算 20-2=18,压回 |
| 7 | 2 |
[3, 18, 2] |
数字入栈 |
| 8 | / |
[3, 9] |
弹出 2 和 18,计算 18/2=9,压回 |
| 9 | + |
[12] |
弹出 9 和 3,计算 3+9=12,压回 |
最终栈中只剩 12,与预期一致。
4.2 完整求值代码
cpp
double evaluatePostfix(const string& postfix) {
stack<double> numStack;
stringstream ss(postfix);
string token;
while (ss >> token) {
// 判断是否为数字(支持负数如 "-3")
if (isdigit(token[0]) || (token.size() > 1 && token[0] == '-')) {
numStack.push(stod(token));
} else {
double b = numStack.top(); numStack.pop();
double a = numStack.top(); numStack.pop();
switch (token[0]) {
case '+': numStack.push(a + b); break;
case '-': numStack.push(a - b); break;
case '*': numStack.push(a * b); break;
case '/': numStack.push(a / b); break;
}
}
}
return numStack.top();
}
注意这里用 stod(token) 将字符串转为 double,这样即使表达式中有小数也能正确处理。另外数字判断中加了 token.size() > 1 && token[0] == '-',是为了识别像 -3 这样的负数 token。
5. 完整可运行代码
把上述两部分拼在一起,加上必要的头文件和测试用例,得到一份完整的可运行程序:
cpp
#include <iostream>
#include <stack>
#include <string>
#include <sstream>
#include <cctype>
using namespace std;
int priority(char op) {
if (op == '+' || op == '-') return 1;
if (op == '*' || op == '/') return 2;
return 0;
}
string infixToPostfix(const string& infix) {
stack<char> opStack;
string postfix;
for (size_t i = 0; i < infix.size(); ++i) {
char c = infix[i];
if (isdigit(c)) {
while (i < infix.size() && isdigit(infix[i])) {
postfix += infix[i++];
}
postfix += ' ';
--i;
} else if (c == '(') {
opStack.push(c);
} else if (c == ')') {
while (!opStack.empty() && opStack.top() != '(') {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
opStack.pop();
} else if (c == '+' || c == '-' || c == '*' || c == '/') {
while (!opStack.empty() && priority(opStack.top()) >= priority(c)) {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
opStack.push(c);
}
}
while (!opStack.empty()) {
postfix += opStack.top();
postfix += ' ';
opStack.pop();
}
return postfix;
}
double evaluatePostfix(const string& postfix) {
stack<double> numStack;
stringstream ss(postfix);
string token;
while (ss >> token) {
if (isdigit(token[0]) || (token.size() > 1 && token[0] == '-')) {
numStack.push(stod(token));
} else {
double b = numStack.top(); numStack.pop();
double a = numStack.top(); numStack.pop();
switch (token[0]) {
case '+': numStack.push(a + b); break;
case '-': numStack.push(a - b); break;
case '*': numStack.push(a * b); break;
case '/': numStack.push(a / b); break;
}
}
}
return numStack.top();
}
int main() {
// 测试用例 1
string infix1 = "3+(4*5-2)/2";
string postfix1 = infixToPostfix(infix1);
cout << "中缀: " << infix1 << endl;
cout << "后缀: " << postfix1 << endl;
cout << "结果: " << evaluatePostfix(postfix1) << endl; // 12
cout << "---" << endl;
// 测试用例 2:多位数
string infix2 = "12+34*5";
string postfix2 = infixToPostfix(infix2);
cout << "中缀: " << infix2 << endl;
cout << "后缀: " << postfix2 << endl;
cout << "结果: " << evaluatePostfix(postfix2) << endl; // 182
cout << "---" << endl;
// 测试用例 3:嵌套括号
string infix3 = "((2+3)*4-(5-2))/3";
string postfix3 = infixToPostfix(infix3);
cout << "中缀: " << infix3 << endl;
cout << "后缀: " << postfix3 << endl;
cout << "结果: " << evaluatePostfix(postfix3) << endl; // 5.66667
return 0;
}
6. 运行结果与分析
编译运行上述代码,输出如下:
text
中缀: 3+(4*5-2)/2
后缀: 3 4 5 * 2 - 2 / +
结果: 12
---
中缀: 12+34*5
后缀: 12 34 5 * +
结果: 182
---
中缀: ((2+3)*4-(5-2))/3
后缀: 2 3 + 4 * 5 2 - - 3 /
结果: 5.66667
三个测试用例覆盖了基础四则运算与括号嵌套 、多位数处理 、多层嵌套括号三种典型场景,全部输出正确结果。
7. 拓展思考
这套代码已经能正确处理多位数、括号和四则运算优先级。如果想让实现更健壮,以下几个方向值得进一步探索:
- 支持小数 :当前代码只处理整数数字,可以在
isdigit判断中加入对小数点.的处理。 - 支持负数 :中缀表达式中的一元负号(如
-3+5或3*(-2))需要特殊处理,因为同一个-符号可能是二元减法也可能是一元取负。 - 支持更多运算符 :比如幂运算
^(右结合)或取模%,需要调整优先级函数和结合性规则。 - 错误处理:对非法输入(如不匹配的括号、连续运算符等)给出友好提示。
- 用模板/泛型改写 :让求值函数支持
int、long long、double等多种数值类型。
8. 总结
中缀转后缀加后缀求值,是栈的经典应用场景,也是《大话数据结构》第 4 章的高光内容。整个方案的核心就两句话:
- 转后缀靠运算符栈------用优先级比较决定运算符的输出时机,括号用来界定子表达式的边界。
- 求值靠操作数栈------见到数字就压,见到运算符就弹两个算一个再压回去。
理解了这个双栈协作的模式,再去理解编译原理中的表达式解析、甚至是逆波兰计算器的实现,都会轻松很多。建议读者把代码复制到本地跑一遍,然后试着改几个表达式观察后缀输出,手感会更好。