表达式求值,可以说是数据结构里一道绕不开的经典关卡。尤其是“怎样用一个队列和一个栈实现求一个表达式的值”这种问法,几乎每个学到栈和队列的人都会碰到。我第一次看到这个问题的时候,第一反应是:表达式里既有数字、又有运算符、还有括号,凭什么要用队列和栈?后来真正把流程捋清楚才发现,这两个数据结构天生就是为了解决“顺序执行、临时保存、逆序回退”这类问题。今天我就把完整的思路、手写代码和调试过程中踩过的坑一起整理出来,希望对正卡在这个题上的朋友有帮助。
这个问题适合谁看?如果你正在学数据结构,或者准备面试,又或者在工作中忽然需要自己实现一个简单的公式计算器,都可以参考这套方案。我会用最直白的方式讲清楚:为什么需要一个队列,为什么需要一个栈,括号和优先级到底是怎么处理的,以及完整的可运行代码长什么样。看完之后你不仅仅能回答这个具体问题,还能把 “怎么把复杂问题拆成数据结构能解决的步骤” 这套思考方式带走。
1. 表达式求值的核心难点与整体设计思路
1.1 为什么表达式求值一直是经典关卡
一个表达式表面上看只是一串字符,比如 1 + 2 * 3,但计算机如果没有经过训练,根本不知道应该先算 1+2 还是先算 2*3。这是因为我们平时写的是中缀表达式,运算符放在两个操作数中间,它依赖三样东西:
- 运算符优先级:
*、/的优先级高于+、-; - 括号:括号可以强制改变优先级;
- 结合性:比如同级运算符从左到右计算,
100 / 10 / 2应该等于5,而不是20。
如果只从前往后扫描一遍,遇到数字就算,遇到运算符就立刻算,那 1 + 2 * 3 会得到 9,显然错了。所以我们需要一种机制,把“暂时不能算”的操作保存起来,等遇到合适的时机再拿出来算。这个保存机制,很快就让人想到栈。
1.2 栈和队列为什么能解决这个问题
栈的特点是后进先出。这个特性特别适合处理嵌套和回退:括号里面的表达式的确应该先算,遇到右括号时,最先处理的必须是最近一个左括号之后的运算符;运算符优先级也可以借助栈来临时挂起,等遇到更低优先级或右括号时再弹出。一句话就是:栈负责“记住最近悬而未决的操作”。
队列的特点是先进先出。表达式本身有严格的顺序性,从左到右读入。用队列保存原始的 token 序列,可以保证每个字符只会被按顺序消费一次;同时队列也可以作为“输出缓冲区”,把转换过程中的结果(比如后缀表达式)按顺序存放,方便下一步继续读取。
用一个不太严谨但很形象的类比:栈就像一叠盘子,后放的先拿走;队列就像排队买票,先来的人先进站。表达式求值里,运算符往往需要“后到先处理”,所以要栈;而读入和输出必须保持先后顺序,所以要队列。
1.3 调度场算法:中缀转后缀的直观方案
实际操作中,我推荐把表达式求值拆成两段:
第一段,把中缀表达式转成后缀表达式,也叫逆波兰表达式。这一步可以用“调度场算法”(Shunting Yard Algorithm),它是 Edsger Dijkstra 发明的思路。我们需要一个队列来存放原始 token,再准备一个栈来临时存放运算符。扫描队列中的每个 token,操作数直接进输出队列,运算符根据优先级决定是压栈还是先弹出栈顶。
第二段,对后缀表达式求值。后缀表达式没有括号,也没有优先级问题,计算机只需要“遇到数字就压栈,遇到运算符就弹出两个数字计算并把结果压回”,操作顺序一目了然。这一步同样需要一个栈来存放中间结果,而这个栈完全可以复用第一阶段的栈。
所以你发现没有,完整流程严格说起来需要“队列 + 栈”和“另外一个栈”。但因为第二阶段和第一阶段不会同时使用同一个栈,代码里完全可以复用同一个数组当栈用。也就是说,从抽象层面,我们就是用一个队列和一个栈实现了整个表达式的求值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 手写数据结构:栈和队列的基础实现
2.1 用列表模拟栈
在写具体算法之前,我先说实现细节。现在主流语言里,栈基本不用自己去封装,比如 Python 的列表天然就可以当栈用:append 是入栈,pop 是出栈,list[-1] 是取栈顶。
python复制stack = []
stack.append(1) # 1 入栈
stack.append(2) # 2 入栈
top = stack[-1] # 查看栈顶,结果是 2
poped = stack.pop() # 弹出 2
不过如果你是为了理解数据结构本身,也可以自己用数组模拟栈,加一个 top 指针:
python复制class ArrayStack:
def __init__(self, capacity=100):
self.data = [None] * capacity
self.top = -1
def push(self, value):
self.top += 1
self.data[self.top] = value
def pop(self):
value = self.data[self.top]
self.data[self.top] = None
self.top -= 1
return value
def is_empty(self):
return self.top == -1
用列表其实足够了,但如果你在面试中手写,建议先把这种基于数组的栈解释清楚,让面试官看到你理解底层原理,然后再用语言自带特性实现最终代码。
2.2 用 deque 模拟队列
队列如果是自己实现,通常用循环数组或链表。但 Python 的 collections.deque 是一个双端队列,左侧 popleft 是出队,右侧 append 是入队,效率都是 O(1)。这比直接对 Python 列表做 pop(0) 要快得多,因为 pop(0) 会把整个列表往左移动,数据量大了特别慢。
python复制from collections import deque
q = deque()
q.append("a") # 入队
front = q.popleft() # 出队,得到 "a"
在表达式求值里,我们用队列存放 token,比如 ['(', '1', '+', '2', ')', '*', '3']。每次从队列头部取一个 token,处理完就扔掉,这正好符合“从左到右扫描一次”的直觉。
2.3 tokenize:把表达式拆成有意义的单元
先别急着写算法,我们得先解决一个基础问题:怎么把 "(1+2)*3" 拆成一个个 token?不要用 for ch in expr 去逐字符处理,因为数字可能是多位数,比如 123;也可能有小数,比如 3.14。
我推荐直接用正则表达式拆:
python复制import re
def tokenize(expr):
# 匹配整数或小数 + 运算符 + 括号
pattern = r"\d+\.?\d*|[+\-*/()^]"
return re.findall(pattern, expr.replace(" ", ""))
简单解释一下这个正则:
\d+\.?\d*匹配数字:整数部分一位以上,小数点可以出现也可以不出现,小数部分随便多少位;[+\-*/()^]匹配加减乘除、幂运算、左右括号;expr.replace(" ", "")先去掉所有空格,避免正则被空格干扰。
比如 tokenize("(1 + 2) * 3") 会得到:
python复制['(', '1', '+', '2', ')', '*', '3']
这一步看起来简单,但很多人一开始会在这里翻车:直接逐字符处理,结果遇到两位数就懵了。先做 tokenize,后面所有算法都会轻松很多。
3. 完整算法拆解:中缀转后缀 + 后缀求值
3.1 中缀转后缀的三个规则
调度场算法的核心规则,我用大白话总结成三条:
- 遇到操作数,直接进输出队列;
- 遇到运算符,先看栈顶:
- 如果栈为空,或者栈顶是左括号,直接压栈;
- 如果当前运算符优先级比栈顶高,压栈;
- 否则,也就是当前优先级小于等于栈顶优先级,就把栈顶运算符弹出到输出队列,然后继续比较新的栈顶;
- 遇到左括号,直接压栈;遇到右括号,不停弹出栈顶到输出,直到碰到左括号,再把左括号弹出丢弃。
最后,如果表达式扫描完了,把栈里剩下的运算符全部弹出到输出队列。
很多人不理解第二条那句“当前优先级小于等于栈顶优先级时弹出”。举个例子,表达式是 1 + 2 * 3:
- 读到
1,进输出队列; - 读到
+,栈为空,压栈; - 读到
2,进输出队列; - 读到
*,当前*优先级比栈顶+高,所以直接压栈; - 读到
3,进输出队列。
这样输出队列是 1 2 3,栈里是 + *,最后全部弹出,得到后缀表达式 1 2 3 * +。计算时先算 2 * 3,再算 1 + 6,结果正好是 7,符合预期。
那如果改成 1 * 2 + 3:
- 读到
1,输出; - 读到
*,栈为空,压栈; - 读到
2,输出; - 读到
+,当前+优先级是 1,栈顶*优先级是 2,所以+的优先级不大于栈顶,于是弹出*到输出; - 然后栈空了,再把
+压栈; - 读到
3,输出。
输出队列变成 1 2 * 3 +,计算时先算 1 * 2,再算 2 + 3,结果也是 5。
这个规则的关键就是:当前运算符一旦遇到优先级小于等于自己的栈顶,说明栈顶那个运算已经可以做了,应该先把它处理掉。
3.2 手动演算:一边看一边跟
光看文字规则容易飘,我在纸上演算一个带括号的例子:(1 + 2) * (3 - 4)。
第一步,token 队列:
python复制['(', '1', '+', '2', ')', '*', '(', '3', '-', '4', ')']
输出队列用 out 表示,运算符栈用 ops 表示。
- 读到
(:左括号压栈。ops = ['('],out = []。 - 读到
1:操作数,直接输出。ops = ['('],out = ['1']。 - 读到
+:栈顶是(,直接压栈。ops = ['(', '+'],out = ['1']。 - 读到
2:输出。ops = ['(', '+'],out = ['1', '2']。 - 读到
):弹出栈顶直到左括号。先把+弹出到输出,再弹出(丢弃。ops = [],out = ['1', '2', '+']。 - 读到
*:栈为空,压栈。ops = ['*']。 - 读到
(:压栈。ops = ['*', '(']。 - 读到
3:输出。out = ['1', '2', '+', '3']。 - 读到
-:栈顶是(,直接压栈。ops = ['*', '(', '-']。 - 读到
4:输出。out = ['1', '2', '+', '3', '4']。 - 读到
):弹出-到输出,再弹出(。ops = ['*'],out = ['1', '2', '+', '3', '4', '-']。 - 扫描结束,栈里还有
*,弹出。out = ['1', '2', '+', '3', '4', '-', '*']。
最终后缀表达式是 1 2 + 3 4 - *,意思是先算 1 + 2 = 3,再算 3 - 4 = -1,最终 3 * (-1) = -3。展开原式 (1+2)*(3-4) = 3 * (-1) = -3,完全正确。
你在纸上手动画一遍这个状态变化,基本上整个算法就吃透了。我自己带新人的时候,第一件事就是让他在白板上画三个列:当前 token、输出队列、运算符栈,画完几个例子,各种问题都会暴露出来。
3.3 后缀表达式求值:注意出栈顺序
有了后缀表达式,求值就轻松了。我们重新用一个栈来存放操作数:
- 从队列头取一个 token;
- 如果是操作数,压栈;
- 如果是运算符,从栈里弹出两个操作数,先弹出来的是右操作数,后弹出来的是左操作数,计算完再压回栈;
- 最终栈底(也是栈顶)就是结果。
对 1 2 + 3 4 - *:
- 取到
1,压栈。栈:[1] - 取到
2,压栈。栈:[1, 2] - 取到
+,弹出2和1,计算1 + 2 = 3,压回。栈:[3] - 取到
3,压栈。栈:[3, 3] - 取到
4,压栈。栈:[3, 3, 4] - 取到
-,弹出4和3,注意先弹出的4是右操作数,后弹出的3是左操作数,计算3 - 4 = -1,压回。栈:[3, -1] - 取到
*,弹出-1和3,计算3 * (-1) = -3,压回。 - 队列空了,栈顶
-3就是结果。
减法和除法尤其要小心。如果你写成了 right - left,遇到 3 - 4 会得到 1,整个结果就错了。我见过不少同学在这里踩坑,所以建议代码里变量名直接叫 right 和 left,强迫自己注意顺序:
python复制right = stack.pop()
left = stack.pop()
result = left - right
4. 一份可运行的 Python 实现
4.1 完整代码
下面是我个人常用的一个实现,支持加减乘除、括号、幂运算和小数:
python复制import re
from collections import deque
def tokenize(expr):
pattern = r"\d+\.?\d*|[+\-*/()^]"
return re.findall(pattern, expr.replace(" ", ""))
def infix_to_postfix(tokens):
precedence = {'+': 1, '-': 1, '*': 2, '/': 2, '^': 3}
output = deque()
op_stack = []
for token in tokens:
# 判断是不是数字(包含小数)
if re.fullmatch(r"\d+\.?\d*", token):
output.append(token)
elif token == '(':
op_stack.append(token)
elif token == ')':
while op_stack and op_stack[-1] != '(':
output.append(op_stack.pop())
op_stack.pop() # 丢弃左括号
else:
# 运算符
while (op_stack and op_stack[-1] != '(' and
precedence[token] <= precedence[op_stack[-1]]):
# 幂运算右结合,同优先级不弹
if token == '^' and op_stack[-1] == '^':
break
output.append(op_stack.pop())
op_stack.append(token)
while op_stack:
output.append(op_stack.pop())
return output
def eval_postfix(postfix):
stack = []
for token in postfix:
if token in '+-*/^':
right = float(stack.pop())
left = float(stack.pop())
if token == '+':
stack.append(left + right)
elif token == '-':
stack.append(left - right)
elif token == '*':
stack.append(left * right)
elif token == '/':
stack.append(left / right)
elif token == '^':
stack.append(left ** right)
else:
stack.append(token)
return stack[0]
def calculate(expr):
tokens = tokenize(expr)
postfix = infix_to_postfix(tokens)
print("后缀表达式:", list(postfix))
return eval_postfix(postfix)
if __name__ == "__main__":
print(calculate("(1 + 2) * (3 - 4)"))
print(calculate("2 + 3 * 4"))
print(calculate("10 / 4"))
print(calculate("2 ^ 3 ^ 2"))
这个代码看起来不长,但每一步都对应前面讲的规则。re.fullmatch 判断 token 是否为数字,比用 isdigit() 更可靠,因为 isdigit() 不认正负号和小数点。注意,这里暂时不支持负数,下面会讲怎么扩展。
4.2 测试几个用例
我在本地跑了一下上面几个表达式:
(1 + 2) * (3 - 4)输出后缀表达式['1', '2', '+', '3', '4', '-', '*'],结果-3.0。2 + 3 * 4输出后缀['2', '3', '4', '*', '+'],结果14.0。10 / 4输出['10', '4', '/'],结果2.5。2 ^ 3 ^ 2输出['2', '3', '2', '^', '^'],结果512.0。
最后一个表达式需要特别注意。数学上 2 ^ 3 ^ 2 一般理解为右结合,也就是 2 ^ (3 ^ 2) = 2 ^ 9 = 512。我在调度场算法里对 ^ 做了特殊处理:当栈顶也是 ^ 且当前也是 ^ 时,不弹出,这样 ^ 就表现出从右到左的结合性。如果你不需要幂运算,完全可以不写这段逻辑。
4.3 扩展:支持一元负号
目前这个实现有个明显短板:表达式如果写成 -3 + 5 会出错,因为 tokenize 得到的 - 会被当成二元减号,前面没有操作数可以减。常见解决办法是:在扫描 token 时,如果当前是 -,并且它前面一个 token 不是操作数也不是右括号,就说明它是一元负号。
可以采用一个简单方案:把一元负号对应的 token 替换成一个自定义标记,比如 neg,在后缀求值阶段单独处理。
具体做法是在 tokenize 之后增加一个函数:
python复制def handle_unary_minus(tokens):
result = []
prev = None
for token in tokens:
if token == '-' and (prev is None or prev == '(' or prev in '+-*/^'):
result.append('neg')
else:
result.append(token)
prev = token
return result
然后在 infix_to_postfix 中,遇到 neg 时可以直接当成一个操作数?不对,它本质是一个一元运算符。更稳妥的方式是:把 -3 转成 0 - 3,也就是在 - 前面补一个 0,并加一个括号,避免优先级影响。
python复制def fix_unary_minus(tokens):
output = []
prev = None
for token in tokens:
if token == '-' and (prev is None or prev == '(' or prev in '+-*/^'):
output.append('0')
output.append('-')
else:
output.append(token)
prev = token
return output
比如 -3 + 5 经过处理后变成 0 - 3 + 5,这是一个完全正常的中缀表达式。如果是 (-3) * 2,则变成 (0 - 3) * 2,结果也一样。这种方法简单粗暴,但非常好用,只需在 tokenize 之后调用一下。
5. 常见坑与排查实录
5.1 出栈顺序搞反,结果差得离谱
后缀求值阶段,减法 3 - 4 和除法 8 / 2 的结果对操作数顺序极其敏感。出栈顺序反了,3 - 4 会变成 4 - 3 = 1,8 / 2 会变成 2 / 8 = 0.25。
排查方法很简单:在遍历后缀表达式时,把每一步的栈内容打印出来。比如:
python复制for token in postfix:
print("token:", token, "stack:", stack)
对比手动演算,很快就能发现问题。我遇到过一位同学,明明算法思路是对的,但他把 left 和 right 变量写反了,检查了很久都没发现,一打印栈内容就曝光了。
5.2 括号不匹配导致的空栈问题
如果输入表达式像 (1 + 2 这样少了一个右括号,算法最后从栈里弹出运算符时可能会依次把所有运算符都弹干净,但不会报错,结果也算得出来,只是结果不对。如果多了一个右括号,比如 1 + 2),在遇到 ) 时一直弹出直到遇到 (,结果栈都弹空了还没遇到 (,这时候 op_stack.pop() 就会抛异常。
所以我在代码里加了一条防御:
python复制while op_stack and op_stack[-1] != '(':
output.append(op_stack.pop())
if op_stack and op_stack[-1] == '(':
op_stack.pop()
else:
raise ValueError("括号不匹配")
同理,后缀求值阶段如果表达式非法,right = stack.pop() 也可能因为栈空而报错。写一个自己的 safe_pop 函数会更友好。
5.3 优先级和结合性没想清楚
很多人的中缀转后缀代码能做对四则运算,但一到幂运算就翻车。关键点在于:
- 普通运算符
+ - * /是左结合,相同优先级时,先弹出的先算,所以条件用<=; - 幂运算符
^是右结合,相同优先级时不能弹出,所以要单独判断。
还有一个容易忽略的坑:如果加了自定义一元负号 neg,它的优先级应该比乘除还高,但在中缀转后缀阶段,我建议一律通过补零转成二元运算,避免在优先级表里给负号单独留位置。这样做虽然会增加一些冗余 token,但对正确性和可读性都更友好。
5.4 非法表达式的防御
在实际项目里,你不可能保证输入表达式永远合法。常见非法情况包括:
- 连续运算符:
1 + * 2 - 只有左括号没有右括号
- 空字符串
- 非数字非运算符字符
- 除数为零
我的建议是分两层。第一层,tokenize 阶段如果匹配到正则之外的内容,直接拒绝。第二层,在 calculate 的入口先判断 token 列表是否为空,在解析过程中遇到异常情况就抛出带上下文信息的错误。
下面是一个简单检查:
python复制def calculate(expr):
if not expr.strip():
raise ValueError("表达式为空")
tokens = fix_unary_minus(tokenize(expr))
postfix = infix_to_postfix(tokens)
return eval_postfix(postfix)
至于除数为零,这个倒是很明确,用 Python 的 ZeroDivisionError 捕获就好。为了用户体验,最好把错误信息改成“除法表达式中除数不能为零”。
5.5 为什么不能只用一个栈直接搞定
回到标题:既然栈这么厉害,为什么还非要一个队列?这里有两种理解。如果你只关心“求一个表达式的值”,确实可以用两个栈直接完成,操作数栈保存数字,运算符栈保存运算符。但题目特别强调“一个队列和一个栈”,通常是想考察你对调度场算法的理解:用队列保存输入和中间输出,用栈保存运算符,把中缀转成后缀后再求值。
严格说,完整流程用了“队列 + 栈”和“另外一个栈”,但第二阶段和第一阶段复用了同一个栈,所以在实现和思路上,就是用一个队列一个栈完成了任务。如果你想在面试中展示得更全面,可以先把双栈法提一嘴,再对比一下调度场方案的优势:流程拆分更清晰,中间结果可以调试,也更容易扩展到更多运算符。
最后一件事:动手画图比背代码有用
我自己带项目时发现,真正理解这个算法的人,很少是纯粹背代码背出来的。我推荐一个笨办法:拿一个表达式,自己画一张三列表格,一列是当前读到的 token,一列是输出队列,一列是栈内容。每一步都真的写下来,画完 10 个例子,你基本就不会再忘记括号和优先级的处理顺序了。
另外,如果你想把这段代码用在真实项目里,还应该考虑更多细节,比如支持变量名、函数调用、逗号参数等等。但万变不离其宗,核心还是队列负责顺序、栈负责回退,这个组合在表达式求值这个场景里,就是最清晰的解法。希望这篇记录能帮你把这道经典题彻底拿捏住。
