
如果你平时写过计算器、公式引擎或者只是刷面试题时撞上过“逆波兰表达式”这个名字肯定体会过那种感觉中缀表达式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。日常大家接触较多的其实是后缀表达式很多教程里提到的 RPNReverse 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空数字直接输出23栈顶为空直接压栈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 851[3, 8, 1]入栈65[3, 8, 1, 5]入栈7-[3, 8, -4]弹出5右、1左1 - 5 -482[3, 8, -4, 2]入栈93[3, 8, -4, 2, 3]入栈10^[3, 8, -4, 8]弹出3右、2左2 ^ 3 811^[3, 8, 65536]弹出8右、-4左(-4) ^ 8 6553612/[3, 0.0001220703125]弹出65536右、8左8 / 65536 0.000122070312513[3.0001220703125]弹出0.0001220703125右、3左3 0.0001220703125最终结果是3.0001220703125。你可以拿各种带优先级的计算器验证只有按2^38、(-4)^865536这个顺序才能对上。后缀表达式的价值在这里体现得淋漓尽致没有括号没有优先级表每一次运算都发生在栈顶顺序由 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都能给出明确、可预期的处理结果时才算是真正掌握。最后再分享一个小技巧如果你要继续深入可以试试把调度场算法反向实现中缀转前缀或者把后缀表达式渲染成一棵表达式树这两个练习能让你对“同一表达式的三种形态”有更立体的理解比单纯背代码有用得多。