想象你是一位掌管后厨的大厨。炉子上有一口锅(分析栈),案板上摆着一排待下锅的食材(输入串)。你的任务不是凭感觉炒菜,而是按照一本写满工序的菜谱(文法)决定:这一铲该继续煸炒,还是该出锅装盘?有的食材要等一等再下锅,有的食材一遇到后面的食材就得立刻盛出来。每一次“下锅”叫移进,每一次“出锅装盘”叫归约。而支撑你做出决断的,是一张写满食材之间“谁让谁”关系的速查表——这就是算符优先分析:一种自底向上的移进归约语法分析方法。 它不是看整锅菜的复杂结构,而是只靠相邻两个终结符(基础食材)之间的优先关系,就能决定动作。这个方法经典、轻量、直观,是理解编译器前端从文法到解析器实现的一条捷径。我们将从菜谱的规则讲起,逐步走到优先矩阵、移进归约决策、优先函数优化,最后看看 Yacc 等工具如何把这种思想变成工业级解析器。

岗位关联

掌握算符优先分析,你就看穿了语法分析中最朴素也最实用的一类决策机制:用符号间的优先级关系驱动整个归约引擎。这是学习任何解析器实现、乃至设计自定义 DSL 时绕不开的底层直觉。

目录
  1. 可视化演示
  2. 算符文法定义
  3. 优先关系判定
  4. FIRSTVT计算
  5. LASTVT计算
  6. 优先矩阵构造
  7. 移进归约算法
  8. 优先函数优化
  9. 错误处理工具
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

算符优先分析是一种自底向上的移进归约方法,通过定义算符间的优先关系构建优先矩阵。本文详解算符文法的定义条件、优... 切换模式查看不同维度。

本节配有交互动效(进入视口后自动加载,约 3KB)。

算符优先分析:核心原理 动态展示关键流程与数据流动 交互控件调节参数实时渲染 f(x) = transform(x) 核心概念的可视化呈现

算符文法定义

一本菜谱的第一页通常写着“本菜谱适用于中餐家庭小炒”。同样,算符优先分析法也不是对任何文法都适用,它只服务一类特殊文法——算符文法。想象大厨手里的菜谱有一条铁规矩:在任何一道工序里,两样生食材不能肩并肩直接下锅,中间必须隔着一层“调料工序”(非终结符)。只要这条规矩成立,大厨就能仅凭食材之间(终结符之间)的关系完成所有决策,不需要关心复杂的工序内部结构。

形式化地,文法 G 是一个四元组 $G=(V_T, V_N, S, P)$。$V_T$ 里的终结符是基础食材(如 +、*、i、(、)),$V_N$ 里的非终结符是工序名(如 E、T、F),P 是菜谱工序(产生式)的集合。算符文法要求:对 P 中任何产生式,右部都不会出现相邻两个终结符。例如 $E \rightarrow E+T$ 中,+ 的左边是 E、右边是 T,没有哪两个终结符直接贴在一起。这个条件看似苛刻,却恰好保证了分析过程“只看终结符”就能走通。

有了“不相邻”的规矩,大厨才能只靠看食材的先后手指挥全局,这是整套分析法坚实地基。它直接导致一个推论:非终结符在推导串中只扮演“缓冲垫”的角色,归约时不必深究它的名字——这让算法可以做得极其轻巧,但代价是分析能力受限。

G=(VT,VN,S,P),  ∀(U→α)∈P,  α 中无相邻终结符对 ab  (a,b∈VT)G = (V_T, V_N, S, P),\; \forall (U \rightarrow \alpha) \in P,\; \alpha \text{ 中无相邻终结符对 } ab \;(a,b \in V_T)
一句话:算符文法就是“终结符不许手拉手”的文法,它是上下文无关文法的真子集。
  1. 文法四元组文法由四个部分构成,终结符集、非终结符集、开始符号和产生式集合,这是描述一切文法的基础框架。
    G=(VT,VN,S,P)G = (V_T, V_N, S, P)
  2. 遍历产生式对产生式集合 P 中的任意一条产生式 $U \rightarrow \alpha$ 都要接受检查,抽查任何一条都不算数。
    ∀(U→α)∈P\forall (U \rightarrow \alpha) \in P
  3. 相邻禁令右部符号串 $\alpha$ 中不允许出现相邻两符号都是终结符的情形,这是算符文法区别于一般文法的标志性约束。
    α≠⋯ab⋯  (a,b∈VT)\alpha \neq \cdots a b \cdots \;(a,b \in V_T)

优先关系判定

有了菜谱,大厨还需要知道每样食材谁先下锅,于是定义三种“下锅先后”关系:$a \lessdot b$ 表示 a 得等 b(b 先下锅,移进 b);$a \gtrdot b$ 表示 a 先下锅(随后立即归约);$a \equiv b$ 表示两样食材同时下锅、一同出锅。注意这是语法规格层面的“先手关系”,与数学上的大小比较无关。

三种关系从哪里来?都藏在产生式结构里。等于关系最容易判定:若产生式右部出现“终结符 a 紧跟终结符 b”(如 $U \rightarrow \cdots ab \cdots$),或“终结符 a 隔着非终结符 V 再碰终结符 b”(如 $U \rightarrow \cdots aVb \cdots$),则记 $a \equiv b$。例如 $F \rightarrow (E)$ 中 ( 与 ) 之间隔着一个非终结符 E,所以 ( 与 ) 是 $\equiv$ 关系。小于关系和大于关系则需要借助两个集合:FIRSTVT(首终结符集)和 LASTVT(尾终结符集),下一节展开。这里先给出小于关系的判定公式:

大厨在冰箱上贴出这批关系规则后,每当锅铲遇见“终结符 a + 工序 V”的组合,就知道 V 产出的一切首味都要给 a 让道。三种关系并非靠直觉规定,而是完全由产生式的骨架派生,这保证了分析器可以机械化地构造。

a⋖b  ⟺  ∃ U→⋯ a V ⋯∈P,  b∈FIRSTVT(V)a \lessdot b \iff \exists\, U \rightarrow \cdots\, a\, V\, \cdots \in P,\; b \in FIRSTVT(V)
通俗地讲:只要产生式右部出现“终结符 a 后面跟着工序 V”,那么工序 V 所能产出的所有“首味终结符 b”都得排在 a 前面。
  1. 左边是结论$a \lessdot b$ 表示 a 的优先级低于 b,当栈顶终结符与输入终结符相遇时,b 会被优先移进。
    a⋖ba \lessdot b
  2. 相位条件存在某条产生式,其右部某个位置上是终结符 a 后紧跟一个非终结符 V,二者之间没有其他符号插入。
    U→⋯ a V ⋯U \rightarrow \cdots\, a\, V\, \cdots
  3. 首味匹配b 恰好属于 V 的首终结符集合,即 V 一开工最先呈现出的终结符之一。
    b∈FIRSTVT(V)b \in FIRSTVT(V)
  4. 充要关系以上条件全部满足时,记 a 与 b 为小于关系,三者缺一不可。
    a⋖b  ⟺  ∃U→⋯aV⋯ ,  b∈FIRSTVT(V)a \lessdot b \iff \exists U \rightarrow \cdots a V \cdots,\; b \in FIRSTVT(V)

FIRSTVT计算

FIRSTVT 全称 First Terminal Set,即“首终结符集”。回到厨房:每种工序(非终结符)一开始运转,锅里最先飘出的香气是哪一味?FIRSTVT(U) 记录的就是从 U 出发能够“最先见到的终结符”清单。它是上一节 $\lessdot$ 关系的燃料——没有这份清单,小于关系就无处落笔。

定义:U 经过一步或多步推导,得到的符号串若以终结符 b 开头,或形如“非终结符 V 紧跟终结符 b”开头,则 b 进入 FIRSTVT(U)。计算时只有两条规则:直接规则——若有产生式 $U \rightarrow a\cdots$ 或 $U \rightarrow Va\cdots$,则 a 直接加入 FIRSTVT(U);传递规则——若有产生式 $U \rightarrow V\cdots$,则 FIRSTVT(V) 全部并入 FIRSTVT(U)。两条规则不断迭代,直到所有集合不再变化为止,这个过程称为“迭代至不动点”。

以经典文法 $E \rightarrow E+T \mid T$、$T \rightarrow T*F \mid F$、$F \rightarrow (E) \mid i$ 为例,算得 $FIRSTVT(F)=\{(, i\}$,$FIRSTVT(T)=\{*, (, i\}$,$FIRSTVT(E)=\{+, *, (, i\}$。有了首味清单,大厨查“一开工第一口是什么”只需一瞥,$\lessdot$ 关系的所有原材料都已备齐。

FIRSTVT(U)={ b∣U⇒+b⋯   或   U⇒+Vb⋯ }FIRSTVT(U) = \{\, b \mid U \Rightarrow^{+} b\cdots \;\text{ 或 }\; U \Rightarrow^{+} V b\cdots \,\}
两条规则合起来就是:工序 U 把原料推进锅后,第一缕香气要么直接来自某个终结符,要么来自它调用的第一个子工序。
  1. 集合定义FIRSTVT(U) 是一个终结符集合,收集从 U 推导出的所有“串首终结符”。
    FIRSTVT(U)={ b∣⋯ }FIRSTVT(U) = \{\, b \mid \cdots \,\}
  2. 直接情形U 经过至少一步推导,直接得到一个以终结符 b 开头的符号串。
    U⇒+b⋯U \Rightarrow^{+} b \cdots
  3. 隔位情形推导结果开头是“非终结符 V 后紧跟终结符 b”的形式,b 同样算作串首终结符。
    U⇒+Vb⋯U \Rightarrow^{+} V b \cdots
  4. 集合合并凡满足上述任一情形的 b 都收进集合,反复用传递规则扩散,直到集合不再增大。

LASTVT计算

与 FIRSTVT 相对,LASTVT 是“串尾终结符集”,记录工序完成装盘前留在锅底的最后一位味道。它与 FIRSTVT 完全对称:一个管开头,一个管结尾,两者拼起来才覆盖 $\lessdot$ 与 $\gtrdot$ 的全部判定需求。

LASTVT 的定义与计算规则完全镜像 FIRSTVT:U 推导出的串若以终结符 a 结尾,或形如“终结符 a 后跟非终结符 V”结尾,则 a 属于 LASTVT(U)。直接规则——若有产生式 $U \rightarrow \cdots a$ 或 $U \rightarrow \cdots aV$,则 a 直接加入 LASTVT(U);传递规则——若有产生式 $U \rightarrow \cdots V$,则 LASTVT(V) 全部并入 LASTVT(U)。同样迭代至不动点。

对同样文法计算得到 $LASTVT(F)=\{), i\}$,$LASTVT(T)=\{*, ), i\}$,$LASTVT(E)=\{+, *, ), i\}$。你会发现尾味清单几乎就是首味清单的“镜像投影”——把 ( 换成 ),把开头的 + 保留到结尾。这份对称性正是算符优先分析优雅之处:记住一套算法,两套集合都能算。

LASTVT(U)={ a∣U⇒+⋯a   或   U⇒+⋯aV }LASTVT(U) = \{\, a \mid U \Rightarrow^{+} \cdots a \;\text{ 或 }\; U \Rightarrow^{+} \cdots a V \,\}
首味清单算完,再把尾味清单照葫芦画瓢算一遍,两张清单在手,所有优先关系的判定材料就齐了。
  1. 串尾终结符LASTVT(U) 收集从 U 推导出的所有“串尾终结符”,与 FIRSTVT 呈镜像对称。
    LASTVT(U)={ a∣⋯ }LASTVT(U) = \{\, a \mid \cdots \,\}
  2. 直接结尾U 推导出的串以终结符 a 收尾,a 直接入选。
    U⇒+⋯aU \Rightarrow^{+} \cdots a
  3. 隔位收尾推导串末尾是“终结符 a 后跟非终结符 V”形式,a 同样入选。
    U⇒+⋯aVU \Rightarrow^{+} \cdots a V
  4. 尾味扩散不断用传递规则把子工序的尾味并入父工序,直到集合稳定。

优先矩阵构造

食材的关系规则攒了一堆,大厨把它们整理成一张“食材搭配速查表”——优先矩阵。矩阵的行和列都是终结符(外加结束符 #),交叉格子里填三者之一:$\lessdot$、$\equiv$、$\gtrdot$,没有关系的格子留空。查询时,行是栈顶终结符,列是当前输入终结符,一眼就能决定动作。

构造算法分三步走。第一步,求所有非终结符的 FIRSTVT 与 LASTVT。第二步,逐条扫描产生式右部:出现相邻终结符对或“a V b“模式则填 $\equiv$;出现”终结符 a 后跟非终结符 V“模式,对 FIRSTVT(V) 的每个 b 填 $a \lessdot b$;出现”非终结符 V 后跟终结符 b”模式,对 LASTVT(V) 的每个 a 填 $a \gtrdot b$。第三步,补结束符 #:# 对文法开始符号 S 的首终结符集合全填 $\lessdot$,S 的尾终结符集合对 # 全填 $\gtrdot$,# 对 # 填 $\equiv$。若同一格子被填上两个不同关系,该文法就不是算符优先文法——这是冲突检测,一票否决。

以经典表达式文法生成的 6×6 优先矩阵如下(# 为结束符):

| 关系 | + | * | i | ( | ) | # |

| --- | --- | --- | --- | --- | --- | --- |

| + | ⋗ | ⋖ | ⋖ | ⋖ | ⋗ | ⋗ |

| * | ⋗ | ⋗ | ⋖ | ⋖ | ⋗ | ⋗ |

| i | ⋗ | ⋗ | — | — | ⋗ | ⋗ |

| ( | ⋖ | ⋖ | ⋖ | ⋖ | ≡ | — |

| ) | ⋗ | ⋗ | — | — | ⋗ | ⋗ |

| # | ⋖ | ⋖ | ⋖ | ⋖ | — | ≡ |

这张表就是大厨贴在灶台旁的“终极速查表”:炒到任何一步,只需看栈顶调味料与案板下一位食材的交叉格,动作一目了然。构造过程完全机械化,因此优先矩阵适合用程序自动生成——这正是我们稍后会在 Python 演示中做的事。

U→⋯ a V ⋯  ⟹  ∀b∈FIRSTVT(V):  a⋖bU \rightarrow \cdots\, a\, V\, \cdots \;\Longrightarrow\; \forall b \in FIRSTVT(V):\; a \lessdot b
一条产生式就像一行菜谱工序,出现“a 后跟 V”就批量给 a 与 V 的所有首味之间画上“让路”标记。
  1. 前提模式扫描产生式右部时发现终结符 a 紧挨着非终结符 V。
    U→⋯ a V ⋯U \rightarrow \cdots\, a\, V\, \cdots
  2. 展开首味取出 V 的 FIRSTVT 集合,集合里每一个终结符 b 都无条件参与填表。
    ∀b∈FIRSTVT(V)\forall b \in FIRSTVT(V)
  3. 填入小于在矩阵位置 $\text{行}=a$、$\text{列}=b$ 处写入 $\lessdot$ 符号。
    a⋖ba \lessdot b

移进归约算法

有了速查表,大厨开始正式掌勺。分析器维护一个栈(锅)与输入缓冲区(案板),栈底和输入末尾都放哨兵 #。每一轮动作只看两样东西:栈中最近的终结符(栈顶终结符)与当前输入符号,查表后四选一:$\lessdot$ 或 $\equiv$ 则移进(把输入符号铲入锅中),$\gtrdot$ 则归约(把锅中最左素短语盛出装盘,压入非终结符),# 对 # 则接受,查不到关系则报语法错误。

这里关键的“出锅”对象叫最左素短语。素短语是句型中一个至少含一个终结符、且自身不再包含更小素短语的短语。它在栈中呈现固定形态:以终结符 $a_i$ 开头,以终结符 $a_j$ 结尾,中间夹着若干非终结符与终结符,且满足“左界 $\lessdot$、内部 $\equiv$、右界 $\gtrdot$”:

以 $i+i*i$ 为例:初始栈 #、输入 i+i*i#,查表 # ⋖ i 移进;栈 #i、输入首 +,查表 i ⋗ +,归约 i→N;随后 # ⋖ + 移进……如此反复,直到 # 与 # 相遇时接受。整个过程中大厨从来不问“这锅菜叫什么名”,只认关系符号,这就是算符优先分析的风格:轻快、机械、无需记忆状态。

Ni−1 ai Ni ai+1 Ni+1 ⋯ Nj aj Nj+1,ai−1⋖ai,  ai≡ai+1≡⋯≡aj,  aj⋗aj+1N_{i-1}\, a_i\, N_i\, a_{i+1}\, N_{i+1}\, \cdots\, N_j\, a_j\, N_{j+1},\quad a_{i-1} \lessdot a_i,\; a_i \equiv a_{i+1} \equiv \cdots \equiv a_j,\; a_j \gtrdot a_{j+1}
素短语就是锅里“恰好熟透、必须立刻盛出”的那一小堆食材,边界的三个关系符号就是“熟了”的信号灯。
  1. 形态骨架素短语在栈中呈现“终结符打头、终结符收尾、非终结符夹心”的统一形状。
    Ni−1 ai Ni ai+1 Ni+1 ⋯ Nj aj Nj+1N_{i-1}\, a_i\, N_i\, a_{i+1}\, N_{i+1}\, \cdots\, N_j\, a_j\, N_{j+1}
  2. 左界信号$a_{i-1} \lessdot a_i$ 表示素短语左侧已封闭,前面的符号不能与它一起出锅。
    ai−1⋖aia_{i-1} \lessdot a_i
  3. 内部等连$a_i$ 到 $a_j$ 之间的终结符两两 $\equiv$,说明这些食材同时下锅、必须同锅出锅。
    ai≡ai+1≡⋯≡aja_i \equiv a_{i+1} \equiv \cdots \equiv a_j
  4. 右界触发$a_j \gtrdot a_{j+1}$ 是归约的直接触发条件,右界一出现就执行归约动作。
    aj⋗aj+1a_j \gtrdot a_{j+1}

优先函数优化

矩阵虽然直观,但查表意味着保存一张 $n \times n$ 的二维表格。大厨的灶台有限,能不能更省?优先函数是一种压缩技巧:给每个终结符 a 派发两个整数 $f(a)$、$g(a)$,用数值比较代替矩阵查询。若 a 所在行和 b 所在列的交叉关系是 $\lessdot$,则要求 $f(a) < g(b)$;$\equiv$ 则 $f(a) = g(b)$;$\gtrdot$ 则 $f(a) > g(b)$。查询时只需 O(1) 次整数比较,空间从 $n^2$ 降到 $2n$。

构造方法是迭代打分:先把所有 f、g 设为 1,然后反复扫描矩阵——若 $a \lessdot b$ 但当前 $f(a) \ge g(b)$,就把 $g(b)$ 抬到 $f(a)+1$;若 $a \gtrdot b$ 但 $f(a) \le g(b)$,就把 $f(a)$ 抬到 $g(b)+1$;直到所有条件满足。若迭代步数超过 $2n$ 仍未收敛,说明原矩阵不存在等价优先函数。

优先函数不仅节省空间,还能借助整数比较提升解析器速度,是早期编译器中的经典优化。但代价也存在:数值比较会把矩阵中“无关系”的空格也强行赋予大小,从而掩盖某些本应报错的组合——因此工业实现中保留冲突检测机制尤为重要。

f(a) < g(b)\; \text{当 } a \lessdot b, \quadf(a) = g(b)\; \text{当 } a \equiv b, \quadf(a) > g(b)\; \text{当 } a \gtrdot b
优先函数像给每样食材贴了两张温度标签:一张管“别人等我”(f),一张管“我等别人”(g),比关系一看温度高低便知。
  1. 函数分派每个终结符对应两个整数 f 与 g,f 对应“行角色”,g 对应“列角色”。
    a↦(f(a), g(a))a \mapsto (f(a),\, g(a))
  2. 小于映射凡是矩阵中 $a \lessdot b$ 的位置,函数值必须严格满足 f(a) 小于 g(b)。
    a⋖b⇒f(a)<g(b)a \lessdot b \Rightarrow f(a) < g(b)
  3. 等于映射凡是 $a \equiv b$ 的位置,两个函数值必须相等。
    a≡b⇒f(a)=g(b)a \equiv b \Rightarrow f(a) = g(b)
  4. 大于映射凡是 $a \gtrdot b$ 的位置,f(a) 必须严格大于 g(b)。
    a⋗b⇒f(a)>g(b)a \gtrdot b \Rightarrow f(a) > g(b)

错误处理工具

大厨也有失手的时候:某一步查表发现交叉格是空的,没有任何优先关系——这意味着锅里的食材搭配根本不在菜谱上,即输入串存在语法错误。算符优先分析的错误处理就像“尝一口发现问题”。常见策略有三种:跳过输入符号直到找到能与栈顶恢复关系的符号;弹出栈顶符号放弃部分已处理内容;两者结合,先弹栈再跳符号,尽量让分析器回到可继续的轨道。由于算符优先分析不携带状态信息,错误恢复做不到精确定位,但足以满足多数编译场景的“报错并继续”需求。

在工业实践中,Yacc/Bison 这类解析器生成器虽然采用 LALR 分析,却借鉴了同一思想:用户用 %left '+'、%left '*'、%prec UMINUS 等声明终结符优先级,生成器据此化解移进/归约冲突。这与算符优先分析中“用终结符关系决定动作”的哲学一脉相承,区别在于 Yacc 的优先级声明是人为附加的语义信息,而算符优先分析中的关系完全从文法自动导出。

与 LR 分析对比:LR 分析器归约的是句柄(含状态与展望信息),能力更强;算符优先分析器归约的是最左素短语,不看状态,只认终结符关系。前者像一位经验丰富、记得每道菜火候细节的大厨;后者像一位只凭食材搭配速查表就能出菜的快厨——快而稳,但只会做表上的菜。

GOPG⊊GCFGG_{OPG} \subsetneq G_{CFG}
算符优先文法只是上下文无关文法的真子集:能处理的文法更少,但算法更简单、更省空间,适合表达式类语言的快速解析。
  1. 真包含算符优先文法集合是上下文无关文法集合的真子集。
    GOPG⊊GCFGG_{OPG} \subsetneq G_{CFG}
  2. 存在反例必然存在上下文无关文法不属于算符优先文法,如含二义性或终结符相邻的文法。
    ∃G∈GCFG,  G∉GOPG\exists G \in G_{CFG},\; G \notin G_{OPG}
  3. 能力取舍算符优先以牺牲一部分文法表达力,换取简单高效的移进归约驱动机制。
    GOPG⇒只需终结符关系即可驱动分析G_{OPG} \Rightarrow \text{只需终结符关系即可驱动分析}

AI 应用场景

数据库查询优化器的表达式解析

阶段:数据查询预处理与解析

解决的问题:SQL 中 WHERE a + b * c > 10 这类条件表达式,传统做法是完整递归下降解析,代码量大且性能吃紧。用算符优先分析构造轻量表达式解析器,只需一张优先矩阵即可快速产出语法树,特别适合高并发场景下反复解析相似查询的模式。

具体案例:某实时风控系统每秒接收上万条规则表达式,每条规则形如 score > 0.6 * limit + 100。团队用算符优先分析法实现专用解析器,将表达式转为语法树后再交给后续规则引擎求值,解析耗时从微秒级进一步压缩,且文法扩展只需新增矩阵条目。

公式引擎与规则引擎

阶段:规则 DSL 的词法与语法分析

解决的问题:表单校验、风控决策、计费规则中大量出现自定义公式语言。手写递归下降解析器维护成本高,引入完整 Yacc 链路又显得笨重。算符优先分析正好处于两者之间:实现简洁、可自动生成、适合规则数量大但表达式结构规整的场景。

具体案例:某低代码平台的公式编辑器支持用户配置 if(total > 1000, total * 0.1, 0) 类公式。开发者在 Python 中实现 FIRSTVT/LASTVT 计算与优先矩阵构造,自动生成解析器,用户新增运算符时只需在文法表中加一行,平台无需重新发布。

模板引擎的表达式解析

阶段:模板渲染中的表达式求值

解决的问题:Jinja2、FreeMarker 等模板引擎的 {{ user.age + 1 }} 片段需要快速安全地解析。模板表达式语法通常刻意保持简单,正好是算符优先分析的主场——解析器可以嵌入主程序,避免依赖庞大外部解析库。

具体案例:某日志分析平台允许用户用模板表达式提取字段并做简单计算,模板表达式限定为四则运算、比较与括号。工程师用算符优先分析法手写约两百行解析器,替换原有正则替换方案,解决了嵌套括号无法处理的问题,渲染性能提升 40%。

Python 演示

python128 行
# -*- coding: utf-8 -*-"""算符优先分析: 计算 FIRSTVT / LASTVT 并构造优先关系矩阵""" raw = {    'E': [['E', '+', 'T'], ['T']],    'T': [['T', '*', 'F'], ['F']],    'F': [['(', 'E', ')'], ['i']],} terminals = {'#'}nonterminals = set()productions = [] for lhs, rhs_list in raw.items():    nonterminals.add(lhs)    for rhs in rhs_list:        productions.append((lhs, rhs))        for s in rhs:            if s.isupper():                nonterminals.add(s)            else:                terminals.add(s)  def firstvt():    first = {nt: set() for nt in nonterminals}    changed = True    while changed:        changed = False        for lhs, rhs in productions:            first_sym = rhs[0]            if first_sym in terminals:                if first_sym not in first[lhs]:                    first[lhs].add(first_sym)                    changed = True            else:                for t in first[first_sym]:                    if t not in first[lhs]:                        first[lhs].add(t)                        changed = True                if len(rhs) > 1 and rhs[1] in terminals:                    if rhs[1] not in first[lhs]:                        first[lhs].add(rhs[1])                        changed = True    return first  def lastvt():    last = {nt: set() for nt in nonterminals}    changed = True    while changed:        changed = False        for lhs, rhs in productions:            last_sym = rhs[-1]            if last_sym in terminals:                if last_sym not in last[lhs]:                    last[lhs].add(last_sym)                    changed = True            else:                for t in last[last_sym]:                    if t not in last[lhs]:                        last[lhs].add(t)                        changed = True                if len(rhs) > 1 and rhs[-2] in terminals:                    if rhs[-2] not in last[lhs]:                        last[lhs].add(rhs[-2])                        changed = True    return last  def build_relations(first, last):    eq, lt, gt = set(), set(), set()    for lhs, rhs in productions:        for i, s in enumerate(rhs):            if s in terminals:                if i + 1 < len(rhs) and rhs[i + 1] in terminals:                    eq.add((s, rhs[i + 1]))                if (i + 2 < len(rhs) and                        rhs[i + 1] in nonterminals and                        rhs[i + 2] in terminals):                    eq.add((s, rhs[i + 2]))            else:                if i > 0 and rhs[i - 1] in terminals:                    for b in first[s]:                        lt.add((rhs[i - 1], b))                if i + 1 < len(rhs) and rhs[i + 1] in terminals:                    for a in last[s]:                        gt.add((a, rhs[i + 1]))    return eq, lt, gt  def main():    first = firstvt()    last = lastvt()    for nt in sorted(nonterminals):        print('FIRSTVT(%s) = %s' % (nt, sorted(first[nt])))        print('LASTVT(%s)  = %s' % (nt, sorted(last[nt])))     eq, lt, gt = build_relations(first, last)    for b in first['E']:        lt.add(('#', b))    for a in last['E']:        gt.add((a, '#'))    eq.add(('#', '#'))     rel = {}    for a, b in eq:        rel[(a, b)] = '='    for a, b in lt:        rel[(a, b)] = '<'    for a, b in gt:        rel[(a, b)] = '>'     ts = ['#'] + sorted(terminals - {'#'})    print('优先关系矩阵 (< 表示先移进, > 表示可归约, = 表示同煎):')    print('     ' + ''.join('%3s' % c for c in ts))    for a in ts:        line = ['%3s' % a]        for b in ts:            line.append('%3s' % rel.get((a, b), '.'))        print(''.join(line))     conflicts = [p for p in rel if sum(p in s for s in (eq, lt, gt)) > 1]    print('冲突数量:', len(conflicts))  if __name__ == '__main__':    main()
目的:自动计算经典表达式文法的 FIRSTVT、LASTVT 集合,并构造 6×6 优先关系矩阵,同时检测潜在关系冲突。
  1. raw = {...}用嵌套字典表示文法,每个非终结符映射到一个产生式列表。
  2. productions.append((lhs, rhs))把每条产生式展平成二元组,方便后续逐条扫描。
  3. first = {nt: set() for nt in nonterminals}为每个非终结符初始化一个空集合,后续迭代填充。
  4. while changed:只要本轮有新增元素就继续迭代,直到集合稳定(不动点)。
  5. if first_sym in terminals:右部首符号是终结符时,直接按直接规则加入集合。
  6. for t in first[first_sym]:右部首符号是非终结符时,把它已有的 FIRSTVT 全部继承过来(传递规则)。
  7. if len(rhs) > 1 and rhs[1] in terminals:处理 U → V a ... 形式,第二个符号是终结符也直接加入。
  8. last_sym = rhs[-1]LASTVT 与 FIRSTVT 完全对称,从右部末尾开始处理。
  9. lt.add((rhs[i - 1], b))发现“终结符 a 后跟非终结符 V”时,批量填入 a 与 FIRSTVT(V) 的小于关系。
  10. conflicts = [p for p in rel if sum(p in s for s in (eq, lt, gt)) > 1]检查是否有同一对关系被重复标记,用于冲突检测。

常见误区

误区我一直以为算符优先分析就是给 `+`、`*` 这些运算符按数学规则定优先级,比如乘法永远比加法优先,然后照着优先级表做解析。

正解算符优先分析中的 $\lessdot$、$\gtrdot$、$\equiv$ 是文法结构关系,由产生式的形状和 FIRSTVT/LASTVT 集合自动推导,与数学运算习惯无关。比如有的文法里 + 与 * 可能压根没有优先关系,甚至关系与直觉相反。它是在“语法层”决定移进还是归约,而不是在“语义层”决定谁先计算。

为何常见:运算优先级是中学就建立的直觉,而算符优先关系与运算符同名,初学者很容易把数学里的“优先级”直接套进来,忽略了它其实是语法骨架的投影。

误区既然 `+ ⋖ *`,那反过来 `* ⋗ +` 一定成立吧?优先关系应该是成对出现的,就像不等式两边换位一样。

正解算符优先关系不保证对称。$a \lessdot b$ 与 $b \gtrdot a$ 是两条独立判定:前者看产生式中“a 后跟 V”且 b 在 FIRSTVT(V) 中,后者看“V 后跟 b”且 a 在 LASTVT(V) 中。两条路可能都不通,也可能同时成立(那就是冲突)。矩阵中 ( 对 ) 是唯一对称的 $\equiv$,其余大量格子都是单向关系。

为何常见:数学中的小于号天然反对称,人的思维惯性会默认所有二元关系都有对称性或传递性,而文法结构并不承诺这一点。

误区分析时就是把栈顶符号和输入符号两个字符拿去查表,如果栈顶是非终结符就没法比了。

正解算符优先分析比较的是栈中最近的一个终结符(可跨过非终结符)与当前输入符号。因为算符文法保证了终结符之间必有非终结符间隔,所以“栈顶终结符”总能唯一定位。例如栈为 #N+ 时,栈顶终结符是 + 而非最后的 N;归约后的 N 只是缓冲垫,比较关系时会被跳过。

为何常见:初学移进归约时习惯把栈看成“符号串”,自然盯着最后一个符号,而忽略了算符文法中终结符才是真正的决策主体。

误区LR 分析归约句柄,算符优先分析归约素短语,我觉得二者差不多,反正都是“该归约的那一段”。

正解句柄是规范推导中某个句型的直接短语,它是唯一确定的、与规范归约对应的归约单位;素短语则是“至少含一个终结符且不再含更小素短语”的短语,最左素短语是算符优先分析发现并归约的对象。素短语可以是句柄,但句柄未必是素短语——算符优先分析不携带完整状态,只能识别这种“以终结符为骨架”的短语。

为何常见:两种分析都属于自底向上移进归约,界面上长得像,初学者容易把内部归约单位混为一谈,直到对比分析步骤时才察觉差异。

误区算符优先分析看起来又简单又快,那是不是所有语法分析都能用它?遇到啥文法都先生成一张优先矩阵再说。

正解算符优先文法要求终结符不相邻,且优先矩阵不得含冲突,这两条约束排除了大量常见文法(如含相邻终结符的类型声明、含复杂二义性结构的语言)。它适合表达式类语言,但对一般程序设计语言力不从心,工业界因此更多采用 LR 家族(Yacc 默认 LALR)。“快”的前提是“能处理”,能力边界必须先认清。

为何常见:算法优雅、实现简单会让人产生“万能”错觉,尤其看到它轻松解析四则运算后,容易高估其覆盖范围。

面试题

Q1. 算符文法的核心特征是什么?

单选题 · easy

  • A.产生式右部不含非终结符
  • B.产生式右部任意两个终结符不相邻← 正确答案
  • C.文法不含左递归
  • D.每个产生式都以终结符结尾

答案:B。算符文法规定产生式右部不能出现相邻终结符,这是它区别于一般上下文无关文法的标志。A 是“正则文法”的错觉;C 左递归可以存在(如 E→E+T,经典算符文法就有左递归);D 完全不符合定义。

Q2. 优先关系 a ≡ b 在什么结构中出现?

单选题 · easy

  • A.产生式右部出现 …ab… 或 …aVb…← 正确答案
  • B.a 与 b 是同一个符号
  • C.a 的优先级高于 b
  • D.a 和 b 可以交换位置

答案:A。≡ 表示“同煎同出”,判定依据是产生式右部相邻终结符对或“终结符-非终结符-终结符”模式,例如 F→(E) 中 '(' 与 ')' 就是 ≡。B 混淆了符号同一性;C 是 ⋗ 的含义;D 与文法无关。

Q3. 求 FIRSTVT 时,若有产生式 U→a…,则?

单选题 · easy

  • A.a ∈ FIRSTVT(U)← 正确答案
  • B.a ∈ LASTVT(U)
  • C.无法确定
  • D.需要先求 LASTVT 才能判断

答案:A。这是 FIRSTVT 的直接规则:右部首符号是终结符 a 时,a 直接进入集合。这就像工序一开始锅里最先冒出的是 a 的味道。B 是串尾集合;C、D 混淆了计算依赖关系。

Q4. 栈顶终结符与当前输入符号关系为 ⋗ 时,分析器应执行什么动作?

单选题 · mid

  • A.移进当前输入符号
  • B.归约栈中最左素短语← 正确答案
  • C.接受整个输入串
  • D.立即报语法错误

答案:B。⋗ 表示栈中终结符优先级高于输入符号,说明栈顶已“熟透”,应归约最左素短语;⋖ 或 ≡ 才移进;#≡# 才接受;查无关系才报错。这就像食材先下锅、已经可以盛出装盘。

Q5. 关于素短语,下列说法正确的是?

单选题 · mid

  • A.是句柄的另一种叫法
  • B.至少含一个终结符,且不再含更小素短语← 正确答案
  • C.一定是规范句型的直接短语
  • D.只含非终结符

答案:B。素短语的定义就是“至少含一个终结符且自身不包含更小素短语”,最左素短语是算符优先分析的归约单位。句柄是规范归约概念,与素短语不同;素短语不要求是直接短语,也不含纯非终结符形态。

Q6. 优先函数 f/g 的主要作用是什么?

单选题 · mid

  • A.用数值比较代替优先矩阵查询,压缩空间加速判断← 正确答案
  • B.消除文法的左递归
  • C.直接计算 FIRSTVT 集合
  • D.代替 Yacc 生成语法树

答案:A。优先函数给每个终结符分派 f、g 两个整数,把矩阵查询变成 f(a) 与 g(b) 的数值比较,空间从 O(n²) 降到 O(n)。它与消左递归、求 FIRSTVT 无关,也不能直接产出语法树。

Q7. 优先函数构造迭代失败(超过 2n 轮未收敛)说明什么?

单选题 · hard

  • A.矩阵规模太大,需要更多轮数
  • B.原优先矩阵不存在等价优先函数← 正确答案
  • C.FIRSTVT 计算有误
  • D.文法不是上下文无关文法

答案:B。有限轮内不收敛意味着无法为当前矩阵找到满足全部约束的 f/g 赋值。迭代构造是单调抬升的过程,若本质矛盾(如循环不等式)存在,分数会无限增长,因此超过阈值即可判定无解。这与集合计算正确性无关。

Q8. 下列哪些关系的判定需要用到 FIRSTVT 或 LASTVT 集合?

多选题 · mid

  • A.产生式 U→…aV… 时,b∈FIRSTVT(V) 则 a ⋖ b← 正确答案
  • B.产生式 U→…Vb… 时,a∈LASTVT(V) 则 a ⋗ b← 正确答案
  • C.产生式 U→…ab… 时,a ≡ b
  • D.产生式 U→…aVb… 时,a ≡ b

答案:AB。⋖ 与 ⋗ 关系的判定必须借助 FIRSTVT 与 LASTVT 完成,这是两套集合的核心用途。而 ≡ 关系直接观察产生式右部的相邻终结符或"aVb"模式即可确定,不需要集合推导。

Q9. 关于算符优先分析与 LR 分析的比较,正确的有?

多选题 · hard

  • A.算符优先分析归约最左素短语,LR 分析归约句柄← 正确答案
  • B.算符优先文法一定是 LR 文法
  • C.LR 分析能力通常更强,可处理更多文法← 正确答案
  • D.两者都采用移进归约的栈式框架← 正确答案

答案:ACD。两者都是自底向上移进归约,但归约单位不同:算符优先认素短语,LR 认句柄。LR 携带状态信息,处理能力覆盖更广文法,算符优先文法并不都被 LR 文法包含(边界互有交错),B 的方向说反了。

Q10. Yacc 中 %left '+' 与 %left '*' 声明的作用,在原理上最接近下列哪一项?

单选题 · extreme

  • A.人为指定终结符优先级,化解移进/归约冲突← 正确答案
  • B.自动计算 FIRSTVT 集合
  • C.定义算符文法
  • D.构造优先函数矩阵

答案:A。Yacc 本质采用 LALR,但用户可用 %left/%right/%prec 显式声明终结符优先级,让生成器在冲突时根据“谁优先级高、是否左结合”决定动作。这与算符优先分析“用终结符关系驱动决策”的哲学相通,区别在于前者是人为声明,后者从文法自动推导。