资讯动态

编译原理实验:NFA转DFA与最小化C++实现及报告模板

发布时间:2026/10/20 20:09:51 来源:尧图企业网站定制
简介这份资源面向高校「编译原理」课程学习者尤其是ZZU的学弟学妹提供NFA转DFA并最小化实验的完整代码与实验报告帮助解决自动机理论中从非确定有限自动机到确定有限自动机的转换及状态化简这一关键实验难题。压缩包共2个文件包含1个cpp源代码和1个doc实验报告整体约722KB代码用C实现子集构造法与DFA最小化算法报告则记录实验目的、步骤、问题与解决方案两者配合便于对照理解算法细节。目前已有415人学习下载说明该实验在课程中具有较高参考价值。读者可借此掌握子集构造法的状态子集划分思路、不可达与冗余状态的识别合并方法并参考报告中的排错与总结思路将抽象理论落到编程实践提升编译器词法分析模块的实现能力。1. 从一道必考题说起为什么 NFA 转 DFA 是编译原理实验的分水岭如果你正在做编译原理课程设计大概率绕不开这个题目给定一个非确定性有限自动机NFA要求把它转换成确定性有限自动机DFA再对 DFA 做最小化最终输出状态转换表或状态转换图。这道题几乎出现在每一本编译原理教材的习题里也是很多高校期中或期末实验的固定考点。它之所以重要是因为它串联了词法分析阶段最核心的三个环节——正规式到 NFA、NFA 到 DFA 的子集构造法、DFA 的最小化Hopcroft 算法或划分法。很多同学在理论课上能听懂子集构造的手工演算但一到写代码就卡住状态集合怎么表示空闭包怎么递归求最小化时等价类怎么划分这份实验方案就是围绕这三个问题展开的包含完整可编译的 C 代码和一份结构清晰的实验报告模板适合正在赶课程设计的学生直接参考或改造。2. 需求拆解与技术选型为什么用 C 而不是 Python2.1 功能需求与非功能需求先把需求列清楚不然后面写代码容易漏。功能需求通常包括四条第一能够读取或硬编码一个 NFA 的定义包括状态集、字母表、转移函数、初态和终态第二实现子集构造法输出对应的 DFA 状态转换表第三对 DFA 进行最小化合并等价状态第四以可读格式输出结果比如状态转换表或 DOT 格式的图描述。非功能需求往往被忽略但实验报告里最好写上代码要能在常见编译器下直接编译通过不依赖冷门第三方库状态数量在几十个以内时运行时间不超过一秒输出格式要方便截图放进报告。我一般会建议把输入设计成两种模式一种是直接在代码里用结构体初始化适合调试另一种是从文本文件读取适合答辩时演示不同用例。文本格式可以约定为每行一条转移例如0 a 1表示状态 0 读入 a 转到状态 1再用单独的行标记初态和终态。这样既简单又不容易解析出错。2.2 为什么选 C 而不是 Python很多同学问能不能用 Python 写当然可以但 C 在这个场景有三个实际优势。第一编译原理实验通常要求展示对底层数据结构的掌控用std::set和std::map实现状态集合与转移表能体现你对集合运算和映射的理解答辩时更容易展开讲。第二C 的bitset或vectorbool在表示状态集合时效率高虽然实验规模小但写进报告里是加分项。第三很多高校的实验验收环境只保证有 g 或 Dev-CPython 环境反而不一定齐全。所以这份方案用 C17 标准编写核心依赖只有标准库的iostream、fstream、set、map、vector、queue和string。下面是一个 NFA 的数据结构定义直接决定了后续算法的写法#include bits/stdc.h using namespace std; // NFA 结构状态用整数编号字母表用 char 表示 struct NFA { int stateNum; // 状态总数编号 0 ~ stateNum-1 setchar alphabet; // 输入字母表不含空字符 mappairint,char, setint trans; // 转移函数状态,字符 - 目标状态集合 int startState; // 初态 setint finalStates; // 终态集合 char epsilon #; // 用 # 表示空串避免与正常字符冲突 };这段代码的关键点在于trans用map的键是pairint,char值是一个setint因为 NFA 的转移目标可能不止一个。空串单独用epsilon成员表示不放进alphabet这样求空闭包时不会混淆。参数stateNum用来控制遍历范围finalStates用set存储方便做集合交并。2.3 实验报告的结构要求实验报告不是代码的附属品它占分往往不低于代码。一份合格的报告通常包含实验目的、实验环境、算法原理简述、数据结构设计、核心代码片段、测试用例与运行结果、遇到的问题与解决、心得体会。其中算法原理部分不要大段抄教材用自己的话把子集构造法和划分法各写一段即可重点放在数据结构设计和测试用例上。测试用例至少准备三组一个简单的不含空串的 NFA、一个含空串的 NFA、一个最小化前后状态数明显减少的 DFA。每组都要有输入描述和输出截图。3. 总体设计三个模块与两条主线3.1 模块划分与调用关系整个程序分成三个模块对应三个核心函数。第一个模块是epsilonClosure输入一个状态集合输出它的空闭包。第二个模块是subsetConstruction输入 NFA输出一个 DFA 结构。第三个模块是minimizeDFA输入 DFA输出最小化后的 DFA。主函数负责读入、调用和输出。调用关系是线性的读 NFA → 求 DFA → 最小化 → 打印。这种线性结构的好处是每个模块可以单独测试比如你可以先只跑子集构造把中间 DFA 打印出来和手工演算对照。DFA 的数据结构比 NFA 简单因为转移目标唯一struct DFA { int stateNum; setchar alphabet; mappairint,char, int trans; // 状态,字符 - 唯一目标状态 int startState; setint finalStates; // 辅助信息每个 DFA 状态对应原 NFA 的状态集合便于调试 vectorsetint nfaSubset; };nfaSubset这个字段是我强烈建议加的。子集构造出来的 DFA 状态编号是新的如果不记录它对应哪些 NFA 状态一旦结果不对排查起来就是黑匣子。有了这个映射你可以直接打印每个 DFA 状态包含的 NFA 状态编号和手工演算一步步对照。3.2 状态编号策略与输出格式子集构造过程中每发现一个新的状态集合就给它分配一个从 0 开始的新编号。用mapsetint, int做这个映射键是 NFA 状态集合值是 DFA 状态编号。这样当同一个集合再次出现时直接查表得到已有编号不会重复创建。输出格式建议同时提供两种一种是纯文本的状态转换表每行DFA状态 字符 目标状态另一种是 DOT 语言描述可以粘贴到 Graphviz 在线工具里生成状态图放进报告很直观。注意DOT 输出时终态要用双圆圈表示初态加一个箭头指向它。如果报告里要贴图建议把 DOT 代码和生成的图片都放上去显得工作完整。4. 核心实现子集构造法与最小化的代码落地4.1 空闭包的计算空闭包是子集构造的基础写不对后面全错。思路是给定一个状态集合 T先把 T 中所有状态加入结果集然后反复检查结果集中每个状态是否有空串转移如果有把转移目标也加入结果集直到结果集不再增大。用队列实现最直观// 计算状态集合 s 的空闭包 setint epsilonClosure(const NFA nfa, const setint s) { setint closure s; // 初始包含自身 queueint q; for (int st : s) q.push(st); // 所有状态入队 while (!q.empty()) { int cur q.front(); q.pop(); // 查找当前状态的所有空串转移 auto it nfa.trans.find({cur, nfa.epsilon}); if (it ! nfa.trans.end()) { for (int nxt : it-second) { if (closure.find(nxt) closure.end()) { closure.insert(nxt); // 新状态加入闭包 q.push(nxt); // 继续扩展 } } } } return closure; }这段代码的时间复杂度是 O(状态数 × 空转移数)实验规模下完全够用。关键点是closure既作为结果集又作为去重依据避免死循环。如果 NFA 里存在空串自环比如状态 1 通过空串回到状态 1这段代码也能正确处理因为closure.find(nxt)会拦住已经存在的状态。4.2 子集构造的主循环子集构造从初态的空闭包开始对每个 DFA 状态和每个字母计算移动后的空闭包如果得到新集合就分配新编号。核心代码如下DFA subsetConstruction(const NFA nfa) { DFA dfa; dfa.alphabet nfa.alphabet; mapsetint, int subsetToId; // NFA 集合 - DFA 编号 queuesetint workList; // 待处理的 NFA 集合 // 初态的空闭包作为 DFA 初态 setint startSet epsilonClosure(nfa, {nfa.startState}); subsetToId[startSet] 0; workList.push(startSet); dfa.nfaSubset.push_back(startSet); while (!workList.empty()) { setint curSet workList.front(); workList.pop(); int curId subsetToId[curSet]; // 判断当前 DFA 状态是否为终态只要包含任一 NFA 终态即可 for (int st : curSet) if (nfa.finalStates.count(st)) { dfa.finalStates.insert(curId); break; } // 对每个字母计算转移 for (char ch : nfa.alphabet) { setint moveSet; for (int st : curSet) { auto it nfa.trans.find({st, ch}); if (it ! nfa.trans.end()) moveSet.insert(it-second.begin(), it-second.end()); } if (moveSet.empty()) continue; // 空集不创建状态 setint nextSet epsilonClosure(nfa, moveSet); if (subsetToId.find(nextSet) subsetToId.end()) { int newId subsetToId.size(); subsetToId[nextSet] newId; workList.push(nextSet); dfa.nfaSubset.push_back(nextSet); } dfa.trans[{curId, ch}] subsetToId[nextSet]; } } dfa.stateNum subsetToId.size(); dfa.startState 0; return dfa; }这里有几个容易翻车的细节。第一moveSet为空时不要创建新状态否则会多出一个无意义的死状态最小化时反而增加麻烦。第二终态判断是“包含任一 NFA 终态”不是“等于终态集合”因为子集构造得到的 DFA 状态是 NFA 状态的集合。第三subsetToId.size()在插入前就是新编号这个顺序不能反否则编号会错位。我见过有同学先插入再取 size结果所有编号偏移一位调了半天。4.3 DFA 最小化的划分法实现最小化用划分法也叫填表法或等价类划分法思路是先把状态分成终态和非终态两组然后反复检查每组内是否有状态对在某个字母下转移到不同的组如果有就拆分直到所有组都稳定。最后每组选一个代表重建转移表。代码比子集构造稍长但逻辑清晰DFA minimizeDFA(const DFA dfa) { // 第一步初始划分终态一组非终态一组 vectorsetint groups; setint finalGroup dfa.finalStates; setint nonFinalGroup; for (int i 0; i dfa.stateNum; i) if (!dfa.finalStates.count(i)) nonFinalGroup.insert(i); if (!finalGroup.empty()) groups.push_back(finalGroup); if (!nonFinalGroup.empty()) groups.push_back(nonFinalGroup); // 第二步反复拆分直到稳定 bool changed true; while (changed) { changed false; vectorsetint newGroups; for (auto g : groups) { // 对组内状态按“转移目标所在组”做签名 mapvectorint, setint signature; for (int st : g) { vectorint sig; for (char ch : dfa.alphabet) { auto it dfa.trans.find({st, ch}); int target (it ! dfa.trans.end()) ? it-second : -1; // 找到 target 属于哪个组 int gid -1; for (int i 0; i (int)groups.size(); i) if (groups[i].count(target)) { gid i; break; } sig.push_back(gid); } signature[sig].insert(st); } if (signature.size() 1) changed true; for (auto p : signature) newGroups.push_back(p.second); } groups newGroups; } // 第三步重建 DFA每组选第一个状态作为代表 DFA minDfa; minDfa.alphabet dfa.alphabet; mapint,int oldToNew; for (int i 0; i (int)groups.size(); i) for (int st : groups[i]) oldToNew[st] i; minDfa.stateNum groups.size(); minDfa.startState oldToNew[dfa.startState]; for (int i 0; i (int)groups.size(); i) for (int st : groups[i]) if (dfa.finalStates.count(st)) { minDfa.finalStates.insert(i); break; } for (auto kv : dfa.trans) { int from kv.first.first; char ch kv.first.second; int to kv.second; minDfa.trans[{oldToNew[from], ch}] oldToNew[to]; } return minDfa; }签名sig是一个vectorint记录当前状态在每个字母下转移目标所在的组号。如果两个状态对所有字母的转移目标组号都相同它们就属于同一个等价类。target -1表示没有转移单独归为一类。这段代码在状态数几十个时运行很快因为每次拆分至少减少一个状态对循环次数有限。4.4 测试用例与预期输出准备三组用例能覆盖大部分情况。用例一NFA 状态 0 初态终态 2转移0 a 0、0 a 1、1 b 2不含空串。子集构造后 DFA 应有 3 个状态最小化后不变。用例二在上面的基础上加0 # 1空闭包会把 0 和 1 合并DFA 状态数可能减少。用例三构造一个最小化前后状态数从 5 降到 3 的 DFA用来验证划分法确实生效。每组用例的输入、中间输出和最终输出都要截图放进报告答辩时老师很可能让你现场跑一组。5. 避坑与排查五个最容易翻车的地方5.1 空闭包忘记包含自身现象子集构造结果比手工演算少状态或者初态不对。原因epsilonClosure初始集合没有把传入的状态本身放进去只放了空转移目标。解决函数开头setint closure s;必须先复制输入集合再扩展。5.2 状态编号从 1 开始导致数组越界现象程序运行到一半崩溃或者输出状态编号对不上。原因NFA 输入时状态编号习惯从 1 开始但代码里用vector或数组索引时按 0 开始。解决统一约定编号从 0 开始读入时如果遇到 1 起始的输入先整体减一。或者在数据结构里用mapint,...代替数组避免下标问题。5.3 最小化时忽略死状态现象最小化后状态数反而比预期多。原因子集构造时对空moveSet创建了死状态或者 DFA 本身包含无法到达终态的状态。解决子集构造里moveSet.empty()时直接continue最小化前可以先做一次可达性分析删掉从初态不可达的状态再划分。5.4 字母表顺序影响输出稳定性现象每次运行输出顺序不一样截图对不上。原因setchar遍历顺序是字典序但map的遍历顺序取决于插入顺序。解决输出前把转移按(状态, 字符)排序或者用map的有序特性统一遍历。报告里贴的输出要稳定不然老师以为你结果随机。5.5 实验报告只贴代码不写设计现象代码跑通了报告分却不高。原因报告变成了代码堆砌缺少数据结构设计和算法选择的说明。解决报告里至少用一段话解释为什么用setint表示状态集合、为什么用map做转移表、子集构造的终止条件是什么。这些才是老师想看的。6. 验收与答辩怎么证明你的方案是对的验收环节通常分两步老师看运行结果然后问几个问题。运行结果方面建议准备一个test文件夹里面放三组输入文件和对应的预期输出文件现场用命令./nfa2dfa test/case1.txt跑一遍输出直接打印到屏幕。如果老师要求现场改输入你要能快速在文本文件里加一行转移重新编译运行不超过十秒。这要求代码里读文件的逻辑足够健壮比如忽略空行和注释行。答辩问题一般围绕算法正确性和复杂度。常见问题有子集构造为什么能保证终止因为 DFA 状态数是 NFA 状态集合的幂集子集有限。最小化后的 DFA 唯一吗在忽略状态命名的情况下唯一。你的空闭包算法时间复杂度是多少O(n×m)n 是状态数m 是空转移数。这些问题提前准备好一句话答案不要临场翻代码。下面这张表可以用来检查你的方案是否完整答辩前逐项打勾检查项合格标准常见扣分点NFA 输入支持文件读取格式清晰硬编码且无注释空闭包含自环和链式空转移漏掉自身或死循环子集构造输出 DFA 状态转换表多出死状态最小化状态数减少且语言等价划分不收敛输出格式文本表 DOT 图只有屏幕打印实验报告含设计说明和测试用例只有代码截图最后说一个我自己的习惯每次交实验前我会把代码复制到一个干净目录用g -stdc17 -Wall -o nfa2dfa main.cpp重新编译一遍确保没有警告。然后随机挑一组用例手工把子集构造的前三步在纸上演算一遍和程序输出对照。这个习惯帮我抓出过好几次编号错位和空闭包遗漏的问题。从那以后我每次验收前都强制走一遍这个流程比反复读代码管用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑