☰
LL(1)预测分析表与过程表:FIRST/FOLLOW手算与Python实现
2026/10/26 4:47:20 网站建设 项目流程

1. 先说清楚这两张表到底在解决什么问题

编译原理这门课里,语法分析那一章通常是分水岭。前面的词法分析还能靠状态机硬啃下来,到了语法分析,尤其是 LL(1) 预测分析表与预测分析过程表这两张表,很多人的学习曲线就在这里突然变陡。我当年第一次手算预测分析表的时候,把 FIRST 集和 FOLLOW 集混着用,填出来的表一半格子是错的,对着答案找了两个小时才发现是把E'的 FOLLOW 集抄成了E的 FIRST 集。后来带别人做课程设计,发现大家栽的坑高度雷同,所以想把这两张表的构造逻辑、手算套路和容易翻车的地方完整捋一遍。

先把概念摆正。所谓LL(1) 预测分析表,是一张二维表,行是文法里的非终结符,列是终结符加上结束符$,格子里面填的是"当前栈顶是这个非终结符、当前输入符号是这个终结符时,应该用哪条产生式去替换"。所谓预测分析过程表,是把某一条具体输入串喂给分析器之后,逐步记录"分析栈内容、剩余输入、当前动作"的流水账。前者是编译程序运行时查的静态数据,后者是你验证这张表到底对不对的动态证据。

这两张表解决的问题非常具体:在不回溯的前提下,让计算机只看一个向前看符号就能决定走哪条产生式。递归下降分析器写起来直观,但每个非终结符对应一个函数,遇到多个候选产生式时要靠试探,试探失败就得回退,回退意味着可能指数级的时间开销。预测分析法把这个"试"的过程提前到了建表阶段——所有判断在建表时做完,运行时只做查表,时间复杂度是线性的。

适合谁看这份内容?如果你是正在啃编译原理、被 FIRST/FOLLOW 集绕晕的学生,这篇会给你一条从集到表、从表到过程的完整链路;如果你是要交课程设计、需要把算法落成代码的人,后面的 Python 实现可以直接跑;如果你只是想把当年没学明白的东西补上,那重点看第 2 节和第 5 节的踩坑记录就够了。全程我用同一个算术表达式文法贯穿,不换例子,避免每节换文法导致的认知断层。


2. 构造预测分析表前的三项基本功

2.1 FIRST 集:一个符号串能打头阵的终结符集合

FIRST 集的直觉很好建立:给你一个符号串 α,问"这个串最终推导出来,第一个字符可能是哪些终结符"。比如产生式右部是T E',那这个串打头的字符一定是T能推导出的第一个字符,于是FIRST(T E') = FIRST(T)。听起来简单,但真正让人卡住的是"空串 ε 要不要算进去"这个问题。

我的经验是把规则背成三句话。第一,终结符a的 FIRST 集就是{a},空串 ε 的 FIRST 集是{ε}。第二,对于非终结符 A 的每条产生式A → X1 X2 ... Xn,从左往右看,把FIRST(X1) - {ε}加进FIRST(A);如果 ε 在FIRST(X1)里,说明 X1 可能整个消失,那就要接着看 X2,把FIRST(X2) - {ε}也加进去,依此类推。第三,如果 X1 到 Xn 全都能推出 ε,那么 ε 也属于FIRST(A)。

第三条是最容易漏的。很多人算完非空部分就收工,结果后面算 SELECT 集时对"能推出空"的产生式判断错误,表里就出现该填 ε 产生式的格子空着。当时我们班有个同学就是因为FIRST(T')少了 ε,导致T'那一行在)和$两列全空,输入串一到结尾就报错。

拿贯穿全文的文法来算。这是个消除了左递归和左公因子的算术表达式文法,产生式编号如下:

(1) E → T E' (2) E' → + T E' (3) E' → ε (4) T → F T' (5) T' → * F T' (6) T' → ε (7) F → ( E ) (8) F → id

算 FIRST 的时候不能一遍过,因为存在依赖关系。FIRST(F)只依赖终结符,可以立刻得出{( , id}。FIRST(T)依赖FIRST(F),因为 ε 不在FIRST(F)里,所以直接等于{( , id}。同理FIRST(E) = {( , id}。而FIRST(E')看产生式 (2) 得到{+},看 (3) 得到{ε},合起来{+ , ε}。FIRST(T')同理是{* , ε}。

提示:处理这类依赖最简单的办法是反复迭代到集合不再变化,也就是不动点算法。手算时按"依赖层级"从下往上算一遍即可,代码实现则直接写 while 循环,省心且不会错。

2.2 FOLLOW 集:非终结符后面紧跟着什么

FOLLOW 集问的是另一个问题:在某个句型里,非终结符 A 的后面可能紧跟哪些终结符。这个集合存在的唯一目的,就是处理"能推出 ε 的产生式"——当 A 决定用A → ε把自己消掉时,得确认当前输入符号确实属于FOLLOW(A),否则消掉之后就对不上了。

规则同样三条。起点的 FOLLOW 集里无条件放$。对于产生式A → α B β,把FIRST(β) - {ε}加进FOLLOW(B)。如果 β 能推出 ε(包括 β 本身就是空的情况),那么FOLLOW(A)也要并进FOLLOW(B),因为 B 后面的东西消失了,A 后面原本跟着什么,现在就直接跟到 B 后面。

还是用上面的文法。FOLLOW(E) = {) , $},)来自产生式 (7)F → ( E ),$来自起点规则。FOLLOW(E')等于FOLLOW(E),因为 E' 出现在 E 的产生式末尾,后面没有东西。FOLLOW(T)先加FIRST(E') - {ε} = {+},再因为 E' 能推出 ε,把FOLLOW(E) = {) , $}并进来,得到{+ , ) , $}。FOLLOW(T')等于FOLLOW(T)。FOLLOW(F)先加FIRST(T') - {ε} = {*},再因为 T' 可空,并入FOLLOW(T),最终{* , + , ) , $}。

这里有个我踩过的坑值得单独说:FOLLOW 集的传播方向是"从右往左"还是"从左往右"取决于你怎么看。准确的表述是,FOLLOW(A)会流向"位于 A 产生式右部末尾的那些非终结符"。所以如果你从左到右扫产生式,遇到A → ... B这种 B 在末尾的情况,就得把FOLLOW(A)加到FOLLOW(B),而FOLLOW(A)自己可能还没算完。这就是为什么手算要迭代,代码要 while 循环。我第一次手算时按顺序扫了一遍就交卷,FOLLOW(T)里的$就是因为FOLLOW(E)当时还没补全而漏掉的。

下面把四个集合整理成一张对照表,便于后续查用:

非终结符FIRST 集FOLLOW 集
E{ ( , id }{ ) , $ }
E'{ + , ε }{ ) , $ }
T{ ( , id }{ + , ) , $ }
T'{ * , ε }{ + , ) , $ }
F{ ( , id }{ * , + , ) , $ }

2.3 SELECT 集:把产生式和终结符一一对上号

有了前两个集合,SELECT 集就是一步简单的组装。对产生式A → α:如果 ε 不在FIRST(α)里,那SELECT(A → α) = FIRST(α);如果 ε 在FIRST(α)里,就把 ε 剔掉,再并上FOLLOW(A)。用大白话讲,SELECT 集回答的是"看到哪些输入符号时,我该选这条产生式"。

这个定义直接决定了一件事:如果同一个非终结符的两条产生式,SELECT 集有交集,那这个文法就不是 LL(1) 的。因为分析器看到那个交集里的符号时,不知道该选哪条,只能猜,猜就要回溯,LL(1) 的前提就破了。所以 SELECT 集不只是填表的中间产物,它本身就是文法性质的判决书。

本文文法的 SELECT 集如下:

产生式SELECT 集计算依据
(1) E → T E'{ ( , id }FIRST(T E') 不含 ε
(2) E' → + T E'{ + }FIRST(+ T E') = {+}
(3) E' → ε{ ) , $ }并上 FOLLOW(E')
(4) T → F T'{ ( , id }FIRST(F T') 不含 ε
(5) T' → * F T'{ * }FIRST(* F T') = {*}
(6) T' → ε{ + , ) , $ }并上 FOLLOW(T')
(7) F → ( E ){ ( }FIRST(( E )) = {(}
(8) F → id{ id }FIRST(id) = {id}

横向检查一遍:E 的两条候选只有 (1),E' 的 (2)(3) 分别是{+}和{) , $},无交集;T' 的 (5)(6) 是{*}和{+ , ) , $},无交集;F 的 (7)(8) 是{(}和{id},无交集。全部通过,确认这是个 LL(1) 文法。这一步的检查在考试里经常直接变成一道判断题,别跳过。


3. 预测分析表的构造实操

3.1 表格骨架与填表规则

建表的动作可以用一句话概括:对每条产生式A → α,遍历SELECT(A → α)里的每一个终结符 a,把M[A, a]填成这条产生式。表头是所有非终结符,表列是所有终结符加$,其余格子留空表示出错。

这里有个细节新手常搞混:$到底算不算终结符。在分析表里它必须占一列,因为文法的起始符后面天然跟着$,而能推出 ε 的产生式(比如E' → ε)恰恰需要在$这一列上填东西,否则输入串读到结尾时分析器不知道该怎么收尾。我在第一次做课设时就漏了这一列,结果输入id + id这种不带结尾标记的串能跑通,一旦补上$就崩。

填出来的完整表长这样,格子里是产生式编号:

非终结符()id+*$
E11
E'323
T44
T'6656
F78

对着表走一遍逻辑:栈顶是E、当前符号是(,查表得产生式 (1),那就把E弹出、把T E'逆序压栈。栈顶是E'、当前符号是),查表得 (3),也就是E' → ε,直接把E'弹出即可。栈顶是F、当前符号是id,查表得 (8),弹出F压入id,下一轮栈顶和输入符号都是id,匹配成功,双双前进。

表格的空白格不是"待定",而是明确的错误信号。这一点在设计分析器时很重要:空白格意味着当前输入串在语法上不合法,应该走错误处理流程,而不是继续往下猜。把"空"当作有意义的语义来用,是预测分析法干净利落的原因之一。

3.2 冲突判定与文法改写

真实的文法很少天生就是 LL(1) 的。两种最常见的病灶是左递归和公共左因子,它们都会导致同一个格子里被填入多条产生式,也就是所谓的"多重入口冲突"。

左递归的典型形态是E → E + T | T。直接照规则填表会发现,FIRST(E + T)和FIRST(T)里都有(和id,M[E, (]和M[E, id]两个格子都得填两条产生式,冲突。原因是左递归让文法具备了左结合的能力,而 LL(1) 是从左向右、最左推导的分析方式,天生处理不了"先递归再展开"的结构。消除办法是标准的改写:

E → T E' E' → + T E' | ε

改写之后E'的右递归形态让分析器可以边读边消耗,不再是先无限展开再回头。这套改写对加减、乘除、函数调用链都适用,本质是把左结合语义转成了循环语义。

公共左因子的典型形态是S → i E t S | i E t S e S | a。两条产生式开头一模一样,FIRST集自然撞车,M[S, i]就会被填两条。提取办法是把公共前缀抽出来:

S → i E t S S' S' → e S | ε

但要注意,提取左因子只是解决了"看到 i 不知道怎么选"的问题,还可能引出新问题。上面这个例子其实是经典的悬空 else 文法,改写后S' → e S和S' → ε的 SELECT 集分别是{e}和FOLLOW(S'),而FOLLOW(S') = FOLLOW(S) = {e , $},e又撞上了。这说明该文法的二义性不是靠机械改写能消掉的,得靠语法之外的规则(比如"else 就近匹配")来定夺。遇到这种情况,正确的做法是承认它不是 LL(1) 文法,改用算符优先或者 LR 系列方法,而不是硬凑。

注意:消除左递归时,如果产生式形如A → Aα | β,改写后是A → βA'、A' → αA' | ε。这里 β 不能为空,否则改写后A → A'、A' → αA' | ε,虽然形式上没问题,但语义上要重新审视一遍,别把原本能推出空串的文法改坏了。

3.3 填表之后的自检三连

表填完千万别急着往下走,我一般做三项检查,能挡掉八成以上的低级错误。

第一项,看每行有没有"该空不空"。所有非终结符在$这一列,要么填一条能推出 ε 的产生式,要么留空。如果某个非终结符既不能推出 ε,$列又填了东西,那一定是 FOLLOW 集算错了。第二项,看每个格子里是不是只有一条产生式,有多条的说明文法有冲突,得回去做 3.2 节的改写。第三项,拿一个最简输入串反向验证,比如id,从$E开始推,看能不能一路走到接受状态。如果最简串都过不去,后面的复杂串更不用试。

我习惯把这三项写成一个固定流程,做完一遍再动笔做过程表。因为过程表是建表的验证,如果表本身错了,过程表只会把错误放大,查起来更痛苦。有一回我图省事跳过了自检,做过程表做到第 14 步发现栈里剩了个E'、输入已经到$,回去查表才发现M[E', $]被我填成了 (2) 而不是 (3),一个格子错,整条推演全废。


4. 预测分析过程表的逐步推演

4.1 分析栈的约定与显示方向

过程表最容易让人对不上答案的,不是推理逻辑,而是栈的书写方向。分析栈的物理结构是"栈底在下、栈顶在上",但纸上记录时有人把栈顶写在左边,有人写在右边。两种写法都对,但必须全文统一,否则你自己回头看会懵,跟标准答案比对时更是对不上。

我推荐栈顶写在左边的记法,原因是它和"下一个要处理的符号"直观对应。按这个约定,初始状态写成$E,这里$在最左边其实是栈底,E在右边是栈顶。等一下,这话有点绕。准确地说:如果栈用列表表示、栈底在索引 0、栈顶在末尾,那么显示时把列表反转过来拼接,得到的字符串就是"栈顶在左"。初始栈["$", "E"]反转后是E$,但很多教材写成$E。

这里必须说清楚,因为这是新手最大的困惑来源。两种主流约定:

约定初始栈写法展开E → T E'后说明
栈顶在左E$E'T$反转列表后拼接,压栈顺序直观
栈底在左$E$E'T正序拼接,符合"栈底在左"的物理直觉

两种写法描述的是同一个栈,只是记号不同。我下文统一用栈顶在左的写法,展开时把产生式右部逆序压栈,所以E → T E'展开后栈从E$变成E'T$。你如果习惯另一种记法,把每行左右翻转一下就行,逻辑完全一致,别在两种记法之间反复横跳。

4.2 完整推演:走一遍 id + id * id

拿输入串id + id * id做完整推演,结尾补上$。栈初始为E$,输入指针指向第一个id。

步骤分析栈(栈顶在左)剩余输入动作
1E$id+id*id$查 M[E, id] = (1),用 E → T E',弹出 E,压入 E'T
2E'T$id+id*id$查 M[T, id] = (4),用 T → F T',弹出 T,压入 T'F
3E'T'F$id+id*id$查 M[F, id] = (8),用 F → id,弹出 F,压入 id
4E'T'id$id+id*id$栈顶 id 与输入 id 匹配,双双弹出前进
5E'T'$+id*id$查 M[T', +] = (6),用 T' → ε,弹出 T'
6E'$+id*id$查 M[E', +] = (2),用 E' → + T E',弹出 E',压入 E'T+
7E'T+$+id*id$栈顶 + 与输入 + 匹配,双双弹出前进
8E'T$id*id$查 M[T, id] = (4),用 T → F T',弹出 T,压入 T'F
9E'T'F$id*id$查 M[F, id] = (8),用 F → id,弹出 F,压入 id
10E'T'id$id*id$栈顶 id 与输入 id 匹配,双双弹出前进
11E'T'$*id$查 M[T',] = (5),用 T' → * F T',弹出 T',压入 T'F
12E'T'F*$*id$栈顶 * 与输入 * 匹配,双双弹出前进
13E'T'F$id$查 M[F, id] = (8),用 F → id,弹出 F,压入 id
14E'T'id$id$栈顶 id 与输入 id 匹配,双双弹出前进
15E'T'$$查 M[T', $] = (6),用 T' → ε,弹出 T'
16E'$$查 M[E', $] = (3),用 E' → ε,弹出 E'
17$$栈顶 $ 与输入 $ 匹配,接受

整个过程 17 步,每一步都是"查表—压栈/弹出—匹配"三选一,没有任何回退。这就是预测分析法能做到一趟扫描的根本原因。你可以注意到一个规律:压栈时永远是把产生式右部逆序压入,因为栈是后进先出,只有逆序压才能保证最左边的符号最先被处理。第 3 步F → id,右部只有一个id,逆序还是id;第 6 步E' → + T E',右部三个符号,逆序压入后栈顶变成+,正好可以先和输入的+匹配。这个"逆序"如果搞反了,栈顶会变成E',就去匹配+了,直接卡死。

还有一个观察点:步骤 5 和步骤 15 都是T' → ε。这两次弹出都没压入任何东西,因为 ε 产生式右部为空。有些教材在过程表里把动作写成"弹出栈顶",有些写成"用 T' → ε",本质一样。写清楚产生式编号,回头排查时方便定位。

4.3 出错时的定位与恢复抓手

输入串不合法时,过程表会停在一个"查表查不到"的格子上。比如输入id + * id,推演到某一刻栈顶是T'、输入符号是*之后,接下来栈顶变成F、输入符号还是*,查M[F, *]是空的,报错。这个错误位置非常精准,就是因为"当前栈顶非终结符 + 当前输入符号"这个组合唯一确定了出错点。

错误恢复在课程设计里通常是加分项。最朴素的做法是恐慌模式:把当前栈顶能"期待"的符号集合拿出来——也就是FOLLOW(栈顶非终结符)——然后不断丢弃输入符号,直到当前输入符号落进这个集合,再继续分析。比如栈顶是F、FOLLOW(F) = {* , + , ) , $},输入是+,那就在+这里恢复,把F弹掉,让T'去处理。

更讲究一点的做法是给每个非终结符维护一个同步集(sync set),通常取FOLLOW集的子集,集中放那些真正能作为恢复锚点的符号。再进一步,可以在分析表里预留专门指向错误恢复例程的格子,对最常见的几类错误(比如漏分号、括号不匹配)给出针对性的提示信息。我在课设里加过一个简单的错误消息生成:如果出错时栈顶是),就提示"缺少右括号或表达式不完整";如果出错时输入是$,就提示"输入意外结束"。虽然粗糙,但比干巴巴甩一句"语法错误"体验好太多。


5. 常见问题与排查技巧实录

5.1 高频错误速查表

手算这两张表出的错,绝大多数集中在几个固定位置。我把带队过程中见过的错误整理成下面这张表,对着查基本能覆盖九成情况:

现象大概率原因排查动作
某行$列应该有条目却是空的FOLLOW 集漏了起始符的$检查起点非终结符的 FOLLOW 是否加了$
某行)列误填了 ε 产生式FOLLOW 集多算了,或把 FIRST 当 FOLLOW 用反查 ε 产生式左部的 FOLLOW 到底含不含)
表格出现多入口,同一格填了两条文法有左递归或公共左因子先做左递归消除,再提取左因子
FIRST 集算完发现没有 ε漏判"整条右部可空"的情况检查每条产生式右部是否所有符号都能推出 ε
过程表推演到一半栈非空但输入已到$表里的 ε 条目填错或漏填检查栈顶非终结符在$列的条目
过程表压栈后符号顺序反了没有逆序压栈记住右部最后一个符号先压,第一个符号后压
两个答案对不上,逻辑看着都通栈的书写方向约定不同确认比对双方用的是"栈顶在左"还是"栈底在左"

这张表里最后一条我得多说两句。很多人以为自己算错了,抱着草稿本跟答案比对了半小时,最后发现只是答案把栈顶写在右边。这种"约定性差异"不是错误,但会消耗大量时间。我的建议是自己做题时在纸头写上"栈顶在左"四个字,跟标准答案比对前先看一眼方向,能省下不少无谓的焦虑。

5.2 左递归和左因子处理的实操心得

消除左递归这件事,教科书给的公式是机械的:A → Aα | β改成A → βA'、A' → αA' | ε。但实操时会遇到两种情况公式覆盖不全。

第一种,同一个非终结符有多条左递归产生式。比如E → E + T | E - T | T,直接套公式会乱。正确做法是把所有以E开头的右部按后缀分组,公共前缀E抽掉,得到E → T E'、E' → + T E' | - T E' | ε。这里的关键是把可选的运算符全部搬进E'的产生式里,而不是一次只搬一个。我见过有人只处理+不处理-,改完还是左递归,白忙一场。

第二种,间接左递归。比如A → B a | ...、B → A b | ...,两个非终结符互相递归,单看每条产生式都不像左递归,但展开两步就暴露了。处理方法是先做代入,把B的产生式代进A,暴露出直接左递归后再套公式。代入过程中产生式数量会膨胀,这时候要耐着性子一步一步来,别急着跳步。我做过一个含五个非终结符、间接左递归绕了三层的文法,光代入就写了四十多行,但每步都对上之后再消除,一次就过。

提取左因子的心得是不要一次提到底。比如A → abc | abd | aef,先提出a得到A → a A'、A' → bc | bd | ef,再看A'里bc和bd还能提一个b,继续得到A' → b A'' | ef、A'' → c | d。分步提的好处是每一步都清晰可查,出错了能回退到上一步。一次提到底容易漏分支。

5.3 手工计算的自检方法

手算这两张表,我总结了一套"三遍法",比一次性算完再检查效率高得多。

第一遍只算 FIRST,算完立刻做一次不动点验证:把每条产生式右部按从左到右扫一遍,看能不能用手上的 FIRST 集推出新的符号,推不出来才算收敛。第二遍算 FOLLOW,重点盯"能推出 ε 的非终结符",把它的 FOLLOW 集往左传播到产生式右部相关的非终结符上,反复扫到不变为止。第三遍组 SELECT、填表,填完立刻做 3.3 节的三项检查。

这套流程的价值在于把错误隔离在单层。如果你把 FIRST、FOLLOW、SELECT 混在一起算,出了错根本不知道是哪一层的问题,只能全部推倒重来。分层算的话,第一遍结果有对照表可查,第二遍有传播规则可验,第三遍有冲突检测兜底,出错时能快速定位到具体环节。

另外有个小技巧:用极端输入反推。文法里如果有括号,一定试试((id))这种嵌套最深的串;有运算符,一定试试id*id+id和id+id*id这两种不同优先级的组合,看过程表里T'和E'的展开时机是不是符合预期的优先级。优先级搞反是填表填错位最常见的表现,而这类错误光看表是看不出来的,必须跑一遍才能暴露。


6. 从手算到代码:把两张表落到程序里

6.1 数据结构怎么选

手算和写代码之间的落差,主要在于"集合怎么表示"和"表怎么存"。我的选择是:集合统一用 Python 的set,文法用字典表示,键是非终结符,值是该非终结符所有右部的列表,空产生式用一个空列表[]表示,避免用字符串"ε"引入额外的符号判断。

分析表用字典存,键是(非终结符, 终结符)的元组,值是对应产生式的右部列表。这样做的好处是查表直接table.get((A, a)),查不到返回None,天然对应"空白格即报错"的语义,不用额外判空。如果用二维列表,得先处理行列索引映射,还容易越界。

分析栈用 Python 的list,栈底在索引 0,栈顶在末尾,压栈用append、弹栈用pop,都是 O(1)。显示时把列表reversed一次再拼接,就得到前面约定的"栈顶在左"格式。这个设计的一致性很重要,别在代码里用一套方向、打印时用另一套。

6.2 FIRST 与 FOLLOW 的不动点实现

这两个集合的计算本质是求不动点,代码结构高度一致:外层while changed,内层遍历所有产生式尝试加新元素,一旦加入就置changed = True继续下一轮,直到某轮一个都没加进去。

grammar = { "E": [["T", "E'"]], "E'": [["+", "T", "E'"], []], "T": [["F", "T'"]], "T'": [["*", "F", "T'"], []], "F": [["(", "E", ")"], ["id"]], } start = "E" nonterms = set(grammar) terms = {"+", "*", "(", ")", "id"} def compute_first(grammar, nonterms, terms): first = {nt: set() for nt in nonterms} changed = True while changed: changed = False for nt, prods in grammar.items(): for prod in prods: if not prod: if "" not in first[nt]: first[nt].add("") changed = True continue for sym in prod: if sym in terms: if sym not in first[nt]: first[nt].add(sym) changed = True break add = first[sym] - {""} if not add <= first[nt]: first[nt] |= add changed = True if "" not in first[sym]: break else: if "" not in first[nt]: first[nt].add("") changed = True return first

这段代码里for ... else的用法值得单独解释。else分支在for循环没有被break打断时执行,也就是"整条右部所有符号都能推出 ε"的情况,此时才把 ε 加进 FIRST。如果中途遇到终结符或者不可空的非终结符,break掉了,else不执行,ε 自然不加。这个写法比用标志位判断更紧凑,但可读性稍差,团队协作时建议加注释。

FOLLOW 的实现要复杂一点,因为传播方向是反向的:

def compute_follow(grammar, first, start, nonterms, terms): follow = {nt: set() for nt in nonterms} follow[start].add("$") changed = True while changed: changed = False for nt, prods in grammar.items(): for prod in prods: for i, sym in enumerate(prod): if sym not in nonterms: continue rest = prod[i + 1:] nullable = True for s in rest: if s in terms: if s not in follow[sym]: follow[sym].add(s) changed = True nullable = False break add = first[s] - {""} if not add <= follow[sym]: follow[sym] |= add changed = True if "" not in first[s]: nullable = False break if nullable: if not follow[nt] <= follow[sym]: follow[sym] |= follow[nt] changed = True return follow

nullable这个标志的意思是"sym后面的部分能不能整体消失"。如果rest全部可空(包括rest本身为空),那sym就处在产生式末尾,FOLLOW(nt)要并进FOLLOW(sym)。这个判断如果写错,症状就是某些 FOLLOW 集少了起始符的$,或者少了外层非终结符的后续符号,很隐蔽。

6.3 建表与分析主循环

SELECT 集和建表可以合并成一步:对每条产生式算 SELECT,然后逐个填格子。填的过程中顺手检测冲突,一旦发现同一格被填两次,直接抛异常并打印冲突的两条产生式,这就是 3.2 节提到的"多重入口"。

def build_table(grammar, first, follow, nonterms, terms): table = {} conflicts = [] for nt, prods in grammar.items(): for prod in prods: select = set() nullable = True for sym in prod: if sym in terms: select.add(sym) nullable = False break select |= first[sym] - {""} if "" not in first[sym]: nullable = False break if nullable: select |= follow[nt] for a in select: key = (nt, a) if key in table: conflicts.append((key, table[key], prod)) else: table[key] = prod return table, conflicts

分析主循环用显式栈,循环条件是"栈和输入都处理完"。每次循环看栈顶top和当前输入cur:top是终结符或$就比较,相等就一起前进,不等就报错;top是非终结符就查表,查到就逆序压栈,查不到就报错。每轮把三要素记进 trace 列表,跑完打印出来,就是一张现成的过程表。

def parse(table, tokens, start, terms): stack = ["$", start] pos = 0 trace = [] while True: top = stack[-1] cur = tokens[pos] snap = ("".join(reversed(stack)), "".join(tokens[pos:])) if top == "$" and cur == "$": trace.append(snap + ("接受",)) return trace if top in terms or top == "$": if top == cur: trace.append(snap + (f"匹配 {cur}",)) stack.pop() pos += 1 else: trace.append(snap + (f"错误:期待 {top},实际 {cur}",)) return trace else: prod = table.get((top, cur)) if prod is None: trace.append(snap + (f"错误:查表 M[{top}, {cur}] 为空",)) return trace stack.pop() for sym in reversed(prod): stack.append(sym) rhs = " ".join(prod) if prod else "ε" trace.append(snap + (f"{top} → {rhs}",))

这段代码跑出来的 trace 和 4.2 节的手算表应该逐行对得上。如果对不上,先检查 4.1 节说的栈方向,再检查reversed(prod)有没有写漏。我在调试时养成一个习惯:先用id这种最短输入跑,结果和手算比对,通了再跑id + id * id,最后跑一个故意错的串测错误分支。三级验证下来,表的正确性基本能确认。

6.4 验证与扩展的一点经验

把程序跑通只是开始,真正能确认实现正确的方法是双向比对:手算的每一行 trace 和程序输出的每一行 trace 逐行对齐,只要有一行不同就停下来查。这比"程序跑通了就完事"靠谱得多,因为程序可能恰好在一个错误的表上跑出了正确结果——我就遇到过E'在)列填成 (2) 而不是 (3) 的情况,输入id时不影响,输入(id)就崩了。

扩展方向上,我建议先加错误恢复再考虑优化。错误恢复用 4.3 节说的恐慌模式,实现只需要十几行:出错时取follow[栈顶]作为同步集,从当前位置往后丢符号,直到当前符号落进同步集或者到$。这个功能对课程设计来说性价比很高,答辩时是明显的加分项。

再往后可以尝试把建表过程可视化,比如把每一步的 FIRST、FOLLOW、SELECT 变化打印出来,或者用文本形式画出栈的变化轨迹。我做这个的时候用了一个简单的文本对齐,每步打印一行,人眼扫一遍就能看出异常。这类辅助工具不值钱,但在调试阶段能省下大量时间。

最后分享一个我踩过的坑:代码里表示空产生式,我一开始用字符串"ε",结果FIRST集里混进了"ε"这个符号,SELECT集计算时判断ε是否在集合里,用"ε" in select一直返回False,因为集合里存的是空字符串"",两个看起来一样的东西在程序里完全不同。排查了两个小时才发现是符号表示不统一。后来我统一用空列表[]表示空产生式右部、空字符串""表示可空标记,问题再没出现过。这种"符号表示一致性"的问题在手算时不存在,一写代码就冒出来,值得提前定好规范。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询