☰
逆波兰表达式与调度场算法:用Python实现表达式求值器
2026/9/30 1:22:32 网站建设 项目流程

如果你平时写过计算器、公式引擎,或者只是刷面试题时撞上过“逆波兰表达式”这个名字,肯定体会过那种感觉:中缀表达式3 + 4 * 2人看得舒服,可一旦让代码去解析“优先级”,事情就瞬间变得拧巴起来。波兰表达式和逆波兰表达式,本质上就是把“人习惯的写法”翻译成“机器不需要思考的写法”,让运算顺序赤裸裸地摆在 token 序列里。这篇文章我会从三者的定义讲起,手动推演一个带括号、带幂运算的完整例子,再用 Python 从零写一个可用的表达式求值器,顺便把一元负号、浮点精度、括号不匹配这些坑挨个踩一遍。适合正在学数据结构、做课程设计,或者准备面试时被问到“为什么计算器不用中缀”的读者。

1. 三种表达式到底在说什么

1.1 中缀表达式的“人味”与计算机的别扭

我们从小写数学算式,默认都是运算符在中间,比如3 + 4、5 * (6 - 2)。这种写法叫中缀表达式,它有个很明显的特征:操作数在前,运算符居中,隐含的优先级和括号共同决定了计算顺序。人眼一看到3 + 4 * 2,大脑会自动给乘法加权,先算出4 * 2再执行加法,这套机制不需要刻意想,更不需要解释。

可计算机拿到的是一个字符串,它没有“优先级直觉”。如果只是从左到右扫,看到3,接着看到+,它并不知道后面那个4 * 2会被优先处理;只有等到扫描完整个 token 流、建立起语法树或者等价结构,才能真正决定先算谁。换句话说,中缀表达式的计算顺序是“结构性的”,不是“线性顺序”的。这让解析器必须维护运算符栈、区分左右括号、考虑结合性规则,每一步都要格外小心。

正是因为这个痛点,逻辑学家扬·武卡谢维奇在 20 世纪 20 年代提出了“波兰表示法”:把运算符放到操作数前面。后来大家为了区分,把运算符放前面的叫波兰表达式,也叫前缀表达式;把运算符放后面的叫逆波兰表达式,也叫后缀表达式。两种写法有一个共同目标——消灭括号和隐式优先级,让机器可以用最简单、最线性的方式完成求值。

1.2 波兰表达式:运算符前置

波兰表达式写作+ 3 4就等价于中缀的3 + 4,它把运算符放在两个操作数之前。看着有点别扭,但规则非常直白:遇到一个运算符时,它后面对应多少个操作数,由运算符本身决定。以二元运算为例,* + 2 3 4表示的是(2 + 3) * 4,因为+先接受2和3,得出5,然后*再接受这个结果和4。

从机器求值的角度看,前缀表达式的扫描方向一般是从右往左。遇到数字就压栈,遇到运算符就从栈里弹出操作数计算结果,再把结果压回去。整个过程不依赖括号,因为运算符的位置已经明确了“谁跟随谁”。当初武卡谢维奇设计这套表示法,本意是研究逻辑公式的结构,后来计算机科学发现它非常适合栈式处理,于是被广泛应用于编译器、自动机理论等场景。

1.3 逆波兰表达式:运算符后置

逆波兰表达式正好反过来,运算符放在操作数后面。比如3 4 +对应中缀的3 + 4,而2 3 * 4 +对应的是2 * 3 + 4。日常大家接触较多的其实是后缀表达式,很多教程里提到的 RPN(Reverse Polish Notation)计算器用的就是这套机制。

后缀表达式的优势很直观:完全不需要括号,也不需要优先级表,扫描到一个运算符时,它前面的两个操作数刚好可以被“取走”参与运算。比如5 1 2 + 4 * + 3 -,翻译成人话就是5 + ((1 + 2) * 4) - 3。你可以看到,原始中缀里的括号在后缀里消失了,但运算顺序一点没丢。这种线性结构很适合用栈来完成求值,后面我会专门展开讲,这也是为什么 HP 的经典工程计算器能凭借 RPN 输入在市场上收获大量工程师拥趸。

2. 三种表达式之间的内在关联与核心转换原理

2.1 它们其实是同一棵表达式树的三种遍历结果

想真正理解为什么三种表达式的转换算法是那样设计的,我建议你先忘掉字符串,改看树。把每个二元运算的运算符当作子树的根节点,操作数当成叶子节点,一个中缀表达式就可以映射成一棵表达式树。

以3 + 4 * 2为例,树的根是+,左孩子是3,右孩子是*,*的左孩子是4,右孩子是2。如果对这棵树做前序遍历,得到+ 3 * 4 2,这就是波兰表达式;做中序遍历,得到3 + 4 * 2,但中序遍历需要额外加括号才能保证语义唯一;做后序遍历,得到3 4 2 * +,这就是逆波兰表达式。

这个观察特别重要。它说明中缀、前缀、后缀不是三种“独立的写法”,而是同一棵树的不同“输出格式”。所谓中缀转后缀,本质上是把中缀表达式还原成一棵表达式树,再按后序遍历输出。但由于大多数人不想显式建树,才有了栈版本的调度场算法。明确这一点后,很多操作细节就顺理成章了。

2.2 调度场算法:中缀转后缀的工程答案

中缀转后缀最经典的算法叫调度场算法,由 Dijkstra 提出,名字来源于火车调度站:车辆(数字)先走一侧轨道排队,分叉路口(运算符)需要判断谁先进入主轨道,最后所有车厢按正确顺序编组出发。

算法维护两个结构:一个输出队列,一个运算符栈。规则如下:

  • 遇到数字,直接加入输出;
  • 遇到左括号,压入运算符栈;
  • 遇到右括号,不断弹出栈顶并加入输出,直到遇到左括号,然后把这个左括号丢弃;
  • 遇到运算符时,需要比较它和栈顶运算符的优先级:如果栈顶优先级更高,或者优先级相同且当前运算符是左结合,就弹出栈顶加入输出,一直弹到不满足条件为止,最后把当前运算符压栈。

这个“弹栈条件”是整个算法的灵魂。左结合运算符遇到同级运算符时,先来的先算,所以要弹;右结合运算符(如幂运算^)遇到同级运算符时,后来的先算,所以不弹,直接压栈。这样处理后,优先级和结合性就被编码进顺序里了。

2.3 后缀求值的栈操作逻辑

后缀表达式求值比转换更简单,核心就三步:从左到右扫描每个 token;遇到数字压栈;遇到运算符,弹出两个数字,先弹出的当右操作数,后弹出的当左操作数,计算后把结果压回栈。扫描结束后,栈里剩下的唯一元素就是表达式的值。

这里有一个很多初学者会栽跟头的细节:为什么先弹出的是右操作数?因为栈是后进先出,而表达式中最靠右的数字最后被压入,正好对应运算顺序里的右操作数。比如后缀8 4 /,从右往左看,4后入栈,被弹出时它就是除数(右操作数),8被弹出时是被除数(左操作数),结果2满足直觉。如果把左右搞反,很多非对称运算(减、除、幂)都会算错,这点在写代码时需要格外警惕。

前缀求值的逻辑与之类似,但扫描方向相反:从右往左扫描,遇到数字压栈,遇到运算符弹出两个数,此时先弹出的反而是左操作数。由于前缀表达式在工程里用得少,本文后面不深究,理解了后缀的对称性,前缀自然也能类推出来。

3. 手推一个完整案例:带括号、带幂运算的复杂表达式

3.1 从中缀到后缀的逐步转换

光讲规则不过瘾,我们来推一个足够复杂的例子:

3 + 4 * 2 / ( 1 - 5 ) ^ 2 ^ 3

这个式子集合了四则运算、括号、除法、幂运算的右结合,非常能检验算法理解。先把优先级表摆出来:

运算符优先级结合性
^3右结合
*/2左结合
+-1左结合

手动转换时,我习惯用一个表格记录“当前 token、输出队列、运算符栈”,每一步都不跳。完整的推演过程如下:

步骤当前 token输出队列运算符栈说明
133空数字直接输出
2+3+栈顶为空,直接压栈
343 4+数字输出
4*3 4+ *栈顶+优先级低于*,压栈
523 4 2+ *数字输出
6/3 4 2 *+ /栈顶*优先级等于/,左结合,弹出*;再比较栈顶+,优先级低,压入/
7(3 4 2 *+ / (左括号直接压栈
813 4 2 * 1+ / (数字输出
9-3 4 2 * 1+ / ( -压栈,栈顶(暂停比较
1053 4 2 * 1 5+ / ( -数字输出
11)3 4 2 * 1 5 -+ /弹出栈顶直到匹配左括号,-被弹出并输出,左括号丢弃
12^3 4 2 * 1 5 -+ / ^栈顶/优先级低于^,压栈
1323 4 2 * 1 5 - 2+ / ^数字输出
14^3 4 2 * 1 5 - 2+ / ^ ^当前^右结合,遇到栈顶同级^不弹出,压栈
1533 4 2 * 1 5 - 2 3+ / ^ ^数字输出
16结束3 4 2 * 1 5 - 2 3 ^ ^ / +清空栈依次弹出所有运算符

最后得到后缀表达式:3 4 2 * 1 5 - 2 3 ^ ^ / +。

3.2 后缀求值全过程

拿到后缀表达式后,我们按从左到右的顺序用栈求值:

步骤token栈状态说明
13[3]数字入栈
24[3, 4]入栈
32[3, 4, 2]入栈
4*[3, 8]弹出2(右)、4(左),4 * 2 = 8
51[3, 8, 1]入栈
65[3, 8, 1, 5]入栈
7-[3, 8, -4]弹出5(右)、1(左),1 - 5 = -4
82[3, 8, -4, 2]入栈
93[3, 8, -4, 2, 3]入栈
10^[3, 8, -4, 8]弹出3(右)、2(左),2 ^ 3 = 8
11^[3, 8, 65536]弹出8(右)、-4(左),(-4) ^ 8 = 65536
12/[3, 0.0001220703125]弹出65536(右)、8(左),8 / 65536 = 0.0001220703125
13+[3.0001220703125]弹出0.0001220703125(右)、3(左),3 + 0.0001220703125

最终结果是3.0001220703125。你可以拿各种带优先级的计算器验证,只有按2^3=8、(-4)^8=65536这个顺序才能对上。后缀表达式的价值在这里体现得淋漓尽致:没有括号,没有优先级表,每一次运算都发生在栈顶,顺序由 token 序列完全决定。

3.3 从这个案例中看出的三个关键点

第一,运算符在第二步/之前把*弹出去,保证了同级左结合运算从左到右,“先来先算”。第二,第 14 步遇到第二个^时,右结合规则让它在栈里叠加而不弹出,这直接保证了2^3先算、然后(-4)^(2^3)后算。第三,右括号的作用是在括号内把运算符“收割”干净,括号本身则不会出现在后缀表达式里。这三点对应了优先级、结合性、括号三大语义要素,一旦理解了,再看调度场算法的代码就不会觉得是在背规则了。

4. 这些表达式能用在哪些真实场景

4.1 计算器、公式引擎与办公表格

最直接的落地场景就是计算器。普通科学计算器按下2 + 3 * 4,内部要么建立表达式树,要么转成后缀后求值。RPN 计算器更是直接把输入方式都改成了后缀,工程师输入时不需要按括号键,操作效率反而高。办公表格软件里的公式计算也属于这一类:用户输入=A1 + A2 * A3这种中缀文本,公式引擎内部通常会先解析成表达式树或后缀中间表示,再进行求值。

我在实际写过一个小型公式引擎之后才理解,用后缀或者树状表达还有一个额外好处:它天然适合增量计算和缓存。因为表达式被拆成了可以独立求值的节点,某个单元格(操作数)变化时,只要重算依赖它的最小子树就行,不用重新解析整个公式字符串。

4.2 编译器与栈式虚拟机

编译器在处理表达式时,常用的一个中间表示就是“三地址码”,但生成它之前,很多编译器会把中缀表达式整理成后缀式的顺序,再基于栈式虚拟机执行。JVM 的字节码指令集里就有大量面向栈的运算指令,比如iadd从操作数栈弹出两个整数相加再压回去,这和后缀求值的逻辑如出一辙。

Python 的字节码也是栈式的,1 + 2 * 3会被编译成LOAD_CONST 1; LOAD_CONST 2; LOAD_CONST 3; BINARY_OP *; BINARY_OP +这样的顺序,操作数栈和运算符的执行顺序就是标准后缀逻辑。理解了后缀求值,等于把“程序是怎么算数学表达式的”这层窗户纸捅破了,再去看 AST 或字节码会轻松很多。

4.3 其他值得留意的使用场景

除了计算器和编译器,后缀表达式还出现在表达式编辑器、规则引擎、科学计算库的解析层等领域。一些数据库查询优化器在解析 WHERE 条件时,也会先把语法树转成便于遍历和重写的中缀/后缀混合形式。甚至很多游戏引擎中的“技能公式”“伤害计算公式”配置,都是直接存一个后缀字符串,运行时用栈求值,避免最终用户手动写复杂括号。

说到底,判断一个方案要不要用后缀表达式,就看一点:你面对的是否是一个“需要频繁解析、求值,且希望解析逻辑尽可能薄”的场景。如果是,后缀表达式能帮你把优先级处理收拢到一小段标准代码里。

5. 用 Python 从零实现一个表达式求值器

5.1 最小可用的代码结构

下面我把前面讲的理论落成代码。这里实现基础的版本:支持整数、小数、+、-、*、/、^、括号,不考虑一元负号的特殊情况。代码分三块:词法分析、中缀转后缀、后缀求值。

import re PREC = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3} RIGHT_ASSOC = {'^'} def tokenize(expr: str): pattern = re.compile(r'\d+(?:\.\d+)?|[()+\-*/^]') tokens = [] for m in pattern.finditer(expr): tokens.append(m.group()) return tokens def infix_to_postfix(tokens): output = [] ops = [] for t in tokens: if t not in PREC and t not in '()': output.append(t) # 数字直接输出 elif t in PREC: while ops and ops[-1] != '(': top = ops[-1] if PREC[top] > PREC[t] or (PREC[top] == PREC[t] and t not in RIGHT_ASSOC): output.append(ops.pop()) else: break ops.append(t) elif t == '(': ops.append(t) elif t == ')': while ops and ops[-1] != '(': output.append(ops.pop()) if not ops: raise ValueError('右括号多余') ops.pop() while ops: if ops[-1] == '(': raise ValueError('左括号未闭合') output.append(ops.pop()) return output def eval_postfix(tokens): stack = [] for t in tokens: if t in PREC: b = float(stack.pop()) a = float(stack.pop()) if t == '+': stack.append(a + b) elif t == '-': stack.append(a - b) elif t == '*': stack.append(a * b) elif t == '/': stack.append(a / b) elif t == '^': stack.append(a ** b) else: stack.append(t) if len(stack) != 1: raise ValueError('表达式不完整') return float(stack[0]) expr = "3 + 4 * 2 / ( 1 - 5 ) ^ 2 ^ 3" tokens = tokenize(expr) postfix = infix_to_postfix(tokens) print('后缀表达式:', ' '.join(postfix)) print('求值结果:', eval_postfix(postfix))

这段代码不长,但覆盖了完整流程。tokenize用的是正则一次性把数字和运算符切出来;PREC和RIGHT_ASSOC是优先级的唯一来源,想扩展%取余、//整除,只需要在表里加对应项、在求值函数里加分支即可。

5.2 调试型版本:把每一步打印出来

初学时我很推荐在转换和求值过程里加打印,肉眼比对每一步栈的变化,比任何讲解都管用。下面这段代码会打印每个 token 处理后的输出队列和运算符栈:

def infix_to_postfix_debug(tokens): output = [] ops = [] for t in tokens: if t not in PREC and t not in '()': output.append(t) print(f'数字: {t:>4} -> output: {" ".join(output):<24} ops: {ops}') elif t in PREC: while ops and ops[-1] != '(': top = ops[-1] if PREC[top] > PREC[t] or (PREC[top] == PREC[t] and t not in RIGHT_ASSOC): output.append(ops.pop()) print(f'弹出运算符: {t:>4} -> output: {" ".join(output):<24} ops: {ops}') else: break ops.append(t) print(f'压入运算符: {t:>4} -> output: {" ".join(output):<24} ops: {ops}') elif t == '(': ops.append(t) print(f'压入左括号: {t:>4} -> output: {" ".join(output):<24} ops: {ops}') elif t == ')': while ops and ops[-1] != '(': output.append(ops.pop()) if not ops: raise ValueError('右括号多余') ops.pop() print(f'处理右括号: {t:>4} -> output: {" ".join(output):<24} ops: {ops}') while ops: output.append(ops.pop()) print(f'清栈: -> output: {" ".join(output):<24} ops: {ops}') return output

跑这个调试版本,你会看到第 6 步*被弹出、第 14 步第二个^压栈时没有弹栈,这些关键行为都一目了然。学这种算法,强烈建议“开着日志学”,而不是干看代码。

5.3 测试用例与边界检查

写完实现一定要拿几组样例验证,尤其是优先级和结合性。我常用的测试集如下:

cases = [ ("3 + 4 * 2", 11.0), ("(3 + 4) * 2", 14.0), ("2 ^ 3 ^ 2", 512.0), # 右结合: 2^(3^2) ("8 / 4 / 2", 1.0), # 左结合: (8/4)/2 ("1.5 * 2 + 3", 6.0), ("3 + 4 * 2 / (1 - 5) ^ 2 ^ 3", 3.0001220703125), ] for expr, expected in cases: tokens = tokenize(expr) postfix = infix_to_postfix(tokens) result = eval_postfix(postfix) status = 'OK' if abs(result - expected) < 1e-9 else 'FAIL' print(f'{status}: {expr} = {result} (期望 {expected})')

2 ^ 3 ^ 2能通过,就说明右结合处理对了;8 / 4 / 2能通过,就说明左结合处理对了。这两组用例是调度场算法最容易出错的地方,也是面试官最喜欢的出题点。

6. 常见问题与避坑指南

6.1 一元负号:最大的“隐形杀手”

我上面代码明确说了不支持一元负号,也就是说-3 + 5这种表达式会出问题:tokenize会把-当成二元运算符,转换时它前面没有操作数,求值阶段就会栈空崩溃。很多初学者在这里踩坑后,直接粗暴地把负号跟数字合并成-3当作一个整体 token。短期看很爽,但遇到-3 ^ 2时,数学上标准结果是-(3^2) = -9,而合并写法会算出(-3)^2 = 9,结果直接错了。

不同系统对-3^2的约定还不同:有的计算器按(-3)^2算,有的按-(3^2)算。所以在设计自己的求值器时,你必须明确产品语义,并且文档里写清楚。如果你需要严格支持一元负号,我建议在词法阶段把它单独设成一个运算符 token,比如U-,再定义它的优先级:低于^、高于* /,结合性按右结合处理。但这又牵扯到2 ^ -3这种场景,处理起来需要更多上下文判断,属于“能做、但要细心”的活。如果不是核心需求,更务实的做法是让用户用(0 - 3)代替负数,或者在解析前对表达式做一层“补零改写”,把开头的-变成0 -。

6.2 浮点误差与“分毫不差”的业务需求

8 / 65536在数学上是精确的0.0001220703125,但用二进制浮点数存储时可能得到0.0001220703125附近的一个近似值,很多场景下没人关心,可一旦是金融、科学计算,误差就不可接受了。这时候可以把float换成decimal.Decimal,并仔细设置精度和舍入模式。代价是速度会变慢,所以要根据业务选择:财务计算要精确,游戏伤害数值用浮点就行。

另外要注意 Python 里/永远是浮点除法,如果你希望整数除法用//,请在优先级表和求值分支里单独实现,而不是偷偷用int(a / b),因为a // b和int(a / b)在负数上的行为并不完全一致。

6.3 括号不匹配与非法表达式

我的代码在tokenize之后,由infix_to_postfix负责括号匹配:遇到右括号时如果栈里没有左括号,直接抛“右括号多余”;扫描结束后栈里还有左括号,说明左括号没闭合。后缀求值阶段也要兜底:弹出栈里两个操作数时,如果栈元素不足,说明表达式操作数不够;如果求值结束后栈里不是恰好一个数,说明操作数多了。非法表达式的形态很多,建议在库的入口统一捕获异常,返回明确的错误码或错误信息,而不是让崩溃现场暴露给用户。

6.4 优先级表和结合性表是“一处出错,全线崩溃”的核心配置

很多人实现调度场算法时,逻辑写对了,但优先级表给错了。比如把^的优先级设得比*低,那2 * 3 ^ 2就会被算成(2 * 3) ^ 2 = 36,正确结果应该是18。结合性表的错误更隐蔽:^写成左结合,2 ^ 3 ^ 2就从512变成了64,这种用例测试时可以快速暴露问题。

给一个实操心法:优先级表用大写字典集中定义,不要散落在各个 if 分支里;结合性用set保存右结合运算符。后面要扩展新运算符,改表和分支各加一处,不容易漏。

6.5 用现成库还是手写求值器

如果只是做工具,不需要一定手写。Python 生态里有很多表达式求值库,比如simpleeval、asteval,它们处理负数、函数调用、变量绑定都很成熟。但面试、课程设计或者想做深入优化时,亲手实现一遍调度场算法和栈求值依然非常值得,因为这两段代码浓缩了栈、优先级、结合性、词法分析这些基础功。手写一遍后再看任何解析库的文档,你会觉得它们都亲切很多。

我个人在实际操作中的体会是,表达式解析这类东西,最大的敌人不是算法难,而是“边界情况多、约定不一致”。尤其是负号、除零、精度、空表达式这四件事,十个初写者九个会踩。写完之后一定把测试用例补全,把各种异常输入都喂一遍。等到你的代码面对((1 + 2) * (3 + 4))、2 ^ -3 + 1、1 / 0都能给出明确、可预期的处理结果时,才算是真正掌握。最后再分享一个小技巧:如果你要继续深入,可以试试把调度场算法反向实现中缀转前缀,或者把后缀表达式渲染成一棵表达式树,这两个练习能让你对“同一表达式的三种形态”有更立体的理解,比单纯背代码有用得多。

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

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

立即咨询