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)继续看 BFIRST(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。右部第一个符号是 FFIRST(F) {(, i}其中没有 ε因此链式推进直接停止。所以 FIRST(T) {(, i}。这里值得停顿一下T 的 First 和 F 完全一样因为 T 的首符号就是 F而 F 不可空。然后 E → T E。以终结符 开头候选 ε 带入 ε所以 FIRST(E) {, ε}。最后 E → T E。右部第一个符号是 TFIRST(T) {(, i}T 不可空所以 FIRST(E) {(, i}。最终非终结符FIRST集E{(, i}E{, ε}T{(, i}T{*, ε}F{(, i}你可能会疑惑为什么 First 计算要从底层往上推因为高层非终结符的 First 依赖低层非终结符的 First如果底层没算出来高层就没法判断链式推进是否继续。不过如果文法中存在环形的依赖关系比如 A 的 First 依赖 BB 的 First 又依赖 A那就不能指望从底到顶一次搞定需要反复迭代直到所有集合不再变化。这在编译原理里叫不动点是很多算法的基础思路。3. Follow集的计算规则、传递逻辑与完整手算3.1 Follow集的定义与四条核心规则如果说 First 集是向前看Follow 集就是向后看。定义如下对非终结符 AFOLLOW(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 ET 的右边是 E。FIRST(E) 除 ε 外是 {}所以 加入 FOLLOW(T)。因为 ε ∈ FIRST(E)按规则三FOLLOW(E) {#} 也要加入 FOLLOW(T)。目前 FOLLOW(T) {, #}。E 在右部最末尾按规则四FOLLOW(E) {#} 加入 FOLLOW(E)。目前 FOLLOW(E) {#}。对 E → T ET 的右边是 E处理方式和上面一样 加入 FOLLOW(T)已有且 FOLLOW(E) {#} 也加入 FOLLOW(T)。FOLLOW(T) 仍为 {, #}。对 T → F TF 的右边是 T。FIRST(T) 除 ε 外是 {*}所以 * 加入 FOLLOW(F)。ε ∈ FIRST(T)所以 FOLLOW(T) {, #} 也加入 FOLLOW(F)。目前 FOLLOW(F) {*, , #}。T 在右部末尾FOLLOW(T) {, #} 加入 FOLLOW(T)。目前 FOLLOW(T) {, #}。对 T → * F TF 的右边是 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 的计算。先求 FirstL → * R | i右部直接以终结符开头FIRST(L) {*, i}。R → LFIRST(R) FIRST(L) {*, i}。S → L R | RFIRST(S) FIRST(L) ∪ FIRST(R) {*, i}。再算 Follow。初始化FOLLOW(S) {#}。逐条扫描所有产生式S → L RL 的右边是终结符 所以 加入 FOLLOW(L)。R 在右部末尾所以 FOLLOW(S) {#} 加入 FOLLOW(R)。当前 FOLLOW(R) {#}。S → RR 在末尾FOLLOW(S) 再次加入 FOLLOW(R)FOLLOW(R) 仍为 {#}。L → * RR 在末尾FOLLOW(L) 加入 FOLLOW(R)。此时 FOLLOW(L) 里有 {}所以 FOLLOW(R) 更新为 {#, }。L → i右部只有终结符没有非终结符需要处理。R → LL 在末尾FOLLOW(R) 加入 FOLLOW(L)。此时 FOLLOW(R) {#, }所以 FOLLOW(L) 更新为 {, #}。到这里第一轮扫描结束。但注意第 5 步中 FOLLOW(R) 的值被第 3 步更新过而 FOLLOW(L) 又反过来可能影响第 3 步——需要再扫一轮。第二轮扫描重新看 L → * RR 在末尾FOLLOW(L) {, #} 加入 FOLLOW(R)。FOLLOW(R) 目前已经是 {, #}没有变化。重新看 R → LL 在末尾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(α) 中的每个终结符 aa ≠ ε在 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 EFIRST(T E) {(, i}所以 M[E, (] 和 M[E, i] 都填 1。产生式 (2)E → T EFIRST( T E) {}M[E, ] 填 2。产生式 (3)E → εFIRST(ε) 含 ε查 FOLLOW(E) {#, )}所以 M[E, #] 和 M[E, )] 都填 3。产生式 (4)T → F TFIRST(F) {(, i}M[T, (] 和 M[T, i] 填 4。产生式 (5)T → * F TFIRST {*}M[T, *] 填 5。产生式 (6)T → ε查 FOLLOW(T) {, #, )}M[T, ]、M[T, #]、M[T, )] 都填 6。产生式 (7)F → ( E )FIRST {(}M[F, (] 填 7。产生式 (8)F → iFIRST {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 BA 的 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 Ca 直接进 FIRST(B)产生式 A → B cc 直接进 FOLLOW(B)。终结符不需要递归推导别在这个环节浪费脑容量。多轮迭代时抄一遍当前集合状态再开下一轮。迭代最容易忘的是我这个集合是从哪个版本开始更新的。我的做法是每轮更新完把整张表重新抄一遍和上一版对比。虽然机械但能把失误率降到最低。这些习惯本身不难难的是坚持用。等你连续算了几个文法、形成肌肉记忆你会发现 First 和 Follow 的计算已经变成了一件非常程序化的事情而编译原理里那些更抽象的概念也会因为这两个集合的扎实理解而变得好啃很多。