☰
中缀转后缀与表达式求值:栈的应用与实现详解
2026/9/26 9:43:40 网站建设 项目流程

中缀表达式转后缀表达式,再加上后缀表达式的求值,这几乎是每个学《数据结构》的人都会撞上的一道坎。我当年第一次写这段代码的时候,对着严蔚敏那本经典教材翻来覆去看了三遍,纸上画了一堆栈的变化图,结果上机一跑还是错——不是优先级判断漏了情况,就是多位数字被拆成了单个字符。后来带学弟做实验报告、帮同事准备面试,这套东西我前后实现过不下十遍,用C、用Python、用Java都写过,才慢慢把里面的坑一个个填平。

这篇文章就是把这十来年攒下来的经验一次性讲清楚。核心关键词就几个:中缀表达式、后缀表达式、数据结构、算法、栈。我会从设计思路讲到逐行实现,把运算符优先级表、栈的变化过程、多位数字处理、括号匹配、负数和小数的边界情况全部拆开揉碎。不管你是正在赶数据结构实验报告的学生,还是准备408考研复习的王道选手,或者只是想把这块知识彻底搞明白的开发者,看完都能自己动手写出一个能跑、能过测试、能讲清楚原理的版本。

我默认你用任意一门语言都能看懂,代码示例主要用C(因为教材和考试大多用C),关键逻辑会补充Python版本方便对照。整篇内容不依赖任何特定平台,你复制到本地就能跑。

1. 为什么这道题值得反复琢磨

1.1 中缀和后缀到底差在哪

我们平时写的3 + 4 * 2这种叫中缀表达式,运算符夹在两个操作数中间,符合人类阅读习惯。但计算机处理它很别扭,因为运算符的优先级和括号会打乱从左到右的计算顺序。你想想,3 + 4 * 2,人一眼知道先算4 * 2,可机器如果老老实实从左往右扫,先遇到+就想算3 + 4,那就错了。

后缀表达式(也叫逆波兰表达式,Reverse Polish Notation)把运算符放到操作数后面,3 + 4 * 2写成3 4 2 * +。它的好处是完全没有优先级和括号的困扰,计算机只需要一个栈,从左往右扫一遍就能算出结果。这也是为什么早期的计算器和很多编译器前端都采用这种表示法。

所以整个任务被拆成两步:第一步把人类友好的中缀转成机器友好的后缀,第二步用栈把后缀算出来。两步的核心数据结构都是栈,这也是这道题被放进《数据结构》栈章节的典型例题的原因。

1.2 这道题到底在考什么

表面上看是让你写两个函数,实际上它在考你对栈这个数据结构的理解深度。转换过程考的是栈的"延迟输出"特性——遇到运算符不能马上输出,得先压栈,等后面有更高优先级的运算符或者括号结束时再弹出来。求值过程考的是栈的"后进先出"特性——遇到操作数压栈,遇到运算符就弹出两个操作数计算再压回去。

我见过太多人代码能跑但讲不清原理,面试官一问"为什么运算符要压栈而不是直接输出"就卡壳。所以下面我不光给代码,更要把每一步"为什么这么做"讲透。理解了原理,你换任何语言、处理任何边界情况都不会慌。

1.3 适合谁来读

如果你是正在做数据结构实验的学生,这篇能直接当你的实现参考和实验报告素材。如果你在准备408或者找工作的算法面试,这里面的优先级表、边界处理、常见追问都是高频考点。如果你已经工作但想补基础,这套栈的应用思路在解析配置、处理表达式、写简易计算器时都用得上。我尽量不假设你有很强的编程功底,每个关键步骤都会解释意图。

2. 整体设计思路与方案选型

2.1 两个阶段的分工

整个方案分成两个独立但衔接紧密的阶段。第一阶段是中缀转后缀,输入是一个中缀表达式字符串,输出是一个后缀表达式(通常用列表或字符串保存)。第二阶段是后缀求值,输入是后缀表达式,输出是一个数值结果。

我强烈建议你把这两个阶段写成两个独立函数,而不是揉在一起。原因有三:一是便于单独测试,转换对不对和求值对不对可以分开验证;二是便于复用,后缀表达式可以保存下来多次求值(比如变量替换场景);三是逻辑清晰,出问题时能快速定位是转换错了还是求值错了。我早期图省事写成一个函数,结果调试时根本分不清是哪一步出的错,血的教训。

2.2 为什么选栈而不是其他结构

有人会问,能不能用队列或者递归来做?队列是先进先出,处理不了运算符优先级的"延迟"需求。递归确实可以(本质是表达式树的后序遍历),但递归写法对初学者不友好,而且栈溢出风险高。栈的"后进先出"恰好匹配运算符优先级的嵌套关系——优先级高的运算符后进栈,也就先出栈,天然符合"先算优先级高的"这个需求。

用一个生活类比:栈就像一摞盘子,你只能从最上面拿。转换时,运算符按优先级"叠"在栈里,优先级高的压在优先级低的上面,要输出时自然先拿上面的高优先级运算符。这个直觉建立起来,代码就好写了。

2.3 运算符优先级表的设计

优先级表是整个转换算法的灵魂。我一般用两个维度来定义:栈内优先级(in-stack priority, ISP)和栈外优先级(incoming priority, ICP)。为什么要分两个?因为同一个运算符在栈内和栈外的"待遇"不一样。比如左括号(在栈外时优先级极高(要赶紧压进去),但在栈内时优先级极低(要等右括号来才弹出)。

下面是我常用的优先级表,这套数值在严蔚敏教材和多数408资料里都能对上:

运算符栈内优先级 ISP栈外优先级 ICP
+32
-32
*54
/54
(16
)61

规则是这样的:当扫描到一个栈外运算符时,如果它的栈外优先级大于栈顶运算符的栈内优先级,就压栈;否则弹出栈顶运算符输出,再继续比较。左括号(栈外优先级最高(6),所以一遇到就压栈;栈内优先级最低(1),所以任何运算符遇到它都不会弹出它,直到右括号出现。右括号)栈外优先级最低(1),遇到它就要一直弹栈直到弹出左括号。

注意:这套数值不是唯一的,你也可以用+ -为1、* /为2、(为0 的简化版本,只要保证相对大小关系正确即可。但用上面这套完整的表,处理括号和边界时更不容易出错,考试时也更好解释。

2.4 多位数字与小数点的处理

这是新手最容易翻车的地方。很多教材示例用的是单个数字,比如3+4,于是有人写代码时遇到数字就直接输出一个字符。但真实表达式里123 + 45这种多位数字太常见了,你必须连续读取所有数字字符,拼成一个完整的数再输出。

我的做法是:扫描到数字或小数点时,进入一个内层循环,一直读到非数字非小数点的字符为止,把这段子串作为一个整体输出。这样123会被完整识别,3.14也能正确处理。小数点要单独判断,避免3.14.15这种非法输入被误读,实际工程里我会加一个校验。

2.5 负数与一元运算符的坑

标准的中缀转后缀算法默认所有运算符都是二元的,也就是-一定有两个操作数。但表达式-3 + 5里的-是一元负号,只有一个操作数。这个情况如果不处理,算法会出错。

判断一元负号的规则是:如果-出现在表达式开头,或者出现在另一个运算符之后、左括号之后,那它就是一元负号。处理方式有两种:一是把-3整体当成一个负数操作数直接输出;二是引入一个特殊的一元运算符(比如用~表示负号),给它最高优先级。我一般用第一种,简单直接。这个细节考试不一定考,但实际写计算器一定会遇到,值得提前想清楚。

3. 中缀转后缀的核心实现

3.1 转换算法的完整流程

先把整体流程用文字走一遍,你脑子里有个全景图,再看代码就不会迷路。算法维护一个运算符栈,从左到右扫描中缀表达式的每个字符:

  1. 遇到操作数(数字),连续读取完整数字,直接输出到后缀结果。
  2. 遇到左括号(,直接压栈。
  3. 遇到右括号),不断弹出栈顶运算符并输出,直到弹出左括号为止(左括号弹出但不输出)。
  4. 遇到运算符,比较它和栈顶运算符的优先级:如果栈空或栈顶是左括号,或当前运算符栈外优先级大于栈顶栈内优先级,就压栈;否则弹出栈顶输出,重复比较,直到满足压栈条件。
  5. 扫描结束后,把栈里剩余的运算符全部弹出输出。

这个流程的关键在于第4步的"重复比较",很多人只比较一次就压栈,导致3 * 4 + 2这种表达式转换错误。一定要用循环,直到当前运算符能压进去为止。

3.2 优先级判断的代码实现

先定义优先级查询函数,这是整个算法的基础设施:

// 返回栈内优先级,非运算符返回 -1 int isp(char op) { switch (op) { case '+': case '-': return 3; case '*': case '/': return 5; case '(': return 1; case ')': return 6; default: return -1; } } // 返回栈外优先级 int icp(char op) { switch (op) { case '+': case '-': return 2; case '*': case '/': return 4; case '(': return 6; case ')': return 1; default: return -1; } }

用switch而不是数组映射,是因为运算符是字符,switch可读性更好,也方便你加新的运算符(比如取模%、幂运算^)。如果加^,记得它的优先级要高于* /,而且它是右结合的,处理方式和普通二元运算符略有不同,这个后面在常见问题里会讲。

3.3 主转换函数的逐段拆解

下面是转换函数的主体,我用C写,注释写得很细:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #define MAX 1000 void infixToPostfix(const char *infix, char *postfix) { char stack[MAX]; // 运算符栈 int top = -1; // 栈顶指针 int k = 0; // 后缀结果的下标 int i = 0; int len = strlen(infix); while (i < len) { char c = infix[i]; // 跳过空格 if (c == ' ') { i++; continue; } // 情况1:操作数,连续读取多位数字和小数点 if (isdigit(c) || c == '.') { while (i < len && (isdigit(infix[i]) || infix[i] == '.')) { postfix[k++] = infix[i++]; } postfix[k++] = ' '; // 用空格分隔操作数,方便后续求值 continue; } // 情况2:左括号,直接压栈 if (c == '(') { stack[++top] = c; i++; continue; } // 情况3:右括号,弹栈直到左括号 if (c == ')') { while (top >= 0 && stack[top] != '(') { postfix[k++] = stack[top--]; postfix[k++] = ' '; } if (top >= 0) top--; // 弹出左括号但不输出 i++; continue; } // 情况4:运算符,循环比较优先级 if (isp(c) != -1) { while (top >= 0 && isp(stack[top]) >= icp(c)) { postfix[k++] = stack[top--]; postfix[k++] = ' '; } stack[++top] = c; i++; continue; } // 非法字符,跳过或报错 i++; } // 扫描结束,弹出剩余运算符 while (top >= 0) { postfix[k++] = stack[top--]; postfix[k++] = ' '; } postfix[k] = '\0'; }

这段代码有几个设计决策值得说明。第一,用空格分隔操作数,这样后缀表达式里12和3不会粘成123,求值时按空格切分就行,比逐字符解析省事得多。第二,多位数字用内层循环读取,这是处理123这类数字的关键。第三,右括号弹出左括号后不输出,因为括号只是分组符号,后缀表达式里不需要它。

3.4 用具体例子跟踪栈的变化

光看代码容易晕,我们拿3 + 4 * 2 - (1 + 5)走一遍,把每一步栈和输出的变化列出来。这个表达式够复杂,包含了优先级和括号两种情况。

步骤扫描字符操作栈内容(底→顶)后缀输出
13输出空3
2+压栈+3
34输出+3 4
4**的ICP=4 >+的ISP=3,压栈+ *3 4
52输出+ *3 4 2
6--的ICP=2 <*的ISP=5,弹*;再比+,2<3,弹+;栈空,压--3 4 2 * +
7(压栈- (3 4 2 * +
81输出- (3 4 2 * + 1
9+栈顶是(,压栈- ( +3 4 2 * + 1
105输出- ( +3 4 2 * + 1 5
11)弹到左括号,弹+输出,弹(-3 4 2 * + 1 5 +
12结束弹出-空3 4 2 * + 1 5 + -

最终后缀表达式是3 4 2 * + 1 5 + -。你可以自己验算一下:4*2=8,3+8=11,1+5=6,11-6=5,结果正确。这个跟踪表我建议你自己动手画一遍,画完对算法的理解会上一个台阶。

4. 后缀表达式求值的实现

4.1 求值算法的核心逻辑

后缀求值比转换简单得多,因为没有了优先级和括号的干扰。算法维护一个操作数栈,从左到右扫描后缀表达式:

  1. 遇到操作数,转成数值压栈。
  2. 遇到运算符,弹出栈顶两个操作数(注意顺序:先弹出的是右操作数,后弹出的是左操作数),做运算,把结果压回栈。
  3. 扫描结束后,栈里只剩一个数,就是最终结果。

这里最容易错的是操作数的顺序。栈是后进先出,所以先弹出的是第二个操作数(右操作数),后弹出的才是第一个操作数(左操作数)。减法a - b和除法a / b对顺序敏感,搞反了结果就错了。我见过太多人在这里栽跟头,包括我自己第一次写的时候。

4.2 操作数栈的代码实现

// 后缀表达式求值,假设操作数是整数 int evalPostfix(const char *postfix) { int stack[MAX]; int top = -1; int i = 0; int len = strlen(postfix); while (i < len) { char c = postfix[i]; if (c == ' ') { i++; continue; } // 情况1:操作数,解析完整数字 if (isdigit(c)) { int num = 0; while (i < len && isdigit(postfix[i])) { num = num * 10 + (postfix[i] - '0'); i++; } stack[++top] = num; continue; } // 情况2:运算符,弹出两个操作数计算 if (isp(c) != -1) { int b = stack[top--]; // 右操作数 int a = stack[top--]; // 左操作数 int result = 0; switch (c) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': result = a / b; break; } stack[++top] = result; i++; continue; } i++; } return stack[top]; }

注意num = num * 10 + (postfix[i] - '0')这行,这是把字符数字转成整数的经典写法。postfix[i] - '0'利用ASCII码把字符'3'转成整数3,然后每次乘10加新位,就能拼出多位数字。这个技巧在处理任何字符数字时都用得上。

4.3 支持小数和浮点运算

如果表达式里有小数,上面的整数版本就不够用了。改成浮点版本,核心变化是操作数栈用double,解析数字时处理小数点:

double evalPostfixFloat(const char *postfix) { double stack[MAX]; int top = -1; int i = 0; int len = strlen(postfix); while (i < len) { char c = postfix[i]; if (c == ' ') { i++; continue; } if (isdigit(c) || c == '.') { double num = 0; // 整数部分 while (i < len && isdigit(postfix[i])) { num = num * 10 + (postfix[i] - '0'); i++; } // 小数部分 if (i < len && postfix[i] == '.') { i++; double factor = 0.1; while (i < len && isdigit(postfix[i])) { num += (postfix[i] - '0') * factor; factor *= 0.1; i++; } } stack[++top] = num; continue; } if (isp(c) != -1) { double b = stack[top--]; double a = stack[top--]; double result = 0; switch (c) { case '+': result = a + b; break; case '-': result = a - b; break; case '*': result = a * b; break; case '/': result = a / b; break; } stack[++top] = result; i++; continue; } i++; } return stack[top]; }

小数部分用factor逐位递减(0.1、0.01、0.001...)来累加,逻辑清晰。不过要注意浮点精度问题,0.1 + 0.2在计算机里不等于0.3,这是IEEE 754标准的固有特性。如果对精度要求高,得用定点数或者专门的十进制库,这个在常见问题里会展开说。

4.4 完整跑通一个例子

把前面的转换和求值串起来,测试3 + 4 * 2 - (1 + 5):

int main() { char infix[] = "3 + 4 * 2 - (1 + 5)"; char postfix[MAX]; infixToPostfix(infix, postfix); printf("后缀表达式: %s\n", postfix); int result = evalPostfix(postfix); printf("计算结果: %d\n", result); return 0; }

输出应该是:

后缀表达式: 3 4 2 * + 1 5 + - 计算结果: 5

如果你跑出来是这个结果,恭喜你,核心逻辑通了。如果不对,对照前面的栈变化表一步步排查,八成是优先级比较或者操作数顺序的问题。

5. 常见问题与排查技巧实录

5.1 优先级比较写错导致的转换错误

这是最高频的错误。典型症状是3 + 4 * 2被转成3 4 + 2 *(错误)而不是3 4 2 * +(正确)。根源在于比较运算符优先级时,用了>而不是>=,或者只比较了一次没循环。

记住规则:当前运算符的栈外优先级icp要严格大于栈顶的栈内优先级isp才能压栈,否则弹栈。用>=会导致相同优先级的运算符(比如3 - 4 + 5里的+)不弹出前面的-,破坏左结合性。3 - 4 + 5正确后缀是3 4 - 5 +,如果写成3 4 5 + -结果就变成3 - (4 + 5) = -6,错了。

5.2 多位数字被拆散的排查

症状是12 + 3被转成1 2 3 +,求值时把1和2当成两个操作数。原因就是没写内层循环读取完整数字。排查方法很简单:打印出后缀表达式,看数字是不是完整的。修复就是加内层while循环,把连续的数字字符一次性读完。

提示:用空格分隔操作数是个好习惯,能避免12和3粘成123这种歧义。如果你不用空格,求值时就得靠字符类型判断边界,容易出错。

5.3 括号不匹配的处理

如果输入是(3 + 4少了右括号,或者3 + 4)多了右括号,算法会出问题。前者扫描结束时栈里还留着左括号,后者遇到右括号时栈里找不到左括号。工程上必须加校验:

  • 遇到右括号时,如果栈空或栈顶不是左括号,报"括号不匹配"。
  • 扫描结束后,如果栈里还有左括号,报"括号不匹配"。

考试时如果题目保证输入合法,可以省略校验,但实际写计算器一定要加。我吃过亏,用户输入个((3+4)程序直接崩了,后来加了校验才稳。

5.4 除零和非法运算

后缀求值时,遇到除法要先检查除数是否为零,否则程序会崩溃或产生未定义行为。加一行判断:

case '/': if (b == 0) { printf("错误:除数为零\n"); return 0; // 或抛出异常 } result = a / b; break;

同理,如果后缀表达式格式错误(比如操作数不够),弹栈时会越界,也要加栈空判断。这些防御性代码在实验报告里可能不要求,但实际项目里是必须的。

5.5 常见问题速查表

问题现象可能原因解决方法
后缀表达式优先级错乱比较用了>而非>=,或没循环比较改用>=,用while循环比较
多位数字被拆散没写内层循环读数字连续读取数字字符拼成完整数
减法/除法结果错误操作数弹出顺序反了先弹的是右操作数,后弹的是左操作数
括号相关崩溃没做括号匹配校验加栈空和左括号检查
除零崩溃没检查除数除法前判断除数是否为零
小数精度不对浮点误差用定点数或十进制库,或设置误差容忍度

5.6 几个容易被忽略的边界情况

除了上面这些,还有几个边界值得注意。空表达式要返回错误而不是崩溃。只有一个数字的表达式42,转换后还是42,求值返回42,这个要能正确处理。连续运算符比如3 * -2,这里的-是一元负号,标准算法处理不了,需要特殊判断。幂运算^的右结合性,2 ^ 3 ^ 2应该是2 ^ (3 ^ 2) = 512而不是(2 ^ 3) ^ 2 = 64,处理时遇到^不能弹出栈里相同优先级的^,比较条件要改成严格大于。

这些边界情况考试不一定全考,但你想把这道题真正吃透,最好都实现一遍。我当年就是把这些都写了一遍,才对栈的应用有了肌肉记忆。

6. 从实验报告到面试考点的延伸

6.1 实验报告怎么写才出彩

如果你是在做数据结构实验报告,光贴代码是拿不到高分的。我建议报告里包含这几块:算法思路的文字描述(用你自己的话讲清楚栈的作用)、栈变化的跟踪表(就像我前面那个表,挑一个复杂表达式画出来)、关键代码的注释(说明每个判断的意图)、测试用例和结果(至少覆盖普通表达式、带括号、多位数字、小数这几种)、复杂度分析(时间O(n),空间O(n),n是表达式长度)。

复杂度分析很多人会漏。转换和求值都是线性扫描,每个字符最多进栈出栈一次,所以时间复杂度是O(n)。空间上栈的最大深度取决于表达式嵌套层数,最坏情况也是O(n)。把这个讲清楚,报告的专业度立刻上一个档次。

6.2 面试里会怎么追问

这道题在面试里经常作为"栈的应用"的引子,面试官会顺着往下问。常见的追问有:如果表达式里有变量怎么办(答案是先做符号表替换,或者求值时查表);如果运算符有很多种怎么扩展(用优先级表驱动,加新运算符只改表);递归和栈两种实现有什么区别(递归本质是系统栈,手动栈更可控,不会栈溢出);怎么处理函数调用比如sin(x)(需要词法分析识别函数名,然后按一元运算符处理)。

我面试别人的时候,最喜欢问"为什么后缀表达式不需要括号",能答出"因为运算符的位置已经隐含了运算顺序"的人,说明是真理解了。你也可以顺着这个思路,把中缀、前缀、后缀三种表示法的关系理一遍,面试时能讲出体系感。

6.3 这套思路还能用在哪

别以为这只是道练习题。配置文件解析里,很多表达式求值(比如条件判断、数值计算)都用这套栈的思路。电子表格软件计算单元格公式,底层就是中缀转后缀再求值。编译器前端把源代码表达式转成中间表示,也是类似的流程。正则表达式引擎处理优先级和括号,思路相通。

我自己在做数据清洗时,就写过一个简易表达式求值器,让用户能输入price * 1.1 + shipping这种公式,底层用的就是这套中缀转后缀。理解了原理,你就能根据实际需求灵活调整,比如支持自定义函数、支持变量、支持字符串拼接等等。

6.4 用Python快速验证你的思路

如果你用C调试觉得麻烦,可以用Python快速验证算法逻辑,因为Python的列表天然就是栈,写起来短平快:

def infix_to_postfix(expr): isp = {'+':3, '-':3, '*':5, '/':5, '(':1, ')':6} icp = {'+':2, '-':2, '*':4, '/':4, '(':6, ')':1} stack = [] result = [] i = 0 while i < len(expr): c = expr[i] if c == ' ': i += 1 continue if c.isdigit() or c == '.': num = '' while i < len(expr) and (expr[i].isdigit() or expr[i] == '.'): num += expr[i] i += 1 result.append(num) continue if c == '(': stack.append(c) elif c == ')': while stack and stack[-1] != '(': result.append(stack.pop()) stack.pop() else: while stack and isp[stack[-1]] >= icp[c]: result.append(stack.pop()) stack.append(c) i += 1 while stack: result.append(stack.pop()) return ' '.join(result)

Python版本逻辑和C完全一致,但代码量少一半,适合你快速验证思路。验证通过后再翻译成C,能省很多调试时间。这是我常用的工作流:先用Python把算法跑通,确认逻辑无误,再用C实现,避免在指针和数组越界上浪费时间。

6.5 一个我踩过的坑:空格处理

最后分享一个我早期踩的坑。有次我写的转换函数没处理输入里的空格,用户输入3 + 4,结果空格被当成非法字符,虽然跳过了但影响了数字的连续读取判断。后来我统一在扫描开头跳过所有空格,问题才解决。这个坑很小,但很隐蔽,因为不带空格的测试用例能过,一带空格就出问题。

提示:如果你的输入可能包含制表符、换行符等空白字符,用isspace()统一判断,比只判断空格字符更稳妥。

这套中缀转后缀加求值的实现,我从学生时代写到工作,每次重写都有新体会。最开始只求能跑,后来追求边界完备,再后来关注代码的可扩展性和可读性。如果你能把优先级表、栈变化过程、操作数顺序、边界处理这几块都吃透,这道题就不再是考试题,而是你工具箱里一个随时能用的技能。后续想扩展的话,可以试试加上变量支持、函数调用、或者把它做成一个带图形界面的计算器,都是很好的练手方向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询