1. 为什么LR分析器的“移进-规约”过程总让人卡在第一步?
你有没有试过翻开编译原理教材,翻到LR分析那一章,看到状态转换图、ACTION/GOTO表、一堆带方括号的项目集规范族,然后盯着“S’ → ·S”这个初始项目发呆——明明每个符号都认识,可当它动起来,移进一个a,规约一个A→α,再移进一个b……整个过程就像看别人打太极拳:动作慢、姿势对、但完全不知道力从哪来、气往哪走。
这根本不是理解力的问题。是绝大多数教学材料把LR分析器讲成了“结果展示机”,而不是“推演引擎”。它只告诉你最终那张表怎么查,却从不带你亲手推一遍:那个状态栈里到底压了什么?输入指针停在哪?为什么此刻必须移进而不是规约?如果选错了,下一步会崩成什么样?
我带过几届某高校编译技术实践课,每次讲到LR(0)分析,总有学生在作业里写出这样的伪代码:
if lookahead in ACTION[current_state]: if ACTION[current_state][lookahead] == 'shift': push_state(new_state) consume_token() elif ACTION[current_state][lookahead] == 'reduce': pop_n_states(n) goto_state(GOTO[old_state][lhs])逻辑没错,但问题出在“pop_n_states(n)”——n是多少?怎么算?没人教。他们抄了课本上“A → α”的长度,却不知道α是文法符号串,不是字符数;更不知道当α为空(ε产生式)时,n=0意味着什么:栈顶状态不动,直接查GOTO表跳转。这种细节缺失,导致调试时栈深错位、状态不匹配、报错信息像天书。
而本篇要做的,就是彻底拆开LR分析器的“黑箱盖子”,用一个真实可手算、可验证、可画图的极简文法,从空栈开始,逐字符喂入,每一步记录栈状态、输入剩余、动作选择、依据来源,不跳步、不省略、不假设你已懂“项目集闭包”。我们不用任何工具生成表,就用纸笔+逻辑,把“移进”和“规约”这两个动作背后的状态演化、冲突判定、归约依据,一帧一帧推演清楚。
这不是复习课,是一次现场直播式的推演手术。你要做的,只是跟住每一个符号、每一次压栈、每一次回溯。当你能独立完成第5步的规约决策,并说出“为什么这里不能移进”,你就真正拿到了LR分析器的操纵杆。
关键词:LR分析器、移进规约、项目集规范族、ACTION表、GOTO表、分析栈、归约依据、冲突判定
2. 实例文法与推演准备:为什么选这个文法,而不是教科书里的S→aSb|ε?
选文法,是推演成败的第一道门槛。太多教程一上来就甩出“E → E + T | T, T → T * F | F, F → ( E ) | id”这种经典但臃肿的表达式文法。它有3个非终结符、7个产生式、状态数超10,手算一个完整句子就要画满三页纸,中间错一个项目,后面全崩。这不是教学,是体能测试。
我们选一个经过精密裁剪的文法,它必须满足四个硬性条件:
- 足够小:非终结符≤2个,产生式≤4条,确保单次推演步骤可控(目标:全程≤12步);
- 必现冲突:在某个状态必须同时存在移进/规约冲突,否则无法解释“为什么LR(0)不够用,需要SLR或LR(1)”;
- 含ε产生式:强制暴露“规约空串”这一极易被忽略的操作细节;
- 有明确语义锚点:每个符号都能对应一个具体动作含义,避免纯符号迷宫。
最终选定文法 G:
S' → S // 增广文法起始 S → a S b // 主递归结构 S → ε // 空产生式,制造规约时机别小看这三条。它简洁得像一把瑞士军刀:
S' → S是所有LR分析的起点,提供唯一入口;S → a S b构建了典型的“外层包裹”结构,a和b是显性标记,S是待展开的内核;S → ε是真正的“暗桩”——它让S可以瞬间坍缩为无,从而在输入流中制造“该继续读a,还是该立刻把空S规约为S?”的抉择点。
我们推演的目标句子是:a a b b
注意:不是“aabb”,而是带空格的a a b b,因为我们要严格按词法单元(token)处理,每个a、每个b都是独立符号。这模拟了真实编译器前端输出的token流。
为什么选它?因为它的推演路径恰好暴露出LR分析器最核心的三类决策节点:
- 第1步:输入首符号a,只能移进(无其他选择);
- 第4步:输入第二个a后,栈顶形成
S' → ·S,S → ·a S b,S → ·三个项目,此时面临“对a移进”还是“对$规约S→ε”的冲突; - 第8步:输入第一个b后,栈中已有
a a,需判断是规约内部S,还是等待更多a。
这个文法不炫技,但每一处都直指要害。接下来,我们将用这张表作为推演地图,它不是结论,而是你下一步要亲手验证的预言:
| 步骤 | 栈顶状态 | 输入剩余 | 动作 | 依据(项目集编号) | 栈新状态(压入后) |
|---|---|---|---|---|---|
| 0 | 0 | a a b b $ | 移进 | I₀: S'→·S | 0 a 3 |
| 1 | 3 | a b b $ | 移进 | I₃: S→a·S b | 0 a 3 a 3 |
| 2 | 3 | b b $ | 规约 | I₃: S→· (ε) | 0 a 3 S 5 |
| 3 | 5 | b b $ | 移进 | I₅: S→a S·b | 0 a 3 S 5 b 6 |
| 4 | 6 | b $ | 规约 | I₆: S→a S b· | 0 a 3 S 5 |
提示:此表仅为预览,后续章节将逐行还原其生成逻辑。重点观察第2步——当栈顶是状态3,输入是b时,为何选择“规约S→ε”而非“移进”?答案不在表里,而在I₃项目集的构成中。这是你必须亲手算出来的关键一跃。
3. 项目集规范族构建:从S'→·S开始,如何手工扩展出全部状态?
所有LR分析的根基,不在栈操作,而在项目集(Item Set)。它是一个状态的全部“认知快照”:当前可能正在识别哪些产生式、识别到了哪个位置(用·分隔)、下一步期待什么符号。没有正确的项目集,ACTION表就是空中楼阁。
我们从增广文法的起始项目出发:S' → ·S。这个点(·)的位置,定义了当前“注意力焦点”——它说:“我现在正站在S'的右边,准备迎接S的到来。”
3.1 闭包(Closure):如何让一个项目“长出所有可能的翅膀”?
闭包不是数学魔术,它是“前瞻性补全”。规则只有一条:如果项目集中有 A → α·Bβ,且B是非终结符,那么必须把B的所有产生式B → ·γ 全部加入,并对这些新项目也递归求闭包。
我们从 I₀ = { S' → ·S } 开始:
- S' → ·S 中,·后是S(非终结符),所以必须加入S的所有产生式,且带·:
- S → ·a S b
- S → ·
- 现在集合变为 { S' → ·S, S → ·a S b, S → · }
- 检查新加入的项目:S → ·a S b 中·后是a(终结符),无需扩展;S → · 后无符号,停止。
所以I₀ = { S' → ·S, S → ·a S b, S → · }
这就是初始状态的全部认知。它告诉你:在程序开头,你既可能在等整个S(S'→·S),也可能在等一个以a开头的S(S→·aSb),还可能直接接受空S(S→·)。
注意:S → · 这个项目是“规约候选者”。只要当前状态包含它,且输入符号在FOLLOW(S)中(此处为{$, b}),就具备规约条件。这是后续冲突判定的伏笔。
3.2 转移(Goto):·向右滑动一格,如何生成新状态?
转移是状态演化的引擎。规则:对项目集I和符号X,Goto(I, X) = Closure({ A → αX·β | A → α·Xβ ∈ I })。简单说:把I中所有·紧挨着X的项目,把·向右挪一格,再对结果求闭包。
我们计算 Goto(I₀, S):
- I₀中·紧挨S的项目:S' → ·S
- 挪点:S' → S·
- 闭包:S' → S· 后无符号,且S'是开始符号,无其他产生式需补。
→ 所以I₁ = { S' → S· }
这是一个“接受状态”(Accept State),当栈顶为I₁且输入为$时,分析成功。
再算 Goto(I₀, a):
- I₀中·紧挨a的项目:S → ·a S b
- 挪点:S → a·S b
- 闭包:·后是S(非终结符),需加S的所有产生式:
- S → ·a S b
- S → ·
→ 所以I₃ = { S → a·S b, S → ·a S b, S → · }
看,I₃和I₀结构相似,但多了一个“已读a”的上下文。它意味着:“我已经吃掉了一个a,现在正等待一个S,之后还要一个b”。
3.3 关键状态I₅与I₆:如何从I₃推导出“读完S后等待b”的状态?
这是推演中最易断链的一环。从I₃出发,我们先算 Goto(I₃, S):
- I₃中·紧挨S的项目:S → a·S b
- 挪点:S → a S·b
- 闭包:·后是b(终结符),无需扩展。
→I₅ = { S → a S·b }
I₅非常干净,只有一条:它说“S已经完整识别(a S b中的S部分已完成),现在只差最后一个b”。
再算 Goto(I₅, b):
- I₅中·紧挨b的项目:S → a S·b
- 挪点:S → a S b·
- 闭包:·在末尾,无扩展。
→I₆ = { S → a S b· }
I₆是“规约触发器”。当栈顶状态为I₆时,无论输入是什么,只要它在FOLLOW(S)中({$, b}),就应执行规约 S → a S b。
现在,我们拥有了推演所需的全部状态:
- I₀ = { S' → ·S, S → ·a S b, S → · }
- I₁ = { S' → S· }
- I₃ = { S → a·S b, S → ·a S b, S → · }
- I₅ = { S → a S·b }
- I₆ = { S → a S b· }
共5个状态,远少于经典文法的10+状态。但这5个,已足够覆盖移进、规约、冲突、接受全部行为。下一节,我们将把这些抽象状态,注入真实的分析栈,看它们如何驱动每一步操作。
4. 完整推演实录:从空栈到接受,12步手写演算全过程
现在,我们进入核心环节:用纸笔,严格按算法,走完 a a b b $ 的全部分析过程。每一步,你都要同步做三件事:更新栈、更新输入、查表决策。我们不预设表,而是根据当前栈顶状态和输入符号,现场推导该做什么。
4.1 步骤0:初始化——空栈与首符号
- 栈:[0] (仅含初始状态0)
- 输入:a a b b $ (记作 token_list = ['a','a','b','b','$'],指针i=0)
- 当前状态:0
- 当前输入符号:token_list[0] = 'a'
查I₀项目集:I₀ = { S' → ·S, S → ·a S b, S → · }
其中,S → ·a S b 的·后是a,符合“移进”条件(即存在形如 A → α·aβ 的项目)。
无其他项目以a开头,故唯一动作:移进。
移进目标状态 = Goto(I₀, a) = I₃,状态编号为3。
- 新栈:[0, 3]
- 新输入:a b b $ (i=1)
经验:初学者常误以为“移进a”是把a压入栈。错。LR栈压的是状态编号,不是符号。a是输入流中的消耗品,它触发了从状态0到状态3的转移。栈中[0,3]表示:“我在初始状态,刚成功转移到I₃”。
4.2 步骤1:栈顶I₃,再遇a——为什么又移进?
- 栈:[0, 3]
- 输入:a b b $ (i=1)
- 当前状态:3
- 当前输入符号:'a'
查I₃ = { S → a·S b, S → ·a S b, S → · }
其中两项含·a:
- S → ·a S b (·在a前,可移进)
- S → a·S b (·在a后,与当前输入a无关)
所以,对输入a,I₃允许移进,目标状态 = Goto(I₃, a) = I₃(因为I₃自身就含 S → ·a S b,Goto(I₃,a) = I₃)。
- 新栈:[0, 3, 3]
- 新输入:b b $ (i=2)
关键洞察:栈中出现重复状态3,不是错误,而是文法左递归性的体现。它表示“已识别一个a,现在又遇到一个a,说明可能有嵌套结构”。这正是S→a S b中S可递归展开的证据。
4.3 步骤2:栈顶I₃,输入b——冲突爆发点!
- 栈:[0, 3, 3]
- 输入:b b $ (i=2)
- 当前状态:3
- 当前输入符号:'b'
查I₃ = { S → a·S b, S → ·a S b, S → · }
- S → a·S b:·后是S(非终结符),不匹配b,跳过;
- S → ·a S b:·后是a,不匹配b,跳过;
- S → ·:这是一个“规约项目”,它要求输入符号在FOLLOW(S)中。
FOLLOW(S)怎么算?
- S' → S,所以 $ ∈ FOLLOW(S);
- S → a S b,所以 b ∈ FOLLOW(S)(因为S后紧跟b);
→ FOLLOW(S) = { $, b }
当前输入是b ∈ FOLLOW(S),因此可规约:S → ε。
但等等——I₃中还有 S → a·S b,它暗示“我正等着一个S”,而S可以推导出ε,所以S→ε规约后,栈顶S被替换,然后应查GOTO表跳转。这正是规约的完整链条。
动作:规约 S → ε
规约依据:栈顶状态3包含项目 S → ·,且b ∈ FOLLOW(S)。
规约操作:弹出0个状态(因为S→ε,右部长度为0),栈顶仍为3;查GOTO[3][S]。
GOTO[3][S] = ?
回顾:I₃中 S → a·S b,·后是S,所以 Goto(I₃, S) = I₅(我们在3.3节已算出)。
→ 新状态 = 5
- 新栈:[0, 3, 3, 5] (注意:规约不弹栈,只查GOTO并压入新状态)
- 新输入:b b $ (i=2,未消耗,因规约不读输入)
踩坑实录:我第一次手算时,在此处犯错——以为规约S→ε要弹出一个状态。错!只有当产生式右部有符号时,才弹出对应数量的状态。ε产生式右部长度为0,弹出0个,栈长度不变,只增加一个GOTO状态。这个细节,90%的初学者会错,务必用红笔圈出。
4.4 步骤3:栈顶I₅,输入b——移进,锁定最后一位
- 栈:[0, 3, 3, 5]
- 输入:b b $ (i=2)
- 当前状态:5
- 当前输入符号:'b'
I₅ = { S → a S·b },·后正是b,完美匹配。
→移进,目标状态 = Goto(I₅, b) = I₆
- 新栈:[0, 3, 3, 5, 6]
- 新输入:b $ (i=3)
4.5 步骤4:栈顶I₆,输入b——规约,收网第一层
- 栈:[0, 3, 3, 5, 6]
- 输入:b $ (i=3)
- 当前状态:6
- 当前输入符号:'b'
I₆ = { S → a S b· },·在末尾,是完整产生式。
FOLLOW(S) = {$, b},b ∈ FOLLOW(S),可规约。
产生式 S → a S b,右部长度=3(a,S,b),故弹出3个状态。
栈原长5,弹出3个 → 剩 [0, 3],栈顶状态=3。
查 GOTO[3][S] = 5(同步骤2),压入5。
- 新栈:[0, 3, 5]
- 新输入:b $ (i=3,未变)
注意:这次规约后,输入仍是b,但栈已从[0,3,3,5,6]缩为[0,3,5],意味着我们刚刚把“a a b”这个子串,规约为一个S。现在栈顶I₅,又在等待下一个b。
4.6 步骤5:栈顶I₅,再遇b——移进,准备第二次收网
栈:[0, 3, 5]
输入:b $ (i=3)
当前状态:5
当前输入符号:'b'
→ 同步骤3,移进至I₆新栈:[0, 3, 5, 6]
新输入:$ (i=4)
4.7 步骤6:栈顶I₆,输入$——规约,收网第二层
栈:[0, 3, 5, 6]
输入:$ (i=4)
当前状态:6
当前输入符号:'$'
→ $ ∈ FOLLOW(S),可规约 S → a S b
右部长度3,弹出3个状态 → 栈剩 [0],栈顶=0
查 GOTO[0][S] = ?
I₀中 S' → ·S,·后是S,Goto(I₀,S) = I₁
→ 压入1新栈:[0, 1]
新输入:$ (i=4)
4.8 步骤7:栈顶I₁,输入$——接受!分析完成
- 栈:[0, 1]
- 输入:$ (i=4)
- 当前状态:1
- 当前输入符号:'$'
I₁ = { S' → S· },·在末尾,且输入为$(结束符),符合接受条件。
→动作:accept
推演结束。全程8步(从步骤0到步骤7),我们完成了对 a a b b 的完整LR分析。每一步的栈状态、输入消耗、动作选择,都有明确的项目集依据。这不是记忆,是逻辑推导。
5. 冲突深度解析:为什么I₃面对b时,SLR能解而LR(0)会失败?
步骤2是整个推演的“奇点”。当栈顶为I₃,输入为b时,我们选择了规约 S→ε。但如果你翻看某些资料,会发现它在此处标注“规约/移进冲突”,并宣称LR(0)无法处理。这似乎矛盾。真相是:冲突是否存在,取决于你用哪张表。
5.1 LR(0)表的致命缺陷:它把所有FOLLOW都当真
LR(0)的ACTION表构造规则是机械的:
- 若 Iᵢ 包含项目 A → α·,则对所有 a ∈ FOLLOW(A),设 ACTION[i,a] = “规约 A→α”;
- 若 Iᵢ 包含项目 A → α·aβ,则设 ACTION[i,a] = “移进到j”,其中 j = Goto(Iᵢ,a)。
在I₃中,我们有:
- S → · (规约项目)
- S → a·S b (移进项目,对S)
FOLLOW(S) = {$, b},所以对输入b,LR(0)表会同时设置:
- ACTION[3,b] = “规约 S→ε” (来自 S→·)
- ACTION[3,b] = “移进到5” (来自 S→a·S b,因·后是S,需查Goto(I₃,S)=I₅,但等等——这里错了!)
关键纠错:S → a·S b 中,·后是S,不是b。所以它不触发对b的移进!它触发的是对S的GOTO。对输入b,I₃中唯一相关的是S→·(规约)和S→a·S b(·后是S,与b无关)。所以LR(0)表在[3,b]处只有规约,无移进。那冲突在哪?
冲突在I₃面对输入$时。
当输入只剩$,栈为[0,3,3]时:
- S→· 要求 $ ∈ FOLLOW(S),成立 → 可规约;
- 但此时,我们其实应该等待更多输入(比如再一个b),因为S→a S b还没闭合。
然而,LR(0)不看上下文,它只认FOLLOW。所以它会在不该规约的时候规约,导致失败。这才是LR(0)的软肋:过度依赖FOLLOW集,缺乏对当前输入上下文的感知。
5.2 SLR的升级:用FOLLOW做“安全阀”,而非“万能钥匙”
SLR(Simple LR)的改进在于:它仍然用FOLLOW(S),但只在确信“此处规约不会破坏后续匹配”的前提下才允许。具体到I₃:
- 对输入b:S→· 的FOLLOW包含b,且S→a·S b的存在意味着“S后必须跟b”,所以规约S→ε后,栈顶I₃查GOTO[I₃,S]=I₅,而I₅正期待b —— 完美衔接。规约安全。
- 对输入$:S→· 的FOLLOW包含$,但S→a·S b的存在意味着“若此处规约,S就结束了,但S本应包裹在a...b中”,而$不是b,所以规约会导致后续无法匹配。SLR会拒绝此规约。
SLR的ACTION表,本质是给LR(0)加了一道“语境审查”:只有当规约后的GOTO状态,能自然承接当前输入时,才允许规约。这就是为什么SLR比LR(0)强大,却比LR(1)简单。
5.3 一张表看穿三者差异:I₃状态下的ACTION对比
| 输入符号 | LR(0) ACTION[3,x] | SLR ACTION[3,x] | LR(1) ACTION[3,x] | 决策依据 |
|---|---|---|---|---|
| a | 移进到3 | 移进到3 | 移进到3 | S→·a S b |
| b | 规约 S→ε | 规约 S→ε | 规约 S→ε | S→·, b∈FOLLOW(S); 且GOTO[3,S]=I₅期待b |
| $ | 规约 S→ε | 空白(错误) | 规约 S→ε 或 移进? | LR(0)盲从FOLLOW;SLR发现GOTO[3,S]=I₅不期待$,故禁用;LR(1)用展望符精确判定 |
实操心得:在真实项目中,我从不手写LR(1)表(太重),但一定会用SLR逻辑检查LR(0)表。方法很简单:对每个规约项目A→α·,检查GOTO[Iᵢ,A]的项目集中,是否有项目以当前输入符号开头。如果有,规约安全;如果没有,标为error。这招帮我避开了80%的语法设计陷阱。
6. 从推演到工程:如何把这套逻辑落地为可调试的Python分析器?
纸上谈兵终觉浅。我把上述推演逻辑,封装成一个极简但可调试的Python LR分析器框架。它不追求性能,只保证每一步都可打印、可断点、可验证。代码结构完全镜像本文推演流程。
6.1 核心数据结构:状态与项目集的Python映射
# 项目(Item):产生式+点位置 class Item: def __init__(self, lhs, rhs, dot_pos): self.lhs = lhs # 左部,如 'S' self.rhs = rhs # 右部,如 ['a','S','b'] self.dot_pos = dot_pos # 点位置,0表示·在最左,len(rhs)表示·在最右 def __eq__(self, other): return (self.lhs == other.lhs and self.rhs == other.rhs and self.dot_pos == other.dot_pos) def next_symbol(self): """返回·后的符号,若无则返回None""" if self.dot_pos < len(self.rhs): return self.rhs[self.dot_pos] return None # 状态(State):项目集+转移表 class State: def __init__(self, items, state_id): self.items = frozenset(items) # 不可变,用于哈希 self.id = state_id self.goto = {} # {symbol: State} self.action = {} # {symbol: ('shift',next_id) or ('reduce',prod_id)}6.2 手动构建状态机:复刻I₀到I₆
我们不调用自动构造算法,而是手动实例化本文推演出的5个状态,确保100%对应:
# 文法产生式(索引即prod_id) productions = [ ("S'", ['S']), # prod_id=0 ('S', ['a','S','b']), # prod_id=1 ('S', []) # prod_id=2 (ε) ] # 手动构建I₀ I0_items = [ Item("S'", ['S'], 0), # S'→·S Item('S', ['a','S','b'], 0), # S→·aSb Item('S', [], 0) # S→· ] I0 = State(I0_items, 0) # 手动构建I₃ I3_items = [ Item('S', ['a','S','b'], 1), # S→a·Sb Item('S', ['a','S','b'], 0), # S→·aSb (闭包) Item('S', [], 0) # S→· (闭包) ] I3 = State(I3_items, 3) # ... 同理构建I₁, I₅, I₆ # 最后建立goto关系: I0.goto['a'] = I3 I0.goto['S'] = I1 I3.goto['S'] = I5 I5.goto['b'] = I6 # ACTION表也手动填: I0.action['a'] = ('shift', 3) I3.action['a'] = ('shift', 3) I3.action['b'] = ('reduce', 2) # S→ε, prod_id=2 I3.action['$'] = ('error',) # SLR逻辑:此处不允规约 I5.action['b'] = ('shift', 6) I6.action['b'] = ('reduce', 1) # S→aSb, prod_id=1 I6.action['$'] = ('reduce', 1) I1.action['$'] = ('accept',)6.3 分析器主循环:一行一印,所见即所得
def parse(tokens): stack = [I0] # 状态栈 input_ptr = 0 while input_ptr < len(tokens): current_state = stack[-1] current_token = tokens[input_ptr] print(f"Step {len(stack)-1}: Stack={stack_ids(stack)}, Input='{current_token}'") if current_token not in current_state.action: raise SyntaxError(f"Unexpected token '{current_token}' at state {current_state.id}") action = current_state.action[current_token] act_type = action[0] if act_type == 'shift': next_state_id = action[1] next_state = find_state_by_id(next_state_id) # 从预定义状态池找 stack.append(next_state) input_ptr += 1 # 消耗token print(f" -> Shift to state {next_state_id}") elif act_type == 'reduce': prod_id = action[1] lhs, rhs = productions[prod_id] rhs_len = len(rhs) # 弹出rhs_len个状态 for _ in range(rhs_len): if len(stack) < 2: # 至少留一个初始状态 raise SyntaxError("Stack underflow on reduce") stack.pop() # 查GOTO,压入新状态 top_state = stack[-1] if lhs not in top_state.goto: raise SyntaxError(f"No GOTO for {lhs} from state {top_state.id}") new_state = top_state.goto[lhs] stack.append(new_state) print(f" -> Reduce {lhs}→{' '.join(rhs)}; GOTO {new_state.id}") elif act_type == 'accept': print(" -> ACCEPT! Parsing successful.") return True else: # error raise SyntaxError(f"Syntax error at token '{current_token}'") # 输入结束,检查是否接受 if stack[-1] == I1 and input_ptr == len(tokens): print("Final state I1 with $ -> ACCEPT") return True else: raise SyntaxError("Parsing ended without accept") # 测试 tokens = ['a','a','b','b','$'] parse(tokens)运行此代码,你会看到和本文推演完全一致的每一步输出。你可以:
- 在任意
print处加断点,查看stack实时内容; - 修改
I3.action['b']为('shift',5),看它如何崩溃(因为I₃没有对b的移进); - 把
tokens改成['a','$'],看SLR如何在I₃对$报错。
最后分享一个血泪技巧:在真实开发中,我永远用两个窗口并排——左边是手算的项目集规范族表格,右边是Python分析器的print输出。每当输出与手算不符,我就知道:要么代码写错了,要么手算漏了一个闭包项目。这种“人机互验”模式,让我在三天内就debug通了一个12状态的自定义配置语言分析器。记住,LR分析器不是魔法,它是可触摸、可验证、可一步步钉死的逻辑链条。