还没谈到“怎么算”,我先说一个判断标准:如果你把一个四则运算式子交给计算机处理,中缀写法的处理难度远高于后缀写法。原因后面展开。
1. 三种表达式的基本形态与命名逻辑
1.1 同一道算式,三种写法长什么样
先看一个最基础的式子:1 + 2 * 3。按照小学数学习惯,运算符在两个数字中间,叫中缀表达式。
如果我把运算符提到两个数字前面,写成+ 1 * 2 3,这叫前缀表达式,历史上也叫波兰式;如果我把运算符放到两个数字后面,写成1 2 3 * +,这叫后缀表达式,又叫逆波兰式。三种写法表达的是同一个计算含义:先算2 * 3 = 6,再算1 + 6 = 7。
很多人第一次看到后缀式会问:数字后面直接跟一个*,是怎么知道要乘哪两个数的?其实就是“数字压栈,运算符弹出两个数字,算完再压回”。这个机制下文会详细拆。
再从命名上补充一个记忆点:中缀、前缀、后缀的“前、中、后”指的是运算符相对于操作数的位置。操作数是1、2、3,运算符是+、*。“中”就是运算符夹在两个数之间,“前”就是放在它们前面,“后”就是放在它们后面。这套命名直白得很,不用死记。
| 表达式名称 | 运算符位置 | 等价的数学写法 | 备注 |
|---|---|---|---|
| 中缀表达式 | 操作数中间 | 1 + 2 * 3 | 人类最熟悉,需要括号与优先级 |
| 前缀表达式 | 操作数前面 | + 1 * 2 3 | 波兰式,无需括号 |
| 后缀表达式 | 操作数后面 | 1 2 3 * + | 逆波兰式,无需括号,最适合栈计算 |
1.2 中缀为什么“反直觉”的地方在于优先级和括号
中缀写法对人类极其友好,但对程序并不友好。原因很具体:1 + 2 * 3这个式子里,+明明先出现,却不能先算,因为*的优先级更高。如果式子变成(1 + 2) * 3,括号又强行改变优先级。程序要在“从左到右扫描”的前提下,额外处理“运算符优先级比较”和“括号匹配”,每一步都得判断手里这个运算符到底应该现在算,还是等一等。
后缀表达式则没有任何这种烦恼。1 2 3 * +里运算顺序是完全平铺的:看到1,压栈;看到2,压栈;看到3,压栈;看到*,弹出3和2,算出6压回;看到+,弹出6和1,算出7。不需要知道谁先谁后,不需要看括号。括号在后缀式里的作用已经被“运算符的绝对顺序”替代了,这也是为什么逆波兰式在工程界一直有生命力。
1.3 历史沿革:为什么叫“波兰式”
前缀表达式叫波兰式,后缀表达式叫逆波兰式,名字源自一位逻辑学家在 20 世纪提出的无括号表达式表示法。他最早提出的是前缀写法,后来有人反其道而行之,把运算符放到操作数后面,于是出现了“逆波兰式”。这段历史在数据结构教材里一般一句话带过,但它解释了为什么英文资料里你会同时看到Polish notation、Reverse Polish notation、RPN这些词。简单理解:前缀是“波兰”,后缀是“逆波兰”,二者是同一思想的两种镜像写法。
我没有考证过过于细节的年份,但有一点是确定的:RPN 之所以至今没被淘汰,是因为它在“人机交互”场景下有一个独特优势——只要输入数字和运算符,不需要等用户输入右括号,计算器就能逐步结算。老式的某些工程计算器就采用了 RPN 输入模式,很多老工程师对那个手感记忆犹新。
2. 后缀表达式求值:一步一步跟着栈走
2.1 后缀求值的手工过程
读后缀表达式时,大脑可以完全模拟一个栈:从左往右扫描每个符号,遇到数字就压入栈顶;遇到运算符,就弹出栈顶的两个数字,按运算符计算,把结果重新压入栈顶。扫描结束后,栈里只会剩下一个数字,就是表达式的值。
拿3 4 2 * 1 5 - 2 ^ / +来走一遍。这个式子对应的中缀是3 + 4 * 2 / (1 - 5) ^ 2,一看就很“重”,但后缀里它只是每个记号依次排队。
| 当前扫描到的记号 | 操作 | 栈内容(栈顶在右) |
|---|---|---|
3 | 数字,压栈 | 3 |
4 | 数字,压栈 | 3 4 |
2 | 数字,压栈 | 3 4 2 |
* | 弹出2、4,算4 * 2 = 8,压回 | 3 8 |
1 | 数字,压栈 | 3 8 1 |
5 | 数字,压栈 | 3 8 1 5 |
- | 弹出5、1,算1 - 5 = -4,压回 | 3 8 -4 |
2 | 数字,压栈 | 3 8 -4 2 |
^ | 弹出2、-4,算(-4) ^ 2 = 16,压回 | 3 8 16 |
/ | 弹出16、8,算8 / 16 = 0.5,压回 | 3 0.5 |
+ | 弹出0.5、3,算3 + 0.5 = 3.5,压回 | 3.5 |
最后栈只剩3.5,这就是最终答案。可以回头用普通计算器核算一下:(1 - 5) ^ 2 = 16,4 * 2 / 16 = 0.5,3 + 0.5 = 3.5,完全一致。
2.2 栈的设计细节:先出栈的是右操作数
做手工计算时很容易忽略一个问题:遇到减法或除法,先弹出的是哪个数?
例如后缀式8 2 /,按照“数字压栈,遇到运算符弹两个”的规则,扫描到/时,栈是8 2,先弹出2,再弹出8。那么正确的计算方式是8 / 2,而不是2 / 8。换句话说,先出栈的是右操作数,后出栈的是左操作数。
代码里如果写成pop(a); pop(b); return a / b,那么当a是2、b是8时,结果会变成0.25,完全错误。正确写法是pop(right); pop(left); return left / right。减法同理,3 1 -应该是3 - 1,而不是1 - 3。我见过太多初学者在调试计算器时,加减乘都正常,一碰到减法和除法结果就驴唇不对马嘴,根因基本都在这里。
2.3 前缀求值:只是方向反过来
前缀表达式求值的套路和后缀几乎对称:如果单独写一个前缀求值器,可以从右往左扫描。遇到数字压栈,遇到运算符弹出两个数,按对称方式计算。
例如前缀式- 8 2,从右往左扫描:先处理2,压栈;再处理8,压栈;然后处理-,弹出8、2,算8 - 2。由于是从右往左,弹出顺序和后缀正好相反,但最终结果一样是6。
实际工程里,把前缀式转为后缀式再求值更常见,因为从左往右读代码更自然。不过理解“前后缀本质对称”这一点,对阅读不同资料很有帮助,因为不同教材的表述习惯不一样,有的喜欢从右扫,有的喜欢转成后缀再处理。只要抓住“栈”和“操作数顺序”两个关键点,怎么看都不会乱。
3. 中缀转后缀:从手算括号法到调度场算法
3.1 括号法:最朴素的手工转换技巧
如果你需要手工把一个中缀表达式转成后缀式,最不容易出错的方法叫“括号法”,一共三步。
第一步,按运算优先级把整个表达式加上完全括号,让每一步计算都有一个对应括号包裹。比如1 + 2 * 3,先算2 * 3,所以写成1 + (2 * 3);又因为1 +在最外层,整体再加一层括号,得到(1 + (2 * 3))。
第二步,把每个右括号替换成紧邻括号内运算符。对于(1 + (2 * 3)),最内层括号是(2 * 3),把它的右括号替换成*,得到(1 + (2 3 *,去掉括号后变为(1 + 2 3 *;外层括号的右括号替换成+,得到(1 2 3 * +;最后删除所有左括号,就是1 2 3 * +。
第三步,检查运算符顺序是否符合“先内层后外层”,如此例是*在前、+在后,计算顺序便没错。
这个方法为什么可靠?因为它强行把优先级信息转变成“括号嵌套关系”,再用“右括号位置”把运算符移到对应计算的末尾。手算时不需要额外维护优先级表,只要括号盖得对,转换结果一定对。缺点是机器不擅长“盖括号”,所以代码实现通常用另一个经典算法。
3.2 调度场算法:机器最常用的中缀转后缀方案
调度场算法的直观类比是火车站调度:一边是列车(记号)顺序到达,一边是岔道(栈)里临时停靠的列车。程序从左到右读取中缀表达式的每个记号,数字直接“发车”到输出队列,运算符则进入岔道排队,等更合适的时机出栈。
算法听起来复杂,核心规则只有几条,我用我自己的话整理了一下:
- 遇到操作数(数字、变量、函数名),直接加入输出。
- 遇到左括号,压入运算符栈。
- 遇到右括号,反复弹出栈顶运算符加入输出,直到遇到左括号;把左括号弹出丢弃。如果栈空了还没遇到左括号,说明括号不匹配。
- 遇到普通运算符,比较它与栈顶运算符的优先级:如果栈顶运算符优先级更高,或者优先级相同且当前运算符是左结合,就把栈顶弹出加入输出,重复比较,直到栈顶优先级更低或栈为空;然后把当前运算符压入栈。
- 表达式扫描结束后,把栈里剩余运算符全部弹出加入输出。
第 4 条是大多数人容易搞错的重点。以1 + 2 * 3为例:扫描到+时栈空,直接压栈;扫描到*时,栈顶是+,*的优先级高于+,所以不弹出,直接压栈;扫描到结束,依次弹出*、+,得到1 2 3 * +。如果两运算符优先级相同时也“直接压栈”,那么1 - 2 + 3会转成1 2 - 3 +吗?会。右结合情况会在下面说明。
3.3 结合性:幂运算的坑必须单独处理
常规的四则运算+ - * /都是左结合,意思是a - b + c等价于(a - b) + c。当新扫描到的运算符和栈顶运算符优先级相同,左结合时栈顶先被弹出,这保证输出序列从左往右计算时符合“左边优先”的直觉。
幂运算^是右结合:2 ^ 3 ^ 2在数学上等价于2 ^ (3 ^ 2),即2 ^ 9 = 512,而不是(2 ^ 3) ^ 2 = 64。因此,当新运算符是^,它的优先级等于栈顶^时,栈顶不能弹出,必须把新的^直接压栈。代码里写条件时,对右结合运算符要单独放行,否则转换出的后缀式会改成错误的结合顺序。
我用一个实际例子验证:- 2 ^ 3 ^ 2中缀式(为避免一元负号先不加)2 ^ 3 ^ 2。如果算法不加右结合特殊规则,它会转成2 3 ^ 2 ^,求值时先算2^3=8,再算8^2=64,这是左结合结果;加上右结合规则后,转成2 3 2 ^ ^,求值时先算3^2=9,再算2^9=512。差别非常大。
我做表达式解析时有个习惯:优先级表单独做成一个字典,结合性也单独维护。别人接手代码时一眼就能看出设计意图,不会在某个if条件里硬翻逻辑。
3.4 完整代码示例:中缀转后缀与后缀求值
我提供一个可以直接运行的 Python 版本,支持+ - * / ^、正整数、小数和括号。代码不是生产级最简写法,而是刻意把每一步注释清楚,方便照抄和学习。
import re def tokenize(expr): # 支持多位数与小数,数字统一当作字符串输出 pattern = re.compile(r'\s*(?:(\d+\.\d+|\d+)|([()+\-*/^]))') tokens = [] pos = 0 while pos < len(expr): match = pattern.match(expr, pos) if not match: raise ValueError("无法识别的字符位置:", pos) if match.group(1) is not None: tokens.append(("num", match.group(1))) else: tokens.append(("op", match.group(2))) pos = match.end() return tokens def infix_to_postfix(tokens): precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3} right_assoc = {'^': True} output = [] op_stack = [] for typ, val in tokens: if typ == "num": output.append(val) elif val == "(": op_stack.append(val) elif val == ")": while op_stack and op_stack[-1] != "(": output.append(op_stack.pop()) if not op_stack: raise ValueError("括号不匹配") op_stack.pop() # 弹出左括号 else: # 运算符 while (op_stack and op_stack[-1] != "(" and (precedence[val] < precedence[op_stack[-1]] or (precedence[val] == precedence[op_stack[-1]] and not right_assoc.get(val, False)))): output.append(op_stack.pop()) op_stack.append(val) while op_stack: if op_stack[-1] == "(": raise ValueError("括号不匹配") output.append(op_stack.pop()) return output def eval_postfix(postfix): stack = [] for token in postfix: if re.match(r'^\d+(\.\d+)?$', token): if '.' in token: stack.append(float(token)) else: stack.append(int(token)) else: if len(stack) < 2: raise ValueError("后缀表达式不合法") right = stack.pop() left = stack.pop() if token == '+': stack.append(left + right) elif token == '-': stack.append(left - right) elif token == '*': stack.append(left * right) elif token == '/': if right == 0: raise ZeroDivisionError("除零错误") stack.append(left / right) elif token == '^': stack.append(left ** right) if len(stack) != 1: raise ValueError("后缀表达式不合法") return stack[0] expr = "3 + 4 * 2 / (1 - 5) ^ 2" tokens = tokenize(expr) postfix = infix_to_postfix(tokens) print("后缀式:", " ".join(postfix)) print("结果:", eval_postfix(postfix))运行后输出:
后缀式: 3 4 2 * 1 5 - 2 ^ / + 结果: 3.5这段代码里有几个值得注意的细节:tokenize用了正则一次性处理空白字符、多位数、小数;求值时把数字统一转成字符串,求值阶段再区分整数还是浮点;右结合只维护了一处right_assoc,将来加一元运算符时,可以在tokenize层把-5这类负号改写为0 5 -,避免在核心算法里引入太多特判。
4. 三种表达式的应用场景
4.1 做一个支持括号的计算器
很多刚刚接触程序设计的同学都会碰到“写一个计算器”的练习。用中缀表达式硬解析,通常会写一个递归下降的小型语法解析器,或者手工维护“当前数字、当前运算符、上一优先级”等多个状态,代码越写越乱。换用“中缀转后缀 + 后缀求值”的路线,问题会变成一个很清晰的流水线:
- 字符串输入
3 + 4 * 2 / (1 - 5) ^ 2 - 词法分析:拆成数字和运算符记号
- 调度场算法转换成后缀式
- 用栈一次性求值
这条链路非常适合教学和软件原型。我自己的体会是,先把“输入 -> 输出”这个数据流想清楚,代码就容易写了;一开始就纠结怎么直接算,反而容易陷入各种边界条件。
如果需要支持变量,比如输入x + 3,那就在词法分析阶段把x当作一个数字型 token,转换规则不变;求值阶段从变量表取值即可。这也说明后缀表达式和符号计算是兼容的。
4.2 编译器与中间表示里的表达式树
中缀转后缀的过程,本质上是在“不显式建树”的情况下,完成表达式的线性化。如果你进一步想知道表达式的结构,后缀式求值时的栈操作可以顺便建二叉表达式树:遇到数字时生成叶子节点压栈,遇到运算符时弹出两个子树,生成以运算符为根的新节点,再压栈。扫描结束后,栈里唯一节点就是整棵表达式树。
表达式树在后缀式之上又是一种中间表示,后续可以做求导、化简、编译到寄存器指令等。很多编译原理教材都会讲“表达式 -> 后缀式 -> 三地址代码/抽象语法树”的路线。理解后缀表达式的确能帮你跨越“语法”和“语义”之间的第一道坎。
4.3 老式计算器的逆波兰输入法
在液晶计算器还没有普及的时候,有相当一部分专业计算器采用 RPN 输入方式:先输入操作数,再按运算符,计算器立即压栈结算。这种设计让计算器“不需要等于号”也能连续计算,多级嵌套括号时用户也不需要去记忆括号配对。虽然现在图形界面计算器大都采用中缀形式,但 RPN 仍有硬件和嵌入式领域的支持者。
实际工作里还有一个常见变体:某些配置文件的表达式、报表系统的公式引擎,底层也采用“后缀式存储”或“后缀式求值”,因为这种格式天然不需要依赖上下文就能确定计算次序,非常适合序列化。比如一份表格需要存“单元格 A1 加上 B2 乘以 C3”,直接存中缀字符串需要另外存优先级规则;存成后缀记号列表,读取时连优先级表都不用带,一个栈就解决了。
4.4 逻辑表达式与布尔运算
后缀表达式不仅能算算术,还能算布尔逻辑。A AND B OR NOT C同样可以转成后缀式,用类似的栈规则逐项求值,甚至可以做短路求值:某些运算符只要前一个操作数已经能确定结果,就不必继续计算后续部分。把后缀式与懒求值结合起来,可以在表达式引擎里实现高效的条件判断系统。这种场景在规则引擎、权限校验、动态配置里很常见。
5. 常见问题排查与避坑经验
5.1 符号优先级写反
最常见的问题就是优先级判断写反。我曾见过一段代码,条件写成了if (precedence[current] > precedence[stack_top]),结果*永远压不过+,正确表达式被转成完全错误的顺序。排查方法很简单:准备一批已知转换结果的小用例,比如1 + 2 * 3必须生成1 2 3 * +,(1 + 2) * 3必须生成1 2 + 3 *。先跑一批最小用例,再跑复杂用例,症状立刻会暴露。
5.2 负号与一元运算没有处理
“- 3 + 5”这种式子,初学者很容易在词法分析阶段把它当成“减号”处理,结果变成“没有左操作数的减法”,导致求值时报错。常见规避方案有两个:第一,在词法分析阶段识别“一元负号”,把它转换成0 3 -这样的等价子表达式;第二,在表达式转换阶段,把“开头的负号”或“紧跟在左括号/运算符后面的负号”标记为一元运算符,求值时只用栈里一个操作数。我推荐第一种,因为它改动最小,单元测试也好写。
5.3 多位数和小数点的 token 化
字符串"123 + 4.5"如果按单个字符切分,会变成1、2、3、+、4、.、5,这明显不是预期。任何表达式解析器都要先做词法分析,把连续数字和小数点合并成一个 token。我上面的示例用正则\d+(\.\d+)?解决;如果不愿意写正则,也可以用循环“读到一个数字字符就继续往后读,直到遇到非数字或点”。
5.4 括号不匹配
算法里右括号弹出时,如果栈已经空了或者遇到表达式结束但栈里还有左括号,必须主动报错。我在测试时习惯准备三个错误用例:"((1+2)"、"(1+2))"、"1+",确保它们都能给出明确提示而不是抛一个底层异常。
5.5 除零与浮点比较
后缀求值阶段直接执行除零会抛异常,但浮点数的“除零”在部分语言里可能表现为无穷大而不是报错。在自己写计算器时,建议显式判断除数是否为零,并且友好提示。另外浮点结果不要用==比较,比如0.1 + 0.2这类精度问题,在计算器场景一般保留到小数点后若干位再显示。
5.6 操作数顺序颠倒
我在 2.2 节已经强调过,这里再放在问题清单里是因为它太容易踩了:后缀求值遇减法和除法时,先弹出的是右操作数。我自己写第一版计算器时,把减法写成left - right但代码里却是pop(left); pop(right);,输出一直不对,检查了半小时才发现变量顺序反了。建议在函数里命名用right和left,而不是a和b,能大幅减少这类错误。
5.7 测试用例速查
下面这张表可以作为调试阶段的标准参考:
| 中缀输入 | 期望后缀输出 | 期望值 |
|---|---|---|
1 + 2 * 3 | 1 2 3 * + | 7 |
(1 + 2) * 3 | 1 2 + 3 * | 9 |
1 - 2 + 3 | 1 2 - 3 + | 2 |
2 ^ 3 ^ 2 | 2 3 2 ^ ^ | 512 |
3 + 4 * 2 / (1 - 5) ^ 2 | 3 4 2 * 1 5 - 2 ^ / + | 3.5 |
(1 + 2) * (3 - 4) / 2 | 1 2 + 3 4 - * 2 / | -0.5 |
测试时可以把这些用例直接跑一遍,能通过基本可以放心。
6. 从逆波兰到表达式解析:我的实操体会
把中缀、前缀、后缀表达式放在一起看,其实它们只是同一种数学含义的三种编码方式。中缀适合人读,后缀适合机器算,前缀适合对称理解。我到现在做表达式相关功能时,还是会先用笔在草稿纸上把中缀转后缀的括号法走一遍,得到期望的输出,再写代码跑一遍,这样调试效率最高。
如果你正准备手写一个表达式解析器,我建议按这个顺序练习:先手工用括号法熟练转换 20 个表达式,再用小数据量写出调度场算法,最后再考虑一元负号、幂运算和变量。不要一开始就追求完美,先把主链路跑通。
最后分享一个小技巧:如果你要处理大量表达式,可以把转换好的后缀式缓存起来,因为后缀式的求值速度非常快,每次只需要扫描一遍 token 序列,不需要重新解析中缀里的优先级与括号。我在某个模拟项目里就是用“中缀转后缀、后缀缓存、批量求值”的思路处理公式数据的,处理速度比每轮都重新解析中缀快不少。