资讯动态

自上而下语法分析完全指南:LL(1)、递归下降与预测分析表实战

发布时间:2026/9/17 16:43:05 来源:尧图企业网站定制
如果你在大学里被编译原理折磨过或者正打算给某个DSL写个解析器那“语法分析”这四个字大概率绕不开。今天这篇只讲自上而下这一路从开始符号出发不断尝试推导直到整条输入串被完整匹配。这个思路看起来朴素真正落地时会遇到左递归、回溯、First集合、Follow集合、预测分析表这一连串问题。本文适合正在啃编译原理教材的学生、准备面试的开发者以及手头有个小语言要写解析器的工程师我会把从文法到可运行代码的完整链路拆开讲清楚。1. 自上而下语法分析的整体思路与设计选型1.1 从语法树看自上而下的本质先说个最直观的类比。语法分析要解决的事情跟“按菜单点菜”很像菜单的语法规则是预先写好的比如“套餐A 主食 饮料 甜点”现在顾客报了一串菜名服务员要判断这串菜名是不是一个合法套餐并且最好能知道这个套餐从结构上是怎么拆开的。自上而下分析就是服务员从“套餐A”这个根节点开始逐层展开看最终产生的叶子序列能不能跟顾客报的菜名对得上。严格点说给定文法 G (V_N, V_T, P, S)其中 V_N 是非终结符集合V_T 是终结符集合P 是产生式集合S 是开始符号。自上而下分析做的事情是从 S 出发反复用产生式右侧替换当前句型中的非终结符试图推导出一个与输入记号流完全一致的终结符串。如果存在这样一条推导路径就说输入串是该文法的一个句子如果试遍所有可能都失败就说输入串不符合语法。这个过程在形式上有两个关键点。第一推导的方向总是“从开始符号向后推进”每一步都根据当前输入记号来选择要展开哪个产生式所以需要“前瞻”能力——决定下一步之前至少要知道下一个输入记号是什么。第二整个分析过程构建的是一棵语法树树根是开始符号叶子是输入记号内部节点对应非终结符的展开。这棵树就是后续语义分析、中间代码生成的骨架。很多初学者会把自上而下分析和“递归下降”划等号。确实递归下降是自上而下最流行的实现方式但严格来说自上而下分析包含两类带回溯的盲目试探本质是深度优先搜索和不带回溯的预测分析靠预读记号做确定性决策。前者在理论上有意义实践中几乎没人用因为它可能指数级回溯后者才是工程中的主流而预测分析又分为递归下降预测分析和表驱动预测分析LL(1)分析法两种。整篇文章我重点围绕预测分析展开。1.2 为什么工程里优先选LL(1)而不是LR每次提语法分析总有人跳出来说“LR技术更强LALR(1)才是工业界标配Yacc、Bison你讲LL(1)是不是过时了”。这个说法有一定道理但“强”和“适合”是两回事。LL(1)属于自上而下家族LR(1)属于自下而上家族它们的分析能力有重叠但不等价LL(1)能处理的文法集合是LR(1)能处理的文法集合的真子集。也就是说不存在“LL(1)能搞定但LR(1)搞不定”的文法反过来却有一大堆。那为什么在讲自上而下时LL(1)依然是绝对的主角因为LL(1)有两个不可替代的优势。第一个是直观性LL(1)的推导过程直接对应语法树的构建顺序从根往下、从左到右代码结构和文法结构几乎是镜像关系。第二个是实现简单递归下降分析器不过就是一组互相调用的函数每个非终结符对应一个函数调试时可以用栈回溯报错时能给出明确的函数调用链。相比之下LR分析器虽然能处理更多文法但它需要维护一个分析栈、一堆状态、一张复杂的ACTION/GOTO表状态数量动不动几百个手写几乎不可能必须依赖工具生成。一旦语法冲突报错信息形如“shift/reduce conflict in state 42”对初学者非常不友好。而在面试实战中面试官让你“手写一个表达式求值器”99%的期望答案就是递归下降而不是让你掏出Yacc。我的结论是如果是写脚本语言、配置文件、查询语法这类中小型语言LL(1)递归下降是性价比最高的方案如果做的是像C、Java那样语法极其复杂的工业级语言才需要LR类工具加持。学习阶段先把LL(1)吃透后面看LR的ACTION/GOTO表也会容易得多因为很多概念是相通的。1.3 自上而下分析必须迈过的两道坎选定了LL(1)马上会撞上两道坎左递归和回溯。左递归的典型形式是 A - A α | β。拿常见的左递归表达式的文法来说E - E T | T T - T * F | F F - ( E ) | id如果直接为 E 写递归下降函数函数一进来就调用自身永远等不到消耗输入记号的那一刻程序立刻栈溢出。这就是左递归在自上而下分析中的致命问题分析的推进必须依靠每次递归至少“吃掉”一个输入记号而左递归让递归发生在消耗记号之前等于原地打转。回溯的问题则是效率。一个非终结符有多个候选式时你得挨个试。比如 A - aB | aC输入当前记号是 a你先试 aB可能走了一段发现后面匹配不上再退回来试 aC。这个“退回来”在实现里意味着要保存现场、恢复输入位置代价很高而且可能出现指数级回溯跟穷举没什么区别。解决左递归的标准手段是“消除左递归”解决回溯的标准手段是“提取左因子”。这两个操作都属于文法等价变换会在第3部分结合实例展开。这里先记住一个核心原则LL(1)分析要求文法中每一个非终结符的候选式在面临同一个前瞻记号时最多只有一个候选式可以选择。这个原则最终会量化成一张预测分析表。2. 核心细节拆解First、Follow与预测分析表2.1 First集合一个符号能开头的终结符大全First集合的定义一句话就能说清对任意文法符号 XFirst(X) 是从 X 出发能够推导出的所有终结符的集合。如果 X 还能推导出空串 ε那么 ε 也属于 First(X)。计算 First 集合的算法分三组规则按“终结符、非终结符、候选式串”分层处理。第一若 X 是终结符则 First(X) {X}。第二若 X 是非终结符对每个产生式 X - Y1 Y2 ... Yk 逐个处理把 First(Y1) 中除 ε 之外的所有终结符加入 First(X)如果 First(Y1) 含 ε继续看 First(Y2)把 First(Y2) 中除 ε 之外的终结符加入依此类推直到某个 Yi 的 First 集合不含 ε 就停止如果 Y1 到 Yk 的 First 集合全都含 ε则把 ε 也加入 First(X)。第三对于产生式右侧的任意串 X1 X2 ... XnFirst(X1 X2 ... Xn) 的计算方式与第二条中“逐个看 Yi”的逻辑完全一致从 X1 开始检查只要前面符号的 First 含 ε 就继续向后看直到遇到第一个不含 ε 的符号为止如果串的最后一个符号的 First 也含 ε则 ε 也属于该串的 First。这个算法看起来啰嗦实际就是“遇终结符就停遇非终结符钻进去看它的First”。听我一句劝First集合宁可手算练一遍也别急着写代码。因为手算过程能让你透彻理解“空串传播”的行为后面写程序查错时才有直觉。我举个例子文法后续还会用到:E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | id计算步骤是F 是基础First(F) {(, id}T 以 F 开头First(T) First(F) {(, id}T 两个候选式一个以 * 开头一个推导出 ε所以 First(T) {*, ε}E 以 T 开头 First(E) {(, id}E 同理First(E) {, ε}。这里最关键的是记住 ε 只出现在“可能推导出空串”的非终结符的 First 集合中比如 E 和 T而 E、T、F 不会推导出空串所以它们的 First 集合里没有 ε。2.2 Follow集合紧跟其后的人都在盯谁First集合管“开头”Follow集合管“后面跟着谁”。对非终结符 A 而言Follow(A) 是“在所有句型中紧跟在 A 之后的终结符集合”记为集合 follow注意不在任何句型中出现在 A 之前的终结符不关心输入结束符 $ 要算作一个特殊终结符纳入集合。计算 Follow 集合的规则有三条。规则一把 $ 加入 Follow(S)其中 S 是开始符号。这个约定解释起来很自然一个句子被完整分析完意味着输入串后面就是输入结束符。规则二若有产生式 A - α B β则把 First(β) 中除 ε 之外的所有终结符全部加入 Follow(B)。这里的关键是“看产生式右侧B 的后面跟着什么”。规则三若有产生式 A - α B或产生式 A - α B β 且 β 能推导出 ε即 ε ∈ First(β)则把 Follow(A) 中所有元素全部加入 Follow(B)。如果 B 后面没有内容或者后面全是可推导出空串的符号那“能跟在 A 后面的所有符号”自然也能跟在 B 后面。还是用上面那个表达式文法。先说 E。产生式 E - T E 中E 位于串尾由规则三Follow(E) 拿到 Follow(E) 的所有元素Follow(E) 由规则二从 F - ( E ) 得到即 First()) {)}再加上规则一的 $所以 Follow(E) {$, )}因此 Follow(E) {$, )}。再看 T。产生式 E - T E 中T 后面跟着 E需要把 First(E) 除 ε 之外的元素加入 Follow(T)也就是 {}再因为 ε ∈ First(E)还要把 Follow(E) 的元素并入 Follow(T)最终 Follow(T) {, $, )}。这个“空串导致Follow传递”的细节是最容易出错的也是最值得多花时间练的。等把 Follow 算清楚预测分析表就呼之欲出了。2.3 预测分析表的构造与冲突判定预测分析表是一个二维表行表示非终结符列表示终结符包括 $表项内容要么是一个产生式要么为空。构造算法同样简洁对产生式 A - α执行两步对 First(α) 中的每个终结符 a在表 M[A, a] 中填入产生式 A - α。若 ε ∈ First(α)则对 Follow(A) 中的每个终结符 b在表 M[A, b] 中填入产生式 A - α。如果 $ ∈ Follow(A)则填入 M[A, $]。如果填表过程中出现某个表项被两个不同产生式同时占用文法就不是 LL(1) 文法因为面对同一个前瞻记号分析器不知道该选哪个候选式。这种情况被称为“文法冲突”分为两类一类是“First/First冲突”比如 A - aB | aC两个候选式的 First 集合都含 a另一类是“First/Follow冲突”比如 A - aB | ε且 a ∈ Follow(A)这里 First(aB) 含 a而 ε 需要看 Follow(A)如果 Follow(A) 也含 a那么 M[A, a] 就会同时被两个候选式占用。判断一个文法是否为 LL(1) 还有另一个等价条件通常写在教材里对任意非终结符 A 的任意两个不同候选式 A - α | βFirst(α) ∩ First(β) ∅若 ε ∈ First(β)则 First(α) ∩ Follow(A) ∅反之亦然。这两个条件本质上是“同一个前瞻记号最多对应一个候选式”这个直觉的数学表达。很多初学者会问LL(1)中的两个1到底什么意思第一个L表示从左向右扫描输入第二个L表示推导出最左推导即每次展开最左边的非终结符括号里的1表示只需向前看1个输入记号就能决定下一步动作。三个特征合在一起就是LL(1)。3. 实操过程与核心环节实现从文法到可运行分析器3.1 消除左递归与提取左因子动手改写文法先处理最麻烦的左递归。原理其实很经典把左递归转换成右递归。对于直接左递归 A - A α | β其中 α 和 β 都是终结符和非终结符组成的串且 β 不以 A 开头可以等效改写为A - β A A - α A | ε这里的核心思想是把“A反复出现在产生式左侧”变成“A反复出现在产生式右侧”。原来的 A - A T | T 经转换后变成 E - T E 和 E - T E | ε很多教材直接给出这个结果但不解释为什么 E 的递归调用写在运算符后面。实际上右递归让每次展开都从左侧开始消耗输入记号而运算符 被“推迟”到 T 之后才处理这就保证了递归发生时已经有输入记号被消耗栈溢出问题得到解决。间接左递归更麻烦比如 A - B α | ...B - A β | ...。算法是“先排序非终结符逐个消除”上下文无关文法消除左递归的统一算法可以归纳为对非终结符排序A1, A2, ..., An。依次对每个 Ai把所有形如 Ai - Aj γ其中 j i的产生式展开用 Aj 的候选式替换 Aj消除间接性。然后消除 Ai 的直接左递归。这个算法我建议只在理论上了解手动应付考试时会算即可工程中遇到间接左递归的概率极低直接用工具处理更省心。提取左因子的操作更贴近“肉眼”。A - aB | aC 这种候选式共享同一个开头 a 的情况改写为A - a A A - B | C注意提取左因子不改变文法语言但能把“延迟决定”变成“立刻决定”。它解决的不是递归问题而是选择问题分析器看到 a 时先把 a 消耗掉再根据下一个记号判断走 B 还是 C。实际项目里我更推荐“消除左递归优先提取左因子为辅”的组合拳先把明显的左递归消除掉再提取左因子最后用First/Follow集合验证一遍是否为LL(1)。如果还有冲突就得考虑改文法结构或者干脆换LR工具。3.2 用Python实现一个递归下降表达式分析器理论铺垫完了直接上代码。下面这个递归下降分析器实现前面那个表达式文法支持加法、乘法、括号和整数常量并直接输出求值结果。这是面试手写题的经典代表。# -*- coding: utf-8 -*- import re class Tokenizer: def __init__(self, s): self.tokens re.findall(r\d|[*()], s) self.pos 0 def peek(self): if self.pos len(self.tokens): return self.tokens[self.pos] return None def advance(self): tok self.tokens[self.pos] self.pos 1 return tok class Parser: def __init__(self, s): self.tok Tokenizer(s) def parse(self): val self.expr() if self.tok.peek() is not None: raise SyntaxError(unexpected token: %s % self.tok.peek()) return val def expr(self): val self.term() while self.tok.peek() : self.tok.advance() val self.term() return val def term(self): val self.factor() while self.tok.peek() *: self.tok.advance() val * self.factor() return val def factor(self): tok self.tok.peek() if tok is None: raise SyntaxError(unexpected end of input) if tok (: self.tok.advance() val self.expr() if self.tok.peek() ! ): raise SyntaxError(missing right paren) self.tok.advance() return val if tok.isdigit(): self.tok.advance() return int(tok) raise SyntaxError(unexpected token: %s % tok) if __name__ __main__: print(Parser(23*4).parse()) # 14 print(Parser((23)*4).parse()) # 20你可以注意到expr 函数里用了 while 循环而不是直接递归调用。这是工程中处理二元运算符的常用变形等价于把 E - T E 和 E - T E | ε 的右递归展开成迭代。这样写的好处是避免在长表达式场景下递归层次过深Python默认递归深度约1000一个1000项加法的表达式用纯递归很容易爆栈。这个例子中的文法实际上是“改造后的LL(1)递归下降”而非严格按照 E - T E | ε 写成的函数。这两种写法语义完全等价while 循环隐式把 E 的递归变成了迭代。面试时两种都能过但能解释清楚为什么要这样写会显得你理解更到位。3.3 表驱动LL(1)分析器的流程与压栈策略递归下降的优点是代码直观但它把分析逻辑分散到各个函数里要改文法就得改代码不利于可维护性。表驱动LL(1)分析器则把“接下来怎么走”全部塞进一张预测分析表里分析算法是固定的文法变了只需要换表。代价是前期构造预测分析表比较繁琐而且报错时定位到具体文法规则不如递归下降直观。表驱动分析器维护一个栈初始栈底是 $栈顶是开始符号 S。算法主循环如下读当前栈顶符号 X 和当前输入记号 a。如果 X a $分析成功结束。如果 X 是终结符且 X a弹出 X输入指针后移一个记号。如果 X 是非终结符查表 M[X, a]。若表项为空报语法错误。若表项为产生式 X - Y1 Y2 ... Yk先弹出 X再把 Yk, ..., Y2, Y1 依次压栈注意压栈顺序是逆序保证栈顶是 Y1。这个压栈顺序是新手最容易踩的坑比如 M[E, ] E - T E弹出 E 后要先压 E再压 T最后压 这样栈顶才是 能与输入记号匹配。如果你顺序写反分析器会立刻陷入“栈顶不匹配”的错误。表驱动分析器还有一个隐含要求非终结符展开时必须“同步”输入否则一旦出错很难恢复。教材里通常用“恐慌模式”错误恢复做法是当栈顶是非终结符 X 而表项为空时跳过输入记号直到遇到一个能在 Follow(X) 中出现的记号再继续。这个技巧足够应付大多数教学场景工程上要做得更精细一般会引入错误产生式。3.4 一个完整案例验证LL(1)分析全过程还是用表达式文法我把整条链路走一遍。手工算好First、Follow集合给出局部预测分析表然后演示输入串 id id * id 的分析过程。已知文法E - T E E - T E | ε T - F T T - * F T | ε F - ( E ) | idFirst集合符号FirstE{(, id}E{, ε}T{(, id}T{*, ε}F{(, id}Follow集合符号FollowE{$, )}E{$, )}T{$, ), }T{$, ), }F{$, ), , *}由First和Follow集合推到预测分析表 M。以 E 为例First( T E) {}所以 M[E, ] E - T Eε ∈ First(ε)而 Follow(E) {$, )}所以 M[E, $] E - εM[E, )] E - ε。T 同理M[T, *] T - * F TM[T, $] T - εM[T, )] T - εM[T, ] T - ε。然后用表驱动算法模拟分析 id id * id步骤栈栈顶在左输入当前记号在左动作1$ Eid id * id $M[E, id] E - T E2$ E Tid id * id $M[T, id] T - F T3$ E T Fid id * id $M[F, id] F - id4$ E T idid id * id $匹配 id弹出5$ E T id * id $M[T, ] T - ε6$ E id * id $M[E, ] E - T E7$ E T id * id $匹配 弹出8$ E Tid * id $M[T, id] T - F T9$ E T Fid * id $M[F, id] F - id10$ E T idid * id $匹配 id弹出11$ E T* id $M[T, *] T - * F T............最终$$分析成功每一步动作都对应预测分析表的一个表项没有回溯没有猜测完全确定性推进。这就是LL(1)名字里“1”的威力只靠当前一个记号就能锁定下一步唯一动作。4. 常见问题、排查技巧与工程实战心得4.1 First集合为什么总是算不对我见过太多同学写代码计算First集合结果要么漏掉 ε要么把 ε 传播过头。最常见的错误是处理“候选式串的First”时没有正确实现“遇不含 ε 的符号就停止”的规则。一个有效的排查方法是先手算一张小文法的First集合再用代码逐条对照。如果代码算出来的集合比手算多出某些终结符大概率是循环没有及时break如果少了终结符大概率是没考虑“当前符号的First含 ε 时需要继续看下一个符号”。另一个陷阱是间接空串传播。比如 A - B | aB - ε那么 First(A) {a, ε}。初学者容易忘记 B - ε 导致 A 也推导出空串。这种间接关系在文法较大时肉眼很难发现建议用固定点迭代算法实现First集合计算初始化所有符号的First集合为空反复扫描所有产生式把新元素加入对应集合直到所有集合不再变化。这个算法简单、可靠也最容易验证正确性。4.2 预测分析表冲突的真正原因与应对当你在构造表时发现 M[A, a] 同时被两个产生式占用好多人第一反应是“文法写错了”其实不然。冲突的根源几乎总是以下三类之一两个候选式First集合相交比如 A - aB | aC需要提取左因子。ε产生式与普通候选式冲突比如 A - a B | ε且 a ∈ Follow(A)这既可能是文法设计问题也可能需要重新审视表达式优先级设计。间接左递归残留表面上没有直接左递归但A经B又绕回A导致First集合出现错误传播。解决流程建议按这个顺序先尝试提取左因子再尝试消除一切形式的左递归然后检查Follow集合是否有意外元素最后考虑改写文法结构。如果这些手段都用尽仍然冲突我的建议是接受现实换LR工具。不要试图跟文法死磕工程师的时间比那点“纯手写”的成就感值钱多了。4.3 递归下降栈溢出和无限循环怎么排查递归下降最常见的两个运行时问题栈溢出和无限循环。栈溢出多半是左递归没清理干净或者间接递归未发现无限循环多半是某个分支没有消耗输入记号比如 factor 里漏了 tok.isdigit() 分支的 advance 调用。排查技巧有一个百试百灵在每次进入非终结符函数时打印函数名和当前输入位置运行一个小输入串观察输出。如果发现某个函数名连续出现多次且输入位置没有前进那就能立刻锁定是哪个非终结符在做无消耗递归。这个方法虽然原始但在解析器调试场景下比任何调试器都好用因为问题往往出在“你没察觉到某条路径没有消耗记号”。另一个经验是给Token流提前做缓冲。如果你直接操作字符串切片每次 peek 和 advance 都涉及字符串复制性能会很难看而且报错时拿不到行列号。正规做法是先做词法分析把输入处理成带位置信息的Token列表再喂给语法分析器。很多初学者为了省事把词法分析揉到语法分析里后续查错会非常痛苦。4.4 面试考点与工程选型避坑清单最后分享一些面试和实战的私货都是踩坑换来的。面试最常见的问题包括什么是左递归为什么自上而下分析要消除左递归First集合和Follow集合分别解决什么问题如何判断一个文法是LL(1)递归下降和LL(1)表驱动有什么区别预测分析表冲突怎么解决。这些问题的答案都在这篇文章里了建议把本文中的表达式文法手算一遍再亲手写一遍代码比背一百道题都管用。工程选型方面我给的路线是小型DSL、配置语言、教学项目手写递归下降不依赖生成器可读性强调试直观。中大型通用语言用ANTLRANTLR 4生成的解析器本质上是ALL(*)可以处理很多非LL(1)文法但上手难度远低于Yacc/Bison且自带语法树生成和Listener/Visitor模式。极端性能敏感的场景手工优化递归下降或考虑PEG解析器如Python的pyparsing、Rust的nom但这类方案对入手门槛要求更高。关于是否应该“一切手动实现”我的看法是学习阶段推荐手动实现一遍LL(1)能让你对编译过程建立完整心智模型生产环境按需选型不要为了炫技浪费工期。真正的高手既懂原理也懂什么时候该用工具。我个人在实际操作中最深的体会是编译原理这门课只看书和做题是不够的必须亲手把一个最小文法从First、Follow集合算到预测分析表再写成代码跑通才算真正入门。语法分析是编译原理里最“模式化”的一环吃透自上而下这一路之后再学LR家族会顺畅很多因为你在面对的不再是“新概念”而是“同一个目标的不同路径”。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价