在 MoonBit 中实现 Shunting Yard 算法

什么是 Shunting Yard 算法?
在编程语言或解释器的实现中,如何处理数学表达式一直是一个经典问题。我们希望能够像人一样理解“中缀表达式”(如 3 + 4 * 2),并正确考虑运算符优先级与括号。
1961 年,Edsger Dijkstra 提出了著名的 Shunting Yard 算法,它提供了一种机械化的方式来将中缀表达式转换为后缀表达式(RPN)或抽象语法树(AST)。算法的名字来源于铁路编组场:火车车厢通过在轨道之间来回调度实现排序,而在表达式处理中,我们通过两个栈来存储和调度操作数与操作符。想象一下你在脑子里计算 3 + 4 * 2 的过程:
- 你知道乘法优先级更高,所以要先算 4 * 2。
- 在此过程中,你会把前面的 3 和 + 临时“记在心里”。
- 等乘法结果出来,再把它和 3 相加。
Dijkstra 的洞见在于:这种人类计算时“临时记住某些东西再回来处理”的思维过程,其实可以用栈(stack)来模拟。就像铁路编组场会把火车车厢先临时停放在侧轨,再根据需要调度一样,算法通过把数字和运算符在不同的栈之间移动,来实现对运算顺序的控制。“Shunting Yard”(调度场算法)的名字正是来自这种铁路类比:
- 火车车厢通过在轨道间移动来完成排序;
- 数学表达式中的运算符和数字,也可以通过在栈之间移动来完成 正确的排序与计算。
Dijkstra 把我们人类零散、混乱的计算过程,抽象成了一个清晰、机械化的流程,让计算机也能按照同样的逻辑来处理算式。
Shunting Yard 算法的基本流程
Shunting Yard 算法通过维护两个栈来保证表达式按照正确的优先级和结合性进行解析:
-
初始化
建立两个空栈:
- 运算符栈(op_stack),用于临时存放尚未处理的运算符和括号;
- 值栈(val_stack),用于存放操作数以及已经构造好的部分子表达式。
-
逐一扫描输入 token
-
若 token 为数字或变量:直接压入 val_stack。
-
若 token 为运算符:
- 检查 op_stack 栈顶元素。
- 当且仅当栈顶运算符的优先级高于当前运算符,或优先级相等且为左结合时,将该栈顶运算符弹出,并结合 val_stack 中的两个操作数,合成一个新的子表达式,再压回 val_stack。
- 重复此过程,直到不满足条件为止,然后将当前运算符压入 op_stack。
-
若 token 为左括号:压入 op_stack,作为分界标记。
-
若 token 为右括号:不断从 op_stack 弹出运算符,并用 val_stack 顶部的操作数组合成子表达式,直到遇到左括号为止;左括号本身丢弃,不会进入 val_stack。
-
-
清空运算符栈
当所有 token 扫描完成后,若 op_stack 中仍有运算符,则依次弹出, 并与 val_stack 中的操作数组合成更大的表达式,直到运算符栈为空。
-
结束条件
最终,val_stack 中应只剩下一个元素,该元素即为完整的抽象语法树或后缀表达式。若栈内元素数量不为一,或存在未匹配的括号,则说明输入表达式存在错误。
演算示例
接下来我们以解析 (1 + 2) * (3 - 4) ^ 2 为例,来展示在读入 token 的过程中,两个栈是如何变化的,从而更好地理解 Shunting Yard 算法:
| 步骤 | 读入 token | 运算符栈(op_stack) | 值栈(val_stack) | 说明 |
|---|---|---|---|---|
| 1 | ( | [(] | [] | 左括号压入运算符栈 |
| 2 | 1 | [(] | [1] | 数字压入值栈 |
| 3 | + | [(, +] | [1] | 运算符压入运算符栈 |
| 4 | 2 | [(, +] | [1, 2] | 数字压入值栈 |
| 5 | ) | [] | [1 + 2] | 弹出直到左括号:1 与 2 结合为 1+2 |
| 6 | * | [*] | [1 + 2] | 运算符压入运算符栈 |
| 7 | ( | [*, (] | [1 + 2] | 左括号压入运算符栈 |
| 8 | 3 | [*, (] | [1 + 2, 3] | 数 字压入值栈 |
| 9 | - | [*, (, -] | [1 + 2, 3] | 运算符压入运算符栈 |
| 10 | 4 | [*, (, -] | [1 + 2, 3, 4] | 数字压入值栈 |
| 11 | ) | [*] | [1 + 2, 3 - 4] | 弹出直到左括号:3 与 4 结合为 3-4 |
| 12 | ^ | [*, ^] | [1 + 2, 3 - 4] | 幂运算符压入栈(右结合,不会触发弹出) |
| 13 | 2 | [*, ^] | [1 + 2, 3 - 4, 2] | 数字压入值栈 |
| 14 | 输入结束 | [] | [(1 + 2) * (3 - 4) ^ 2] | 清空运算符栈:先弹出 ^,结合 3-4 与 2;再弹出 *,结合 1+2 与结果 |
在这个例子中,有以下几点值得注意:
-
括号优先处理 在第一组括号
(1 + 2)中,运算符+被延迟存放在运算符栈中,直到遇到右括号才与 1 和 2 结合。第二组括号(3 - 4)的处理过程完全相同。 -
优先级的体现 当遇到
*时,它被压入运算符栈。但随后遇到幂运算符^时,由于^的优先级高于*,且是右结合,因此直接压栈,而不会触发*的弹出。 -
结合性的作用 幂运算符
^通常定义为右结合,这意味着表达式a ^ b ^ c会被解析为a ^ (b ^ c)。在本例中,(3-4) ^ 2保持这种结合方式,正确构建了子表达式。 -
最终结果 在输入结束后,运算符栈依次被清空,最终形成完整的表达式:
(1 + 2) * ((3 - 4) ^ 2)
在 MoonBit 中实现 Shunting Yard 算法
首先我们需要定义表达式和 token 的类型:
enum Expr {
(Int) -> Expr
Literal(Int
Int)
(String, Expr, Expr) -> Expr
BinExpr(String
String, enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr, enum Expr {
Literal(Int)
BinExpr(String, Expr, Expr)
} derive(Show)
Expr)
} derive(trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
enum Token {
(Int) -> Token
Literal(Int
Int)
(String) -> Token
Op(String
String)
Token
LeftParen
Token
RightParen
} derive(trait Show {
output(Self, &Logger) -> Unit
to_string(Self) -> String
}
Trait for types that can be converted to String
Show)
我们可以利用 MoonBit 中的正则表达式匹配语法,快速的实现一个简单的 tokenizer:
pub fn fn tokenize(input : StringView) -> Array[Token] raise
tokenize(StringView
input : type StringView
StringView) -> type Array[T]
An Array is a collection of values that supports random access and can
grow in size.
Array[enum Token {
Literal(Int)
Op(String)
LeftParen
RightParen
} derive(Show)
Token] raise {
let Array[Unit]
tokens = []
for StringView
str = StringView
input {
lexmatch StringView
str {
"[0-9]+" as n, rest => {
tokens.push(Token::Literal(@strconv.parse_int(n)))
continue rest
}
Unit
"[\-+*/^]" as o, rest => {
tokens.push(Token::Op(o.to_string()))
continue StringView
rest
}
"\(", rest => {
tokens.push(Token::LeftParen)
continue Unit
rest
}
"\)", rest => {
tokens.push(Token::RightParen)
continue rest
}
"[ \n\r\t]+", rest => continue rest
"$", _ => break
_ => fail("Invalid input")
}
}
tokens
}
tokenize 函数的作用是把输入字符串分割成一系列 token:
- 匹配数字
[0-9]+,转换为 Token::Literal; - 匹配四则运算符和幂运算符
[-+*/^],转换为 Token::Op; - 匹配括号
(与),分别转换为 LeftParen 和 RightParen; - 匹配空格、换行等空白字符则直接跳过;
- 如果遇到不符合规则的字符,则报错。 通过 lexmatch 和正则表达式,整个分词过程既简洁又高效。
接下来我们定义一个全局的操作符表,用于存储操作符的优先级和结合性:
priv enum Associativity {
Associativity
Left
Associativity
Right
}
priv struct OpInfo {
Int
precedence : Int
Int
Associativity
associativity : enum Associativity {
Left
Right
}
Associativity
}
let Map[String, OpInfo]
op_table : struct Map[K, V] {
// private fields
}
Mutable linked hash map that maintains the order of insertion, not thread safe.
Example
test {
let map = { 3: "three", 8: "eight", 1: "one" }
assert_eq(map.get(2), None)
assert_eq(map.get(3), Some("three"))
map.set(3, "updated")
assert_eq(map.get(3), Some("updated"))
}
Map[String
String, struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo] = {
"+": { Int
precedence: 10, Associativity
associativity: Associativity
Left },
"-": { Int
precedence: 10, Associativity
associativity: Associativity
Left },
"*": { Int
precedence: 20, Associativity
associativity: Associativity
Left },
"/": { Int
precedence: 20, Associativity
associativity: Associativity
Left },
"^": { Int
precedence: 30, Associativity
associativity: Associativity
Right },
}
这里通过 op_table 定义了常见 运算符的优先级与结合性:
+和-的优先级最低(10),是左结合;
-
*和/的优先级更高(20),同样是左结合; -
^(幂运算)的优先级最高(30),但它是右结合。
接下来我们定义一个辅助函数,用于判断在遇到一个新的运算符时,是否需要先处理(弹出)栈顶的运算符:
fn fn should_pop(top_op_info~ : OpInfo, incoming_op_info~ : OpInfo) -> Bool
should_pop(OpInfo
top_op_info~ : struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo, OpInfo
incoming_op_info~ : struct OpInfo {
precedence: Int
associativity: Associativity
}
OpInfo) -> Bool
Bool {
OpInfo
top_op_info.Int
precedence fn Compare::op_gt(x : Int, y : Int) -> Bool
> OpInfo
incoming_op_info.Int
precedence (Bool, Bool) -> Bool
||
(
OpInfo
top_op_info.Int
precedence fn Eq::equal(self : Int, other : Int) -> Bool
Compares two integers for equality.
Parameters:
self : The first integer to compare.
other : The second integer to compare.
Returns true if both integers have the same value, false otherwise.
Example:
test {
inspect(42 == 42, content="true")
inspect(42 == -42, content="false")
}
== OpInfo
incoming_op_info.Int
precedence (Bool, Bool) -> Bool
&&
OpInfo
top_op_info.Associativity
associativity is Associativity
Left
)
}
should_pop 的逻辑是 Shunting Yard 算法的核心之一:
- 如果栈顶运算符的优先级高于新运算符,则应当先处理栈顶运算符;
- 如果两者优先级相等,并且栈顶运算符是左结合,同样应当先处理栈顶运算符;
- 否则,保留栈顶运算符,把新运算符直接压入栈。