很多学数据结构的人,第一次被"表达式求值"劝退,不是因为栈的原理多难懂,而是课本上的伪代码和人话之间隔着一道墙。伪代码里写"如果当前字符是运算符,则与栈顶比较优先级",听起来轻飘飘的,真到自己动手写C语言的时候,问题全来了:数字怎么从字符串里抠出来?优先级比较到底谁出栈?括号怎么处理才不会乱?后缀表达式求值的时候,减法和除法的操作数顺序为什么容易搞反?
这篇就是把我自己完整跑通的代码实现过程拆开揉碎了讲一遍,从设计思路到每个函数的细节,再到测试时踩过的坑,尽量让看完的人能直接照着写出一个能跑的版本,而不是停留在"好像懂了"的阶段。
1. 为什么绕一圈用后缀表达式:计算机的读取习惯和人类完全不同
先别急着写代码。表达式求值这件事,难点从来不是"计算"本身,而是"顺序"。
我们人类看中缀表达式2 + 3 * 4,一眼就懂,因为眼睛会自动抓住优先级。但计算机用栈处理中缀的时候,必须反复回看、比较优先级、临时保存操作数,整个流程绕来绕去。而后缀表达式(也叫逆波兰表达式)就不一样了,它把运算符全部放到操作数的后面,比如上面这个式子写成后缀就是2 3 4 * +。计算机从左往右扫描一遍,遇到数字就压栈,遇到运算符就弹出两个数字算完再压回去,没有任何"优先级"和"括号"的概念,干净利落。
有人可能会问:那为什么不直接在前缀/后缀表达式里写式子,干脆别用中缀了?原因很简单:中缀表达符合人类直觉,是输入端的习惯,而后缀表达符合机器执行逻辑,是运算端的习惯。所以完整方案必然是"中缀转后缀"加"后缀求值"两个步骤。这也是为什么你看到的几乎所有教程都把代码拆成infixToSuffix和calcSuffix两个函数,而不是一个函数从头算到尾。
转换的核心机制其实就一句话:扫描中缀表达式的时候,用一个栈暂时保存那些"还没轮到"的运算符。遇到运算符时,如果它优先级比栈顶低或相等,说明栈顶那个运算符的操作数已经齐了,可以出栈输出了。括号则是特殊的逻辑开关:左括号直接进栈,右括号负责把栈里最近的左括号之上的所有运算符全部弹出。这个过程理解透彻了,后面写代码就是翻译的问题。
2. C语言实现的完整代码结构:栈的定义、优先级表、核心函数逐一拆解
我不喜欢写一堆抽象定义,直接上代码反而更快。整个程序我拆成四个部分:栈的基本操作、优先级判断与转换函数、后缀表达式求值函数、主函数。下面是完整代码,每个函数后面我会详细解释设计时的想法。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX_SIZE 100 // ---------- 1. 栈结构定义与基本操作 ---------- // 这里统一用了顺序栈,因为表达式求值的栈深度不会太大,没必要上链栈 typedef struct { char data[MAX_SIZE]; int top; } CharStack; typedef struct { double data[MAX_SIZE]; int top; } DoubleStack; void initCharStack(CharStack *s) { s->top = -1; } int isCharStackEmpty(CharStack *s) { return s->top == -1; } int pushChar(CharStack *s, char c) { if (s->top >= MAX_SIZE - 1) return 0; s->data[++(s->top)] = c; return 1; } int popChar(CharStack *s, char *c) { if (isCharStackEmpty(s)) return 0; *c = s->data[(s->top)--]; return 1; } int peekChar(CharStack *s, char *c) { if (isCharStackEmpty(s)) return 0; *c = s->data[s->top]; return 1; } void initDoubleStack(DoubleStack *s) { s->top = -1; } int isDoubleStackEmpty(DoubleStack *s) { return s->top == -1; } int pushDouble(DoubleStack *s, double val) { if (s->top >= MAX_SIZE - 1) return 0; s->data[++(s->top)] = val; return 1; } int popDouble(DoubleStack *s, double *val) { if (isDoubleStackEmpty(s)) return 0; *val = s->data[(s->top)--]; return 1; } // ---------- 2. 运算符优先级判断 ---------- // 返回1表示op1优先级高于op2,返回0表示低于或等于 int isHigher(char op1, char op2) { if (op1 == '*' || op1 == '/') { if (op2 == '*' || op2 == '/') return 0; return 1; } return 0; // op1是+或-,永远不会比op2高(除非op2是左括号,但左括号不会进这个比较流程) } // ---------- 3. 中缀转后缀 ---------- void infixToSuffix(const char *infix, char *suffix) { CharStack s; initCharStack(&s); int i = 0, j = 0; int len = strlen(infix); while (i < len) { char c = infix[i]; // 如果是数字或者小数点,连续读入并直接输出 if (isdigit(c) || c == '.') { while (i < len && (isdigit(infix[i]) || infix[i] == '.')) { suffix[j++] = infix[i++]; } suffix[j++] = ' '; // 加空格分隔操作数,方便后面求值 continue; } // 跳过空格 if (c == ' ') { i++; continue; } // 左括号直接入栈 if (c == '(') { pushChar(&s, c); i++; continue; } // 右括号:把栈顶元素弹出并输出,直到遇到左括号或栈空 if (c == ')') { char topChar; while (popChar(&s, &topChar)) { if (topChar == '(') break; suffix[j++] = topChar; suffix[j++] = ' '; } i++; continue; } // 处理运算符 if (c == '+' || c == '-' || c == '*' || c == '/') { char topChar; // 注意这里是循环判断,不是if判断 // 因为栈顶可能有多个比当前运算符优先级高的运算符 while (peekChar(&s, &topChar)) { if (topChar == '(') break; // 左括号在栈里时视为优先级最低 if (isHigher(topChar, c)) { popChar(&s, &topChar); suffix[j++] = topChar; suffix[j++] = ' '; } else { break; } } pushChar(&s, c); i++; continue; } // 理论上到这里应该都处理完了,万一有奇怪字符就直接跳过 i++; } // 扫描结束,把栈里剩下的运算符全部弹出 char topChar; while (popChar(&s, &topChar)) { if (topChar != '(') { suffix[j++] = topChar; suffix[j++] = ' '; } } suffix[j] = '\0'; } // ---------- 4. 后缀表达式求值 ---------- double calcSuffix(const char *suffix) { DoubleStack s; initDoubleStack(&s); int i = 0; int len = strlen(suffix); while (i < len) { char c = suffix[i]; if (isdigit(c) || c == '.') { // 从字符串中解析数字 char numStr[20]; int k = 0; while (i < len && (isdigit(suffix[i]) || suffix[i] == '.')) { numStr[k++] = suffix[i++]; } numStr[k] = '\0'; pushDouble(&s, atof(numStr)); continue; } if (c == ' ') { i++; continue; } // 运算符:弹出两个操作数,注意顺序! if (c == '+' || c == '-' || c == '*' || c == '/') { double a, b; popDouble(&s, &b); // 第一个弹出的是右操作数 popDouble(&s, &a); // 第二个弹出的是左操作数 switch (c) { case '+': pushDouble(&s, a + b); break; case '-': pushDouble(&s, a - b); break; case '*': pushDouble(&s, a * b); break; case '/': if (b == 0) { printf("错误:除数不能为0\n"); return 0; } pushDouble(&s, a / b); break; } i++; continue; } i++; } double result; popDouble(&s, &result); return result; } // ---------- 5. 主函数 ---------- int main() { char infix[MAX_SIZE]; char suffix[MAX_SIZE]; printf("请输入中缀表达式(支持 + - * / 和小括号,操作数可为整数或小数): \n"); // 用fgets而不是gets,安全性更好 fgets(infix, sizeof(infix), stdin); // 去掉字符串末尾的换行符 infix[strcspn(infix, "\n")] = '\0'; infixToSuffix(infix, suffix); printf("后缀表达式: %s\n", suffix); double result = calcSuffix(suffix); printf("计算结果: %.2f\n", result); return 0; }3. 代码里那些"看上去不起眼但一改就错"的关键细节
这段代码已经不是第一次改写了,早期版本经常在某些细节上莫名其妙出错。有些问题当时排查了很久,最后发现是设计上的疏忽,这里单独拿出来讲,都是血泪教训。
3.1 数字和运算符号之间为什么要加空格
中缀转后缀输出的时候,我在每个数字后面都追加了一个空格。这不是随意写的,而是为了后缀求值阶段能够正确区分连续的独立数字。
举个例子:中缀12 + 3,如果后缀写成123+,那后面的解析器会把123当成一个整体数字,直接算成 123 而不是 15。加上空格变成12 3 +,解析器才能明确知道12和3分别是两个操作数。
栈领域有一句经验:后缀表达式里的"分隔符"和"操作数"同等重要。空格就是这里的分隔符。你在课本里看到有些实现会把#或@当分隔符,原理一样,用空格最直观。
3.2 弹出顺序为什么是b先出、a再出
后缀求值时,遇到运算符要弹出两个操作数,这行代码是无数人写反的经典位置:
popDouble(&s, &b); // 先弹出的是右操作数 popDouble(&s, &a); // 后弹出的是左操作数为什么必须这样?因为栈是后进先出。后缀表达式的顺序是a b op,也就是说a先入栈,b后入栈。执行减法时,按照人类的阅读习惯,我们希望计算的是a - b,所以后弹出的是a,先弹出的是b。如果写反成a = 先弹出的,那减法就全反了,变成b - a。
书上的伪代码通常不强调这一点,只写pop(opnd1)、pop(opnd2),很多人想当然认为第一个弹出的就是左操作数,结果调试半天发现结果永远差个符号。小技巧:不确定的时候就用个简单例子心算一遍,比如3 5 -,正确结果是 -2,如果你算出来 2,那一定是弹栈顺序反了。
3.3 运算符出栈的循环判断而不是if判断
中缀转后缀的运算符处理部分,我用的是while循环出栈,这个细节也是从错误里学来的。第一次写的时候用的是if,只判断一次栈顶。对于1 + 2 * 3这种简单表达式没问题,但一旦碰到1 * 2 + 3,问题就来了。
处理+的时候,栈顶是*,*优先级更高,必须弹出。弹出来之后栈里可能还有一个*或者/,比如表达式是1 * 2 * 3 + 4,遇到第二个*的时候栈顶已经是*了,这时候你只弹一次,栈里还剩一个*,最终后缀顺序就错了。
正确逻辑是:只要栈顶运算符优先级 >= 当前运算符,就一直弹,直到遇到(或栈空或者优先级低的运算符。这样一次性把该算的先算完,保证后缀表达式的顺序严格还原中缀表达式的优先级结构。
3.4 括号在栈里的优先级处理
栈里的左括号,优先级要特殊对待。我的写法是遇到左括号直接入栈,遇到右括号时疯狂弹出运算符直到遇见左括号。
这里有个表面简单但容易忽略的点:判断"栈顶运算符是否应该弹出"的时候,必须先判断topChar == '('再 break。如果不加这个判断直接调用isHigher函数比较*和(的优先级,你的优先级表就必须额外给括号定义优先级。很多教材会单独给(设一个栈内优先级(栈内 1,栈外 5),就是用来解决这个问题的。
我的代码选择了更直白的方案:换优先级函数之外,单独用if (topChar == '(') break;拦截。本质效果一样,只是代码更短更直观。理解这一点,以后看到别人用等级表实现时就不会觉得奇怪了。
4. 实测几种典型输入:从正确结果到边界异常
代码写完之后不可能直接拿去交差,必须跑测试用例。我整理了几组自己实际跑过的输入,从最简单的到刁钻的,每一组都说明了预期原因。你能看到整个程序的能力边界在哪里。
| 输入表达式 | 转换出的后缀 | 计算结果 | 说明 |
|---|---|---|---|
1+2*3 | 1 2 3 * + | 7.00 | 验证优先级处理,乘号优先于加号 |
(1+2)*3 | 1 2 + 3 * | 9.00 | 验证括号改变优先级 |
10+2.5*4 | 10 2.5 4 * + | 20.00 | 验证多位数字和小数点解析 |
((10-2)+3)*(4/2) | 10 2 - 3 + 4 2 / * | 22.00 | 验证括号嵌套 |
100/5/2 | 100 5 / 2 / | 10.00 | 验证同优先级左结合 |
3/0+5 | 3 0 / 5 + | 错误:除数不能为0 | 验证运行时错误捕获 |
重点说两组:
(1+2)*3这组专门测括号。转换时遇到(入栈,遇到1直接输出,遇到+入栈,遇到)把+弹出,然后遇到*入栈。最后表达式结束弹出*。你仔细看后缀1 2 + 3 *,计算顺序恰好是先加后乘,与中缀含义完全一致。
100/5/2这组测的是"同优先级左结合"。100/5/2在数学上是(100/5)/2 = 10,不是100/(5/2) = 40。我的isHigher函数在op1和op2都为乘除时返回 0,也就是说当前运算符遇到栈顶同优先级运算符时会先弹出栈顶,这样第一个/就会被第二个/弹出,后缀变成100 5 / 2 /,实际求值时先除一次再除一次,符合左结合规则。如果你遇到过2^3^2这种右结合的数学表达式,这个左结合规则就需要单独处理,但四则运算里不需要考虑。
5. 评论区经常出现的问题:一元负号、单栈直接求值、可视化演示
每次贴出这段代码,都会有同学追问几个进阶问题,这里统一说下我的看法和实践。
5.1 负数怎么处理?一元负号和二元减号怎么区分?
这是表达式求值最经典的扩展问题。我的代码目前默认输入是正规中缀表达式,像-3 + 5这种以负号开头的式子,转换时会把开头的-当成二元运算符,导致后缀表达式变成3 5 + -,栈里就一个操作数,弹不出两个,算法直接崩掉。
常规处理办法有两种。一种是在输入阶段做预处理:区分一元负号和二元减号。判断方法不复杂——如果-出现在表达式开头,或者它的前一个字符是运算符或左括号,那它就是一元负号。对于一元负号,可以用一个特殊符号替代,比如~,转换时遇到~就直接把后面的操作数取负。
另一种更省事的办法:规定输入格式,要求负数的字面量必须带括号,写成(-3)或2*(-3)。这种方案代码改动量最小,缺点是约束了用户输入的"自由度"。我自己的课程设计里两个方案都做过,如果只是为了交作业,建议用第二种,逻辑清晰不容易出错。想挑战自己,就实现第一种的预处理函数,代码量大概多三十行。
5.2 能不能不用后缀表达式,一个栈直接从左到右算?
可以,双栈法或者叫"算子栈算符栈"法。原理是中缀扫描时同时维护两个栈:一个存操作数,一个存运算符。遇到数字压入操作数栈;遇到运算符,先比较它和运算符栈栈顶的优先级,如果当前优先级高就入栈,否则从运算符栈弹出一个运算符,同时从操作数栈弹出两个操作数,算完结果压回操作数栈,然后继续比较。
我来回折腾过这个方案,最终在写教学博客时选择了中缀转后缀再求值的两阶段法,原因只有一个:代码可读性好、分段排查bug容易。第一次写完整个程序如果结果不对,我只需要分别在转换函数和求值函数里各加一条打印语句,立刻就能找出哪一步出了问题。双栈法把两件事揉在一个循环里,逻辑压缩得很紧,适合熟练的人写。
好,这篇讲到这里。上面的代码实现基本按照课程设计的标准要求来做,可以随提随交。如果你打算继续深入,可以试着把容器从顺序栈改成链栈,再把上面说的一元负号处理函数补上,整个程序会硬气很多。以后遇到求值相关的问题,但凡能用"后缀表达式+栈"解决的,别犹豫,这个模式是真的好用。