1. 这两个集合到底用来干什么——先建立"计算直觉"再看规则
1.1 LL(1)分析里,First集和Follow集各管哪一段
First集和Follow集,这大概是编译原理课程里让最多人卡住的第一道坎。我第一次学的时候,教材上的定义看三遍没看懂,后来发现其实这两玩意儿的计算过程非常机械,真正拦人的不是计算本身,而是没搞明白"我算这些东西是要干什么"。
在自顶向下的 LL(1) 分析中,我们手里有一组产生式(语法规则),输入是一个待分析的终结符序列。分析器要从文法的开始符号出发,不断选择产生式进行展开,直到把输入串完全匹配掉。问题是:当一个非终结符有多个候选式时,凭什么选这一条而不是那一条?最直觉的做法就是"看当前输入符号是什么"。如果候选式一只能推导出以 a 开头的句子,候选式二只能推导出以 b 开头的句子,那当输入符号是 a 时,当然优先展开候选式一。
First集解决的问题正是"一个文法符号或符号串,可能推导出以哪些终结符开头";而Follow集解决的是"某个非终结符在推导过程中,后面可能紧跟哪些终结符"。前者决定"往下能长出什么",后者决定"当前这个位置过去之后,下一个要匹配什么"。学的时候把这两个问题装在心里,后面所有规则都会变得顺理成章。
1.2 用"排队"和"首词"两种直觉快速建立画面感
关于First集,你可以把它理解成一个非终结符的"首词集合"。比如一个非终结符 A,它有产生式 A → aB 和 A → c,那么从 A 出发推导出的任何句子,最左边要么是 a,要么是 c,所以 FIRST(A) 至少包含 {a, c}。如果 A 还有一个候选式是 ε,那 A 可以被整个跳过,这时候 ε 也放进 FIRST(A),表示这个非终结符可能什么都不产出,让位给后面的符号。这个 "ε 代表可缺席" 的视角非常重要。
Follow集可以换个角度想:把非终结符想成一个正在排队的"坑位",Follow集就是"坐在它后面的可能人选"。坐在后面的人只能是终结符,或者是表示输入结束的 #。如果某个产生式右部出现了 目标非终结符 后面跟一串符号,那这一串符号能推导出的首终结符,就是那个"坐后面的人";如果后面的整串符号都能变成空串,那么真正坐在目标后面的,就要看外层产生式左部后面的符号了,这就产生了 Follow 集的"传递"行为。
其实编译原理里很多抽象定义,落地到直觉上都很朴素。带着这两种画面感去读下一节的计算规则,你会发现每条规则都能对应到一个朴素的场景。
2. First集的计算:规则、链式逻辑与完整手算
2.1 First集的定义与三条核心规则
正式定义是:对文法符号串 α,FIRST(α) = { a | α 推导出 aβ,a 为终结符 }。如果 α 可以推导出空串 ε,则 ε ∈ FIRST(α)。但在实际操作中,建议不要跟这个带星号的推导记号死磕,直接按下面三条规则执行即可。
规则一:终结符的 First 就是它自己。如果 X 是终结符,那么 FIRST(X) = {X}。终结符不能继续展开,它最左边的符号当然就是它本身。
规则二:产生式右部以终结符开头,直接收入。如果 X → a β,其中 a 是终结符,那么 a 一定属于 FIRST(X)。这是最"白给"的一步,看到右部首符号是终结符,直接加进左部的 First 集合。
规则三:产生式右部以非终结符开头,采用链式推进。如果 X → Y1 Y2 ... Yk,那么:
- 先把 FIRST(Y1) 中除 ε 之外的所有符号放入 FIRST(X);
- 如果 ε ∈ FIRST(Y1),继续看 Y2,把 FIRST(Y2) 中除 ε 之外的符号放入 FIRST(X);
- 如果 ε 同时属于 FIRST(Y1) 和 FIRST(Y2),继续看 Y3,依此类推;
- 如果从 Y1 到 Yk 全部都能推导出 ε,最后把 ε 也放入 FIRST(X)。
规则三是整个 First 计算的核心,也是最容易绕晕的地方。为什么需要这样往右"扫描"?因为 X → Y1 Y2 ... 说的是 X 第一步展开后最左边出现的是 Y1。如果 Y1 能变成空串,那么真正的首符号转由 Y2 决定;如果 Y1 和 Y2 都能变成空串,再往后顺延到 Y3。这和"一个位置没人坐,就顺延到下一个位置"是一模一样的逻辑。还有个小细节需要强调:规则三是对产生式右部串而言的,一个非终结符的 First 集,是它的所有候选式右部 First 集的并集。只要有一条候选式能给某个终结符,它就算数。
2.2 带ε产生式时的"链式推进"到底怎么操作
链式推进是在求 First 时最容易出错的地方。核心就一句话:当右部以非终结符开头时,不要只收第一个符号的 First 结果,还要判断这个符号的 First 里有没有 ε,有 ε 才能继续看下一个符号。
来看一个具体的链式推进场景。假设有产生式 S → A B C,已知 FIRST(A) = {a, ε},FIRST(B) = {b, ε},FIRST(C) = {c}。求 FIRST(S) 的过程:
- 先把 FIRST(A) 除 ε 之外的部分 {a} 放入 FIRST(S);
- 因为 ε ∈ FIRST(A),说明 A 可能消失,继续看 B,把 {b} 放入 FIRST(S);
- 因为 ε ∈ FIRST(B),继续看 C,把 {c} 放入 FIRST(S);
- 此时 C 的 First 里没有 ε,链式推进停止,ε 不能放入 FIRST(S)。
如果 C 也恰好有 ε-产生式,即 C → ε,那么 A、B、C 三个符号全部可空,S 整体可以推导出空串,此时 ε 才属于 FIRST(S)。只有当右部所有符号都能推导出 ε 时,左部的 First 里才放入 ε。中间任何一个环节断了,ε 就断在里面了。这个结论要刻在脑子里。
2.3 手算实例:S→AB, A→aB|ε, B→b|ε
来完整计算一个小的文法,走一遍闭环流程:
S → A B A → a B | ε B → b | ε依次确定每个非终结符的 First:
先看 A。A 的两个候选式分别是 aB 和 ε。对于 aB,右部首符号是终结符 a,所以 a ∈ FIRST(A);对于 ε,直接把 ε 放入 FIRST(A)。因此 FIRST(A) = {a, ε}。
再看 B。B 的两个候选式是 b 和 ε,同理 FIRST(B) = {b, ε}。
最后看 S。S 的右部是 A B。从 A 开始:FIRST(A) 除 ε 外是 {a},所以 a 进入 FIRST(S);因为 ε ∈ FIRST(A),继续看 B,FIRST(B) 除 ε 外是 {b},所以 b 进入 FIRST(S);ε ∈ FIRST(B),右部已经扫描完,且 A 和 B 都能推出 ε,所以 ε 进入 FIRST(S)。最终 FIRST(S) = {a, b, ε}。
汇总结果:
| 非终结符 | FIRST集 |
|---|---|
| S | {a, b, ε} |
| A | {a, ε} |
| B | {b, ε} |
这个文法的推导结果也符合直觉:S 能推导出的句子包括 ab、a、b 和空串,首符号集合自然就是 {a, b, ε}。你可以观察到一个被反复强调的坑点——A 的 First 里有 ε,S 的 First 里也有 ε,这不是因为 A 能推 ε,而是因为 A 和 B 都能推 ε。
2.4 手算实例:经典算术表达式文法(消除左递归版)
再看一个更贴近真实课程的文法。原始的算术表达式文法往往带左递归(E → E + T | T),但 LL(1) 分析前要消除左递归,常见的版本是这样:
E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | i这里的 i 指标识符(id),() 是括号,+ 和 * 是运算符。
计算顺序建议从底层开始,也就是从依赖链最底端的非终结符算起。
先看 F。F 的候选式 (E) 以终结符 ( 开头,候选式 i 以终结符 i 开头,所以 FIRST(F) = {(, i}。
再看 T'。T' → * F T',右部首符号是终结符,所以 * ∈ FIRST(T');候选式 ε 使 ε 也加入 FIRST(T')。FIRST(T') = {, ε}。
接着 T → F T'。右部第一个符号是 F,FIRST(F) = {(, i},其中没有 ε,因此链式推进直接停止。所以 FIRST(T) = {(, i}。这里值得停顿一下:T 的 First 和 F 完全一样,因为 T 的首符号就是 F,而 F 不可空。
然后 E' → + T E'。以终结符 + 开头,候选 ε 带入 ε,所以 FIRST(E') = {+, ε}。
最后 E → T E'。右部第一个符号是 T,FIRST(T) = {(, i},T 不可空,所以 FIRST(E) = {(, i}。
最终:
| 非终结符 | FIRST集 |
|---|---|
| E | {(, i} |
| E' | {+, ε} |
| T | {(, i} |
| T' | {*, ε} |
| F | {(, i} |
你可能会疑惑:为什么 First 计算要从底层往上推?因为高层非终结符的 First 依赖低层非终结符的 First,如果底层没算出来,高层就没法判断链式推进是否继续。不过如果文法中存在环形的依赖关系,比如 A 的 First 依赖 B,B 的 First 又依赖 A,那就不能指望"从底到顶一次搞定",需要反复迭代直到所有集合不再变化。这在编译原理里叫"不动点",是很多算法的基础思路。
3. Follow集的计算:规则、传递逻辑与完整手算
3.1 Follow集的定义与四条核心规则
如果说 First 集是"向前看",Follow 集就是"向后看"。定义如下:对非终结符 A,FOLLOW(A) = { a | 从文法的开始符号 S 出发,能够推导出某个句型,其中 A 的后面紧跟 a }。如果 A 可能出现在某个句型的最末尾,那么输入结束标记 # 也属于 FOLLOW(A)。
计算 Follow 集有四个规则,我建议这样记忆:
规则一(开始符号):如果 S 是文法的开始符号,那么 # ∈ FOLLOW(S)。
规则二(后面有内容):如果有产生式 A → α B β,其中 β 不是空串,那么 FIRST(β) 中除 ε 之外的所有符号都放入 FOLLOW(B)。
规则三(后面的内容可变为空):如果有产生式 A → α B β,且 ε ∈ FIRST(β),那么 FOLLOW(A) 中的所有符号都放入 FOLLOW(B)。
规则四(B 在右部末尾):如果有产生式 A → α B,那么 FOLLOW(A) 中的所有符号都放入 FOLLOW(B)。
规则三和规则四本质上可以合并成一条更朴素的规则:在产生式 A → α B β 中,如果 β 能整体推导出 ε(包括 β 本身就是空串的情况),那么 FOLLOW(A) 全部传给 FOLLOW(B)。后面的实际操作里,建议你用这个合并后的视角去判断,比分开记两条更不容易漏。
还有一个特别容易踩的点:Follow 集合里只可能有终结符和 #,永远不会有 ε。原因是 Follow 描述的是"实际句型中紧跟在某个非终结符后面的终结符",而 ε 表示空串,不是一个真正的"后面来的符号"。算完后如果发现自己的 Follow 集合里有 ε,几乎可以断定执行规则时出了问题。
3.2 最容易误解的"β 可以为空"传递规则
规则三是初学者最容易绕晕的地方。先看一个抽象例子:
A → B C D C → γ | ε在这个文法中,B 的右边是 C D。如果 C 不能推出 ε,那么 FOLLOW(B) 里只需要放入 FIRST(C D) 除 ε 之外的符号。但 C 可以推出 ε,那么在实际推导中,B 后面可能直接就是 D 能吃出的首终结符;如果 D 也能推出 ε,那 B 后面还可能直接就是 A 后面的符号。这就是为什么当 β 整体可空时,FOLLOW(A) 要"传"给 FOLLOW(B)。
排队类比依然好用:B 前面排的是 A,后面原本安排的是 C、D。如果 C 和 D 都可以"临时有事不来",那排在 B 后面的人,最终就成了排在 A 后面的人。Follow 的传递规则,本质上就是把外层紧随符号一层层往里传。你自己做题时遇到形如 A → α B,或者 A → α B β 但 β 全可空的情况,可以直接写一行 "FOLLOW(A) ⊆ FOLLOW(B)",然后到迭代阶段统一处理。
3.3 手算实例:经典算术表达式文法的 Follow 集
继续用算术表达式文法,完整算一遍 Follow。先把产生式列出来:
E → T E' E' → + T E' | ε T → F T' T' → * F T' | ε F → ( E ) | iFirst 结果我们已经有了,方便对照:
| 非终结符 | FIRST集 |
|---|---|
| E | {(, i} |
| E' | {+, ε} |
| T | {(, i} |
| T' | {*, ε} |
| F | {(, i} |
第一步,初始化。E 是开始符号,所以 FOLLOW(E) = {#}。
第二步,逐条扫描产生式,收集"直接可见"的部分。
对 E → T E':
- T 的右边是 E'。FIRST(E') 除 ε 外是 {+},所以 + 加入 FOLLOW(T)。
- 因为 ε ∈ FIRST(E'),按规则三,FOLLOW(E) = {#} 也要加入 FOLLOW(T)。目前 FOLLOW(T) = {+, #}。
- E' 在右部最末尾,按规则四,FOLLOW(E) = {#} 加入 FOLLOW(E')。目前 FOLLOW(E') = {#}。
对 E' → + T E':
- T 的右边是 E',处理方式和上面一样:+ 加入 FOLLOW(T)(已有),且 FOLLOW(E') = {#} 也加入 FOLLOW(T)。FOLLOW(T) 仍为 {+, #}。
对 T → F T':
- F 的右边是 T'。FIRST(T') 除 ε 外是 {*},所以 * 加入 FOLLOW(F)。
- ε ∈ FIRST(T'),所以 FOLLOW(T) = {+, #} 也加入 FOLLOW(F)。目前 FOLLOW(F) = {*, +, #}。
- T' 在右部末尾,FOLLOW(T) = {+, #} 加入 FOLLOW(T')。目前 FOLLOW(T') = {+, #}。
对 T' → * F T':
- F 的右边是 T',所以 * 再次加入 FOLLOW(F)(已有),FOLLOW(T') = {+, #} 也加入 FOLLOW(F),FOLLOW(F) 保持 {*, +, #}。
对 F → ( E ):
- E 的右边是终结符 )。FIRST()) 就是 {)},所以 ) 加入 FOLLOW(E)。FOLLOW(E) 更新为 {#, )}。
这时候你会发现一个关键问题:FOLLOW(E) 在最后一刻增加了 ),而前面推导 FOLLOW(T)、FOLLOW(E')、FOLLOW(T')、FOLLOW(F) 时,都用到了"FOLLOW(E) 传入"这个动作。也就是说,第一轮扫描的结果可能不是最终结果,需要再来一轮。
第三轮扫描,重点检查所有用到 FOLLOW(E)、FOLLOW(T)、FOLLOW(T') 的传递:
- 重新看 E → T E':由于 ε ∈ FIRST(E'),FOLLOW(E) = {#, )} 全部加入 FOLLOW(T)。FOLLOW(T) 变为 {+, #, )}。
- 同时 FOLLOW(E) 加入 FOLLOW(E'),FOLLOW(E') 变为 {#, )}。
- 重新看 T → F T':由于 ε ∈ FIRST(T'),FOLLOW(T) = {+, #, )} 加入 FOLLOW(F)。FOLLOW(F) 变为 {*, +, #, )}。
- 同时 FOLLOW(T) 加入 FOLLOW(T'),FOLLOW(T') 变为 {+, #, )}。
继续扫描一轮,发现集合都不再变化。最终结果:
| 非终结符 | FOLLOW集 |
|---|---|
| E | {#, )} |
| E' | {#, )} |
| T | {+, #, )} |
| T' | {+, #, )} |
| F | {*, +, #, )} |
这里有个重要观察:FOLLOW(T') 和 FOLLOW(T) 完全一样,因为 T' 只出现在 T 产生式的末尾,T 后面能接什么,T' 也能接什么。类似地,FOLLOW(E') = FOLLOW(E)。这种"尾部非终结符继承左部 Follow"的现象非常普遍,可以作为自查的参考。
3.4 手算实例:交叉递归文法(需要多轮迭代)
刚才的例子虽然涉及第二轮扫描,但还算温和。下面这个例子是两个非终结符互相依赖,必须靠多轮迭代才能收敛。文法如下:
S → L = R | R L → * R | i R → L这个文法经常在讨论"非 LL(1) 文法"时出现,我们先拿它练 Follow 的计算。
先求 First:
- L → * R | i,右部直接以终结符开头,FIRST(L) = {*, i}。
- R → L,FIRST(R) = FIRST(L) = {*, i}。
- S → L = R | R,FIRST(S) = FIRST(L) ∪ FIRST(R) = {*, i}。
再算 Follow。初始化:FOLLOW(S) = {#}。
逐条扫描所有产生式:
S → L = R:
- L 的右边是终结符 =,所以 = 加入 FOLLOW(L)。
- R 在右部末尾,所以 FOLLOW(S) = {#} 加入 FOLLOW(R)。当前 FOLLOW(R) = {#}。
S → R:
- R 在末尾,FOLLOW(S) 再次加入 FOLLOW(R),FOLLOW(R) 仍为 {#}。
L → * R:
- R 在末尾,FOLLOW(L) 加入 FOLLOW(R)。此时 FOLLOW(L) 里有 {=},所以 FOLLOW(R) 更新为 {#, =}。
L → i:右部只有终结符,没有非终结符需要处理。
R → L:
- L 在末尾,FOLLOW(R) 加入 FOLLOW(L)。此时 FOLLOW(R) = {#, =},所以 FOLLOW(L) 更新为 {=, #}。
到这里,第一轮扫描结束。但注意,第 5 步中 FOLLOW(R) 的值被第 3 步更新过,而 FOLLOW(L) 又反过来可能影响第 3 步——需要再扫一轮。
第二轮扫描:
- 重新看 L → * R:R 在末尾,FOLLOW(L) = {=, #} 加入 FOLLOW(R)。FOLLOW(R) 目前已经是 {=, #},没有变化。
- 重新看 R → L:L 在末尾,FOLLOW(R) = {=, #} 加入 FOLLOW(L)。FOLLOW(L) 目前已经是 {=, #},没有变化。
- 其余产生式也没有带来新元素。
于是最终结果为:
| 非终结符 | FOLLOW集 |
|---|---|
| S | {#} |
| L | {=, #} |
| R | {=, #} |
如果只扫一轮就直接交卷,你很可能把 FOLLOW(L) 算成 {=},把 FOLLOW(R) 算成 {#}。但实际上,R → L 这条产生式的存在,让 FOLLOW(R) 和 FOLLOW(L) 互相"传染",必须迭代到不动点。这也是 Follow 计算和 First 计算一个很重要的区别:First 更像"自下而上的汇总",Follow 更像"全局传导的扩散",后者对迭代敏感得多。
4. 把两个集合串起来:构造LL(1)预测分析表并验证文法性质
4.1 预测分析表的填表规则
First 和 Follow 算完之后真正要干什么?对于很多课程来说,紧接着的任务就是构造 LL(1) 预测分析表。表的行是非终结符,列是终结符和 #,表项写的是"当前输入符号为该终结符时,应该选用哪个产生式"。
填表规则非常机械,对每个产生式 A → α:
- 对 FIRST(α) 中的每个终结符 a(a ≠ ε):在 M[A, a] 位置填入 A → α;
- 如果 ε ∈ FIRST(α):则对 FOLLOW(A) 中的每个符号 b(包括 #),在 M[A, b] 位置填入 A → α。
第二条规则值得细品:当 α 能推导出空串时,A 可以选择"原地消失",但前提是消失后,输入串中的当前符号必须在 FOLLOW(A) 里,否则后面会接不上。所以 ε-产生式什么时候用,不看 FIRST(α)(因为 ε 不代表实际输入符号),而是看 FOLLOW(A)。第一条和第二条合起来,就是填表的全部逻辑。
如果在填表过程中,某个格子被填入了两个不同的产生式,说明文法在这个位置上存在冲突,文法就不是 LL(1) 文法。这也是 First 和 Follow 在自顶向下分析里最核心的应用:判断一个文法的 LL(1) 性。
4.2 完整填表与 LL(1) 判定
继续沿用算术表达式文法,给每个产生式编个号:
(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 → i前面已算出两类集合:
| 非终结符 | FIRST集 | FOLLOW集 |
|---|---|---|
| E | {(, i} | {#, )} |
| E' | {+, ε} | {#, )} |
| T | {(, i} | {+, #, )} |
| T' | {*, ε} | {+, #, )} |
| F | {(, i} | {*, +, #, )} |
逐条填表:
- 产生式 (1):E → T E',FIRST(T E') = {(, i},所以 M[E, (] 和 M[E, i] 都填 1。
- 产生式 (2):E' → + T E',FIRST(+ T E') = {+},M[E', +] 填 2。
- 产生式 (3):E' → ε,FIRST(ε) 含 ε,查 FOLLOW(E') = {#, )},所以 M[E', #] 和 M[E', )] 都填 3。
- 产生式 (4):T → F T',FIRST(F) = {(, i},M[T, (] 和 M[T, i] 填 4。
- 产生式 (5):T' → * F T',FIRST = {*},M[T', *] 填 5。
- 产生式 (6):T' → ε,查 FOLLOW(T') = {+, #, )},M[T', +]、M[T', #]、M[T', )] 都填 6。
- 产生式 (7):F → ( E ),FIRST = {(},M[F, (] 填 7。
- 产生式 (8):F → i,FIRST = {i},M[F, i] 填 8。
全部格子都没有冲突,因此这个算术表达式文法是 LL(1) 文法。你可以看到,大多数非 ε-产生式只靠 FIRST 就能确定填表位置,而 ε-产生式必须依赖 FOLLOW 来"兜底",不然根本不知道该放在哪一列。如果把 FOLLOW 算错,这里立刻就会暴露出来。
4.3 非LL(1)文法的冲突,长什么样
并不是所有文法都像算术表达式这样"乖巧"。冲突一般有两类:First 冲突和 First 与 Follow 冲突。
第一类:两个候选式有重叠的 First。比如 A → a B | a C,两个候选式都能推导出以 a 开头的句子,那么在 M[A, a] 位置就同时出现两个产生式,冲突。这种问题通常可以用提取左因子来缓解,把 a 提取出来变成 A → a (B | C)。
第二类:候选式 α 的 First 里有 a,而另一个候选式 β 能推出 ε,并且 FOLLOW(A) 里也有 a。这时情况就暧昧了:输入是 a,既可以选择展开 α 去匹配 a,也可以选择用 β 让 A 消失,然后寄希望于后面的内容以 a 开头。两种解释同时成立,表里也会出现冲突。典型例子就是:
S → i E t S | i E t S e S | a两个候选式都以 i 开头,M[S, i] 直接打架,所以它不是 LL(1) 文法。
实际做题时,算出 First 和 Follow 后不要急着交卷,填一遍预测分析表,看有没有格子冲突。填表是检验集合算没算对、也检验文法 LL(1) 性的最直接工具,比单独看集合更直观。如果表冲突了,先别怀疑文法,先回去核对两个集合,很多时候是 Follow 多算或少算了一个符号导致的。
5. 高频错误、自检方法和手算加速技巧
5.1 五个最容易被扣分的细节
看了这么多年作业和论坛提问,下面这几个错误重复率极高,建议你对着自查。
第一,把 ε 塞进 Follow 集合。这是最经典的错误。Follow 里只允许终结符和 #。如果你在某一步把 FIRST(β) 里的 ε"顺手"放进了 Follow,那后面填表一定全部错乱。记住这条铁律:Follow 永无 ε。
第二,求 First 时漏掉链式推进,尤其是漏判"右部是否全部可空"。例如 S → A B,A 的 First 有 ε,B 的 First 没有 ε,那 FIRST(S) 不能放 ε。有些人看到 A 能推空,就顺手套用了 First(A) 的结论,结果把 ε 错误地放进了 FIRST(S)。
第三,Follow 计算时漏掉"目标后面的串整体可空"的传递。不少同学只记住了"目标非终结符在右部末尾时要传 FOLLOW(左部)",却忘了目标后面跟了一串可空符号时也需要传。其实这两条是同一个逻辑——后面的东西能消失到空,外层紧随符号就得兜底。建议做题时统一写成:只要目标之后的内容能推导出 ε,就把 FOLLOW(左部) 传给 FOLLOW(目标)。
第四,全局视角缺失,只盯着单条产生式。一个非终结符可能出现在多条产生式的右部,每条都得检查。Follow 是非终结符在整个文法中的属性,不是某条产生式单独决定的。漏掉任意一条产生式,集合就可能缺元素。
第五,初始化时把 # 只给开始符号就觉得万事大吉。开始符号的 Follow 里先放 # 是初始化,但其他非终结符的 Follow 里也可能出现 #,只要它们能出现在某个句型的最末尾。不要因为 # 只初始化给了开始符号,就默认它不会出现在别的集合里。
5.2 如何验证你的计算结果真的正确
最实用的验证方法是"不动点程序对照法"。First 和 Follow 的计算本质上都是集合的不断扩张,直到不再变化。你可以用 Python 写一个三四十行的脚本做交叉验证。核心思路是维护一个字典,每个非终结符对应一个 set,然后循环扫描产生式,只要某个集合发生了更新,就继续下一轮,直到所有 set 都不变。
下面是一个求 First 集的极简伪代码框架:
# 伪代码:求所有非终结符的 FIRST,迭代到不动点 while changed: for (A, rhs) in productions: if rhs == "": # A -> ε FIRST[A].add("ε") else: for X in rhs: FIRST[A] |= (FIRST[X] - {"ε"}) if "ε" not in FIRST[X]: break else: FIRST[A].add("ε")Follow 的伪代码类似,只需要额外处理"β 全可空时把 FOLLOW(A) 传给 FOLLOW(末尾符号)"这一逻辑。写完后把程序输出与手算结果对比,不一致就说明某一步出了问题。
另一个更轻量的验证方式是"试试定义反推"。比如你算出 FOLLOW(B) = {a, b, #},那就从开始符号出发,试着构造一个能推导出 "… B a …" 的句型。如果怎么构造都构造不出来,那 a 很可能放多了;反之,如果确实存在这样的句型,那它就应该在 Follow 中。这种方法虽然比较费时,但对理解概念极有帮助,考试时也可以用来排除明显错误。
5.3 手算时的操作习惯建议
最后分享几个我实际做题时养成的习惯,能显著降低出错率。
先建表再扫描。把所有非终结符列成一张表,每列对应一个集合,扫描产生式时,每发生一次更新就在表上改一次。这是把不动点算法人工化的关键,比你脑子里"感觉要不要更新"可靠得多。
给产生式编号。填预测分析表、Follow 回溯、检查为什么某个符号进了某个集合,都要用到产生式编号。不编号很容易漏,也说不清楚"这个符号是哪条产生式加进来的"。
先求完 First 再求 Follow。Follow 计算里有一类核心判断是"右部符号串是否能整体推出 ε",这个判断必须依赖 First。所以永远保持 First → Follow → 填表 的顺序,不要跳跃。
终结符是"死信息",看到了就直接用。比如 B → a C,a 直接进 FIRST(B);产生式 A → B c,c 直接进 FOLLOW(B)。终结符不需要递归推导,别在这个环节浪费脑容量。
多轮迭代时,抄一遍当前集合状态再开下一轮。迭代最容易忘的是"我这个集合是从哪个版本开始更新的"。我的做法是每轮更新完,把整张表重新抄一遍,和上一版对比。虽然机械,但能把失误率降到最低。
这些习惯本身不难,难的是坚持用。等你连续算了几个文法、形成肌肉记忆,你会发现 First 和 Follow 的计算已经变成了一件非常"程序化"的事情,而编译原理里那些更抽象的概念,也会因为这两个集合的扎实理解而变得好啃很多。