ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SLR(1)分析器构建闭环训练:从文法改写到Python实现

SLR(1)分析器构建闭环训练:从文法改写到Python实现 简介本资源是西安交通大学2022年《编译原理》课程的作业考核试题Word文档面向计算机专业本科生及编译技术自学者聚焦文法分析、语法树构造、LR(0)分析表、符号表管理、中间代码生成等核心考点助力系统复习与应试强化。压缩包仅含1个13KB的.docx文件内容完整覆盖选择题共19道每题均附标准答案与关键解析点如算符优先关系判定、基本块定义辨析、无二义文法性质、Chomsky 2型文法识别、三元式优化价值、下推自动机对应语言等知识点密集且紧扣教学重点。已有215人下载学习适合用于课后自测、考前冲刺或教学参考。试题排版规范题干清晰答案标注明确便于逐题对照理解编译器前端各阶段的设计逻辑与理论依据。1. 这不是一份普通作业它是一套可复现的 LR 分析器构建闭环训练题2022年西安交通大学编译原理作业考核试题.docx——光看文件名你可能以为这只是某届学生交上去就尘封的 Word 文档。但实际打开后你会发现它根本不是填空简答的应试卷而是一套完整驱动你从文法设计、FIRST/FOLLOW 集手算、SLR(1) 分析表手工构造到最终用 Python 实现可运行 LR 分析器的工程化训练链。我去年带三个本科生做课程设计时就是拿这份题当蓝本从头跑通了整个流程输入id id * id输出规约步骤序列最后生成抽象语法树AST节点。它不考死记硬背专治“学完 LR 感觉懂了一写代码就崩”的典型玄学困境。适合正在啃《编译原理》王生原第3版第三章、刚学完 LL(1) 想进阶 LR、或正被课程设计卡在分析表构造环节的同学——尤其当你发现教材例题太简略、课后习题没答案、网上搜到的“LR 分析器 demo”全是黑匣子式 import 就跑却看不到状态转移怎么来的这份题就是你的后悔药。它把编译原理中最易翻车的 LR 分析落地环节拆成五步可验证动作先给定一个含左递归和二义性的原始文法要求你改写为无二义性、无左递归的等价文法再强制你手算每个非终结符的 FIRST 和 FOLLOW 集不是查表是推导过程要写全接着用 SLR(1) 方法构造分析表必须标出所有冲突项并说明是否可消解然后要求用任意语言我们选 Python实现分析器核心逻辑输入字符串、输出规约/移进动作流最后一步常被忽略——让你对比手工分析表与程序输出的动作序列逐帧对齐。这不是考试是调试编译器前端的最小可行沙盒。你不需要懂词法分析器怎么写但必须清楚为什么这个状态会 goto 到那个状态为什么这里报 shift/reduce 冲突为什么*的优先级能压过——所有答案都在这份题的题干和评分细则里埋着线索。2. 从文法改写到 FIRST/FOLLOW 集手算不是形式主义是调试前置条件2.1 原始文法陷阱识别为什么直接上 LR 必翻车题目给出的原始文法长这样节选关键部分E → E T | E * T | T T → T * F | F F → ( E ) | id表面看是经典表达式文法但细看有两处致命问题左递归E → E T和T → T * F是直接左递归LR 分析器的状态机无法处理无限深度的自循环转移二义性E → E T | E * T导致id id * id可以按((id id) * id)或(id (id * id))两种方式规约而 LR 分析器本身不带语义动作无法靠“优先级”自动选择——它只认分析表里的动作表里冲突不解决程序直接报错。提示很多同学跳过这步直接拿原始文法去算 FIRST 集结果 FOLLOW(E) 算出来包含和*后续构造分析表时冲突爆炸。记住LR 分析器不吃“理论上可消歧”只吃“分析表里每格唯一动作”。改写是硬门槛不是可选项。2.2 消除左递归的标准流程三步不能省少一步表就错标准消除法分三步必须严格按顺序执行我们用E,T表示新引入的非终结符提取左递归链对E → E T | T改写为E → T EE → T E | ε处理T的左递归T → T * F | F→T → F TT → * F T | ε保持终结符优先级显式化原始文法中*优先级高于是隐含的改写后必须通过产生式结构体现。最终得到无左递归、无二义性的文法题目要求写出全部产生式共 9 条E → T E E → T E | ε E → - T E | ε # 题目扩展了减法注意新增 T → F T T → * F T | / F T | ε F → ( E ) | id | num注意E和T的 ε 产生式不是摆设。它们决定了 FOLLOW 集的传播路径——比如FOLLOW(E)必须包含$输入结束符和)因为E出现在( E )的右括号前这个细节直接决定分析表中E行的)列是否填reduce动作。2.3 FIRST/FOLLOW 集手算用推导树代替死记规则FIRST 集计算口诀“终结符自己进ε 看右边全 ε 才传”。但纯背口诀容易漏 case。我们用推导树辅助以FIRST(E)为例E → T E是终结符 →FIRST(E)加入E → - T E-是终结符 →FIRST(E)加入-E → ε直接加入ε→ 所以FIRST(E) {, -, ε}FOLLOW 集更关键。规则是“谁用你你就跟它混”。对FOLLOW(E)E → T EE是E的最后一个符号 →FOLLOW(E)加入FOLLOW(E)( E )中E后跟)→FOLLOW(E)包含)E是开始符号 →FOLLOW(E)包含$→ 所以FOLLOW(E) {), $}实操技巧在草稿纸上画个依赖图节点是各非终结符箭头表示 “A 的 FOLLOW 依赖 B 的 FOLLOW”比纯文字推导快 3 倍且不易漏。3. SLR(1) 分析表构造从 LR(0) 项目集到冲突诊断的完整链路3.1 构造 LR(0) 项目集规范族用闭包和 GOTO 模拟状态机SLR(1) 的基础是 LR(0) 项目集。题目要求画出全部状态我们最终得到 15 个状态每个状态是形如E → · T E的项目集合。关键操作有两个Closure闭包若状态含A → α · B β则要把所有B → · γ加入该状态。GOTO(I, X)对状态 I 中所有A → α · X β取A → α X · β的闭包得到新状态。以初始状态I0为例E是增广文法的开始符号I0: E → · E E → · T E T → · F T F → · ( E ) F → · id F → · num→ 对E → · E做 Closure需加入E的所有产生式因E在点后同理E → · T E要加T的产生式T → · F T要加F的产生式……直到没有新项目可加。提示手算时用不同颜色笔标“点前符号”和“点后符号”能快速定位 GOTO 目标。比如I0中F → · ( E )的点后是(那么GOTO(I0, ()就是把所有F → ( · E )类项目取闭包得到I1。3.2 填充分析表ACTION 和 GOTO 表的物理含义分析表是二维数组行是状态编号0~14列是终结符,-,*,/,(,),id,num,$和非终结符E,E,T,T,F。填法分两类ACTION 表终结符列若GOTO(Ii, a) Ij且a是终结符 → 填sjshift 到 j若Ii含A → α ·点在末尾且a ∈ FOLLOW(A)→ 填rjreduce 用第 j 条产生式若Ii含E → E ·接受项目→ 填accGOTO 表非终结符列若GOTO(Ii, A) Ij→ 填j关键检查点I3状态含E → · T E对终结符应填s?但对FOLLOW(E) {), $}不在其中 → 此处绝不能填r否则就是逻辑错误。3.3 冲突诊断与消解为什么这题能用 SLR(1) 而不用 LALR(1)题目明确要求判断是否存在 shift/reduce 或 reduce/reduce 冲突并说明能否用 SLR(1) 解决。我们发现两处I7状态含T → * · F T和F → · ( E )shift 项目同时含T → ·reduce 项目对应T → ε。此时FOLLOW(T) {, -, ), $}而*不在 FOLLOW 中 → 无 reduce/reduce 冲突*列填s?即可。I9状态含E → T · Eshift和E → T E ·reduce。FOLLOW(E) {), $}而)在 FOLLOW 中 →)列应填r?reduce但I9的 GOTO(I9, )) I10所以)列填s10等等——这里出现shift/reduce 冲突)既可 shift 到 I10又可 reduce 用E → T E。解法查FOLLOW(E)确实含)但I9中E → T E ·的点后无符号是规约态而E → T · E的点后是E需移进。SLR(1) 的 FOLLOW 集太粗把)同时划给了 shift 和 reduce。但题目文法中)出现在E被完整解析后即( E )的右括号此时E必须规约完毕才能匹配)所以此处 reduce 优先于 shift。SLR(1) 无法自动判别需人工指定 —— 这正是题目考察点告诉你冲突存在但因文法本身无二义性可通过调整规约优先级解决不必升级到 LALR(1)。4. Python 实现 LR 分析器从状态栈到动作日志的逐帧还原4.1 核心数据结构设计状态栈、符号栈、输入缓冲区三位一体分析器不是单个函数而是三个栈协同工作的状态机# 初始化 state_stack [0] # 当前状态号栈初始为 I0 symbol_stack [$] # 符号栈存已规约出的符号终结符/非终结符 input_buffer [id, , id, *, id, $] # 词法单元列表末尾加 $关键逻辑每次循环读取input_buffer[0]当前输入符号查ACTION[state_stack[-1]][current_symbol]根据动作类型分支sjstate_stack.append(j),symbol_stack.append(current_symbol),input_buffer.pop(0)rj弹出len(β)个符号β 是第 j 条产生式右部查 GOTO 表得新状态压入新符号和状态acc成功空报错注意symbol_stack存的是符号名如id,E不是 token 对象state_stack和symbol_stack长度必须始终相等栈顶状态对应栈顶符号。4.2 ACTION/GOTO 表的 Python 表示用嵌套字典避免索引越界手算出的表不能硬编码为二维列表易错且难 debug推荐用字典# ACTION 表action[state_id][terminal] action_str ACTION { 0: {id: s5, num: s6, (: s4, $: }, 1: {): r0, $: acc}, # r0 表示用第 0 条产生式规约 2: {: s7, -: s8, ): r2, $: r2}, # r2: E → ε # ... 其他状态 } # GOTO 表goto[state_id][non_terminal] state_id GOTO { 0: {E: 1, T: 2, F: 3}, 1: {}, 2: {E\: 9, T\: 10}, # ... }优势查表时if current_symbol in ACTION[current_state]比try-except更清晰r0这种字符串可直接int(action_str[1:])得产生式编号方便后续调用规约函数。4.3 规约动作的语义实现不只是弹栈还要构建 AST 节点题目虽未明说但考核隐含要求输出 AST。我们在rj动作中插入构建逻辑def reduce_rule(rule_idx, symbol_stack): # rule_idx3 对应 T → F T右部长度为 2 → 弹 2 个符号 right_len len(RULES[rule_idx][1]) # RULES [(0, [E\, E]), (1, [E\, , T, E\]), ...] popped_symbols [symbol_stack.pop() for _ in range(right_len)] popped_states [state_stack.pop() for _ in range(right_len)] # 状态栈同步弹 # 构建 AST 节点非终结符为根弹出符号为子节点 node ASTNode(RULES[rule_idx][0], childrenpopped_symbols[::-1]) # 压入新符号和 GOTO 状态 symbol_stack.append(RULES[rule_idx][0]) new_state GOTO[popped_states[-1]][RULES[rule_idx][0]] state_stack.append(new_state) return node # 示例输入 id id * id当规约出 T → F T 时popped_symbols [id, T\]构建 T 节点提示popped_symbols顺序是反的栈是后进先出所以[::-1]恢复原始右部顺序。这是血泪经验——不反转AST 的节点左子树是id*id右子树是id完全颠倒。5. 避坑指南五个让 90% 人卡住的硬核细节5.1 现象ACTION 表某行全空程序直接 exit原因FOLLOW(A)计算遗漏。例如FOLLOW(T)应包含,-,),$但漏了导致I2状态含T → ·对列为空。解决重新推导FOLLOW(T)T出现在E → T · E中T后是E所以FOLLOW(T)包含FOLLOW(E) {), $}同时E后可跟E → T E所以也属于FOLLOW(T)。务必画依赖图。5.2 现象输入id报 shift/reduce 冲突但手算表里I0对id是s5原因input_buffer初始化时没加$或$被误当作终结符参与查表。SLR(1) 表中$列只用于判断 acc 和 reduce不能当普通终结符移进。解决确保input_buffer tokens [$]且查 ACTION 表前若current_symbol $单独处理只允许acc或r不允许s。5.3 现象规约后symbol_stack顶端是E但GOTO查不到新状态原因GOTO表键名大小写/空格不一致。手算时写E代码里存成E\或E_查表失败。解决统一用原始文法中的符号名EPython 字符串中E\是合法的但字典 key 必须完全匹配。打印GOTO.keys()和GOTO[0].keys()调试。5.4 现象AST 节点 child 顺序混乱乘法节点左子树是id右子树是原因规约时popped_symbols未反转且RULES[rule_idx][1]存的是右部符号列表如[F, T\]但弹栈顺序是[T\, F]直接作为 children 会导致左右颠倒。解决children popped_symbols[::-1]且确保RULES定义顺序与文法一致题目给的产生式顺序就是标准顺序。5.5 现象程序跑通但和手算分析步骤不一致比如多了一次r原因input_buffer在s动作后未pop(0)导致同一符号被反复读取。或者state_stack和symbol_stack长度不等常见于r动作中只弹符号栈没弹状态栈。解决在s分支末尾加input_buffer.pop(0)在r分支中for _ in range(right_len): symbol_stack.pop(); state_stack.pop()必须成对出现。加一行assert len(state_stack) len(symbol_stack)防御性编程。6. 验证与进阶用测试用例反向驱动分析表正确性6.1 构建黄金测试集覆盖所有冲突点和边界 case不要只测id id * id。题目隐含要求验证以下 5 类输入每类对应一个分析表关键区域测试用例目的关键状态id检查F → id规约路径I5→r到I0(id)验证括号嵌套和F → ( E )规约I4→I1→I11id id触发E → T E移进和E → ε规约I7→I9→I1id * id id测试*优先级高于的规约顺序I3→I6→I10→I2id 输入不完整应报错在I7对$列为空I7状态查$执行命令python lr_parser.py --test-case id id --debug输出应包含每步的state_stack,symbol_stack,input_buffer,action与手算步骤逐行对齐。6.2 动态打印分析过程把黑匣子变成透明流水线在主循环中加入日志print(f[{step}] Stack: {state_stack} | Syms: {symbol_stack} | Input: {input_buffer} | Action: {action})但更实用的是可视化状态转移图。用graphviz导出from graphviz import Digraph dot Digraph(commentLR Automaton) for i, transitions in enumerate(GOTO.values()): for nt, j in transitions.items(): dot.edge(fI{i}, fI{j}, labelf{nt}) dot.render(lr_automaton.gv, viewTrue)生成的图中你能直观看到I0如何通过id到I5再通过到I7——如果某条边缺失说明 GOTO 表构造有误。6.3 从 SLR(1) 到 LR(1) 的平滑演进只需改两处题目是 SLR(1)但实际工业编译器多用 LR(1)。想升级只需改两点项目定义升级LR(0) 项目A → α · β变成 LR(1) 项目A → α · β, a其中a是向前看符号lookaheadFOLLOW 替换为 lookahead 集合reduce 动作条件从a ∈ FOLLOW(A)变为a等于该项目的 lookahead 符号。实操价值I9的 shift/reduce 冲突在 LR(1) 中会分裂为两个状态I9alookahead)只填rI9blookahead$只填s冲突自然消失。这意味着——你手算的 SLR(1) 表就是 LR(1) 表的骨架所有状态、GOTO 边都复用只需为每个项目补 lookahead。下次做课程设计直接从这份题出发加个 lookahead 计算模块就能产出真正的 LR(1) 分析器。我带学生做这个题时最大的教训是永远先手算 3 个状态再写代码永远用id这种最短输入启动调试永远在r动作后打印symbol_stack[-1]确认新符号压入正确。这些习惯省下至少 8 小时 debug 时间。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进