ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

数据结构与算法:前缀、中缀、后缀表达式原理与栈实现

2026/8/11 8:36:56 拓冰建站 浏览量
数据结构与算法:前缀、中缀、后缀表达式原理与栈实现

1. 从“1+2”到计算机的“语言”:为什么我们需要三种表达式?

如果你刚开始学习数据结构与算法,或者正在准备相关的面试,那么“前缀表达式”、“中缀表达式”和“后缀表达式”这三个词,大概率会让你感到一阵困惑。我们从小到大学的数学,不都是像1 + 2这样写的吗?为什么计算机世界里要搞出这么多“花里胡哨”的写法?这背后其实是一个关于“人类友好”与“机器高效”之间巨大鸿沟的故事。

我们人类习惯的1 + 2这种写法,在计算机科学里被称为“中缀表达式”。它的特点是运算符(+,-,*,/)写在两个操作数(12)的中间。这种写法非常直观,符合我们的阅读和思维习惯。但是,当表达式变得复杂,比如(1 + 2) * 3 - 4 / 5时,计算机要理解它就变得异常困难。难点在于“优先级”和“括号”的处理。计算机需要不断地“向前看”和“向后看”,判断哪个运算符先计算,括号从哪里开始到哪里结束,这个过程对于顺序执行的计算机来说,解析逻辑非常复杂,效率低下。

为了解决这个问题,计算机科学家们发明了另外两种表达式表示法:前缀表达式和后缀表达式。它们的核心思想是消除运算符的优先级和括号,让表达式的计算顺序变得唯一且明确,从而可以被计算机以一种非常简单、线性的方式(通常借助栈这种数据结构)高效地求值。

简单来说:

  • 中缀表达式:给人看的,直观但解析复杂。例如:(1 + 2) * 3
  • 前缀表达式(波兰表达式):运算符在前,操作数在后。例如:* + 1 2 3
  • 后缀表达式(逆波兰表达式):操作数在前,运算符在后。例如:1 2 + 3 *

这篇文章,我将带你彻底搞懂这三种表达式。我们不仅会弄清楚它们长什么样、怎么互相转换,更重要的是,我会结合栈这个核心数据结构,手把手带你实现中缀转后缀的算法,并完成后缀表达式的求值。这是编译原理、计算器设计、乃至很多表达式解析场景下的基础功,理解了它,你对“数据是如何被组织和处理”的认识会上一个台阶。

2. 三种表达式的“样貌”与核心规则

在深入技术细节之前,我们必须先像认识新朋友一样,搞清楚这三种表达式各自长什么样,以及它们遵循的基本规则。

2.1 中缀表达式:我们最熟悉的“老朋友”

中缀表达式就是我们日常书写数学表达式的方式。它的定义非常直接:运算符位于两个操作数的中间

基本形式操作数1 运算符 操作数2例子

  • A + B
  • A - B * C
  • (A + B) * (C - D)

特点与挑战

  1. 直观易读:完全符合人类的思维和阅读习惯。
  2. 需要定义优先级:乘除(*,/)的优先级高于加减(+,-)。
  3. 需要括号来改变顺序:当运算顺序不符合默认优先级时,必须使用括号( )来显式指定。
  4. 对计算机不友好:计算机在解析时,必须不断地“瞻前顾后”来判断下一个要执行的操作,算法复杂度高。例如,看到A - B * C,它不能直接计算A - B,必须看到后面的*C,才知道要先算B * C

2.2 前缀表达式:运算符打头阵的“波兰式”

前缀表达式,也叫波兰表达式,由波兰数学家扬·武卡谢维奇提出。它的核心规则是:运算符位于其对应的两个操作数之前

基本形式运算符 操作数1 操作数2例子

  • + A B等价于中缀的A + B
  • - A * B C等价于中缀的A - B * C(注意,这里* B C作为一个整体,是-的第二个操作数)
  • * + A B - C D等价于中缀的(A + B) * (C - D)

如何“阅读”前缀表达式?前缀表达式的解析需要从右向左扫描,但更通用的方法是递归地识别“运算符-操作数对”。对于- A * B C

  1. 第一个符号是-,这是一个运算符,它需要两个操作数。
  2. 接下来的A是第一个操作数。
  3. 接下来的*又是一个运算符,它也需要两个操作数,因此* B C这个整体构成了-的第二个操作数。
  4. * B C内部,*的操作数是BC

最大优点:完全不需要括号来指定运算顺序,运算符的位置本身就隐含了计算顺序。这使得它的求值算法可以非常简单地从右向左扫描,并使用一个栈来存储操作数。

2.3 后缀表达式:操作数先行的“逆波兰式”

后缀表达式,也叫逆波兰表达式,是前缀表达式的“镜像”。它的核心规则是:运算符位于其对应的两个操作数之后

基本形式操作数1 操作数2 运算符例子

  • A B +等价于中缀的A + B
  • A B C * -等价于中缀的A - B * C(计算顺序:先B C *得到结果R,再A R -
  • A B + C D - *等价于中缀的(A + B) * (C - D)

如何“阅读”后缀表达式?后缀表达式的解析是从左向右扫描,这是它比前缀表达式更受欢迎的一个重要原因,因为符合我们自然的阅读方向。算法极其优雅:

  1. 初始化一个空栈(用于存放操作数)。
  2. 从左到右扫描表达式:
    • 如果遇到操作数,则将其压入栈中。
    • 如果遇到运算符,则从栈中弹出两个操作数(注意顺序:先弹出的是右操作数,后弹出的是左操作数),用该运算符对它们进行运算,然后将运算结果压回栈中。
  3. 扫描结束后,栈顶元素就是表达式的最终结果。

为什么后缀表达式如此重要?因为它完美地契合了“栈”这种后进先出数据结构的特性,求值过程清晰、高效,且无需处理优先级和括号。早期的一些计算器(如HP计算器)和许多编程语言的解释器内部都采用逆波兰表示法来处理表达式。

注意:在前缀和后缀表达式中,运算符作用于其之后(前缀)或之前(后缀)最近的两个可用操作数。这个“最近”和“两个”的关系,是理解其无歧义性的关键。

3. 核心转换:从中缀到后缀的“编译”过程

理解了三种表达式的定义后,最关键也最常考的一步来了:如何将我们熟悉的中缀表达式,转换为计算机更易处理的后缀表达式?这个过程模拟了编译器前端的一部分工作。

我们不能凭感觉移动运算符的位置,需要一个系统化的算法。这个算法的核心依然是。我们需要一个栈来存放运算符左括号

3.1 算法步骤与手动推演

假设我们有中缀表达式:A + B * (C - D) / E

我们的目标是得到后缀表达式。我们设定运算符的优先级:*/优先级为2,+-优先级为1,括号具有特殊作用。

算法步骤如下:

  1. 初始化两个空结构:一个用于输出后缀表达式的列表(或字符串),一个用于暂存运算符的栈。
  2. 从左到右扫描中缀表达式的每个元素(操作数、运算符、括号)。
  3. 对每个元素进行处理:
    • 如果是操作数:直接添加到输出列表。
    • 如果是左括号(:将其压入运算符栈。
    • 如果是右括号)
      • 反复将栈顶的运算符弹出并添加到输出列表,直到遇到左括号(
      • 将左括号弹出(丢弃,不输出)。
    • 如果是运算符(记为op
      • 循环判断:当栈不为空,且栈顶运算符的优先级大于或等于op的优先级,且栈顶元素不是左括号(时,将栈顶运算符弹出并添加到输出列表。
      • 循环结束后,将op压入栈中。
  4. 当扫描完整个表达式后,检查运算符栈。将栈中剩余的所有运算符依次弹出并添加到输出列表。
  5. 输出列表连接起来,就是最终的后缀表达式。

让我们手动推演一遍A + B * (C - D) / E

扫描元素运算符栈 (栈底->栈顶)输出列表说明
AA操作数,直接输出。
++A栈空,+入栈。
B+A B操作数,直接输出。
*+ *A B*优先级高于栈顶的+,直接入栈。
(+ * (A B左括号,直接入栈。
C+ * (A B C操作数,直接输出。
-+ * ( -A B C-入栈。
D+ * ( -A B C D操作数,直接输出。
)+ *A B C D -遇到右括号,弹出栈顶至左括号:弹出-输出,弹出(丢弃。
/+ /A B C D - */与栈顶*优先级相等,弹出*输出;/优先级高于新栈顶+/入栈。
E+ /A B C D - * E操作数,直接输出。
结束A B C D - * E / +扫描结束,弹出栈中剩余所有运算符:/,+,依次输出。

最终后缀表达式A B C D - * E / +

你可以用3.2节的后缀求值算法验证一下,这个结果是否与原始中缀表达式等价。

3.2 代码实现与关键细节

理解了原理,我们用代码来实现它。这里以支持+ - * / ( )和整数操作数的简单版本为例。

def infix_to_postfix(infix_expr): """ 将中缀表达式字符串转换为后缀表达式字符串。 假设输入表达式元素间有空格分隔,如 "A + B * ( C - D ) / E" """ # 定义优先级字典 precedence = {'+': 1, '-': 1, '*': 2, '/': 2} output = [] # 输出列表 op_stack = [] # 运算符栈 tokens = infix_expr.split() # 按空格分割表达式 for token in tokens: if token.isalnum(): # 如果是操作数(这里简单判断为字母或数字组合) 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: # token是运算符 + - * / # 关键循环:当栈顶运算符优先级 >= 当前运算符,且不是左括号时 while (op_stack and op_stack[-1] != '(' and precedence.get(op_stack[-1], 0) >= precedence.get(token, 0)): output.append(op_stack.pop()) op_stack.append(token) # 扫描结束,弹出栈中所有剩余运算符 while op_stack: output.append(op_stack.pop()) return ' '.join(output) # 测试 infix = "A + B * ( C - D ) / E" postfix = infix_to_postfix(infix) print(f"中缀表达式: {infix}") print(f"后缀表达式: {postfix}") # 输出: A B C D - * E / +

关键细节与踩坑点:

  1. 优先级比较中的“大于等于”:在while循环判断时,条件是precedence[栈顶] >= precedence[当前]。这意味着当遇到相同优先级的运算符时(如+-*/),也要将栈顶的弹出。这保证了相同优先级的运算符按从左到右的顺序计算(左结合性)。如果只写>,对于A - B - C会得到错误的后缀表达式。
  2. 括号的处理:左括号(在入栈时具有最低的优先级(实际上我们没给它赋值),它只被右括号)匹配弹出。在遇到右括号前,栈中的左括号像一个“屏障”,阻止其下方的运算符被弹出。这是实现括号强制优先级的核心。
  3. 操作数的判断:示例中用了简单的token.isalnum(),实际应用中可能需要更复杂的逻辑来识别负数、小数、函数名或变量名。
  4. 空格分隔:示例要求输入表达式有空格,这是为了简化分词。一个更健壮的实现需要自己编写词法分析器来处理无空格的表达式,如A+B*(C-D)/E,这会涉及更复杂的字符扫描和数字拼接。

4. 后缀表达式的求值:栈的经典舞台

得到后缀表达式后,求值就变得异常简单了。这正是后缀表达式设计的精妙之处:求值算法只需要一个栈,且严格从左到右扫描,无需任何回溯或优先级判断。

4.1 算法详解与示例

我们以刚才得到的后缀表达式A B C D - * E / +为例,假设A=1, B=2, C=3, D=4, E=2

求值算法步骤:

  1. 初始化一个空栈(用于存放操作数)。
  2. 从左到右扫描后缀表达式的每个元素。
  3. 对每个元素:
    • 如果是操作数:将其转换为数值(如果需要)并压入栈中。
    • 如果是运算符
      • 从栈中弹出两个操作数。注意顺序:先弹出的是右操作数(right),后弹出的是左操作数(left)。对于减法和除法,顺序至关重要。
      • 执行运算:left op right
      • 将运算结果压回栈中。
  4. 扫描结束后,栈中应只剩下一个元素,即为表达式的最终结果。

手动求值1 2 3 4 - * 2 / +(其中A=1, B=2, C=3, D=4, E=2):

扫描元素操作数栈 (栈底->栈顶)动作说明
11操作数,入栈。
21 2操作数,入栈。
31 2 3操作数,入栈。
41 2 3 4操作数,入栈。
-1 2 -1弹出4(右),3(左),计算3 - 4 = -1,结果入栈。
*1 -2弹出-1(右),2(左),计算2 * (-1) = -2,结果入栈。
21 -2 2操作数,入栈。
/1 -1弹出2(右),-2(左),计算-2 / 2 = -1,结果入栈。
+0弹出-1(右),1(左),计算1 + (-1) = 0,结果入栈。

最终结果0。验证一下原中缀表达式1 + 2 * (3 - 4) / 2 = 1 + 2 * (-1) / 2 = 1 + (-2) / 2 = 1 + (-1) = 0,结果正确。

4.2 代码实现与错误处理

def evaluate_postfix(postfix_expr, var_dict=None): """ 计算后缀表达式的值。 postfix_expr: 空格分隔的后缀表达式字符串,如 "1 2 3 4 - * 2 / +" var_dict: 可选,变量名到值的映射字典,如 {'A': 1, 'B': 2} """ stack = [] tokens = postfix_expr.split() for token in tokens: if token.replace('.', '', 1).isdigit() or (token[0] == '-' and token[1:].replace('.', '', 1).isdigit()): # 处理整数、小数、负数 stack.append(float(token) if '.' in token else int(token)) elif var_dict and token in var_dict: # 如果是变量,从字典中取值 stack.append(var_dict[token]) else: # 是运算符 if len(stack) < 2: raise ValueError(f"无效的后缀表达式:运算符 {token} 缺少足够的操作数") right = stack.pop() left = stack.pop() if token == '+': result = left + right elif token == '-': result = left - right elif token == '*': result = left * right elif token == '/': if right == 0: raise ZeroDivisionError("除零错误") result = left / right else: raise ValueError(f"不支持的运算符: {token}") stack.append(result) if len(stack) != 1: raise ValueError("无效的后缀表达式:表达式不完整或格式错误") return stack[0] # 测试1:直接计算数值表达式 postfix_num = "1 2 3 4 - * 2 / +" print(f"后缀表达式 '{postfix_num}' 的结果是: {evaluate_postfix(postfix_num)}") # 输出: 0.0 # 测试2:计算含变量的表达式 postfix_var = "A B C D - * E / +" var_values = {'A': 10, 'B': 20, 'C': 5, 'D': 2, 'E': 2} print(f"后缀表达式 '{postfix_var}' (A=10, B=20, C=5, D=2, E=2) 的结果是: {evaluate_postfix(postfix_var, var_values)}") # 计算: 10 + 20 * (5-2) / 2 = 10 + 20*3/2 = 10+30 = 40 # 输出: 40.0

关键细节与踩坑点:

  1. 操作数弹出顺序:这是最容易出错的地方。栈是后进先出,所以当遇到运算符时,先弹出的是右操作数,后弹出的是左操作数。对于加法和乘法,顺序不影响结果;但对于减法和除法,left - rightleft / right才是正确的。
  2. 错误处理:一个健壮的求值器必须处理错误情况:
    • 操作数不足:当遇到运算符时,栈中元素少于2个。
    • 除零错误:在除法运算中,右操作数为0。
    • 无效运算符:遇到了未定义的运算符。
    • 表达式不完整:扫描结束后,栈中元素数量不为1(可能多也可能少)。
  3. 数据类型:示例中统一使用了float来容纳除法和可能的小数结果。在实际应用中,可能需要根据需求区分整数和浮点数运算。
  4. 变量替换:如果后缀表达式包含变量(如A, B, C),需要在求值前提供一个变量名到具体数值的映射字典。

5. 前缀表达式的求值与转换

虽然前缀表达式不如后缀表达式常用,但理解其求值和与中缀的转换也是完整的知识闭环。

5.1 前缀表达式求值

前缀表达式求值是从右向左扫描,同样使用一个栈。但与后缀求值栈存操作数不同,前缀求值栈通常用来存中间结果,不过更直观的方法是递归求值或使用操作数栈并从右向左扫描。

从右向左扫描的算法:

  1. 初始化一个空栈(操作数栈)。
  2. 从右向左扫描前缀表达式。
  3. 对每个元素:
    • 如果是操作数:压入栈中。
    • 如果是运算符:从栈中弹出两个操作数(注意顺序!此时先弹出的是左操作数,后弹出的是右操作数,因为扫描方向反了),执行运算左操作数 op 右操作数,将结果压回栈中。
  4. 扫描结束后,栈顶元素即为结果。

示例:前缀表达式* + 1 2 3(等价于中缀(1+2)*3

  • 从右向左扫描:3(入栈) ->2(入栈) ->1(入栈) ->+(弹出12,计算1+2=3,入栈) ->*(弹出33,计算3*3=9,入栈)。结果9

5.2 中缀转前缀算法

中缀转前缀的算法思路与转后缀类似,但更复杂一些,通常有两种方法:

  1. 方法一(推荐):先将中缀表达式反转,然后按照类似中缀转后缀的算法处理(但需要调整括号和优先级比较的方向),得到的结果再反转回来。这是因为前缀表达式是“运算符-操作数-操作数”的结构,从右向左处理更自然。
  2. 方法二(递归):找到中缀表达式中最后计算的运算符(即优先级最低且最靠右的运算符,括号外),以此运算符为根,递归地将其左右两部分转换为前缀表达式。

由于前缀表达式在实际应用中较少,且转换算法相对繁琐,这里不展开详细代码实现。理解其与后缀表达式的对称性以及“从右向左”的特性更为重要。

6. 实际应用场景与扩展思考

学完了原理和算法,这些知识到底用在哪里呢?绝不仅仅是应付考试。

  1. 计算器与表达式求值:这是最直接的应用。许多编程语言(如Forth、PostScript)和早期硬件计算器直接使用逆波兰表示法。现代编程语言的解释器和编译器在解析表达式时,内部也会先将中缀表达式转换为一种类似后缀的中间表示(如抽象语法树的三地址码),再进行求值或优化。
  2. 编译原理:中缀转后缀的算法是编译器“语法分析”阶段的一个简化模型。编译器需要将源代码中的复杂表达式解析成计算机能顺序执行的指令序列,这个过程中就需要处理运算符优先级、结合性和括号。
  3. 调度场算法:我们实现的中缀转后缀算法,其核心思想就是著名的“调度场算法”。它像铁路调度场一样,将操作数(车厢)直接输出到正确的轨道,将运算符(车头)暂时存放在栈(侧线)上,等待合适的时机再输出。
  4. 函数式编程:前缀表达式(+ 1 2)这种形式,与Lisp、Scheme等函数式编程语言的语法非常相似。在这些语言中,函数调用本身就是前缀形式的。
  5. 解决特定问题:有些问题天然适合用栈来处理表达式。例如,LeetCode上就有多道关于基本计算器(实现加减乘除和括号)的题目,其核心解决方案就是中缀转后缀再求值,或者用双栈直接模拟。

扩展思考:

  • 如何处理一元运算符?例如负号-A或阶乘A!。这需要在词法分析时区分一元和二元减号,并在转换和求值算法中为它们定义不同的逻辑。
  • 如何支持函数调用?例如max(A, B+C)。函数名可以视为一个特殊的运算符,其“操作数”是括号内的参数列表(本身可能又是一个表达式),这需要更复杂的语法树来构建。
  • 如何支持赋值运算符?例如A = B + C。这涉及到表达式求值和变量存储两个不同的阶段。

我自己在第一次实现这个算法时,曾在优先级判断的“大于等于”上栽过跟头,写成了“大于”,导致一连串的同级运算符(如1-2-3)计算顺序错误。调试了很久才发现是结合性没处理好。另一个坑是在处理多位数和负数时,简单的按字符分割会出问题,“-123”会被分成‘-‘,‘1’,‘2’,‘3’,必须实现一个完整的词法分析器来正确识别数字和运算符。这些细节恰恰是区分“懂了原理”和“能写出健壮代码”的关键。