简介:这份资源面向学习编译原理、自动机理论或需要深入理解正则表达式底层实现机制的开发者,提供一套用C++完整实现「正则表达式→NFA→DFA→最小化DFA」全流程的编程代码。压缩包内共1个文件,为单个cpp源文件,整体约7KB,代码量逾千行,涵盖子集构造法、状态集合表示、可达状态删除与等价状态合并等核心算法,并借助图、状态表、栈等数据结构完成自动机的构建与操作。已有191人学习,适合作为课程设计、编译原理实验或模式匹配相关项目的参考实现。读者可从中获得从理论模型到可运行代码的完整映射思路,理解非确定性到确定性的转换细节以及最小化对状态数与可维护性的优化价值,也可将其作为文本编辑器、查询解析等场景中模式匹配模块的学习蓝本,对照代码梳理算法脉络并动手调试。
1. 从一条正则到一张状态图:这个 C++ 项目到底在做什么
很多人第一次接触编译原理课设,都是被“正则表达式转最小化 DFA”这几个字劝退的。可真正把代码跑起来你会发现,它解决的是一个很具体的问题:给定一条正则表达式,比如(a|b)*abb,程序要把它一步步变成一张确定有限自动机(DFA)的状态转移表,并且这张表还得是最简的。这个 C++ 项目干的就是这条完整链路——读入正则、补显式连接符、转后缀、构造 NFA、子集构造出 DFA、最后用 Hopcroft 或等价类划分做最小化。适合谁?正在做编译原理课程设计的学生、想搞懂词法分析器底层怎么把正则变成状态机的开发者,以及想拿一份能直接编译运行的 C++ 工程当二次开发起点的人。它不依赖任何第三方库,纯标准 C++ 就能跑,这一点对新手特别友好。
2. 工程结构与技术选型:为什么用纯 C++ 手写而不是调库
2.1 目录怎么摆,每个文件管什么
这类项目通常不会搞得很复杂,一个能跑的最小工程大概长这样:
regex2dfa/ ├── include/ │ ├── RegexParser.h // 正则解析、补连接符、转后缀 │ ├── NFA.h // Thompson 构造,NFA 状态与边 │ ├── DFA.h // 子集构造、状态集合管理 │ └── Minimize.h // 最小化算法 ├── src/ │ ├── RegexParser.cpp │ ├── NFA.cpp │ ├── DFA.cpp │ ├── Minimize.cpp │ └── main.cpp // 命令行入口,读正则、打印各阶段结果 ├── tests/ │ └── cases.txt // 一批测试正则和期望接受串 └── CMakeLists.txtmain.cpp是整条流水线的调度中心,它按顺序调用四个模块,每步都把中间结果打印出来。这个设计对调试极其重要——正则转 DFA 最容易翻车的地方就是中间某一步错了,但最终结果看起来“差不多”,没有中间输出根本定位不到。我一般会在main里加一个--verbose开关,默认只打印最终 DFA 表,调试时打开看 NFA 和子集构造过程。
技术选型上,为什么不用std::regex?因为标准库的std::regex是“匹配引擎”,它内部确实也构造自动机,但不暴露状态转移结构,你拿不到 NFA/DFA 的中间形态,做课设或教学演示就没意义了。手写的价值在于每一步都可见、可改。C++ 选型上建议用 C++17,std::set、std::map、std::vector足够撑起状态集合和转移表,不需要额外依赖。
2.2 核心数据结构:状态集合怎么表示
整个项目的数据结构设计决定了后面算法写起来顺不顺。NFA 和 DFA 的状态都用整数编号,转移用容器存:
// NFA.h struct NFAState { int id; // 字符边:字符 -> 目标状态集合(NFA 一个字符可到多个状态) std::map<char, std::set<int>> trans; // ε 边单独存,方便求 ε-闭包 std::set<int> epsilon; bool accept = false; }; class NFA { public: std::vector<NFAState> states; int start; // 求某个状态集合的 ε-闭包 std::set<int> epsilonClosure(const std::set<int>& s) const; };这里有个关键点:NFA 的字符边用map<char, set<int>>,因为一个状态读同一个字符可能跳到多个状态;而 ε 边单独用set<int>存,因为 ε 闭包计算是子集构造里调用最频繁的操作,单独存能少一层 map 查找。DFA 那边就简单了,一个状态读一个字符只能到一个状态:
// DFA.h struct DFAState { int id; std::map<char, int> trans; // 字符 -> 唯一目标状态 bool accept = false; };DFA 的状态编号是子集构造时动态分配的,每个 DFA 状态背后其实对应一个 NFA 状态集合,这个映射关系要额外存一份std::map<std::set<int>, int> subsetToId,最小化的时候还要用到它来还原接受状态。参数上,字符集建议先限定在a-z、0-9和几个运算符,别一上来就支持 Unicode,那是另一个量级的工作。
3. 核心链路拆解:从正则字符串到最小化 DFA 的四步
3.1 第一步:补连接符与转后缀表达式
正则里ab这种隐式连接,解析时最容易出错。稳妥做法是先扫描一遍,在“前一个字符是操作数或右括号”且“后一个字符是操作数或左括号”的位置插入显式连接符·,再走调度场算法转后缀。这样后缀表达式里只有操作数和二元运算符,后面 Thompson 构造就是标准的栈式处理:
// RegexParser.cpp std::string RegexParser::insertConcat(const std::string& re) { std::string out; for (size_t i = 0; i < re.size(); ++i) { out += re[i]; if (i + 1 < re.size()) { char cur = re[i], nxt = re[i + 1]; bool curOperand = isalnum(cur) || cur == ')' || cur == '*'; bool nxtOperand = isalnum(nxt) || nxt == '('; // 两个操作数相邻,或 ) 后接操作数/(,补一个连接符 if (curOperand && nxtOperand) out += '·'; } } return out; }isalnum判断操作数,*当作后缀一元运算符处理,所以它后面接操作数也要补连接符。转后缀用调度场算法,优先级*最高、·次之、|最低,左结合。这里有个血泪经验:*是后缀运算符,遇到它直接输出到结果栈,不要压运算符栈,否则优先级处理会乱。转完后缀,(a|b)*abb会变成类似ab|*a·b·b··的形式,后面构造就按这个顺序扫。
3.2 第二步:Thompson 构造把后缀变成 NFA
Thompson 构造的核心是维护一个“片段栈”,每个片段有入口和出口两个状态。遇到操作数就建两个状态一条字符边;遇到·弹出两个片段串起来;遇到|建新入口出口分叉;遇到*加 ε 边形成闭环:
// NFA.cpp 片段结构 struct Frag { int start, end; }; Frag NFA::buildFromPostfix(const std::string& post) { std::stack<Frag> stk; for (char c : post) { if (isalnum(c)) { int s = newState(), e = newState(); states[s].trans[c].insert(e); // 字符边 stk.push({s, e}); } else if (c == '·') { Frag b = stk.top(); stk.pop(); Frag a = stk.top(); stk.pop(); states[a.end].epsilon.insert(b.start); // a 出口 ε 到 b 入口 stk.push({a.start, b.end}); } else if (c == '|') { Frag b = stk.top(); stk.pop(); Frag a = stk.top(); stk.pop(); int s = newState(), e = newState(); states[s].epsilon.insert(a.start); states[s].epsilon.insert(b.start); states[a.end].epsilon.insert(e); states[b.end].epsilon.insert(e); stk.push({s, e}); } else if (c == '*') { Frag a = stk.top(); stk.pop(); int s = newState(), e = newState(); states[s].epsilon.insert(a.start); states[s].epsilon.insert(e); states[a.end].epsilon.insert(a.start); // 回环 states[a.end].epsilon.insert(e); stk.push({s, e}); } } Frag res = stk.top(); states[res.end].accept = true; start = res.start; return res; }参数说明:newState()每次分配一个新编号并 push 进states;Frag的start/end是片段入口出口。*那段是重点,入口 ε 到片段入口和出口(允许零次),片段出口 ε 回片段入口(允许重复)再 ε 到新出口。构造完记得把最终片段的end标成接受态。这一步出错通常表现为“某些串该接受却不接受”,多半是*的回环边漏了或者|的分叉出口没连到统一出口。
3.3 第三步:子集构造把 NFA 拍平成 DFA
子集构造从 NFA 起始状态的 ε-闭包开始,对每个 DFA 状态(本质是一个 NFA 状态集合),对字母表里每个字符求“先走字符边再求 ε-闭包”得到新集合,没见过的集合就分配新 DFA 状态编号:
// DFA.cpp void DFA::subsetConstruction(const NFA& nfa, const std::set<char>& alphabet) { std::set<int> startSet = nfa.epsilonClosure({nfa.start}); subsetToId[startSet] = newDFAState(startSet, nfa); std::queue<std::set<int>> work; work.push(startSet); while (!work.empty()) { auto cur = work.front(); work.pop(); int curId = subsetToId[cur]; for (char c : alphabet) { std::set<int> moveSet; for (int s : cur) if (nfa.states[s].trans.count(c)) for (int t : nfa.states[s].trans.at(c)) moveSet.insert(t); if (moveSet.empty()) continue; std::set<int> next = nfa.epsilonClosure(moveSet); if (!subsetToId.count(next)) { subsetToId[next] = newDFAState(next, nfa); work.push(next); } states[curId].trans[c] = subsetToId[next]; } } }newDFAState里要做两件事:分配编号,并检查这个 NFA 集合里有没有接受态,有就把 DFA 状态标成接受。字母表alphabet要提前从正则里扫出来,别用固定 26 个字母,否则转移表里全是空边,最小化时等价类划分会被这些空边干扰。这一步的常见翻车点是 ε-闭包写错——epsilonClosure必须做传递闭包,不能只求一层,用 BFS 或 DFS 都行,我一般用栈迭代避免递归深度问题。
3.4 第四步:等价类划分做最小化
最小化用 Hopcroft 思路的简化版:先把 DFA 状态分成接受态和非接受态两组,然后反复对每组按“读每个字符后落到哪个组”细分,直到组数不再变化。最后每组选一个代表,重建转移表:
// Minimize.cpp std::vector<std::set<int>> Minimize::partition(const DFA& dfa, const std::set<char>& alphabet) { std::set<int> acc, nonAcc; for (auto& s : dfa.states) (s.accept ? acc : nonAcc).insert(s.id); std::vector<std::set<int>> groups; if (!acc.empty()) groups.push_back(acc); if (!nonAcc.empty()) groups.push_back(nonAcc); bool changed = true; while (changed) { changed = false; std::vector<std::set<int>> next; for (auto& g : groups) { std::map<std::vector<int>, std::set<int>> split; for (int s : g) { std::vector<int> sig; for (char c : alphabet) { int t = dfa.states[s].trans.count(c) ? dfa.states[s].trans.at(c) : -1; sig.push_back(groupOf(groups, t)); // 目标状态所在组号 } split[sig].insert(s); } for (auto& kv : split) next.push_back(kv.second); if (split.size() > 1) changed = true; } groups = next; } return groups; }groupOf查某个状态当前属于哪个组,找不到(比如-1表示无转移)返回一个特殊值。sig是“读每个字符后落到哪个组”的签名,签名相同就还在一组。循环终止条件是某轮没有组被拆开。重建时每组选最小 id 当代表,把组内所有状态的转移合并到代表上,接受态组标接受。这里要注意:最小化前最好先把 DFA 里不可达状态删掉,否则等价类划分会把不可达状态也拉进来,结果虽然正确但状态数不是最少。
4. 编译运行与验证:三条命令跑通整条链路
4.1 用 CMake 构建并跑第一个例子
工程根目录有CMakeLists.txt,标准流程三条命令:
mkdir build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j编译产物是regex2dfa可执行文件。跑一个经典例子:
./regex2dfa "(a|b)*abb" --verbose输出会分四段:补连接符后的正则、后缀表达式、NFA 状态表、DFA 转移表、最小化后的 DFA 表。先看最小化结果,状态数应该明显少于子集构造的原始 DFA。如果编译报std::set相关错误,检查是否漏了#include <set>;如果链接报错,确认CMakeLists.txt里add_executable把五个 cpp 都列进去了。
4.2 用测试用例验证接受性
tests/cases.txt里每行格式建议是“正则 空格 待测串 空格 期望结果”,写个小脚本批量跑:
while read re str expect; do got=$(./regex2dfa "$re" --match "$str") [ "$got" = "$expect" ] && echo "PASS $re $str" || echo "FAIL $re $str got=$got" done < tests/cases.txt--match模式让程序用最小化 DFA 逐字符走转移,走完看是否停在接受态。这一步是整条链路的最终验收,前面 NFA、子集构造、最小化任何一步错了,这里都会暴露。建议至少准备三组用例:只含连接和选择的基础串、带*的重复串、以及边界情况如空串和单字符。
提示:
--match模式要处理空串,空串走完零个字符后直接看起始状态是否接受,别在循环里漏掉这个分支。
5. 避坑与排查:五个真实会卡住你的问题
5.1 现象:(a|b)*匹配空串失败
原因:Thompson 构造里*的入口 ε 边只连了片段入口,没连新出口,导致零次重复的路径断了。解决:*处理时入口状态必须同时 ε 到片段入口和新出口,这两条边缺一不可,改完用空串用例回归。
5.2 现象:子集构造后 DFA 状态数爆炸
原因:字母表用了全量字符集,或者 ε-闭包没做传递闭包导致同一集合被反复当成新集合。解决:字母表从正则里实际出现的字符扫出来;epsilonClosure用 BFS 求传递闭包,并在subsetToId里用std::set<int>做 key 去重,别用 vector 当 key。
5.3 现象:最小化后接受串变少
原因:重建转移表时只合并了转移,忘了把组内任一接受态的信息带到代表状态上。解决:分组后遍历每组,只要组内有accept == true的状态,代表状态就标接受;重建时先建代表状态再填转移。
5.4 现象:后缀表达式转换后运算符顺序错乱
原因:*被当成普通二元运算符压栈了。解决:*是后缀一元运算符,扫描到直接输出到结果,不参与栈内优先级比较;·和|才走调度场算法的压栈弹栈逻辑。
5.5 现象:程序在长正则上栈溢出
原因:epsilonClosure或后缀转 NFA 用了递归。解决:全部改成显式栈迭代,epsilonClosure用std::stack<int>加visited集合,NFA 构造本身已经是栈式处理,问题通常出在闭包递归上。
6. 二次开发:把命令行工具改成词法分析器的一环
这个项目最有价值的二次开发方向,是把它接进一个简易词法分析器。现在的main只处理单条正则,实际词法分析需要多条规则按优先级匹配。改造思路是:定义一个规则表,每条规则有正则和 token 类型,对每条正则分别构造最小化 DFA,然后做一个“并行模拟”——把所有 DFA 的当前状态集合放在一起,逐字符推进,取最长匹配且优先级最高的规则。
struct Rule { std::string regex; std::string token; int priority; }; // 并行模拟多条 DFA,返回最长匹配的 token std::string Lexer::nextToken(const std::string& input, size_t& pos) { std::vector<int> cur; // 每条规则 DFA 的当前状态 for (auto& d : dfas) cur.push_back(d.start); size_t bestLen = 0; std::string bestTok; for (size_t i = pos; i < input.size(); ++i) { bool alive = false; for (size_t k = 0; k < dfas.size(); ++k) { if (cur[k] < 0) continue; auto& tr = dfas[k].states[cur[k]].trans; cur[k] = tr.count(input[i]) ? tr.at(input[i]) : -1; if (cur[k] >= 0) { alive = true; if (dfas[k].states[cur[k]].accept) { // 记录最长接受 bestLen = i - pos + 1; bestTok = rules[k].token; } } } if (!alive) break; } pos += bestLen; return bestTok.empty() ? "ERROR" : bestTok; }参数说明:cur[k]是第 k 条规则 DFA 的当前状态,-1表示已死;bestLen/bestTok记录到目前为止最长的接受位置,这是最长匹配原则;优先级在长度相同时用rules[k].priority决胜。验证方法很直接:拿一段源码片段跑,看 token 序列是否符合预期,重点测关键字和标识符的优先级冲突——比如if既是关键字又能被标识符规则匹配,靠优先级表解决。
改造时我踩过的一个坑是:多条 DFA 并行模拟时,某条规则死了不代表整体结束,只要还有一条活着就得继续推进,否则会漏掉更长匹配。从那以后我每次改匹配循环,都强制用“关键字 vs 标识符”这组冲突用例走一遍回归,确认最长匹配和优先级都还对。希望这个拆解能帮到你,把这份 C++ 工程真正跑起来、改下去。
本文还有配套的精品资源,点击获取