尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

编译原理课设实战:NFA确定化、DFA最小化与First/Follow集合实现

编译原理课设实战:NFA确定化、DFA最小化与First/Follow集合实现 简介面向编译原理课程设计的学习者内容覆盖词法分析、语法分析、语义分析等关键环节并以NFA确定化、DFA最小化及First、Follow集合计算为算法核心可作为计算机专业学生完成课设、备战面试或复习编译器构造要点的系统参考。压缩包共160个文件约81.15MB其中cpp源码与sln、vcxproj工程文件构成完整可编译的Visual Studio项目docx报告与总结文档记录了设计思路、测试过程与常见问题排错exe、pdb、tlog等辅助文件则方便直接运行和调试对照。已有266人学习下载资源的热度反映了其实用性。报告中不仅给出算法流程和数据结构设计还提供若干测试用例与结果分析源代码按词法分析、语法分析、语义分析等子工程组织便于按模块理解与二次开发。既适合快速入门也适合作为课设报告撰写的模板整体完成度和参考价值较高。 说实话每次到编译原理课程设计这个环节总有一批人在 NFA 确定化和 First/Follow 集合上栽跟头。这次我的课设题目正好是把经典词法分析和语法分析的前置工具串起来实现 NFA 到 DFA 的确定化、DFA 最小化以及 First、Follow 集合的计算最后还要写出一份像样的课程设计报告。整条链路走下来踩了不少坑也沉淀了一批可以直接复用的代码思路和调试技巧。我把完整实现过程、算法选型、测试方案和报告写法一次性整理出来适合正在做编译原理课设、或者想系统理解这些经典算法的同学参考。1. 项目概述与整体设计思路1.1 核心需求解析这个课设题目一看就知道四个模块是相互关联的NFA 确定化、DFA 最小化、First 集合、Follow 集合。前两个属于词法分析阶段的核心算法后两个是语法分析中构造预测分析表的前置步骤。四个模块合在一起几乎把编译原理前半本书的重点考点都覆盖了。我在动手之前先把需求拆成了四块每一块的输入输出都定义清楚模块输入输出NFA明确化NFA 的五元组定义状态集、字母表、转移函数、初态、终态等价的 DFA 状态转换表DFA最小化DFA 状态转换表最小化的 DFA 状态转换表First集合上下文无关文法产生式每个非终结符的 First 集合Follow集合上下文无关文法产生式每个非终结符的 Follow 集合这样拆完之后每个模块的测试就可以独立进行不用等到全部写完再去联调。我当时就是吃了代码写完才准备测试的亏后来改成边写模块边跑用例效率明显高很多。1.2 技术选型与模块划分语言我选了 Python原因很简单集合操作太方便了。NFA 确定化里反复用到的并集、交集、差集运算在 Python 里就是set和frozenset一句话的事换 Java 写光是HashSet的拷贝和比较就得写不少样板代码。课设重点是算法本身不是语言特性选工具的原则是让表达尽可能贴近算法描述。模块划分上我按功能拆成了三个文件nfa_dfa.py放 NFA 确定化和 DFA 最小化first_follow.py放 First、Follow 计算main.py负责从文件读取输入并调用核心逻辑。每个文件里都保留了独立的test()函数方便单独调试。这样划分还有个好处最终写报告的时候每个章节对应的代码文件一目了然不用在几百行代码里找逻辑。2. NFA确定化子集构造法的完整实现2.1 算法原理与数据结构设计NFA 确定化的核心是子集构造法也叫幂集构造法。核心思想是NFA 的一个状态集合在 DFA 里对应一个状态。比如 NFA 经过若干次 ε 转移后能同时处于状态 {1, 2, 3}那么 DFA 就有一个状态代表这个集合。这也是确定化的本质——把不确定性用集合的方式合并消除。这里最关键的操作有两个ε-closure(T)和move(T, a)。ε-closure(T)是从状态集合 T 出发只通过 ε 边能到达的所有状态的集合move(T, a)是从状态集合 T 出发通过输入符号 a 能直接到达的状态集合。DFA 的每个状态就是通过反复交替做这两个操作构建出来的。我在设计数据结构时NFA 的转移表用字典嵌套字典来表示trans[state][symbol] - set of states。ε 用一个特殊符号ε表示。这样实现move操作时代码非常直观。注意状态集合一定要用set因为同一个状态可能通过不同路径到达多次集合天然去重。2.2 核心代码实现下面是ε-closure和子集构造法的核心片段def epsilon_closure(states: set, trans: dict) - set: stack list(states) closure set(states) while stack: state stack.pop() for next_state in trans.get(state, {}).get(ε, set()): if next_state not in closure: closure.add(next_state) stack.append(next_state) return closure def subset_construction(nfa): # nfa: 包含 states, alphabet, trans, start, accept start_closure epsilon_closure({nfa[start]}, nfa[trans]) dfa_states [start_closure] unmarked [start_closure] dfa_trans {} while unmarked: t unmarked.pop() for symbol in nfa[alphabet] - {ε}: move_result set() for state in t: move_result | nfa[trans].get(state, {}).get(symbol, set()) next_closure epsilon_closure(move_result, nfa[trans]) if not next_closure: continue key (frozenset(t), symbol) dfa_trans[key] frozenset(next_closure) if frozenset(next_closure) not in {frozenset(s) for s in dfa_states}: dfa_states.append(next_closure) unmarked.append(next_closure) return dfa_states, dfa_trans这里有个容易踩的坑dfa_states里存的是set但 set 不能做字典的键所以我用frozenset作为 DFA 状态的唯一标识。实际调试时你会发现如果忘了这一步代码会在字典查找时直接报TypeError: unhashable type: set。另一个容易忽略的点是 DFA 的终态判断。DFA 的某个状态本质是 NFA 状态集合只要包含 NFA 的任意一个终态这个 DFA 状态就是终态。这个判断要在确定化完成后单独遍历一遍千万不能漏。我当时漏掉这个判断导致输出 DFA 的终态集合同空集一样后面最小化直接逻辑混乱。2.3 边界情况与验证我在测试时准备了三个用例第一个是标准教材上的四状态 NFA带有多个 ε 边第二个是完全没有 ε 边的 NFA这时确定化退化成简单的状态合并第三个是包含不可达状态的 NFA用来验证输出 DFA 是否会自动去掉这些不可达状态。实测下来子集构造法在遇到大规模 NFA 时状态集会呈指数增长但课设级别的输入规模完全不用担心。另外需要注意的是NFA 中可能存在对某个符号完全没有转移边的情况代码里要处理trans.get(state, {}).get(symbol, set())返回空集合的情况我的实现里已经做了默认值处理。3. DFA最小化划分法的实现与优化3.1 划分法原理DFA 最小化的目标是把等价的状态合并成一个状态。所谓等价是指两个状态在输入任意符号串后要么都能到达终态要么都不能到达终态且转移后的状态也等价。这个定义用大白话理解就是两个状态如果对外表现完全一致那合并成一个也不影响认出来的语言。最经典的算法是填表法和划分法。课设我选了划分法因为它思路清晰、实现量小。算法分成两步初始划分把终态和非终态分成两组。反复细化对当前每个状态组检查其中的状态在某个输入符号下转移到的目标状态是否落在相同的组里。如果在同一组就继续否则按目标状态所在的组进行二次分组。这个过程一直重复直到划分不再变化每个组对应的就是一个合并后的状态。3.2 实现细节与代码以下是划分法的核心代码def minimize_dfa(states, alphabet, trans, accept): partition [set(accept), set(states) - set(accept)] partition [g for g in partition if g] changed True while changed: changed False new_partition [] for group in partition: split_map {} for state in group: key [] for symbol in alphabet: target trans.get((state, symbol)) for g_idx, g in enumerate(partition): if target in g: key.append(g_idx) break else: key.append(-1) key tuple(key) split_map.setdefault(key, set()).add(state) new_partition.extend(split_map.values()) if len(split_map) 1: changed True partition new_partition return partition这里有个很重要的细节分组时用的是状态在某个符号下转移到的目标状态属于哪个组作为分组键而不是直接比较目标状态是否相同。因为两个状态转移到同一个具体状态并不能说明它们等价只有转移到同一个组才说明在当前划分下它们行为一致。3.3 死状态处理很多人在做 DFA 最小化时会漏掉死状态sink state。所谓死状态就是某个状态在输入某个符号后没有定义转移。在最小化之前我会统一加一个显式的死状态把所有未定义的转移都指向它并让死状态在任意符号下都转移回自身。这样划分法可以正常处理最小化完之后再把死状态删掉。如果不做这一步划分法在判断目标状态时就会因为找不到目标组而报错或者出现逻辑漏洞。我第一次实现时没处理结果在跑一个含未定义转移的 DFA 时target in g的判断全部落空所有状态被错误地并到了同一个组里最小化的结果完全错误。4. First与Follow集合语法分析的前置工具4.1 First集合的计算First 集合的定义是从某个符号终结符或非终结符出发通过一步或多步推导能出现在推导串开头的终结符集合。计算规则我用大白话整理一下如果 X 是终结符那么 First(X) {X}。如果 X 是非终结符且有产生式 X → ε那么 ε 加入 First(X)。如果 X → Y1 Y2 ... Yk那么 First(Y1) 中除 ε 之外的符号全部加入 First(X)如果 Y1 能推出 ε就继续看 First(Y2) 中的非 ε 符号依此类推。实现的关键是数据的表示。我把产生式存成(left, right)的元组right是一个符号列表。然后用一个first字典键是非终结符值是 set。由于文法中可能存在循环依赖比如A - B和B - A同时存在所以计算必须采用不动点迭代反复扫描所有产生式直到所有 First 集合都不再变化。def compute_first(productions, nonterminals, terminals): first {nt: set() for nt in nonterminals} changed True while changed: changed False for left, right in productions: for symbol in right: if symbol in terminals: if symbol not in first[left]: first[left].add(symbol) changed True break else: before len(first[left]) first[left] | first[symbol] - {ε} if len(first[left]) ! before: changed True if ε not in first[symbol]: break else: if ε not in first[left]: first[left].add(ε) changed True return first注意这里的for...else结构如果整个产生式右部都成功遍历完也就是所有符号都能推出 ε才把 ε 加入 First(left)。这个写法在 Python 里很优雅但很容易被忽略我在调试时就是因为没走else分支导致某些文法算出的 First 集合缺了 ε。4.2 Follow集合的计算与迭代Follow 集合的定义是在推导过程中可能紧跟在某个非终结符后面的终结符集合。起点是开始符号的 Follow 一定包含结束符$。计算规则对开始符号 S把$加入 Follow(S)。对形如 A → αBβ 的产生式把 First(β) 中除 ε 之外的所有符号加入 Follow(B)。对形如 A → αB 的产生式把 Follow(A) 全部加入 Follow(B)。对形如 A → αBβ 且 β 能推出 ε 的产生式同样把 Follow(A) 加入 Follow(B)。实现时同样用不动点迭代外层 while 循环反复扫描所有产生式直到每个非终结符的 Follow 集合都不再变化。def compute_follow(productions, nonterminals, terminals, first, start): follow {nt: set() for nt in nonterminals} follow[start].add($) changed True while changed: changed False for left, right in productions: for i, symbol in enumerate(right): if symbol not in nonterminals: continue if i 1 len(right): beta right[i 1:] first_beta first_of_sequence(beta, first) before len(follow[symbol]) follow[symbol] | first_beta - {ε} if len(follow[symbol]) ! before: changed True if ε in first_beta: before len(follow[symbol]) follow[symbol] | follow[left] if len(follow[symbol]) ! before: changed True else: before len(follow[symbol]) follow[symbol] | follow[left] if len(follow[symbol]) ! before: changed True return follow这个辅助函数first_of_sequence负责计算一个符号串的 First 集合可能包含 ε逻辑和 First 集合计算中连续扫描直到遇到不能推出 ε 的符号的思路一致。实现时我单独抽了一个函数因为 Follow 计算里有两处都要用到这个逻辑抽出来有利于保持代码一致。4.3 调试心得与常见错误我在实现 Follow 时犯过一个典型错误第三条和第四条规则只写了β 能推出 ε就加入 Follow(A)但忘了加β 为空串的条件。也就是说对产生式 A → αBβ 根本不存在这时候必须直接把 Follow(A) 加入 Follow(B)。我的代码里用if i 1 len(right)做了区分就避免了这个问题。另一个常见错误是在计算 First 时把 ε 错误地漏掉或错误地加入。比如产生式 A → B C、B → ε、C → ε那么 First(A) 应该包含 ε。用上面基于不动点迭代的代码这个问题可以通过for...else结构正确解决但如果用递归实现就需要特别注意递归深度问题对于有循环依赖的文法容易栈溢出。5. 测试用例设计与报告撰写要点5.1 三组测试用例覆盖从教材到边界课设报告里测试用例的分量相当重这直接决定老师对代码质量的评价。我个人建议至少准备三组测试用例覆盖不同难度第一组是标准教材上的经典例子比如正则表达式(a|b)*abb对应的 NFA用来展示 NFA 确定化和 DFA 最小化的完整过程结果要和教材上的一致这样评审一眼就能看出正确性。第二组是带 ε 转移的复杂 NFA特意包含多个 ε 边和不可达状态用来验证确定化和最小化对这类边界的处理。第三组是一个可能产生左递归的文法比如E - E T | T用来验证 First 和 Follow 计算在循环依赖存在时依然能收敛。每组用例都配好期望输出测试时直接断言比对。5.2 报告结构和中间过程展示课程设计报告我建议按这个结构写引言简述目的和任务设计需求分析明确输入输出和数据格式总体设计讲模块划分和调用关系详细设计讲每个算法的原理和核心代码测试与结果分析展示测试用例和运行结果最后写总结讲遇到问题和解决方案。需要特别提醒的是报告里除了贴代码一定要有中间过程的输出展示。比如 NFA 确定化时把每个 ε-closure 的结果打印出来这样老师能直观看到算法每一步在做什么比只贴一张最终 DFA 状态图有说服力得多。我还在main.py里加了--debug参数开启后会打印每一步的中间状态包括划分法的每一轮分组变化、First/Follow 的迭代过程这些截图放进报告里既精确又省时间。5.3 结果展示与人工验证我在报告中做了两个验证一是把最小化前后的状态数做对比表格直观展示状态数从多少个降到了多少个二是手工推导一遍样例文法和程序输出做对比确认 First 和 Follow 集合完全一致。这种交叉验证的证据在答辩时特别有用遇到老师追问也能从容应对。对于 First/Follow 集合我建议把每个非终结符的计算结果单独列一行表格并用不同字体标注新增的符号。这样老师可以清楚地看到迭代过程中每个集合的演化甚至能对照手工推导逐步验证。这一部分如果写得足够清晰答辩时基本不会在这个环节被追问。6. 课设避坑指南四个直接影响验收的细节第一数据结构的设计直接决定实现难度。用 Python 做这类集合运算类算法优先考虑set、frozenset、dict的组合避免自造复杂的链表结构。NFA 状态集合用frozenset作键DFA 状态转移表用(state, symbol)作键整份代码会清晰很多。第二不要急于写代码先把三组测试用例和期望输出准备好。我在确定化和最小化的时候犯了两次错都是因为测试用例设计得不全导致某些分支没走到。把测试用例当成需求规格来写程序的正确性验证会高效得多。第三报告里的图表输出可以完全由程序自动生成没必要手工画。比如确定化的过程用表格打印每个 DFA 状态对应的 NFA 状态集合比手画状态图清晰可靠最小化时打印每一轮的分组情况比事后手工推演不容易出错。第四如果时间允许建议把 NFA 确定化和 DFA 最小化分成两个独立函数测试时分别调用。课程设计的验收经常是现场输入新用例如果四个模块耦合在一起现场出 bug 的时候会非常被动。独立函数还有一个好处报告里的模块化设计章节就有了实质例证也算加分项。这次课设做完最大的体会是编译原理的算法看起来抽象但一旦动手实现一遍很多原来死记硬背的概念会自然串起来。比如确定化和最小化解决了同样的语言用更少状态表示的问题First/Follow 则是在回答推导到某个位置时下一个可能的终结符是什么。这套小工程做完不仅课设报告有了扎实的数据支撑连带着 LL(1) 预测分析表的构造、LR 分析里的项目集规范族理解起来都顺畅了不少。希望这篇记录能帮正在做课设的你少走几步弯路尤其是在那些只有实测才会暴露的边界问题上别等到答辩当天才手忙脚乱。本文还有配套的精品资源点击获取
返回列表