简介:高校《编译原理》课程的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、move
NFA定义为五元组(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状态 | 是否终态 | a | b |
|---|---|---|---|---|
| A | {0,1} | 否 | A | B |
| B | {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的敬畏感少了很多,因为代码一跑,状态表清清楚楚摆在眼前,原来抽象的东西突然就具体了。
本文还有配套的精品资源,点击获取