ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手把手实现LL(1)语法分析器:文法解析、FIRST/FOLLOW计算与预测表构造

手把手实现LL(1)语法分析器:文法解析、FIRST/FOLLOW计算与预测表构造 简介本资源是一份面向计算机专业本科生与编译原理初学者的LL(1)语法分析器实验报告聚焦语法分析核心原理的工程实现与验证。报告完整覆盖左递归消除、FIRST/FOLLOW集计算、LL(1)分析表构建及C分析程序开发全过程配套可编辑PDF文档内含详细实验目的、算术文法E→ET|T等定义、函数级源码input_grammer、preprocess、create_table、analyse等、关键算法说明与分析过程示例便于理解自顶向下分析的逻辑链条与代码映射关系。资源为单文件PDF格式共1个文件大小256KB轻量易读适合作为课程实验参考、期末复习材料或编译器开发入门实践范本。目前已有1154人学习下载内容结构清晰、注释充分特别适合需将理论知识转化为可运行代码的学习者快速上手与调试验证。1. 为什么手写一个 LL(1) 语法分析器比直接跑通课本例题难十倍你刚翻完《编译原理》第三版第4章觉得“预测分析表 栈 输入串”就三步代码写个百来行应该能过实验验收。结果一上手FIRST 集算错导致表里填了空格、FOLLOW 集漏了终结符让 $ 放不进表、文法左递归没消除就硬套 LL(1) 条件——调试到凌晨三点控制台只输出Syntax error at position 5连错在哪都不知道。这不是你能力问题是 LL(1) 构造本身藏着三重黑匣子文法预处理的边界条件、集合计算的隐式依赖、分析表驱动逻辑的时序陷阱。这篇笔记不讲定义不抄书只记录我带三届本科生做完“LL(1)语法分析器构造”实验后从翻车现场扒出来的可复现路径用 Python 从零实现完整流程覆盖山东科技大学、燕山大学等高校常用实验要求含文法输入解析、FIRST/FOLLOW 自动计算、预测分析表生成、可视化推导过程所有代码可粘贴即跑关键参数全标注坑位标红加粗。适合正在赶编译原理实验 deadline 的你也适合想真正吃透 LL(1) 落地细节的进阶者。2. 文法建模用 Python 字典结构承载上下文无关文法避开 BNF 解析玄学LL(1) 分析器的起点不是代码是可被程序精确理解的文法表示。很多同学直接拿课本上的 BNF 形式如E → E T | T去硬编码结果改个产生式就得重写一堆 if-else。真实实验场景中文法常以文本文件或交互输入方式提供必须先做结构化解析。我们采用轻量级但鲁棒的 Python 字典结构兼顾可读性与机器处理效率。2.1 文法数据结构设计为什么不用正则硬切而用分层字典常见错误是用re.split(r→| \| , line)拆产生式但遇到id、、*等终结符含特殊字符时必然崩。正确做法是先分离非终结符再对右部做原子化切分。我们约定文法输入格式为E → T E E → T E | ε T → F T T → * F T | ε F → ( E ) | id每行一个产生式→左侧为单个非终结符右侧用|分隔多个候选式ε表示空产生式。这种格式与教材完全一致且无歧义。对应 Python 结构为grammar { E: [[T, E], [T, E]], # 错误示例重复了 E: [[, T, E], []], # [] 表示 ε T: [[F, T]], T: [[*, F, T], []], F: [[(, E, )], [id]] }注意这里E和T用字符串而非E\避免 Python 转义问题空产生式统一用空列表[]不是[ε]或None后续集合计算和表填充逻辑才不会分支爆炸。2.2 文法加载函数支持文件读取与手动输入双模式实验报告常要求“支持用户输入文法”但实际调试时反复敲文法极耗时。我们封装一个load_grammar()函数自动识别输入源def load_grammar(source: str) - dict: source: 可以是文法字符串含换行也可以是 .txt 文件路径 返回标准 grammar 字典结构 if source.strip().endswith(.txt) and os.path.exists(source): with open(source, r, encodingutf-8) as f: lines [l.strip() for l in f if l.strip() and not l.startswith(#)] else: lines [l.strip() for l in source.strip().split(\n) if l.strip() and not l.startswith(#)] grammar {} for line in lines: if → not in line: continue left, right_part map(str.strip, line.split(→, 1)) # 处理左侧只取第一个 token防 E → ... 中的 被误切 left left.split()[0] # 处理右侧按 | 分割每个候选式再按空格切分 candidates [] for cand_str in map(str.strip, right_part.split(|)): if not cand_str: continue # 过滤空格跳过注释# 后内容 tokens [] for t in cand_str.split(): if # in t: t t.split(#)[0] if t: tokens.append(t) if tokens [ε]: candidates.append([]) else: candidates.append(tokens) grammar[left] candidates return grammar参数说明source: 支持两种形态纯文法字符串如E → T E\nE → T E | ε或本地.txt路径如./grammar.txt#开头行为注释自动跳过对ε的识别严格匹配字符串ε不接受e或避免歧义这个函数已通过山东科技大学往年真题文法测试含S,A,B等带引号非终结符能正确处理S → a S b | ε和A → B c | d混合情况。3. FIRST/FOLLOW 集计算迭代收敛算法的终止条件与 ε-传播陷阱LL(1) 的核心门槛不在代码而在集合计算的数学严谨性。课本公式FIRST(X) {a | X ⇒* aα} ∪ {ε | X ⇒* ε}看似简单但实现时若忽略 ε 在产生式链中的传递性FIRST 集会永远算不准。比如A → B C,B → ε,C → d则FIRST(A)必须包含d但若先算B再算A而B的 ε 尚未稳定就会漏掉d。必须用迭代法直到集合不再变化。3.1 FIRST 集迭代算法为什么必须用 while True deep copy错误做法遍历一遍文法就停。正确做法是持续更新直到稳态。关键点有三初始时所有终结符FIRST(a) {a}所有非终结符FIRST(X) ∅每轮扫描所有产生式X → α若α全由可推出 ε 的符号组成则ε ∈ FIRST(X)若α Y1 Y2 ... Yk则FIRST(X)应加入FIRST(Y1)中所有非 ε 元素若ε ∈ FIRST(Y1)再加入FIRST(Y2)非 ε 元素……以此类推def compute_first_sets(grammar: dict, terminals: set) - dict: first {sym: set() for sym in grammar.keys()} # 终结符的 FIRST 就是自己 for t in terminals: first[t] {t} changed True while changed: changed False for nonterm, productions in grammar.items(): for prod in productions: if not prod: # ε 产生式 if ε not in first[nonterm]: first[nonterm].add(ε) changed True else: # 处理 Y1 Y2 ... Yk i 0 while i len(prod): Yi prod[i] if Yi in terminals: # 遇到终结符加入并停止传播 if Yi not in first[nonterm]: first[nonterm].add(Yi) changed True break elif Yi in grammar: # 是非终结符 # 加入 FIRST(Yi) 中所有非 ε 元素 for t in first[Yi] - {ε}: if t not in first[nonterm]: first[nonterm].add(t) changed True # 若 ε 不在 FIRST(Yi)停止传播 if ε not in first[Yi]: break else: # Yi 是未定义符号如 ε 字符本身跳过 pass i 1 # 如果所有 Yi 都能推出 ε则加入 ε if i len(prod): if ε not in first[nonterm]: first[nonterm].add(ε) changed True return first关键参数与逻辑说明terminals: 终结符集合需提前从文法中提取如{id, , *, (, ), $}$是输入结束符必须显式加入while changed: 迭代主循环changed标志本轮是否有新元素加入不可用 for 循环固定次数替代某些复杂文法需 5~7 轮才能收敛i len(prod): 表示Y1...Yk全部被扫描完且每个都含 ε此时才可向FIRST(X)添加 ε3.2 FOLLOW 集计算$ 符号的注入时机与左递归文法的致命伤FOLLOW 集更易出错。课本公式FOLLOW(A) {a | S ⇒* αAaβ}中的a是终结符但学生常忘记$输入结束符必须初始加入FOLLOW(S)。更隐蔽的坑是若文法含左递归如E → E T则 FOLLOW 计算会无限循环或结果为空——因为 LL(1) 要求文法必须是无左递归、无公共前缀的这是前置条件不是计算步骤能解决的。def compute_follow_sets(grammar: dict, first: dict, start_symbol: str) - dict: follow {nt: set() for nt in grammar.keys()} follow[start_symbol].add($) # 关键起始符号 FOLLOW 必含 $ changed True while changed: changed False for A, productions in grammar.items(): for prod in productions: # 对每个产生式 A → αBβ将 FIRST(β) - {ε} 加入 FOLLOW(B) # 若 ε ∈ FIRST(β)则将 FOLLOW(A) 加入 FOLLOW(B) for i, B in enumerate(prod): if B not in grammar: # B 是终结符跳过 continue # 找 β prod[i1:] beta prod[i1:] if not beta: # B 在末尾即 A → αB for t in follow[A]: if t not in follow[B]: follow[B].add(t) changed True else: # B 不在末尾即 A → αBγ # 计算 FIRST(β) first_beta set() j 0 while j len(beta): Yj beta[j] if Yj in grammar: first_beta.update(first[Yj] - {ε}) if ε not in first[Yj]: break elif Yj in first: # 终结符 first_beta.add(Yj) break j 1 else: # β 全能推出 ε first_beta.add(ε) # 加入 FIRST(β) - {ε} for t in first_beta - {ε}: if t not in follow[B]: follow[B].add(t) changed True # 若 ε ∈ FIRST(β)加入 FOLLOW(A) if ε in first_beta: for t in follow[A]: if t not in follow[B]: follow[B].add(t) changed True return follow血泪经验follow[start_symbol].add($)必须在初始化时执行晚一秒整个 FOLLOW 链就断了beta prod[i1:]的切片必须用i1不是i否则把B自己也算进去了当beta为空即B是产生式最右符号时直接继承FOLLOW(A)这是 FOLLOW 定义的核心不能漏4. 预测分析表构造二维字典映射与冲突检测的三重校验有了 FIRST/FOLLOW就能填预测分析表Predictive Parsing Table。表本质是一个二维映射M[A, a] α表示当栈顶为非终结符A、当前输入符号为a时应选用产生式A → α。但填表不是简单查集合必须做三重校验否则实验报告里“无冲突”结论就是空中楼阁。4.1 表结构设计用嵌套字典还是 Pandas DataFrame实验代码追求可读性与调试便利不用 Pandas增加依赖且打印不直观。采用dict[str, dict[str, list[str]]]即table[nonterm][terminal] [T, E]。这样print(table[E][id])直接看到动作for t in table[E]: print(t, table[E][t])一行遍历所有列。4.2 填表主逻辑为什么必须先清空表再逐项填充常见错误是边算边填导致同一格被多次写入而不报错。正确流程初始化空表table {A: {a: None for a in terminals} for A in nonterms}对每个产生式A → α对每个a ∈ FIRST(α)若table[A][a]已有值则冲突若ε ∈ FIRST(α)则对每个b ∈ FOLLOW(A)若table[A][b]已有值则冲突冲突时抛出ValueError并打印具体位置def build_parsing_table(grammar: dict, first: dict, follow: dict, terminals: set, start_symbol: str) - dict: nonterms set(grammar.keys()) table {A: {a: None for a in terminals} for A in nonterms} for A, productions in grammar.items(): for alpha in productions: # 情况1α ≠ ε填 FIRST(α) if alpha: # 非空产生式 first_alpha set() i 0 while i len(alpha): X alpha[i] if X in terminals: first_alpha.add(X) break elif X in nonterms: first_alpha.update(first[X] - {ε}) if ε not in first[X]: break i 1 else: first_alpha.add(ε) for a in first_alpha - {ε}: if a in terminals: if table[A][a] is not None: raise ValueError(fConflict at M[{A},{a}]: already has {table[A][a]}, now try {alpha}) table[A][a] alpha # 情况2α ε 或 ε ∈ FIRST(α)填 FOLLOW(A) if not alpha or ε in (first_alpha if alpha else {ε}): for b in follow[A]: if b in terminals: if table[A][b] is not None: raise ValueError(fConflict at M[{A},{b}]: already has {table[A][b]}, now try {alpha}) table[A][b] alpha return table参数说明terminals: 必须是set类型且已包含$否则b in terminals判断失效first_alpha: 对α的 FIRST 计算复用前面函数逻辑不可直接用first[X]拼接必须模拟推导链冲突提示含具体A,a,alpha方便定位哪条产生式惹的祸4.3 冲突检测与诊断三类必现冲突及修复口诀LL(1) 实验失败90% 源于这三类冲突。我们把检测逻辑拆成独立函数运行时自动报错并给修复建议def diagnose_conflict(grammar: dict, first: dict, follow: dict, terminals: set): 返回冲突类型、涉及产生式、修复建议 conflicts [] # 类型1公共前缀如 A → aB | aC for A, prods in grammar.items(): prefix_map {} for i, alpha in enumerate(prods): if not alpha: continue first_a first.get(alpha[0], set()) if alpha[0] in first else {alpha[0]} for a in first_a - {ε}: if a not in prefix_map: prefix_map[a] [] prefix_map[a].append((i, alpha)) for a, hits in prefix_map.items(): if len(hits) 1: conflicts.append({ type: common_prefix, symbol: A, terminals: [a], productions: [p for _, p in hits], fix: 提取左公因子A → a A\, A\ → B | C }) # 类型2左递归如 A → A α for A, prods in grammar.items(): for alpha in prods: if alpha and alpha[0] A: conflicts.append({ type: left_recursion, symbol: A, productions: [alpha], fix: 消除左递归A → α A\, A\ → β A\ | ε }) # 类型3FOLLOW 与 FIRST 交集非空如 A → α, ε∈FIRST(α), 且 a∈FIRST(α)∩FOLLOW(A) for A, prods in grammar.items(): for alpha in prods: if not alpha or ε not in (first.get(alpha[0], set()) if alpha[0] in first else {alpha[0]}): continue first_alpha set() # ...同上计算 first_alpha for a in first_alpha follow[A]: if a in terminals: conflicts.append({ type: first_follow_overlap, symbol: A, terminal: a, productions: [alpha], fix: f检查 {A} → {alpha} 是否真需 ε 产生式或调整 FOLLOW(A) 计算 }) return conflicts避坑 / 常见问题 / 排查现象ValueError: Conflict at M[E,id]: already has [T, E], now try [T, E]原因同一产生式被填了两次通常因id同时在FIRST(T)和FOLLOW(E)中但E的 ε 产生式又触发了 FOLLOW 填表解决检查E的FOLLOW是否包含了不该有的id确认T的FIRST是否误含ε现象table[E][]为None但输入idid却卡在原因不在任何FIRST(α)中也未被FOLLOW(E)覆盖说明E的 FOLLOW 计算遗漏了解决回溯E的产生式E → T E | ε是其直接后继FOLLOW(E)必须包含FOLLOW(E)因E在E → T E末尾和因E → T E中紧跟E现象build_parsing_table运行超时无输出原因while changed迭代未收敛大概率FIRST或FOLLOW计算中存在死循环常见于文法含未声明的符号如S但未在 grammar 字典中定义解决打印每轮first字典大小若某轮后 size 不变但changedTrue说明有符号未初始化用print(set(grammar.keys()) - set(first.keys()))找出漏掉的非终结符现象diagnose_conflict报common_prefix但文法明明是课本标准 LL(1) 文法原因first.get(alpha[0], set())中alpha[0]是终结符如id但first字典未存终结符的 FIRST应为{id}导致first_a为空解决确保first初始化时包含所有终结符如for t in terminals: first[t] {t}现象FOLLOW(S)不含$但table[S][$]却有值原因$未加入terminals集合导致b in terminals判断失败FOLLOW(S)的$被忽略解决terminals {id, , *, (, ), $}$必须显式添加5. 驱动分析器实现栈模拟、输入流控制与推导过程可视化预测分析表只是静态结构真正让 LL(1) “活起来”的是驱动器Driver它维护一个符号栈、一个输入缓冲区根据表查出动作不断展开、匹配、弹出直到栈空且输入耗尽。实验报告要求“显示每一步推导”这意味着不能只返回 accept/reject而要记录完整动作序列。5.1 驱动器核心状态三个变量决定一切stack:list[str]栈底为$栈顶为当前待匹配符号初始为[$, start_symbol]input_stream:list[str]输入符号序列末尾必须加$如[id, , id, $]steps:list[dict]每步记录{step: i, stack: [...], input: [...], action: match id or expand E-TE\}def parse_input(table: dict, grammar: dict, input_tokens: list, start_symbol: str, terminals: set) - list: stack [$, start_symbol] input_stream input_tokens [$] steps [] step_num 0 while stack: step_num 1 top stack[-1] current_input input_stream[0] # 记录当前状态 steps.append({ step: step_num, stack: stack.copy(), input: input_stream.copy(), action: }) if top current_input $: steps[-1][action] accept break elif top current_input: # 匹配终结符 stack.pop() input_stream.pop(0) steps[-1][action] fmatch {top} elif top in table and current_input in table[top] and table[top][current_input] is not None: # 展开非终结符 production table[top][current_input] stack.pop() if production: # 非 ε 产生式 # 反向压入因栈顶在右产生式从左到右写 for symbol in reversed(production): stack.append(symbol) steps[-1][action] fexpand {top} → { .join(production) if production else ε} else: steps[-1][action] ferror: no entry for M[{top},{current_input}] break return steps关键参数说明input_tokens: 如[id, , id]函数自动补$table[top][current_input]: 查表若为None则报错不默认 fallbackreversed(production): 因栈是 LIFOA → B C需先压C再压B才能保证B在栈顶先被处理5.2 可视化输出用 Markdown 表格生成推导过程适配实验报告实验报告要求“清晰展示分析过程”终端打印print(steps)不够直观。我们生成对齐的 Markdown 表格可直接复制进.pdfdef print_steps_as_table(steps: list): print(| 步骤 | 栈 | 输入 | 动作 |) print(|---|---|---|---|) for s in steps: stack_str .join(s[stack]) input_str .join(s[input]) print(f| {s[step]} | {stack_str} | {input_str} | {s[action]} |) # 示例调用 steps parse_input(table, grammar, [id, , id], E, terminals) print_steps_as_table(steps)输出效果步骤栈输入动作1$ Eid id $expand E → T E2$ E Tid id $expand T → F T3$ E T Fid id $expand F → id4$ E T idid id $match id技巧stack_str用空格连接input_str同理配合反引号保证 Markdown 渲染对齐match和expand动作用中文符合国内实验报告习惯。5.3 错误恢复当M[A,a]为空时跳过非法符号而非崩溃严格 LL(1) 遇错即停但实验报告常要求“尽可能继续”。我们加一个skip_on_error参数在else分支中跳过当前输入符号# 在 parse_input 函数中替换 else 分支 else: steps[-1][action] ferror: no entry for M[{top},{current_input}], skip {current_input} input_stream.pop(0) # 跳过错误符号继续提示此模式仅用于调试和实验报告演示不可用于生产环境。真实编译器需精准报错位置。6. 实验报告落地技巧从代码到 PDF 的三步闭环与燕山大学真题验证写完代码只是开始实验报告要体现“理解深度”。我带过的山东科技大学、燕山大学学生高分报告都有一个共同特征用同一套代码跑通教材例题、本校往年真题、自拟边界案例并对比分析。下面给出可直接复用的闭环工作流。6.1 三组测试用例设计覆盖 95% 的实验评分点不要只测idid。按评分标准反推必须覆盖测试组输入目的评分点教材基准id id * id验证基本运算优先级正确推导步数、无冲突燕山大学2022真题( id id ) * id检验括号处理与FOLLOW(F)(和)必须在FOLLOW(F)中否则F → ( E )无法触发边界压力id * id模拟词法错误后的恢复能力*前缺id应报错在*位置而非崩溃生成燕山大学真题的文法已验证yan_shan_grammar { E: [[T, E]], E: [[, T, E], []], T: [[F, T]], T: [[*, F, T], []], F: [[(, E, )], [id]] } terminals {id, , *, (, ), $}运行后FOLLOW(F)必须含)和$否则F → ( E )的)无法填入表。6.2 报告图表生成用 Python 自动生成分析表 PNG教授爱看“表格是否填满”。用matplotlib画热力图绿色格子有产生式红色空一眼看出稀疏度import matplotlib.pyplot as plt import numpy as np def plot_parsing_table(table: dict, nonterms: list, terminals: list): data np.zeros((len(nonterms), len(terminals))) for i, A in enumerate(nonterms): for j, a in enumerate(terminals): data[i, j] 1 if table[A].get(a) is not None else 0 plt.figure(figsize(10, 6)) plt.imshow(data, cmapRdYlGn, aspectauto) plt.xticks(range(len(terminals)), terminals, rotation45) plt.yticks(range(len(nonterms)), nonterms) plt.colorbar(labelEntry exists) plt.title(LL(1) Parsing Table) plt.tight_layout() plt.savefig(parsing_table.png, dpi300, bbox_inchestight)后悔药曾有学生交报告时忘截图答辩前 2 小时用此函数 30 秒生成高清图救回 5 分。6.3 最后一公里PDF 报告自动化拼接用weasyprint将 Markdown 转 PDF无需 LaTeXpip install weasyprintfrom weasyprint import HTML HTML(stringf # LL(1) 语法分析器实验报告 ## 文法{grammar_text}## 预测分析表 ![](parsing_table.png) ## 推导过程 {steps_markdown_table} ).write_pdf(report.pdf)我坚持了三年每次实验先跑通燕山大学真题再跑教材例题最后用diagnose_conflict扫一遍所有可能冲突把报错信息截图贴进报告“问题分析”章节。不是为了炫技而是让学生明白——LL(1) 的价值不在“能跑”而在“知道为什么能跑、哪里会卡住”。这套流程跑下来编译原理实验从“赶 deadline”变成“摸清编译器第一道门”的实感。希望帮到你。本文还有配套的精品资源点击获取
返回列表