ARTICLE DETAIL

资讯详情

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

NFA转DFA与DFA最小化实战:编译原理实验的完整实现

NFA转DFA与DFA最小化实战:编译原理实验的完整实现 简介高校《编译原理》课程的NFA转DFA并最小化实验完整资料面向计算机专业学生及需要完成自动机实验的读者也适合ZZU同学对照课程要求使用。资源压缩包共2个文件包含C源码文件与doc实验报告整体约722KB轻量易下载代码与文档可配套学习。目前已有413人学习浏览具备一定参考热度。源码以C实现NFA到DFA转换及最小化涵盖状态集合处理、子集构造、不可达状态去除与等价状态合并等核心逻辑实验报告则详细记录实验目的、步骤、遇到的问题及解决方案结构清晰便于快速跑通实验并理解编译原理中有限自动机的工作机制。这份资料既能支撑课程实战演练也可作为撰写课程实验报告的思路参考。 这学期ZZU的编译原理课实验里有一道绕不开的题把NFA转成DFA再做DFA最小化。我第一反应是这算法课上都讲过子集构造法和划分细化法嘛应该很快就能写完。结果真正动手后才发现纸上推流程和写能跑通、能交给老师检查的代码完全是两回事。中间因为状态表示、ε闭包、最小化分组这些细节反复改了好几版最后整理成一份可以复用的Python代码和实验报告。这篇博客就是把我踩过的坑和最终实现思路完整写一遍给同样在写这个实验的同学当参考。先说明这个实验在整门课里的位置。编译原理的词法分析阶段核心就是把正则表达式变成有穷自动机。但正则表达式直接转DFA比较麻烦常规做法是先转成NFA再用子集构造法确定化最后通过最小化算法压缩状态数。我实现的代码接收一个手写的NFA描述输出最小化后的DFA状态表同时生成实验报告需要的推导过程和运行结果。适合拿到题目不知道从哪下手的同学也适合想改进现有实现的人。1. 先把实验轮廓搞清楚输入、输出和评分点1.1 这次实验到底要交什么老师要的东西很直接一份能运行的源代码一份实验报告。源代码要做的是从NFA生成最小化DFA报告里要写清楚算法原理、设计思路、测试结果和复杂度分析。很多同学卡在“原理都懂但不知道代码怎么组织”或者反过来“代码跑通了报告写得很水”。这里先说我最后采用的方案Python实现NFA用JSON描述DFA用状态编号和转移矩阵输出报告用Markdown整理成图文混合的推导过程。选Python不是因为性能好而是因为它表达集合运算方便适合这种教学型实验C当然能写但要花很多时间在处理集合和哈希上没必要。1.2 为什么非要从NFA绕一圈有一种偷懒方式是直接让用户输入DFA那实验就没有意义了。NFA转DFA这个步骤本质上要把“不确定性”变成“确定性”。NFA的优点是表达正则语言很自然比如并运算和闭包很容易画出来缺点是对于一个输入符号可能同时进入多个状态这种多值跳转没法直接实现词法分析器。DFA则是每个状态对每个符号只有唯一后继可以直接写成查表程序。最小化则是在保证等价的前提下把DFA状态数量压缩到最少翻译成工程语言就是节省内存、加快匹配速度。1.3 评分点到底落在哪里我说一下自己的判断不一定代表所有老师但三个点基本逃不掉第一算法实现是否正确给几个NFA样例必须能得到正确的DFA第二代码里是否处理了ε转移很多NFA带空转移如果没处理结果一定错第三最小化之后是否与原来的DFA等价是否删掉了不可达状态。报告部分老师主要看能不能用自己的话讲清子集构造法和划分细化的流程以及复杂度分析是不是像自己写的。所以下面先把算法梳理一遍再给代码最后讲报告怎么写。2. 算法原理子集构造法和划分细化法是怎么运作的2.1 三个基础概念状态集、ε-closure、moveNFA定义为五元组(Q, Σ, δ, q0, F)但实操中我们重点关注三个运算。第一个是ε-closure表示从某个状态出发只经过ε边能到达的所有状态集合。第二个是move(T, a)表示从状态集T里的任意一个状态经过一个符号a能一步到达的状态集合。第三个是子集构造法的核心从当前DFA状态它是一个NFA状态集合出发读入符号a之后先做move再做ε-closure得到新的DFA状态。我一开始直接用递归写closure遇到有环的ε转移就会死循环后来改成用栈做传递闭包就好了这个细节后面代码里会体现。2.2 子集构造法的完整流程整个算法可以描述成初始DFA状态是nfa_start的ε-closure维护一个未处理队列和一个已存在状态表每次从队列里取一个DFA状态S对字母表里每个符号a计算T ε-closure(move(S, a))如果T非空且之前没见过就把它加入状态表并放进队列最后在S的转移表里记录S --a-- T。重复到队列空得到的DFA状态数一定小于等于2的NFA状态数次方。这里有个实际经验写代码时一定要区分“状态编号”和“状态内容”DFA状态内容是一个frozenset但输出给老师看时需要重新编号为0、1、2这个映射关系建议单独用一个字典维护。2.3 最小化为什么是划分而不是并集DFA最小化常用的算法有两种一种叫填表法一种叫划分细化法两者殊途同归。我选择划分法主要是因为思路简单也方便在报告里画分组变化图。初始把所有状态分成两组终态组和非终态组因为终态能否接收字符串不一样肯定不等价。然后反复检查每一组对同一个输入符号a如果组内状态跳转到不同的组就说明它们可区分要把这组拆开。一直拆到每个分组对任意符号都稳定为止。合并时把同一组的状态看成同一个新状态转移关系也跟着合并。可能有同学问为什么不用并集合并等价状态因为我们要找的是“不可区分”状态的等价类不是直接“放置在一起”划分法从全量出发一步步拆分天然保证正确。2.4 复杂度分析的几句话报告里复杂度不能只写一句O(n²)。子集构造法最坏情况下DFA状态数是2的n次方因此一般说时间复杂度O(2^n * |Σ| * n)级别空间也类似。划分细化法如果用朴素实现每一轮扫描所有状态和符号最多状态数轮所以是O(k * n² * |Σ|)k通常是常数如果用Hopcroft算法可以做到O(n log n)。我的实验报告里写了朴素划分的推导再补了一句工程上对于常见词法规则规模完全够用这就比只贴结论要扎实。3. 代码实现从NFA描述到最小化DFA的关键细节3.1 输入格式和数据结构设计我设计的输入是一个JSON对象包括nfa_states、alphabet、transitions、start、accept。transitions用字典键是状态,符号值是目标状态列表。特别注意空转移的表示我用空字符串作为ε放在alphabet之外单独处理。这样在计算closure时就方便了而且不会把ε当成真实输入符号。代码里我用字典表示DFA转移表dfa_transitions[dfa_state_index] {symbol: next_dfa_state_index}因为Python的dict本身就是映射表输出也很方便。{ nfa_states: [0, 1, 2], alphabet: [a, b], transitions: { 0,: [1], 0,a: [0], 1,b: [2] }, start: 0, accept: [2] }这个例子对应的NFA可以识别语言a*b状态0上可以不断读a也可以走ε边到状态1然后读一个b到终态2。虽然简单但足够验证ε-closure和move的正确性。3.2 closure和move的实现闭包和移动是两个最基础的小函数我建议把它们拆出来单独写后续所有逻辑都复用。def epsilon_closure(states, transitions): stack list(states) closure set(states) while stack: s stack.pop() for nxt in transitions.get(f{s},, []): if nxt not in closure: closure.add(nxt) stack.append(nxt) return frozenset(closure) def move(states, symbol, transitions): result set() for s in states: result.update(transitions.get(f{s},{symbol}, [])) return result闭包里必须先初始化closure set(states)这代表每个状态到自身的空串路径如果漏了这一步后面会少状态。用栈迭代而不是递归是为了避免深递归和环的问题。move里用dict.get加默认空列表保证未定义转移直接跳过。3.3 子集构造法主逻辑有了上面两个函数子集构造法就很简单了。DFA的每个状态用一个frozenset表示同时给它编一个整数序号。队列里放的是序号而不是集合本身这样切换状态时不会乱。def nfa_to_dfa(nfa): alphabet nfa[alphabet] start_closure epsilon_closure({nfa[start]}, nfa[transitions]) dfa_states [start_closure] dfa_transitions [] dfa_accept [] queue [0] index_map {start_closure: 0} while queue: cur queue.pop() trans {} for symbol in alphabet: target epsilon_closure( move(dfa_states[cur], symbol, nfa[transitions]), nfa[transitions] ) if not target: continue if target not in index_map: index_map[target] len(dfa_states) dfa_states.append(target) queue.append(index_map[target]) trans[symbol] index_map[target] dfa_transitions.append(trans) dfa_accept.append(any(s in nfa[accept] for s in dfa_states[cur])) return dfa_states, dfa_transitions, dfa_accept这里用pop()还是pop(0)其实都可以因为DFA状态生成的顺序不影响最终结果。我习惯用列表当栈简单够用。dfa_accept的判断是看当前DFA状态对应的NFA状态集合里有没有任何一个终态。3.4 最小化核心逻辑最小化我用划分细化法代码核心是一轮轮检查分组是否还需要分裂。def minimize_dfa(state_count, transitions, accept): groups [] non_accept [i for i in range(state_count) if i not in accept] accept_list sorted(accept) if non_accept: groups.append(non_accept) if accept_list: groups.append(accept_list) changed True while changed: changed False new_groups [] for group in groups: if len(group) 1: new_groups.append(group) continue split_dict {} for s in group: sig [] for symbol in sorted(transitions[s].keys()): target transitions[s][symbol] group_id next( gid for gid, g in enumerate(groups) if target in g ) sig.append((symbol, group_id)) sig tuple(sig) split_dict.setdefault(sig, []).append(s) if len(split_dict) 1: new_groups.append(group) else: new_groups.extend(split_dict.values()) changed True groups new_groups return groups这里最容易出错的地方是比较转移目标时应该比较“目标状态所属的分组编号”而不是目标状态本身的编号。因为两个DFA状态编号不同也可能已经被合并到同一组里了按编号比较会让最小化不彻底。3.5 用前面那个NFA样例跑一遍用3.1节给出的JSON做输入最后输出如下DFA状态包含的NFA状态是否终态abA{0,1}否ABB{2}是--最小化之后A和B各自成组结果不变。虽然这个样例简单但用来验证代码流程足够了。实际交实验时我还会准备一个更复杂的NFA比如包含多个终态和多个ε转移就是为了证明代码不是只处理特殊情况。4. 实验报告怎么写才能不白做4.1 报告结构可以直接参考实验报告我按这个顺序写实验目的、实验环境、算法原理、程序设计、测试结果、复杂度分析、实验心得。很多人喜欢把代码全贴进报告我不建议这样老师更想看的是你的思路。算法原理部分写子集构造法和划分细化法配合一个手推例子程序设计部分写数据结构设计比如为什么用frozenset为什么用JSON当输入格式测试结果部分放运行截图或输出表格。4.2 推导过程一定要有中间步骤报告里最有价值的内容是NFA到DFA的逐步展开过程。我在报告里贴了子集构造法的推导表每一列是新状态、当前符号、move结果、ε-closure结果、是否已存在。最小化部分则画出初始分组、每轮分裂依据和最终分组和代码输出对照。老师看到这个就知道你是真的理解了而不是从网上抄一段代码跑通就交。如果怕画图太麻烦可以用Graphviz生成NFA图再把DFA转移表整理成Markdown表格。4.3 复杂度分析和等价性说明复杂度要结合自己的实现写不要抄书。我在报告里写的是子集构造法最坏指数级但对典型词法规则规模可接受最小化算法每一轮需要遍历所有状态和所有输入符号最坏状态数轮所以是O(k * n² * |Σ|)。等价性说明也很重要划分法只合并不可区分状态所以最小化后的DFA识别语言不变再用随机串验证原DFA和最小化DFA接受集合一致这个测试过程写进报告会显得实验完成度很高。5. 我踩过的坑和排查方法5.1 用可变set做状态键导致报错我第一次写时直接用set作为字典的键结果运行到一半就报TypeError: unhashable type: set。这个错误很好认但新手容易懵。解决方法是统一用frozenset表示DFA状态因为只有不可变对象才能作为字典键。我后来干脆在epsilon_closure和move里都返回frozenset从源头避免问题。5.2 ε闭包漏掉自反状态算ε-closure时起始状态本身一定要加进结果里。比如epsilon_closure({0}, ...)至少应该包含0因为从0出发走0条ε边也能到0。如果代码初始化时没有closure set(states)后续所有基于闭包的状态都会缺一块而且结果往往非常隐蔽不容易一眼看出来。调试方法是打印每个DFA状态对应的NFA状态集合和手推结果对比。5.3 最小化按转移目标状态编号分组这个问题最隐蔽。最小化的核心是“可区分性”两个DFA状态如果对某个符号跳转到已经等价的组那它们当前就不需要分裂但如果直接按目标状态编号比较编号不同就认为可区分会导致分组过于细碎最小化不彻底。修正方法就是3.4节代码里的做法先根据当前groups把每个目标状态映射到组号再按组号签名分组。5.4 死状态和未定义转移的处理很多NFA转换出来的DFA并不是完全定义的某些状态对某些符号没有转移。学校实验一般不强制补全死状态但如果你要做成完整的词法分析器最好补一个死状态所有未定义转移都指向它这样状态表看起来更完整。补死状态时要注意不要把它和普通状态合并否则会让原本不可接受的串变成可接受测试时会出大问题。5.5 测试时一定要覆盖边界情况我最后提交前写了一个随机验证函数随机生成若干测试串分别在原DFA和最小化DFA上模拟比较接受结果是否一致。还专门测了空串、单个字母、很长的重复串。这个动作帮我抓出了两个隐藏bug强烈建议你也这样做。报告里附上“随机测试1000条字符串全部一致”这句结论比干巴巴的“测试通过”有说服力得多。6. 自己跑通一遍之后的几点体会6.1 做完这个实验我留下的习惯第一先手推再写代码。我第二次实现时先拿a*b这个例子在纸上把子集构造法展开每一步该得到什么状态都写清楚再去写代码效率高很多。第二代码里所有集合类型统一能frozenset就frozenset能tuple就tuple避免到后期到处处理哈希问题。第三保留测试脚本不是跑通一次就删后续改算法、改输出格式都能回归验证。6.2 想做扩展可以从这些方向入手如果做完实验还有余力可以把最小化算法从朴素划分换成Hopcroft算法复杂度能到O(n log n)代码也不复杂就是把“待处理组”按逆转移一层层拆。更进一步的玩法是写一个正则表达式解析器直接从正则表达式构造NFA再套上现有转换和最小化流程做成一个小型词法分析器生成器。这个实验做完我对NFA和DFA的敬畏感少了很多因为代码一跑状态表清清楚楚摆在眼前原来抽象的东西突然就具体了。本文还有配套的精品资源点击获取
返回列表