☰
编译原理实验:NFA转DFA与最小化C++实现指南
2026/10/20 7:25:51 网站建设 项目流程

简介:这份资源面向高校「编译原理」课程学习者,尤其是ZZU的学弟学妹,提供NFA转DFA并最小化实验的完整代码与实验报告,帮助解决自动机理论中从非确定有限自动机到确定有限自动机转换及状态最小化的实践难题。压缩包共2个文件,包含1个cpp源码和1个doc实验报告,整体约722KB,源码对应子集构造法与DFA最小化算法的C++实现,报告则记录实验目的、步骤、问题与解决方案,便于对照理解算法细节。目前已有415人学习下载,说明该实验在课程中具有较高参考需求。读者可借助源码直接运行验证转换流程,结合报告梳理子集构造、不可达状态消除与等价状态合并等关键环节,快速完成实验并加深对词法分析中有限自动机应用的理解,适合作为课程实验的参考模板与排错对照材料。

1. 从一道让无数人卡壳的实验题说起:NFA 转 DFA 并最小化到底在做什么

如果你正在上编译原理这门课,大概率会在某个深夜对着“子集构造法”和“Hopcroft 最小化”这两个词发呆。这个项目就是冲着这个场景来的:一份完整的 NFA 转 DFA 并最小化的 C++ 实现,附带实验报告。它解决的不是“编译原理是什么”这种科普问题,而是“实验报告明天要交,代码还跑不通”这种具体困境。适合两类人:一是正在做编译原理实验、需要一份能跑通且能看懂的参考实现的学生;二是想用 C++ 把自动机理论从纸面落到代码里的开发者。整个项目围绕一个核心链路展开——读入 NFA 定义、子集构造确定化、DFA 最小化、输出结果并验证。技术栈是标准 C++,不依赖第三方库,编译即用。

2. 项目结构与核心数据模型:自动机在代码里长什么样

2.1 目录组织与文件职责

拿到一份源码,我习惯先看目录结构,因为它直接暴露了作者的模块划分思路。这个项目的组织方式很直白,没有过度设计:

nfa2dfa/ ├── main.cpp // 入口,串联整个流程 ├── nfa.h / nfa.cpp // NFA 数据结构与读入 ├── dfa.h / dfa.cpp // DFA 数据结构、子集构造、最小化 ├── utils.h // 集合操作、打印辅助 └── report.md // 实验报告

main.cpp只做流程编排,不掺业务逻辑;nfa和dfa各自管好自己的数据结构和算法;utils放一些通用的集合运算。这种分法在课程实验里算比较规范的,好处是你想单独测子集构造,直接调dfa.cpp里的函数就行,不用把整个main跑一遍。

2.2 NFA 的数据结构设计

NFA 的核心要素是状态集、字母表、转移关系、初态、终态。转移关系因为一个状态对同一输入可能有多个后继,所以用map嵌套set来表达最自然:

// nfa.h struct NFA { int stateCount; // 状态总数,状态编号 0 ~ stateCount-1 set<char> alphabet; // 输入字母表 map<pair<int,char>, set<int>> trans; // (状态, 输入字符) -> 后继状态集合 int startState; // 初态 set<int> acceptStates; // 终态集合 // 获取某状态在某输入下的所有后继 set<int> move(int state, char ch) const { auto it = trans.find({state, ch}); if (it != trans.end()) return it->second; return {}; // 无转移则返回空集 } // ε-闭包:从给定状态集合出发,沿 ε 边能到达的所有状态 set<int> epsilonClosure(const set<int>& states) const; };

这里有几个设计点值得说。trans用pair<int,char>做键而不是二维数组,是因为 NFA 的转移是稀疏的,用数组会浪费大量空间,而且状态数不固定时数组也不好开。move函数封装了查找逻辑,调用方不用关心map的迭代器细节。epsilonClosure单独声明,因为它在子集构造里会被反复调用,是性能热点。

注意:ε-闭包的实现一定要用栈或队列做迭代,不要用递归。状态数一多,递归深度可能直接爆栈,这是很多人第一次写时踩的坑。

2.3 DFA 的数据结构与最小化前提

DFA 和 NFA 最大的区别是转移的确定性:每个状态对每个输入最多一个后继。所以转移表可以用二维结构,但考虑到最小化过程中状态编号会变,用map仍然更灵活:

// dfa.h struct DFA { int stateCount; set<char> alphabet; map<pair<int,char>, int> trans; // (状态, 输入) -> 唯一后继,-1 表示死状态 int startState; set<int> acceptStates; // 判断是否为死状态(对所有输入都转移到自身且非终态) bool isDeadState(int state) const; // 最小化:返回一个新的 DFA DFA minimize() const; };

trans的值用int而不是set<int>,因为 DFA 的转移是函数式的。-1表示死状态,这个约定在最小化时很有用——所有死状态可以合并成一个。minimize()返回新对象而不是原地修改,这样原始 DFA 还能用于对比验证,调试时很方便。

3. 核心算法拆解:子集构造与 Hopcroft 最小化的代码实现

3.1 子集构造法:从 NFA 到 DFA 的关键一步

子集构造的思路是:DFA 的每个状态对应 NFA 的一个状态集合。从 NFA 初态的 ε-闭包出发,对每个输入字符计算后继集合,如果这个集合没出现过就加入队列,直到不再产生新状态。

// dfa.cpp DFA subsetConstruction(const NFA& nfa) { DFA dfa; dfa.alphabet = nfa.alphabet; // 初始状态:NFA 初态的 ε-闭包 set<int> startSet = nfa.epsilonClosure({nfa.startState}); // 用 map 记录:NFA 状态集合 -> DFA 状态编号 map<set<int>, int> setToId; queue<set<int>> workQueue; setToId[startSet] = 0; workQueue.push(startSet); dfa.startState = 0; int nextId = 1; while (!workQueue.empty()) { set<int> current = workQueue.front(); workQueue.pop(); int curId = setToId[current]; // 如果当前集合包含 NFA 终态,则对应 DFA 状态为终态 for (int s : current) { if (nfa.acceptStates.count(s)) { dfa.acceptStates.insert(curId); break; } } // 对每个输入字符计算后继 for (char ch : nfa.alphabet) { set<int> nextSet; for (int s : current) { set<int> moved = nfa.move(s, ch); nextSet.insert(moved.begin(), moved.end()); } if (nextSet.empty()) continue; // 对后继集合求 ε-闭包 nextSet = nfa.epsilonClosure(nextSet); // 如果这是新集合,分配新编号并入队 if (setToId.find(nextSet) == setToId.end()) { setToId[nextSet] = nextId++; workQueue.push(nextSet); } dfa.trans[{curId, ch}] = setToId[nextSet]; } } dfa.stateCount = nextId; return dfa; }

这段代码有几个关键参数和逻辑需要说清楚。setToId是整个算法的记忆核心,它把 NFA 的状态集合映射到 DFA 的状态编号,同时充当“已访问”标记。workQueue保证每个新发现的集合都会被处理一次。nextId从 1 开始,因为 0 已经分配给了初始状态。

内层循环里,先对当前集合中每个状态求move,把所有结果合并成nextSet,再求 ε-闭包。注意顺序不能反——必须先做 move 再做闭包,因为 ε 边可能从后继状态继续延伸。如果nextSet为空,说明这个输入下没有转移,直接跳过,DFA 里不建这条边。

提示:如果你的 NFA 包含 ε 转移,epsilonClosure的实现正确性直接决定最终 DFA 是否正确。建议单独写几个小测试用例验证闭包函数,比如链式 ε 转移和环形 ε 转移。

3.2 Hopcroft 最小化:合并等价状态的工程实现

DFA 最小化的目标是合并所有等价状态,得到状态数最少的 DFA。Hopcroft 算法的核心是不断细分状态集合,直到不能再分。工程实现上,我一般用“划分 + 迭代”的方式,比严格按论文里的栈实现更好懂:

// dfa.cpp DFA DFA::minimize() const { // 第一步:去掉死状态(可选,但能简化后续划分) // 第二步:初始划分——终态集合和非终态集合 vector<set<int>> partitions; set<int> acceptSet = acceptStates; set<int> nonAcceptSet; for (int i = 0; i < stateCount; i++) { if (!acceptStates.count(i)) nonAcceptSet.insert(i); } if (!acceptSet.empty()) partitions.push_back(acceptSet); if (!nonAcceptSet.empty()) partitions.push_back(nonAcceptSet); // 第三步:迭代细分 bool changed = true; while (changed) { changed = false; vector<set<int>> newPartitions; for (auto& group : partitions) { // 尝试按转移目标所在的划分块来拆分当前组 map<vector<int>, set<int>> splitter; for (int state : group) { vector<int> signature; for (char ch : alphabet) { int target = -1; auto it = trans.find({state, ch}); if (it != trans.end()) target = it->second; // 找到 target 属于哪个划分块 int blockId = -1; for (int i = 0; i < (int)partitions.size(); i++) { if (partitions[i].count(target)) { blockId = i; break; } } signature.push_back(blockId); } splitter[signature].insert(state); } if (splitter.size() > 1) changed = true; for (auto& kv : splitter) { newPartitions.push_back(kv.second); } } partitions = newPartitions; } // 第四步:用划分块构造新 DFA DFA result; result.alphabet = alphabet; map<int, int> stateToNew; for (int i = 0; i < (int)partitions.size(); i++) { for (int s : partitions[i]) stateToNew[s] = i; } result.stateCount = partitions.size(); result.startState = stateToNew[startState]; for (int i = 0; i < (int)partitions.size(); i++) { for (int s : partitions[i]) { if (acceptStates.count(s)) result.acceptStates.insert(i); for (char ch : alphabet) { auto it = trans.find({s, ch}); if (it != trans.end()) { result.trans[{i, ch}] = stateToNew[it->second]; } } } } return result; }

这段代码的工程味比较重,我拆开说。初始划分把终态和非终态分开,这是最小化的起点,因为终态和非终态永远不可能等价。signature是每个状态对字母表中所有字符的转移目标所属划分块编号组成的向量——两个状态等价当且仅当它们的 signature 完全相同。splitter用map<vector<int>, set<int>>按 signature 分组,一组内 signature 相同的状态暂时认为等价。

changed标志控制迭代:只要某一轮有划分块被拆开,就继续下一轮,直到所有块的 signature 都一致。这个实现的时间复杂度比理论上的 Hopcroft 稍高,但对于课程实验的状态规模(通常几十个状态)完全够用,而且逻辑清晰,不容易写错。

最后构造新 DFA 时,stateToNew把旧状态映射到划分块编号,转移表重建。注意result.trans里可能有多条边指向同一个新状态,这是正常的,因为等价状态合并后转移自然收敛。

注意:如果原始 DFA 里有死状态(对所有输入都转移到自身且非终态),最小化后所有死状态会合并成一个。如果你的实验要求保留死状态,需要在划分前把它们单独拿出来。

3.3 主流程串联与结果验证

main.cpp把上面这些串起来,流程很清晰:

// main.cpp int main() { NFA nfa; // 从标准输入或文件读入 NFA 定义 readNFA(nfa); // 第一步:子集构造 DFA dfa = subsetConstruction(nfa); cout << "=== DFA (子集构造后) ===" << endl; printDFA(dfa); // 第二步:最小化 DFA minDfa = dfa.minimize(); cout << "=== DFA (最小化后) ===" << endl; printDFA(minDfa); // 第三步:验证——用若干测试串检查两个 DFA 接受性一致 vector<string> testCases = {"ab", "aab", "abb", "b", ""}; for (auto& s : testCases) { bool r1 = dfa.accepts(s); bool r2 = minDfa.accepts(s); cout << "串 \"" << s << "\": 原DFA=" << r1 << ", 最小DFA=" << r2 << (r1 == r2 ? " [一致]" : " [不一致!]") << endl; } return 0; }

验证环节是我强烈建议保留的。很多人写完最小化就直接输出结果,不检查是否和原 DFA 等价。用几个典型测试串跑一遍,如果接受性不一致,说明最小化过程中划分或重建出了问题。accepts函数的实现就是沿着转移表走,走完看是否停在终态,这里不展开。

读入部分,常见做法是从文件读,格式一般是:第一行状态数和字母表,第二行初态,第三行终态集合,后面每行一条转移。具体格式看实验报告里的约定,代码里readNFA按那个格式解析即可。

4. 编译运行与实验报告对照:怎么跑起来、怎么对答案

4.1 编译命令与运行方式

项目不依赖第三方库,用 g++ 直接编译即可。我一般会开-std=c++11和-Wall,前者保证auto、范围 for 这些语法可用,后者帮你发现潜在问题:

# 编译 g++ -std=c++11 -Wall -o nfa2dfa main.cpp nfa.cpp dfa.cpp # 运行,从文件读入 NFA 定义 ./nfa2dfa < test_nfa.txt

test_nfa.txt的格式要和readNFA里的解析逻辑对齐。如果你拿到的源码里读入部分是从标准输入读的,那就用重定向;如果是从固定文件名读的,改一下readNFA里的文件路径就行。编译报错的话,先看是不是 C++ 标准版本不对——有些写法在 C++98 下编译不过。

4.2 实验报告与代码的对照阅读

实验报告通常包含几个部分:NFA 定义、子集构造过程的状态集合变化、最小化的划分过程、最终结果。读报告时建议对照代码里的中间输出。比如报告里写“初始划分:{0,1,2} 和 {3,4}”,你就在minimize函数里加一行打印partitions,看是否一致。不一致的地方往往就是理解偏差所在。

报告里的状态编号可能和代码输出不同,这很正常——子集构造时新状态的编号顺序取决于队列处理顺序和字母表遍历顺序。只要转移关系和接受性一致,编号不同不影响正确性。对答案时关注结构,不要纠结编号。

4.3 常见输入格式与边界情况

NFA 定义里容易出问题的地方有几个。一是 ε 转移的表示,有的用特殊字符'e'或'#',有的用空字符,代码里要统一。二是终态集合可能为空,这时 DFA 不接受任何串,最小化后可能只剩一个非终态。三是字母表里字符的顺序会影响输出编号,但不影响语言等价性。

提示:如果你的 NFA 里某个状态对某输入有转移到不存在的状态,读入时要检查并报错,否则后续move会返回空集,导致 DFA 缺失转移,最终接受性判断出错。

5. 避坑与排查:那些年我在自动机实验里翻过的车

5.1 ε-闭包写成递归导致栈溢出

现象:小规模 NFA 跑得好好的,换了一个状态数稍多的测试用例直接崩溃,报段错误。原因:epsilonClosure用递归实现,ε 转移形成长链时递归深度等于链长,状态数上百就可能爆栈。解决:改成显式栈或队列的迭代版本,用一个set记录已访问状态,每次取出一个状态,把它的 ε 后继中未访问的加入栈。

5.2 子集构造时忘记对后继集合求 ε-闭包

现象:最小化后的 DFA 和原 NFA 接受性不一致,某些含 ε 转移的串被错误拒绝。原因:move之后直接拿结果当 DFA 状态,没有再做 ε-闭包,导致遗漏了通过 ε 边可达的状态。解决:在subsetConstruction里,nextSet计算完后必须调用nfa.epsilonClosure(nextSet),顺序不能反。

5.3 最小化划分迭代不收敛

现象:程序在minimize里死循环,CPU 跑满。原因:changed标志的判断逻辑有误,或者signature计算时用了不稳定的排序,导致每轮都产生“新”划分。解决:确保signature只依赖转移目标所属的划分块编号,且划分块编号在每轮结束后重新分配。加一个最大迭代次数保护,超过状态数就报错退出。

5.4 死状态处理不一致导致状态数偏多

现象:最小化后状态数比预期多一个,多出来的状态对所有输入都转移到自身且非终态。原因:原始 DFA 里存在死状态,最小化时没有把它们合并,或者合并后没有正确标记。解决:在初始划分前,先识别所有死状态并归入同一个集合,或者在构造新 DFA 时把死状态统一映射到一个编号。

5.5 读入时状态编号越界

现象:程序运行到一半崩溃,或者输出结果明显不对。原因:NFA 定义文件里的状态编号超过了stateCount,move访问trans时虽然不会越界(因为是map),但后续用编号做数组下标时会出问题。解决:readNFA里加校验,任何状态编号必须在[0, stateCount)范围内,终态集合和初态也要检查。发现越界直接报错并打印行号。

6. 二次开发与扩展:从能跑到好用,我一般会做这几件事

这份代码作为课程实验的参考实现是合格的,但如果你想把它改成一个能复用的自动机工具,有几个方向值得动手。第一个是输入输出格式的规范化。现在的读入逻辑大概率是硬编码的,我一般会改成支持标准格式,比如类似.nfa的文本格式,每行一条定义,用#开头表示注释。输出方面,加一个--dot选项,把 DFA 导出成 Graphviz 的 dot 格式,直接能画出状态转移图,对写实验报告和调试都很有用。

第二个是补全 DFA 的完整功能。现在只有accepts做串的接受性判断,可以加上isEmpty()(语言是否为空)、isUniversal()(是否接受所有串)、intersect()和union()等操作。这些在后续的语法分析实验里会用到,提前写好能省不少事。实现intersect的思路是笛卡尔积构造,两个 DFA 的状态对作为新状态,转移同步走,接受性取交集。

第三个是性能优化。当前minimize里找target所属划分块用的是线性扫描,状态数上千时会明显变慢。常见做法是维护一个stateToBlock数组,每次划分更新后同步刷新,查找就变成 O(1)。另外signature用vector<int>做键,可以改成字符串或哈希值,减少比较开销。

第四个是加单元测试。课程实验的代码通常没有测试,改一处可能崩三处。我一般会用 Catch2 或 Google Test 写几个用例:一个不含 ε 的简单 NFA、一个含 ε 链的 NFA、一个最小化前后状态数明显减少的 DFA。每个用例断言最小化前后的接受性一致,以及最小化后的状态数符合预期。这样每次改完跑一遍测试,心里有底。

最后一个技巧是关于调试的。自动机算法的中间状态很多,光靠打印很难定位问题。我的习惯是在subsetConstruction和minimize里加一个verbose开关,打开时每处理一个状态就打印当前集合、输入字符、后继集合和编号分配情况。跑一个小用例,把输出和手工推导对照,哪一步对不上就一目了然。这个习惯帮我省过很多次“盯着代码看不出错”的时间。

从那以后我每次写自动机相关的代码,都强制先写三个测试用例:空串、单字符、含 ε 的串。跑通了再往下写,比写完再调快得多。希望帮到你。

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

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

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

立即咨询