想象你是一位掌管后厨的大厨。炉子上有一口锅(分析栈),案板上摆着一排待下锅的食材(输入串)。你的任务不是凭感觉炒菜,而是按照一本写满工序的菜谱(文法)决定:这一铲该继续煸炒,还是该出锅装盘?有的食材要等一等再下锅,有的食材一遇到后面的食材就得立刻盛出来。每一次“下锅”叫移进,每一次“出锅装盘”叫归约。而支撑你做出决断的,是一张写满食材之间“谁让谁”关系的速查表——这就是算符优先分析:一种自底向上的移进归约语法分析方法。 它不是看整锅菜的复杂结构,而是只靠相邻两个终结符(基础食材)之间的优先关系,就能决定动作。这个方法经典、轻量、直观,是理解编译器前端从文法到解析器实现的一条捷径。我们将从菜谱的规则讲起,逐步走到优先矩阵、移进归约决策、优先函数优化,最后看看 Yacc 等工具如何把这种思想变成工业级解析器。
掌握算符优先分析,你就看穿了语法分析中最朴素也最实用的一类决策机制:用符号间的优先级关系驱动整个归约引擎。这是学习任何解析器实现、乃至设计自定义 DSL 时绕不开的底层直觉。
可视化演示
算符优先分析是一种自底向上的移进归约方法,通过定义算符间的优先关系构建优先矩阵。本文详解算符文法的定义条件、优... 切换模式查看不同维度。
本节配有交互动效(进入视口后自动加载,约 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,没有哪两个终结符直接贴在一起。这个条件看似苛刻,却恰好保证了分析过程“只看终结符”就能走通。
有了“不相邻”的规矩,大厨才能只靠看食材的先后手指挥全局,这是整套分析法坚实地基。它直接导致一个推论:非终结符在推导串中只扮演“缓冲垫”的角色,归约时不必深究它的名字——这让算法可以做得极其轻巧,但代价是分析能力受限。
- 文法四元组文法由四个部分构成,终结符集、非终结符集、开始符号和产生式集合,这是描述一切文法的基础框架。
- 遍历产生式对产生式集合 P 中的任意一条产生式 $U \rightarrow \alpha$ 都要接受检查,抽查任何一条都不算数。
- 相邻禁令右部符号串 $\alpha$ 中不允许出现相邻两符号都是终结符的情形,这是算符文法区别于一般文法的标志性约束。
优先关系判定
有了菜谱,大厨还需要知道每样食材谁先下锅,于是定义三种“下锅先后”关系:$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 \lessdot b$ 表示 a 的优先级低于 b,当栈顶终结符与输入终结符相遇时,b 会被优先移进。
- 相位条件存在某条产生式,其右部某个位置上是终结符 a 后紧跟一个非终结符 V,二者之间没有其他符号插入。
- 首味匹配b 恰好属于 V 的首终结符集合,即 V 一开工最先呈现出的终结符之一。
- 充要关系以上条件全部满足时,记 a 与 b 为小于关系,三者缺一不可。
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) 是一个终结符集合,收集从 U 推导出的所有“串首终结符”。
- 直接情形U 经过至少一步推导,直接得到一个以终结符 b 开头的符号串。
- 隔位情形推导结果开头是“非终结符 V 后紧跟终结符 b”的形式,b 同样算作串首终结符。
- 集合合并凡满足上述任一情形的 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) 收集从 U 推导出的所有“串尾终结符”,与 FIRSTVT 呈镜像对称。
- 直接结尾U 推导出的串以终结符 a 收尾,a 直接入选。
- 隔位收尾推导串末尾是“终结符 a 后跟非终结符 V”形式,a 同样入选。
- 尾味扩散不断用传递规则把子工序的尾味并入父工序,直到集合稳定。
优先矩阵构造
食材的关系规则攒了一堆,大厨把它们整理成一张“食材搭配速查表”——优先矩阵。矩阵的行和列都是终结符(外加结束符 #),交叉格子里填三者之一:$\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 演示中做的事。
- 前提模式扫描产生式右部时发现终结符 a 紧挨着非终结符 V。
- 展开首味取出 V 的 FIRSTVT 集合,集合里每一个终结符 b 都无条件参与填表。
- 填入小于在矩阵位置 $\text{行}=a$、$\text{列}=b$ 处写入 $\lessdot$ 符号。
移进归约算法
有了速查表,大厨开始正式掌勺。分析器维护一个栈(锅)与输入缓冲区(案板),栈底和输入末尾都放哨兵 #。每一轮动作只看两样东西:栈中最近的终结符(栈顶终结符)与当前输入符号,查表后四选一:$\lessdot$ 或 $\equiv$ 则移进(把输入符号铲入锅中),$\gtrdot$ 则归约(把锅中最左素短语盛出装盘,压入非终结符),# 对 # 则接受,查不到关系则报语法错误。
这里关键的“出锅”对象叫最左素短语。素短语是句型中一个至少含一个终结符、且自身不再包含更小素短语的短语。它在栈中呈现固定形态:以终结符 $a_i$ 开头,以终结符 $a_j$ 结尾,中间夹着若干非终结符与终结符,且满足“左界 $\lessdot$、内部 $\equiv$、右界 $\gtrdot$”:
以 $i+i*i$ 为例:初始栈 #、输入 i+i*i#,查表 # ⋖ i 移进;栈 #i、输入首 +,查表 i ⋗ +,归约 i→N;随后 # ⋖ + 移进……如此反复,直到 # 与 # 相遇时接受。整个过程中大厨从来不问“这锅菜叫什么名”,只认关系符号,这就是算符优先分析的风格:轻快、机械、无需记忆状态。
- 形态骨架素短语在栈中呈现“终结符打头、终结符收尾、非终结符夹心”的统一形状。
- 左界信号$a_{i-1} \lessdot a_i$ 表示素短语左侧已封闭,前面的符号不能与它一起出锅。
- 内部等连$a_i$ 到 $a_j$ 之间的终结符两两 $\equiv$,说明这些食材同时下锅、必须同锅出锅。
- 右界触发$a_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,f 对应“行角色”,g 对应“列角色”。
- 小于映射凡是矩阵中 $a \lessdot b$ 的位置,函数值必须严格满足 f(a) 小于 g(b)。
- 等于映射凡是 $a \equiv b$ 的位置,两个函数值必须相等。
- 大于映射凡是 $a \gtrdot b$ 的位置,f(a) 必须严格大于 g(b)。
错误处理工具
大厨也有失手的时候:某一步查表发现交叉格是空的,没有任何优先关系——这意味着锅里的食材搭配根本不在菜谱上,即输入串存在语法错误。算符优先分析的错误处理就像“尝一口发现问题”。常见策略有三种:跳过输入符号直到找到能与栈顶恢复关系的符号;弹出栈顶符号放弃部分已处理内容;两者结合,先弹栈再跳符号,尽量让分析器回到可继续的轨道。由于算符优先分析不携带状态信息,错误恢复做不到精确定位,但足以满足多数编译场景的“报错并继续”需求。
在工业实践中,Yacc/Bison 这类解析器生成器虽然采用 LALR 分析,却借鉴了同一思想:用户用 %left '+'、%left '*'、%prec UMINUS 等声明终结符优先级,生成器据此化解移进/归约冲突。这与算符优先分析中“用终结符关系决定动作”的哲学一脉相承,区别在于 Yacc 的优先级声明是人为附加的语义信息,而算符优先分析中的关系完全从文法自动导出。
与 LR 分析对比:LR 分析器归约的是句柄(含状态与展望信息),能力更强;算符优先分析器归约的是最左素短语,不看状态,只认终结符关系。前者像一位经验丰富、记得每道菜火候细节的大厨;后者像一位只凭食材搭配速查表就能出菜的快厨——快而稳,但只会做表上的菜。
- 真包含算符优先文法集合是上下文无关文法集合的真子集。
- 存在反例必然存在上下文无关文法不属于算符优先文法,如含二义性或终结符相邻的文法。
- 能力取舍算符优先以牺牲一部分文法表达力,换取简单高效的移进归约驱动机制。
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 演示
# -*- 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()
raw = {...}用嵌套字典表示文法,每个非终结符映射到一个产生式列表。productions.append((lhs, rhs))把每条产生式展平成二元组,方便后续逐条扫描。first = {nt: set() for nt in nonterminals}为每个非终结符初始化一个空集合,后续迭代填充。while changed:只要本轮有新增元素就继续迭代,直到集合稳定(不动点)。if first_sym in terminals:右部首符号是终结符时,直接按直接规则加入集合。for t in first[first_sym]:右部首符号是非终结符时,把它已有的 FIRSTVT 全部继承过来(传递规则)。if len(rhs) > 1 and rhs[1] in terminals:处理U → V a ...形式,第二个符号是终结符也直接加入。last_sym = rhs[-1]LASTVT 与 FIRSTVT 完全对称,从右部末尾开始处理。lt.add((rhs[i - 1], b))发现“终结符 a 后跟非终结符 V”时,批量填入 a 与 FIRSTVT(V) 的小于关系。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. 算符文法的核心特征是什么?
- A.产生式右部不含非终结符
- B.产生式右部任意两个终结符不相邻← 正确答案
- C.文法不含左递归
- D.每个产生式都以终结符结尾
答案:B。算符文法规定产生式右部不能出现相邻终结符,这是它区别于一般上下文无关文法的标志。A 是“正则文法”的错觉;C 左递归可以存在(如 E→E+T,经典算符文法就有左递归);D 完全不符合定义。
Q2. 优先关系 a ≡ b 在什么结构中出现?
- A.产生式右部出现 …ab… 或 …aVb…← 正确答案
- B.a 与 b 是同一个符号
- C.a 的优先级高于 b
- D.a 和 b 可以交换位置
答案:A。≡ 表示“同煎同出”,判定依据是产生式右部相邻终结符对或“终结符-非终结符-终结符”模式,例如 F→(E) 中 '(' 与 ')' 就是 ≡。B 混淆了符号同一性;C 是 ⋗ 的含义;D 与文法无关。
Q3. 求 FIRSTVT 时,若有产生式 U→a…,则?
- A.a ∈ FIRSTVT(U)← 正确答案
- B.a ∈ LASTVT(U)
- C.无法确定
- D.需要先求 LASTVT 才能判断
答案:A。这是 FIRSTVT 的直接规则:右部首符号是终结符 a 时,a 直接进入集合。这就像工序一开始锅里最先冒出的是 a 的味道。B 是串尾集合;C、D 混淆了计算依赖关系。
Q4. 栈顶终结符与当前输入符号关系为 ⋗ 时,分析器应执行什么动作?
- A.移进当前输入符号
- B.归约栈中最左素短语← 正确答案
- C.接受整个输入串
- D.立即报语法错误
答案:B。⋗ 表示栈中终结符优先级高于输入符号,说明栈顶已“熟透”,应归约最左素短语;⋖ 或 ≡ 才移进;#≡# 才接受;查无关系才报错。这就像食材先下锅、已经可以盛出装盘。
Q5. 关于素短语,下列说法正确的是?
- A.是句柄的另一种叫法
- B.至少含一个终结符,且不再含更小素短语← 正确答案
- C.一定是规范句型的直接短语
- D.只含非终结符
答案:B。素短语的定义就是“至少含一个终结符且自身不包含更小素短语”,最左素短语是算符优先分析的归约单位。句柄是规范归约概念,与素短语不同;素短语不要求是直接短语,也不含纯非终结符形态。
Q6. 优先函数 f/g 的主要作用是什么?
- A.用数值比较代替优先矩阵查询,压缩空间加速判断← 正确答案
- B.消除文法的左递归
- C.直接计算 FIRSTVT 集合
- D.代替 Yacc 生成语法树
答案:A。优先函数给每个终结符分派 f、g 两个整数,把矩阵查询变成 f(a) 与 g(b) 的数值比较,空间从 O(n²) 降到 O(n)。它与消左递归、求 FIRSTVT 无关,也不能直接产出语法树。
Q7. 优先函数构造迭代失败(超过 2n 轮未收敛)说明什么?
- A.矩阵规模太大,需要更多轮数
- B.原优先矩阵不存在等价优先函数← 正确答案
- C.FIRSTVT 计算有误
- D.文法不是上下文无关文法
答案:B。有限轮内不收敛意味着无法为当前矩阵找到满足全部约束的 f/g 赋值。迭代构造是单调抬升的过程,若本质矛盾(如循环不等式)存在,分数会无限增长,因此超过阈值即可判定无解。这与集合计算正确性无关。
Q8. 下列哪些关系的判定需要用到 FIRSTVT 或 LASTVT 集合?
- 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 分析的比较,正确的有?
- A.算符优先分析归约最左素短语,LR 分析归约句柄← 正确答案
- B.算符优先文法一定是 LR 文法
- C.LR 分析能力通常更强,可处理更多文法← 正确答案
- D.两者都采用移进归约的栈式框架← 正确答案
答案:ACD。两者都是自底向上移进归约,但归约单位不同:算符优先认素短语,LR 认句柄。LR 携带状态信息,处理能力覆盖更广文法,算符优先文法并不都被 LR 文法包含(边界互有交错),B 的方向说反了。
Q10. Yacc 中 %left '+' 与 %left '*' 声明的作用,在原理上最接近下列哪一项?
- A.人为指定终结符优先级,化解移进/归约冲突← 正确答案
- B.自动计算 FIRSTVT 集合
- C.定义算符文法
- D.构造优先函数矩阵
答案:A。Yacc 本质采用 LALR,但用户可用 %left/%right/%prec 显式声明终结符优先级,让生成器在冲突时根据“谁优先级高、是否左结合”决定动作。这与算符优先分析“用终结符关系驱动决策”的哲学相通,区别在于前者是人为声明,后者从文法自动推导。