☰
ZZU编译原理实验:NFA转DFA并最小化C++实现与避坑指南
2026/10/11 14:01:21 网站建设 项目流程

简介:这份资源面向高校「编译原理」课程学习者,尤其是ZZU的学弟学妹,提供NFA转DFA并最小化实验的完整代码与实验报告,帮助理解子集构造法、DFA最小化等自动机理论核心算法。压缩包共2个文件,包含1个cpp源码和1个doc实验报告,整体约722KB,源码可直接编译运行,报告则记录了实验目的、步骤、问题与解决方案,便于对照学习。目前已有414人学习下载,说明该实验在课程中具有较高参考价值。通过阅读代码与报告,读者能掌握如何用C++实现NFA到DFA的转换及状态合并优化,理解不可达状态与冗余状态的消除思路,并借鉴实验报告的写作框架与排错经验,适合作为课程实验的参考模板或复习资料。

1. 从一道 ZZU 编译原理实验说起:NFA 转 DFA 并最小化到底在考什么

如果你正在做 ZZU 的编译原理实验,大概率会在「词法分析器生成」这一关卡住:老师给的正规式要先转成 NFA,再确定化成 DFA,最后还得最小化,三步走完才能拿去做词法分析。很多人第一次看到这个任务会觉得无从下手——Thompson 构造、子集构造、Hopcroft 分割,每个名字都认识,连起来就不知道从哪写。这份资源就是一套完整的 C++ 实现加实验报告,把 NFA→DFA→最小化整条链路跑通,代码可直接编译,报告里有状态转移表和测试用例。它适合两类人:一是赶实验 deadline、需要一份能跑通且能讲清楚原理的参考实现;二是想真正搞懂自动机转换细节、不想只抄个壳子的同学。下面我按「先跑通、再拆解、最后避坑」的顺序,把这份代码包里的关键实现和参数配置讲透。

2. 环境准备与代码结构:把工程跑起来再谈原理

2.1 编译环境与文件组织

这份代码是标准 C++ 实现,没有依赖第三方库,用 g++ 或 clang++ 都能编。我一般会先确认编译器版本,因为代码里用到了std::unordered_map和std::set,C++11 及以上才稳。

# 查看编译器版本,建议 g++ 7.0 以上 g++ --version # 编译主程序,-std=c++11 是底线,-O2 可选 g++ -std=c++11 -O2 -o nfa2dfa main.cpp nfa.cpp dfa.cpp minimize.cpp # 运行,输入文件默认从 stdin 读或按报告里的格式传参 ./nfa2dfa < test_input.txt

代码包通常包含这几个文件:main.cpp负责流程调度和输入输出,nfa.h/cpp定义 NFA 数据结构与 Thompson 构造,dfa.h/cpp实现子集构造法,minimize.h/cpp做 Hopcroft 最小化,外加一份实验报告文档和若干测试用例。如果你拿到的版本文件命名不同,按grep -r "class NFA"找一下定义位置即可。

提示:如果编译报unordered_map找不到,检查是否漏了#include <unordered_map>,有些老版本代码把它写在.cpp里而头文件没带。

2.2 输入格式与状态表示

跑通的第一步是搞清楚输入长什么样。常见做法是让程序读一个正规式,或者直接读 NFA 的五元组描述。这份代码我翻了一下,它支持两种模式:命令行传正规式字符串,或者从文件读状态转移表。状态转移表一般长这样:

# 第一行:状态数 字母表大小 初态 终态数 终态列表 5 2 0 1 4 # 后续每行:当前状态 输入符号 目标状态(ε 用 'e' 或空串表示) 0 a 1 1 e 2 2 b 3 3 e 4

对应的解析逻辑在main.cpp里,核心是parseInput()函数。它按行读,遇到e就当作 ε 边处理。这里有个容易翻车的点:不同版本对 ε 的表示不统一,有的用#,有的用空字符,你得先看报告里的示例输入,别直接拿自己的格式硬套。

// 解析单条转移边,symbol 为 'e' 时表示 ε 边 void addTransition(const string& from, char symbol, const string& to) { if (symbol == 'e') { epsilonTrans[from].insert(to); // ε 闭包单独存 } else { trans[from][symbol].insert(to); // 普通转移按符号索引 } }

参数说明:from和to是状态名,可以是整数也可以是字符串,代码内部统一转成string做 key,避免状态编号不连续时数组越界。symbol只取字母表里的字符或e,如果你输入了字母表外的符号,程序一般会忽略或报 warning,具体看validateInput()的实现。

2.3 先跑一个最小用例验证链路

在深入改代码之前,我强烈建议先拿一个最短的正规式跑一遍,比如a或a|b,确认 NFA→DFA→最小化三步都有输出。最小用例能帮你快速定位是解析错了、闭包算错了还是最小化把状态合并错了。

# 用正规式 a 测试,观察输出状态数 echo "a" | ./nfa2dfa --regex # 预期:NFA 约 2-3 个状态,DFA 2 个状态,最小化后仍是 2 个状态

如果这一步就报错,别急着看最小化,先查 NFA 构造。常见问题是 ε 闭包没算传递闭包,只算了一层。比如0 -e-> 1 -e-> 2,正确的 ε 闭包是{0,1,2},只算一层会漏掉 2,后面子集构造全错。

3. NFA 转 DFA 的核心实现:子集构造法与 ε 闭包

3.1 ε 闭包为什么必须用 DFS/BFS 算传递闭包

ε 闭包是子集构造的地基。定义很简单:从某个状态出发,只走 ε 边能到达的所有状态集合。但实现时很多人只做一层扩展,导致0 -e-> 1 -e-> 2这种链式 ε 边漏算。正确做法是对每个状态做一次 DFS 或 BFS,把能走到的全收进来。

// 计算单个状态的 ε 闭包,用 DFS 递归收集 set<string> epsilonClosure(const string& state) { set<string> closure; stack<string> stk; stk.push(state); closure.insert(state); while (!stk.empty()) { string cur = stk.top(); stk.pop(); // epsilonTrans[cur] 是当前状态直接走 ε 边能到的集合 for (const string& next : epsilonTrans[cur]) { if (closure.find(next) == closure.end()) { closure.insert(next); stk.push(next); // 继续深入,保证传递性 } } } return closure; }

逻辑说明:用栈做深度优先,每遇到一个新状态就压栈继续找它的 ε 后继,直到没有新状态为止。参数上,epsilonTrans是map<string, set<string>>,key 是状态名,value 是直接 ε 后继集合。时间复杂度是 O(状态数 + ε 边数),对实验规模完全够用。如果你用递归写,注意状态多时可能爆栈,改成显式栈更稳。

3.2 子集构造:从状态集合到 DFA 状态

子集构造的核心思想是把 NFA 的状态集合当作 DFA 的一个状态。流程是:从初态的 ε 闭包开始,对字母表里每个符号,算出移动后的集合再取 ε 闭包,如果这个新集合没出现过就加入队列。

// 子集构造主循环 void subsetConstruction() { set<string> start = epsilonClosure(nfaStart); queue<set<string>> q; map<set<string>, string> stateMap; // NFA 集合 -> DFA 状态名 q.push(start); stateMap[start] = "D0"; dfaStart = "D0"; while (!q.empty()) { set<string> cur = q.front(); q.pop(); string dfaState = stateMap[cur]; for (char c : alphabet) { set<string> moveSet; // 先对集合里每个状态走 c 边 for (const string& s : cur) { for (const string& t : trans[s][c]) { moveSet.insert(t); } } if (moveSet.empty()) continue; // 再对 moveSet 里每个状态取 ε 闭包并合并 set<string> nextSet; for (const string& s : moveSet) { set<string> ec = epsilonClosure(s); nextSet.insert(ec.begin(), ec.end()); } if (stateMap.find(nextSet) == stateMap.end()) { string newName = "D" + to_string(stateMap.size()); stateMap[nextSet] = newName; q.push(nextSet); } // 记录 DFA 转移:dfaState --c--> stateMap[nextSet] dfaTrans[dfaState][c] = stateMap[nextSet]; } } }

参数说明:alphabet是字母表集合,从输入里提取;trans[s][c]是 NFA 状态 s 走符号 c 能到的集合;stateMap用set<string>做 key,因为集合比较是逐元素比较,能保证相同集合映射到同一个 DFA 状态。这里有个性能坑:如果状态集合很大,set比较开销高,可以转成排序后的vector或位集做 key,但实验规模没必要。

3.3 终态判定与转移表输出

DFA 的终态判定规则是:只要一个 DFA 状态对应的 NFA 集合里包含任意一个 NFA 终态,这个 DFA 状态就是终态。输出转移表时,建议按状态名排序,方便和实验报告里的表格对照。

// 判断 DFA 状态是否为终态 bool isFinal(const set<string>& nfaSet) { for (const string& s : nfaSet) { if (nfaFinalStates.count(s)) return true; } return false; } // 输出转移表,格式:状态 符号 目标状态 void printDFA() { for (auto& kv : dfaTrans) { for (auto& edge : kv.second) { cout << kv.first << " " << edge.first << " " << edge.second << endl; } } }

跑完这一步,你应该能看到一张完整的 DFA 转移表。如果状态数比预期多很多,检查是不是 ε 闭包算重了,或者字母表里混入了不该有的符号。常见做法是先把字母表打印出来确认一遍。

4. DFA 最小化:Hopcroft 分割与等价类合并

4.1 初始划分:终态与非终态分开

最小化的第一步是把 DFA 状态分成两组:终态组和非终态组。这是最粗的划分,后续再按转移行为细分。Hopcroft 算法的核心是不断分裂:如果某个组里的两个状态在某个输入符号下转移到了不同的组,就把它们分开。

// 初始划分:终态一组,非终态一组 vector<set<string>> partition; set<string> finalGroup, nonFinalGroup; for (const string& s : dfaStates) { if (dfaFinalStates.count(s)) finalGroup.insert(s); else nonFinalGroup.insert(s); } if (!finalGroup.empty()) partition.push_back(finalGroup); if (!nonFinalGroup.empty()) partition.push_back(nonFinalGroup);

参数说明:dfaStates是所有 DFA 状态集合,dfaFinalStates是终态集合。注意如果某个组为空就不要加进partition,否则后续分裂会出空组,影响状态合并。

4.2 分裂循环:按转移目标组号区分状态

分裂的判断依据是:对每个输入符号,看组内状态转移到哪个组。如果转移目标组号不一致,就按组号把当前组拆开。实现时给每个组一个编号,用map<string,int>记录每个状态属于哪个组。

// 一轮分裂,返回是否有组被拆开 bool splitOnce(vector<set<string>>& partition) { map<string, int> groupOf; for (int i = 0; i < partition.size(); i++) { for (const string& s : partition[i]) groupOf[s] = i; } for (int i = 0; i < partition.size(); i++) { map<vector<int>, set<string>> splitter; for (const string& s : partition[i]) { vector<int> signature; for (char c : alphabet) { string target = dfaTrans[s][c]; // 可能为空 signature.push_back(target.empty() ? -1 : groupOf[target]); } splitter[signature].insert(s); } if (splitter.size() > 1) { // 有多个签名,说明要拆 partition.erase(partition.begin() + i); for (auto& kv : splitter) partition.push_back(kv.second); return true; // 一轮只拆一个,简化实现 } } return false; }

逻辑说明:signature是当前状态在每个输入符号下转移目标的组号序列,组号相同说明行为一致。splitter按签名分组,如果一组里出现多个签名就拆开。这里我故意写成「一轮只拆一个组」,因为一次性拆多个组容易在遍历时迭代器失效,实验代码求稳不求快。参数上,dfaTrans[s][c]如果目标为空,用 -1 表示死状态,死状态通常可以在最小化前先删掉。

4.3 合并等价状态与重建转移表

分裂到不能再分为止,每个组就是一个等价类,可以合并成一个状态。重建转移表时,组内任选一个代表状态,把原来指向组内状态的转移都改成指向代表状态。

// 用分组结果重建最小 DFA void rebuildDFA(const vector<set<string>>& partition) { map<string, string> rep; // 原状态 -> 代表状态 for (const auto& group : partition) { string r = *group.begin(); // 取第一个作为代表 for (const string& s : group) rep[s] = r; } for (const string& s : dfaStates) { for (char c : alphabet) { string t = dfaTrans[s][c]; if (!t.empty()) { minTrans[rep[s]][c] = rep[t]; } } } // 初态和终态也要映射到代表状态 minStart = rep[dfaStart]; for (const string& f : dfaFinalStates) minFinal.insert(rep[f]); }

参数说明:rep是原状态到代表状态的映射,minTrans是最小化后的转移表。注意初态和终态都要做映射,否则输出会引用不存在的状态。跑完后对比最小化前后的状态数,一般能减少 20% 到 50%,具体看原始 DFA 的冗余程度。

注意:如果最小化后状态数没变,不一定是代码错了,可能原始 DFA 本身就已经最小。可以先手工构造一个有明显冗余的 DFA 验证,比如两个终态行为完全一致的情况。

5. 避坑与排查:实验里最容易翻车的五个点

5.1 现象:程序输出状态数爆炸,DFA 状态比 NFA 还多

原因:ε 闭包只算了一层,导致子集构造时每个集合都不完整,相同集合被当成不同集合,状态数指数级膨胀。解决:在epsilonClosure里加打印,确认0 -e-> 1 -e-> 2能返回{0,1,2}。如果只返回{0,1},就是没做传递闭包,改成显式栈或递归。

5.2 现象:最小化后转移表里有状态指向空,运行时报段错误

原因:重建转移表时只映射了普通状态,初态或终态没做rep映射,导致minTrans里出现原状态名,查表时找不到。解决:在rebuildDFA里先把dfaStart和所有dfaFinalStates过一遍rep,再输出。另外检查dfaTrans[s][c]为空时是否跳过了,别把空字符串当状态名塞进去。

5.3 现象:输入正规式含|或*时解析报错

原因:Thompson 构造对运算符优先级处理不对,或者解析器没做递归下降。常见做法是先把正规式转成后缀表达式(中缀转后缀),再按后缀构造 NFA。解决:检查parseRegex里对*和|的优先级,*高于连接高于|。如果代码只支持简单连接,那就手工构造 NFA 输入,别硬套正规式模式。

5.4 现象:字母表提取错误,DFA 转移表缺列

原因:字母表是从输入里扫出来的,如果输入格式不统一,可能把 ε 的e也当成普通符号。解决:在提取字母表时显式排除e和空字符,打印字母表确认。常见做法是维护一个set<char> alphabet,只插入合法符号,遇到e跳过。

5.5 现象:实验报告里的状态转移表和代码输出对不上

原因:报告可能是手工画的,代码输出顺序不同,或者状态命名规则不一致。解决:以代码输出为准,把输出重定向到文件,再复制进报告。如果老师要求特定命名(如q0,q1),在输出函数里加一层映射,别改核心逻辑。

6. 进阶技巧:用脚本自动比对最小化前后等价性

跑通之后,我一般会写个小脚本验证最小化前后语言是否等价。思路很简单:随机生成一批字符串,分别喂给最小化前和最小化后的 DFA,看接受结果是否一致。这个技巧能帮你抓出合并等价类时的隐蔽 bug,比手工看转移表靠谱得多。

import random, subprocess def run_dfa(dfa_file, s): # 调用你的 C++ 程序,传入字符串,返回 accept/reject result = subprocess.run(['./nfa2dfa', '--dfa', dfa_file, '--input', s], capture_output=True, text=True) return 'accept' in result.stdout alphabet = ['a', 'b'] for _ in range(200): s = ''.join(random.choice(alphabet) for _ in range(random.randint(0, 8))) before = run_dfa('dfa_before.txt', s) after = run_dfa('dfa_after.txt', s) if before != after: print(f'Mismatch on "{s}": before={before}, after={after}') break else: print('All 200 random strings passed.')

逻辑说明:随机生成长度 0 到 8 的字符串,分别跑最小化前后的 DFA,比对接受结果。参数上,--dfa指定转移表文件,--input传测试串。如果你的程序不支持命令行模式,可以改成把字符串写进临时文件再读。200 个用例通常能覆盖大部分边界,想更稳就加到 1000 个,但注意运行时间。

这个脚本我每次改完最小化逻辑都会跑一遍,有一次就是靠它发现两个本该合并的终态因为转移目标组号算错没合并,手工看表根本看不出来。从那以后我每次改自动机代码都强制走一遍随机比对,比盯着转移表看半小时管用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询