如果你在学计算机组成原理或者数据结构,可能听过一个很常见的说法:CPU 只会执行机器指令,真正把 1+2*3 这种人类算式翻译成机器能执行步骤的,是编译器。这个回答没错,但容易让人觉得“编译器一下就搞定了”。实际上,计算算式这件事在底层是一套非常具体的算法流程,其中最关键的两个名字就是调度场算法和逆波兰表达式。简单说,调度场负责把一个带优先级、带括号的中缀表达式,转成没有括号、只有线性顺序的后缀表达式;CPU 或虚拟机再按这个顺序就能一步步算完。搞懂这套东西,你不仅会写一个计算器,还会理解为什么很多语言编译后的指令都是“先压栈、再运算”的形状。
1. 先搞清楚卡点:中缀表达式难在“看全貌”
1.1 CPU 不是靠“扫一眼整条算式”工作的
人类写 1 + 2 * 3 的时候,眼睛会在脑子里自动做两件事:识别数字和运算符,再按小学学过的“先乘除后加减”分配优先级。这个动作太自然了,自然到很少有人会去想:CPU 拿到这条式子时,真的能看到“整条公式再决定先算谁”吗?
答案是否定的。CPU 的工作模型简单得近乎机械:从内存读指令或数据,放到寄存器,执行一次加法、一次乘法,把结果写回内存。它没有全局视野,只有一小段一小段的指令流。如果直接把 1 + 2 * 3 交给一个“只会从左往右执行的机器”去算,它会先算 1 + 2 = 3,再算 3 * 3 = 9,于是得到错误结果 9。
所以问题不是“CPU 不会乘法”,而是“算式里的优先级和括号,应该被谁消化掉”。大多数现代 CPU 并不会在做乘法指令之前重新观察一个字符串,于是就需要一个预处理层,把带有优先级的中缀表达式,转成没有歧义的线性指令序列。这个预处理层所用的算法,就是调度场算法。
1.2 逆波兰表达式本质上是“把决策顺序写出来”
逆波兰表达式又叫后缀表达式。它最大的特点是没有括号,也不需要依赖优先级规则,只要从左往右扫一遍就能完成求值。人类习惯的 1 + 2 * 3 写成逆波兰就是:
code复制1 2 3 * +
看不懂不要紧,我拆开说一下求值过程:
- 从左往右扫描,看到数字
1,先记下来; - 看到数字
2,再记下来; - 看到数字
3,也记下来; - 看到
*,取出最近记下的两个数2和3,算2 * 3 = 6,并把这个结果当成一个新数记下来; - 看到
+,再次取出最近的两个数,1和6,算1 + 6 = 7。
为什么这样的顺序天然正确?因为 1 2 3 * + 里的 * 出现在 2 和 3 后面,所以乘法优先绑定这两个数;+ 出现在 1 和乘法结果后面,所以加法绑定的对象已经是一个中间值了。括号消失之后,操作顺序全部变成了“先压栈,后运算”的机械步骤。这种形式对机器极其友好,因为它不需要“往前看”,不需要递归解析括号,只有一个栈和一个循环。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手实现前,先备好两份“物料清单”:token 和优先级表
2.1 第一件事是把输入字符串切成“令牌”
调度场算法处理的输入不是一串字符,而是一串有类型的 token。比如 "12 + 3 * (45 - 2)" 不能一个字符一个字符地看,因为 12 是一个整体,45 也是一个整体。你首先要做的是分词。
最朴素的方式是用正则表达式切分。下面这段 Python 代码可以处理小数、四则运算和括号:
python复制import re
def tokenize(expr: str) -> list[str]:
pattern = r"\d+(?:\.\d+)?|[()+\-*/^]"
return re.findall(pattern, expr)
有个细节很容易踩:- 在正则字符组里要写成 \-,否则它会表示一个范围。比如 [()+\-*/^] 里如果不转义,JS 或 Python 可能直接报错,或者把 * 到 / 当成一段范围。这属于“工具都没用对就先跑到算法层”的经典错误。
分词后的字符串 "5 + 3 * 4 - 2" 会变成:
code复制["5", "+", "3", "*", "4", "-", "2"]
如果你直接把用户在界面输入的字符串丢给调度场算法,第一步就废了。因为算法需要一个一个 token 去判断“这是数字还是运算符”,而不是自己去猜 12 是 1 和 2 两个数字还是整数十二。
2.2 优先级和结合性是调度场算法的“红绿灯”
有了 token,还要有一张表告诉算法谁是老大。只给优先级还不够,还要给结合性。
| 操作符 | 优先级 | 结合性 | 含义 |
|---|---|---|---|
^ |
4 | 右结合 | 2^3^2 = 2^(3^2) = 512 |
* / |
3 | 左结合 | 12/3*2 = (12/3)*2 = 8 |
+ - |
2 | 左结合 | 8-3+2 = (8-3)+2 = 7 |
优先级好理解,数字大的先算。结合性容易被忽略,尤其是指数运算 ^。
加法是左结合,1 + 2 + 3 应该看成 (1 + 2) + 3,而不是 1 + (2 + 3)。对加法和乘法来说,两种结合顺序数值碰巧一样;但减法和除法不一样,8 - 3 - 2 如果看成 8 - (3 - 2) = 7 就错了,正确应该是 (8 - 3) - 2 = 3。所以规则定的是:相同优先级的左结合运算符,从左往右算。
指数则是右结合,2^3^2 数学上默认等于 2^(3^2),也就是 2^9 = 512。如果错误地按左结合算成 (2^3)^2 = 64,结果直接差出好几倍。
“右结合”这四个字在后缀表达式里表现为:当再次遇到相同优先级的 ^ 时,不能把栈顶的 ^ 先弹出,而应该继续压栈,等最后再从栈顶弹出,让后面的指数先算。
3. 调度场算法到底在调度什么:一条手工推演就能看懂
3.1 数据结构只有两个:输出队列和运算符栈
调度场算法会维护两个东西:
- 输出队列:保存已经确定顺序的数字和运算符,实际上一个 Python list 就够了;
- 运算符栈:临时存放还没轮到输出的运算符,以及左括号。
每次读到一个 token,只有四种选择:
- 如果是数字,直接加入输出队列,因为它不需要等待任何人,谁也不能改变数字本身;
- 如果是普通运算符,先看栈顶运算符的优先级是否能“压过”当前运算符,能压过就先弹出到输出队列,等处理完再把自己压栈;
- 如果是左括号,直接压栈,它像一个暂停标记,告诉后面的运算符先别急着输出;
- 如果是右括号,把栈顶运算符一直弹到左括号为止,左括号本身丢弃。
用一句话提炼就是:优先级高的运算符要赶紧落到输出里,优先级低的运算符得在栈里排队,等优先级高的处理完再出来。括号的作用就是把排队规则暂时挂起。
3.2 手工推演:5 + 3 * 4 - 2
我们拿四则运算里最经典的一个表达式来推:
| 当前 token | 输出队列 | 运算符栈 | 发生的事 |
|---|---|---|---|
5 |
[5] |
[] |
数字直接输出 |
+ |
[5] |
[+] |
栈空,+ 入栈 |
3 |
[5, 3] |
[+] |
数字直接输出 |
* |
[5, 3] |
[+, *] |
栈顶 + 优先级小于 *,所以 * 直接入栈 |
4 |
[5, 3, 4] |
[+, *] |
数字直接输出 |
- |
[5, 3, 4, *] |
[+] |
* 优先级大于 -,先弹出 |
- |
[5, 3, 4, *, +] |
[] |
+ 和 - 同优先级,且左结合,所以 + 也要弹出 |
- |
[5, 3, 4, *, +] |
[-] |
弹完同优先级后,- 入栈 |
2 |
[5, 3, 4, *, +, 2] |
[-] |
数字直接输出 |
| 结束 | [5, 3, 4, *, +, 2, -] |
[] |
把栈里剩余 - 弹出 |
最终得到逆波兰表达式:
code复制5 3 4 * + 2 -
求值时,读到 * 先算 3 * 4 = 12,再读到 + 算 5 + 12 = 17,最后读到 - 算 17 - 2 = 15。结果和正常中缀计算完全一致。
3.3 右结合和括号在调度场里是怎么体现的
再来看一个带右结合运算符的例子:2^3^2。
处理第一个数字 2,输出 [2];第一个 ^ 入栈;数字 3 输出;遇到第二个 ^ 时,栈顶已经有一个 ^。如果这里是普通左结合运算符,比如 -,就应该把旧 - 弹出来,表示先算左边;但 ^ 是右结合,所以新 ^ 压栈。最后弹栈时先弹新的 ^,输出成:
code复制2 3 2 ^ ^
求值时先算 3 ^ 2 = 9,再算 2 ^ 9 = 512。整个过程完全不需要知道“幂是右结合”这件事,因为在转换为后缀的过程中,已经把结合性翻译成运算符出现的先后顺序了。
括号的作用则是这样:表达式 (1 + 2) * 3 扫描到 ( 时直接压栈,扫描到 + 时本来想和栈顶比较,但发现栈顶是 (,于是不弹出。等到右括号出现,才把 + 弹出到输出队列。左括号之后,右括号之前的内容被当成一个独立子式处理完,最终得到的后缀是:
code复制1 2 + 3 *
这个结果读起来就是“先把括号里的 1、2 加起来,再乘 3”,和原意完全吻合。
4. 从伪代码到能跑的 Python:中缀转后缀加求值一起搞定
4.1 完整的中缀转后缀实现
下面我直接给一个能跑的实现。代码不追求最短,但尽量每一步都看得懂:
python复制import re
PREC = {"+": 1, "-": 1, "*": 2, "/": 2, "^": 3}
RIGHT_ASSOC = {"^": True}
def tokenize(expr: str) -> list[str]:
pattern = r"\d+(?:\.\d+)?|[()+\-*/^]"
return re.findall(pattern, expr)
def infix_to_rpn(tokens: list[str]) -> list[str]:
output = []
op_stack = []
for token in tokens:
if token == "(":
op_stack.append(token)
elif token == ")":
while op_stack and op_stack[-1] != "(":
output.append(op_stack.pop())
if not op_stack:
raise ValueError("左括号缺失")
op_stack.pop() # 丢弃左括号
elif token in PREC:
while (
op_stack
and op_stack[-1] != "("
and op_stack[-1] in PREC
and (
PREC[op_stack[-1]] > PREC[token]
or (
PREC[op_stack[-1]] == PREC[token]
and not RIGHT_ASSOC.get(token, False)
)
)
):
output.append(op_stack.pop())
op_stack.append(token)
elif re.fullmatch(r"\d+(?:\.\d+)?", token):
output.append(token)
else:
raise ValueError(f"无法识别的 token:{token}")
while op_stack:
if op_stack[-1] == "(":
raise ValueError("右括号缺失")
output.append(op_stack.pop())
return output
核心判断在倒数第二个 elif 里其实不是,核心在 while 那段。这里可以解释一下那个看上去有点绕的条件:
- 如果栈顶运算符优先级大于当前运算符,说明栈顶该算,弹出;
- 如果优先级相同,并且当前运算符是左结合,也弹出一个;
- 如果优先级相同,但当前是右结合(比如
^),不弹,继续压栈。
很多第一次写调度场的人会把目光放在“栈里是不是有个 (”上,这是个好习惯。如果栈顶是 (,绝对不能因为优先级比较而弹出。所以条件中专门写了 op_stack[-1] != "("。
我来跑几个表达式测试:
python复制for expr in ["5+3*4-2", "(1+2)*3", "2^3^2", "8-3-2"]:
rpn = infix_to_rpn(tokenize(expr))
print(f"{expr} -> {' '.join(rpn)}")
输出应该是:
code复制5+3*4-2 -> 5 3 4 * + 2 -
(1+2)*3 -> 1 2 + 3 *
2^3^2 -> 2 3 2 ^ ^
8-3-2 -> 8 3 - 2 -
这个结果可以对着中缀表达式的优先级自己验证一遍。
4.2 后缀表达式求值:一个栈就能走完全程
后缀求值比转后缀还要简单。规则只有一句:看到数字就压栈,看到运算符就弹出需要的操作数,算完把结果压回去。
python复制def eval_rpn(tokens: list[str]) -> float:
stack = []
for token in tokens:
if token in PREC:
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("除数为 0")
stack.append(left / right)
elif token == "^":
stack.append(left ** right)
else:
stack.append(float(token))
return stack[-1]
注意求值顺序。减法 left - right 里的 left 是栈里先弹出的还是后弹出的?我刚学的时候总在这里写反。RPN 中 8 3 - 表达的是“8 减 3”,求值时遇到 -,会先弹出 right = 3,再弹出 left = 8,然后算 8 - 3。如果心里不明确“后弹出的才是左边的操作数”,非常容易写出 3 - 8,调试半天也找不到问题。
把整条链路串起来就是:
python复制expr = "5+3*4-2"
rpn = infix_to_rpn(tokenize(expr))
result = eval_rpn(rpn)
print(rpn) # ['5', '3', '4', '*', '+', '2', '-']
print(result) # 15.0
这段代码放到任何环境里都能跑,不依赖别人写好的 eval,逻辑完全自己掌控。别看它短,已经是一个小计算器引擎的核心了。
4.3 为什么不直接 Python eval?
很多人会问:Python 里不是有 eval("5+3*4-2") 吗,为什么还要自己写几十行?
原因至少有两个。
第一,eval 在工程上下文里等于“把用户输入直接当代码执行”。如果你写了一个网页计算器,把用户输入的字符串原封不动丢给 eval,那用户输入一个 __import__('os').system('...') 就可能造成严重后果。自己实现 parser 不是为了炫技,而是为了把计算范围限制在你能控制的 token 类型里。
第二,eval 的结果是内部黑盒。你无法知道计算过程里哪些数字参与过哪一步,也无法拦截除零、无法进行中间过程统计,更没法把求值过程搬到一套没有 Python eval 的嵌入式或浏览器环境里。使用调度场加 RPN 之后,你得到的是数据流,哪一步做什么完全透明。
5. 调度场算法在真实系统里的位置:从计算器到 JVM 字节码
5.1 计算器、电子表格公式引擎和调度场的关系
如果你写过简单的计算器,可能直接用递归下降或者别的表达式解析算法。递归下降当然能做同样的事,但调度场算法的优势是:它把“优先级比较”这个逻辑收敛到了一个 while 循环里,不需要调用栈去模拟不同优先级,也不需要在代码里为每个优先级写一层递归函数。
很多成熟系统里,公式引擎会把输入的公式转成后缀表达式再保存。电子表格的单元格公式、低代码平台的条件公式、甚至某些硬件设备里的脚本计算器,都可能采用类似思路。因为一旦表达式成了 RPN,执行时不需要反复扫描字符串,也不需要每次重新解析优先级,一个栈就足够。对性能敏感的场景,RPN 求值循环还可以被优化到非常紧凑,甚至写成固定指令序列。
5.2 编译器和虚拟机里到处是“逆波兰的影子”
学计算机组成原理或者操作系统时,条件转移、子程序调用这些概念很自然,但表达式求值怎么做往往要到汇编或者 JVM 字节码层面才看明白。
JVM 字节码是典型的基于栈的指令集。比如计算 a + b * c,编译后大概会生成这样的逻辑:先把局部变量 b 压栈,再把 c 压栈,然后执行乘法指令,此时 JVM 从栈里弹出两个数做乘法,把结果压栈;接着把 a 压栈,再执行加法。这个顺序和后缀表达式 b c * a + 几乎一一对应。
如果去看 GNU 社区里一些为小型计算器或参数解析场景写的代码,也能发现同样的模式:先把 AST、表达式树或 RPN 构建出来,之后执行阶段变得异常简单。因为表达式解析的难点已经被前置处理阶段消化掉了,后面只做线性压栈出栈。你理解了调度场和 RPN,就不难理解为什么 JVM 没有“先算乘法再算加法”这种复杂的指令,只要把所有表达式都摊平成一条条栈操作即可。
5.3 栈机与寄存器机的差别来自“表达式如何求值”的选择
写汇编的人会有一种印象:CPU 里有寄存器,可以直接 mov eax, ebx。但很多早期高级语言编译器会把表达式转换成栈形式,再映射到寄存器机或者栈机。这个过程本质上是树的后序遍历。
逆波兰表达式和中缀表达式的关系就像树的两种遍历顺序:中缀是让人读的,后缀是让机器按遍历顺序执行的。一个带括号的表达式其实可以看成二叉树,操作符在中间,左操作数和右操作数挂在两边。调度场就是把这个树从左到右、按优先级摊平成后序序列的过程。想通这一层,很多后续内容,比如 AST 的生成、代码生成、虚拟机执行,都会变得顺理成章。
6. 实战中容易踩的坑:一元负号、括号匹配、精度和小数
6.1 一元负号是个大坑
上面代码只处理了二元运算符,也就是 a - b 这种“操作数减操作数”。但真实用户输入非常喜欢写 -3 + 5,或者 3 * (-2)。这里的 - 不是“减”,而是“取负”,一元运算符,只需要一个操作数。
正则分词会把负号也切成 "-",调度场算法如果仍然把它当二元运算符处理,就会尝试从输出队列里找左右两个操作数,结果看到数字 3 前面没有左操作数,直接乱套。
处理一元负号的办法有很多。最简单的思路是在分词阶段判断:如果 - 出现在表达式开头、或者紧跟在 ( 后面、或者紧跟在另一个运算符后面,那么它是一元负号,而不是减法。工程上通常会给一元负号单独一个 token,比如 "u-",再给它一个介于乘除和指数之间的特殊优先级,并在求值阶段遇到 u- 时只弹出一个操作数,取反后压回栈。
这个改造比想象中麻烦,因为在 -2^2 这种表达式里,“负号要不要先作用于 2^2”本身就存在约定歧义。很多语言按数学惯例认为 -2^2 = -(2^2) = -4,但不同解析器可能给出不同答案。所以遇到一元负号不要急着加一个正则分支就完事,最好先明确你的计算器到底想遵循哪套规则。
6.2 括号不匹配和空表达式要单独处理
我在写第一个可用的表达式计算器时,被括号问题坑了一次。调度场算法处理右括号时,会一直弹到左括号为止。如果用户输入 (1 + 2)),右括号出现两次,第二次却没有左括号可以匹配,此时必须报错,而不是静默跳过。
同理,如果用户输入 "1 + ",整个 token 序列不符合“数字-运算符-数字”的交替规律。我的早期实现会把这种非法输入当成“缺一个操作数”继续跑,最后在求值阶段直接 IndexError。工程上建议在 parser 入口就校验 token 序列的结构,或者至少在算法结束时检查输出队列和运算符栈是否有不该出现的东西。括号匹配也要考虑,左括号压栈后如果整个表达式结束还没有右括号,最后也要报错。
6.3 浮点数比较和除法精度
求值阶段我用的是 float,这在很多普通四则运算场景够用。但要小心,0.1 + 0.2 在二进制浮点数里并不精确等于 0.3。如果计算结果要用于判断“是否等于某个值”,比如订单金额合计,必须用 Decimal 或者固定整数单位来避免精度问题。
改成 Decimal 时,上面的 eval_rpn 要做的改动也简单:只要把压栈数字从 float(token) 改成 Decimal(token),再把加减乘除换成 Decimal 支持的运算。注意幂运算 ** 对 Decimal 支持有限,指数通常只能传整数或者要进行更精细的处理。所以不要等到线上出了金额差一分钱的问题,才想起浮点数这个坑。
6.4 扩展方向:变量、函数、比较运算符
如果学会调度场的核心逻辑,你会发现它天然支持扩展。
比较运算符 > < = != 可以看作优先级很低的二元运算符。1 + 2 > 3,本质是 (1 + 2) > 3,所以 > 应该比 + 的优先级低。转换到 RPN 后:1 2 + 3 >,求值时先算 1 + 2 = 3,再执行比较得到布尔值。布尔和比较运算一起使用时,只要在优先级表里继续排位置就行。
变量赋值也是类似。把变量名当作数字 token 输出,求值时从环境字典里取值。函数调用则稍微复杂一点,sqrt(9) 可以理解成一个一元运算符,但它的操作数在括号里。调度场处理函数调用的常见做法是:把函数名当作运算符压栈,遇到右括号并弹出匹配的左括号后,若栈顶是函数 token,则将它弹出到输出队列。这样得到的 RPN 可能是 9 sqrt 或 2 3 + sqrt,求值时执行相应函数即可。
这里建议做一个小工具来验证自己的理解:写一个支持 + - * / ^、括号、小数点的 Python 脚本,再加上一元负号和 log、sqrt 这类常见函数。不要急着抄开源代码,先自己把优先级表扩展几行,跑几百个测试用例,你会发现调度场算法就像一个搭积木的核心:真正困难的从来不是“能不能算”,而是“在边界条件下能不能稳定地按你想要的方式算”。
我自己的经验是,调度场算法光看文章只能记住三天。找一张白纸,把 (2 + 3) * 4 - 5 / 2 这个表达式手工推一遍,推完再动手写代码,跑出 2 3 + 4 * 5 2 / - 这一串,对逆波兰的理解才会真正长在脑子里。之后再遇到编译器、虚拟机、自定义公式引擎,你都不会觉得表达式解析是玄学。
