
简介一份用C语言实现的正则表达式转最小化DFA完整源码面向编译原理学习者、自动机理论研究者以及需要编写模式匹配程序的开发者。正则表达式是描述字符串集合的经典工具DFA则具有确定且高效的匹配特性代码约1000余行完整覆盖正则表达式解析、NFA构造、子集构造法转DFA、DFA最小化四个核心阶段。程序对“或”“连接”“闭包”等正则运算做了状态建模并借助状态表、转移表等数据结构实现自动机的存储与操作注释清晰可直接编译运行验证。资源包仅1个cpp文件压缩后大小7KB体量精炼便于本地保存、修改或移植到自己的词法分析项目中。目前已有190人学习使用。通过研读源码可以深入理解从正则表达式到最小化DFA的完整算法流程掌握子集构造法、状态划分与不可达状态删除等关键技巧体会C在复杂算法实现中的工程优势适合作为编译原理课程设计、期末项目或自学进阶的参考实现。 真正自己动手写一个正则表达式引擎才敢说理解了自动机那套理论是怎么回事。用C把“正则表达式转最小化DFA”完整跑通之后再回头看编译原理教材里关于有限自动机的章节视角完全不一样。这个项目麻雀虽小但五脏俱全词法解析、语法树构建、Thompson构造、子集构造、DFA最小化每个环节都对应着教科书上的经典算法非常适合拿来练手。无论你是正在找编译原理课程设计方向还是想搞明白正则表达式底层匹配的原理又或者单纯想检验自己对C容器和指针运用的熟练度这个项目都值得完完整整写一遍。1. 项目整体设计与思路拆解1.1 为什么选择C而不是Python做这个项目之前我其实纠结过一阵子Python写起来明显快得多几个库一调正则匹配结果就出来了。但仔细想想用Python做这件事有点“作弊”的味道语言本身就把正则表达式藏起来了。用C实现每一步都得自己管理状态、自己设计数据结构、自己处理内存反而能把NFA和DFA的图结构理解得更透。C在这类图算法上的表达力确实强vector当状态池unordered_map做字符映射set天然适合做状态集合去重指针和引用又能灵活地拼接NFA片段。配合VSCode的调试功能断点打在子集构造的循环里能看到DFA状态集合一点点扩张的全过程这种直观感受是Python版本给不了的。1.2 一条完整流水线从正则表达式到最小化DFA整个项目本质上是一条多阶段的数据流水线每一阶段输入一种结构输出另一种结构阶段输入输出核心算法词法与语法解析正则表达式字符串AST抽象语法树递归下降解析NFA构建ASTε-NFAThompson构造法NFA转DFAε-NFADFA状态集映射子集构造法DFA最小化DFA最小化DFAHopcroft划分法我强烈建议按这条流水线分模块开发而不是试图一口气从正则跳到最小化DFA。数学上直接转换当然可行但工程上调试会非常痛苦。分阶段实现的好处是每一阶段都能独立测试递归下降解析出错了先别往下走打印一下AST看看对不对NFA构建好了先画出来验证一下再继续。实际开发中90%的bug都靠这种“中间结果检查法”定位。1.3 先认识三个核心算法Thompson构造法负责把AST变成带ε转移的NFA思路很朴素每种正则结构对应一种NFA拼装方式像搭乐高一样从叶子节点往上拼。子集构造法负责把NFA不确定的分支“拍扁”成DFA核心是把NFA的一组状态打包成DFA的一个状态再用ε闭包处理空转移。Hopcroft划分法则做反向操作把等价的DFA状态合并让自动机变得精炼。这三个算法如果单独背公式很快就会忘但放在这个项目里串成一条流水线每一步的“为什么”就都清楚了。2. 前端解析细节正则表达式到AST2.1 运算符优先级与文法设计正则表达式虽然看着简单其实也有一套运算符优先级规则括号优先级最高闭包*其次连接相邻字符再次或|最低。这些优先级直接决定了语法树的形状处理不好就会得到错误的匹配行为。为了让解析统一我先把输入字符串转换成带显式连接符的标记序列。比如ab|c会变成a·b|c用·表示连接运算。这样做的好处是后续递归下降解析时连接这个操作不会再依赖位置关系去隐式判断逻辑清晰很多。对应的文法可以写成expr → term (| expr)? term → factor* factor → atom *? atom → ( expr ) | char这个文法看起来简单写起来却很顺手基本不会出现歧义。2.2 AST的数据结构设计AST节点的设计我一开始走了弯路想用一个结构体装所有的字段结果不同类型节点的字段利用率极低。后来改成枚举类型加孩子列表的方式清爽多了enum class NodeType { CHAR, CONCAT, UNION, STAR, EPSILON }; struct ASTNode { NodeType type; char ch; // 仅 CHAR 类型使用 std::vectorASTNode* children; // 不同类型的孩子数量不同 };CONCAT节点有两个孩子UNION节点有两个孩子STAR节点只有一个孩子CHAR节点没有孩子。用vector存孩子天然适配这种多形态需求。还有一点最好额外定义EPSILON类型因为在处理空表达式和空括号时它能让AST的表达更统一避免后面Thompson构造时去判断空节点这种特例。2.3 递归下降解析的实现递归下降解析器我写了三个函数层次关系和文法一一对应std::shared_ptrASTNode parseExpr() { auto left parseTerm(); if (curToken Token::UNION) { nextToken(); auto right parseExpr(); auto node makeNode(NodeType::UNION); node-children {left, right}; return node; } return left; } std::shared_ptrASTNode parseTerm() { std::vectorstd::shared_ptrASTNode operands; while (curToken Token::CHAR || curToken Token::LPAREN) { operands.push_back(parseFactor()); } if (operands.empty()) return makeNode(NodeType::EPSILON); if (operands.size() 1) return operands[0]; auto node makeNode(NodeType::CONCAT); node-children std::move(operands); return node; } std::shared_ptrASTNode parseFactor() { auto base parseAtom(); if (curToken Token::STAR) { nextToken(); auto node makeNode(NodeType::STAR); node-children {base}; return node; } return base; }这里最需要注意的就是parseTerm里while循环的判断条件它决定了连接运算的合并时机。不少初学朋友会在“a*和ab*解析后的AST长什么样”这个问题上栽跟头核心就在于闭包运算符的优先级高于连接必须要在factor层先处理完闭包再回到term层做连接。2.4 解析时的边界情况与错误处理空括号()怎么处理字符串末尾出现孤立的|怎么处理这些都是实际测试时会遇到的边界情况。我的处理方式是空括号直接生成EPSILON节点表示匹配空串孤立|可以报语法错误也可以按“无右操作数”生成一个空节点具体取决于你希望这个正则表达式多宽容但建议至少给出警告。解析阶段建议顺便打印AST的遍历结果这样能和后面NFA的构建结果对照排错效率高很多。3. Thompson构造法的实现细节3.1 用C表达NFANFA本质上是一张带ε转移的有向图。我在代码里直接用邻接表来表达一个状态节点就是一个结构体struct NFAState { bool isAccept false; std::unordered_mapchar, std::vectorint transitions; std::vectorint epsilonTransitions; };整个NFA就是一个std::vectorNFAState状态编号就是数组下标。这种设计有一个很大的好处不需要管内存释放状态池是vector自动管理的节点之间的转移关系只是整数索引跨模块传递时非常轻量。unordered_mapchar, vectorint用来存字符转移边vector是因为同一个字符可能通向多个状态这正是NFA不确定性的体现。3.2 五种拼接模式Thompson构造法的核心就是五种子图拼装规则。我写这段时先画了一个对照表把每种模式都记清楚了再动手模式拼装方式单字符a新建两个状态起点到终点标a边空串ε新建两个状态起点到终点标ε边连接ABA的接受态通过ε边连到B的起点或AB闭包A*A的接受态ε回A起点新起点ε到A起点和终点实现时我定义了一个Frag结构体只记录起始状态和接受状态两个编号拼接的时候只需要操作这两个端口struct Frag { int start; int end; }; Frag buildNFA(const ASTNode* node) { if (node-type NodeType::CHAR) { int s newState(); int e newState(); nfa[s].transitions[node-ch].push_back(e); return {s, e}; } if (node-type NodeType::UNION) { Frag a buildNFA(node-children[0]); Frag b buildNFA(node-children[1]); int s newState(), e newState(); nfa[s].epsilonTransitions {a.start, b.start}; nfa[a.end].epsilonTransitions.push_back(e); nfa[b.end].epsilonTransitions.push_back(e); return {s, e}; } // CONCAT 和 STAR 类似处理 }用Frag这种小结构体携带状态端口递归返回时语义非常清楚不会在状态编号传递上出错。3.3 一个容易忽略的细节状态编号管理NFA构建过程中会不断创建新状态状态编号我建议用一个独立的全局计数器管理每创建一个状态就分配一个新id保证全NFA唯一。这个看起来很简单但真的有人会在拼接时复用错误的编号导致两个子NFA共用了状态转移关系全乱。经验是任何拼接操作依赖的端口编号必须严格来自对应子图构建函数的返回值不要自己做“等价替代”。4. NFA转DFA子集构造法实操4.1 计算ε闭包用栈而不是递归子集构造法里最基础的操作是ε闭包计算从一个状态出发不断沿着ε边扩张直到把能到达的所有状态都收进来。很多教材用递归写这段但我实际测试时发现如果NFA里出现长链式ε转移比如很多个闭包嵌套递归深度可能很高程序直接栈溢出。稳妥做法是显式用栈配合visited标记std::setint epsilonClosure(const std::setint states) { std::setint closure states; std::vectorint stack(states.begin(), states.end()); while (!stack.empty()) { int s stack.back(); stack.pop_back(); for (int next : nfa[s].epsilonTransitions) { if (closure.insert(next).second) { stack.push_back(next); } } } return closure; }注意closure.insert(next).second这个返回值如果插入成功说明这个状态是新的需要继续扩展它的ε边如果插入失败说明已经处理过直接跳过。这套去重逻辑是ε闭包性能的关键如果用线性查找代替整个算法的时间复杂度会从近乎线性退化到平方级别。4.2 DFA状态就是NFA状态的集合子集构造法的核心思想是把NFA的一组等价状态看作DFA的一个状态。我习惯用std::setint表示一个DFA状态对应的NFA状态集合用std::mapstd::setint, int把这个集合映射到DFA状态编号。构建主循环如下std::setint initSet epsilonClosure({nfaStart}); std::mapstd::setint, int dfaStateMap; std::vectorstd::setint dfaStates; std::queueint workQueue; dfaStateMap[initSet] 0; dfaStates.push_back(initSet); workQueue.push(0); while (!workQueue.empty()) { int idx workQueue.front(); workQueue.pop(); for (char c : alphabet) { std::setint moveSet; for (int s : dfaStates[idx]) { for (int t : nfa[s].transitions[c]) { moveSet.insert(t); } } if (moveSet.empty()) continue; std::setint closure epsilonClosure(moveSet); if (!dfaStateMap.count(closure)) { int newIdx (int)dfaStates.size(); dfaStateMap[closure] newIdx; dfaStates.push_back(closure); workQueue.push(newIdx); } dfaTransitions[idx][c] dfaStateMap[closure]; } }字母表alphabet需要在前端阶段收集好可以在解析时把所有出现在AST里的字符去重后存进std::setchar。遍历字符时顺序保持一致后面调试打印转移表时肉眼对比会更轻松。4.3 子集构造的关键坑这个环节有两个高频错误。第一忘了标记DFA接受状态正确规则是只要当前DFA状态对应的NFA状态集合里含有任意一个接受状态这个DFA状态就是接受状态。第二初始集合只算ε闭包转移集合也必须先做move再求ε闭包顺序写反会导致丢失一堆状态。如果你处理的正则包含ε分支比如a|这种模式闭包计算不完整的问题会暴露得更明显匹配结果忽好忽坏。5. DFA最小化与实战排坑5.1 划分法反向分类消除等价状态DFA最小化我用的是经典划分法先把所有状态按“是否接受”分成两组然后反复检查每个组如果同一组里的状态在某个输入字符下跳向不同的分区就把这个组继续拆分直到不再变化为止。std::vectorint partitionState; // 每个DFA状态所属分区 int partitionCount 2; // 初始两个分区接受/非接受 while (true) { bool changed false; std::vectorstd::vectorint newGroups(partitionCount); // 对每个分区按各输入字符的转移目标分区重新分组 for (int p 0; p partitionCount; p) { std::mapstd::vectorint, std::vectorint bucketMap; for (int state : groups[p]) { std::vectorint sig; for (char c : alphabet) { int target dfaTransitions[state][c]; sig.push_back(target -1 ? -1 : partitionState[target]); } bucketMap[sig].push_back(state); } if (bucketMap.size() 1) changed true; for (auto kv : bucketMap) { newGroups.push_back(kv.second); } } if (!changed) break; groups std::move(newGroups); // 重新计算 partitionState }这里有个细节如果某个输入字符没有转移我习惯用-1表示“死状态”的转移目标这样签名向量里就能统一处理。实现时每次迭代都要重新洗牌所有分区的状态只要任何一个分区产生了分裂就再跑一轮。5.2 不可达状态与死状态的处理这个坑我觉得值得单独拿出来说。DFA里很容易出现两类“废状态”一类是永远不会被访问到的状态另一类是吸引态输入任何字符都回到自己且不接受。如果不过滤就做最小化最后产出的DFA可能包含一个谁也到不了的状态输出结果不够干净。我的做法是在最小化之前先做一遍可达性检查从起始状态开始BFS只保留可达的状态把其他状态全部排除掉。这步操作非常简单却能让最小化结果漂亮很多尤其在处理复杂正则时不做清理几乎必然出现无用的孤立状态。5.3 完整实例验证为了验证整条流水线是否正确我拿经典的正则(a|b)*abb跑了一遍。这个正则描述的是“以abb结尾的a/b串”教材上很常见手算结果也容易对照。整个流程跑下来NFA生成大概10个状态子集构造后得到5个DFA状态最小化后收敛到4个状态。如果你算出来的结果和这个不一致多半是某一步的状态标记或转移表出了问题。另一个能直观体现最小化价值的例子是a|aa。这个正则直接构造出的DFA有3个状态但最小化后合并成2个状态因为状态“接受单个a”和“接受两个a”在区分输入的意义上可以被合并成一个终结类。看着状态数减少才算真正理解“最小化”这三个字的分量。5.4 调试技巧与常见问题速查现象可能原因排查方法合法的字符串被拒绝ε闭包不完整或转移表缺失打印每个DFA状态的转移表和接受标志最小化后状态数没有变化原DFA已经最小化或划分循环没有正确分裂用两个语言等价但结构不同的正则验证程序崩溃状态id越界或转移目标未初始化打开地址消毒器ASan检查数组访问最小化结果有孤立状态不可达状态参与了划分先做可达性清理再最小化多字符正则解析结果错误闭包优先级处理不当打印AST结构对比预期形状我用过一个很笨但很有效的验证方法写一个简单的“模拟匹配函数”输入一个字符串从DFA起始状态走一遍最后判断是否停在接受状态。然后拿一批已知的正则匹配用例跑一遍对比结果。这个方法虽然简单但能一次性验证前端解析、NFA构建、DFA转换和最小化四个阶段的正确性强烈建议加上。另外提一句开发效率方面VSCode配上C调试环境非常顺手在子集构造的循环里打断点观察dfaStateMap中集合的加入顺序能对“状态集合的膨胀”留下直观印象。如果觉得代码逻辑太绕可以先画一个小NFA的纸面推导再对着代码走一遍。我个人做完这个项目最大的体会是正则表达式背后真的就是几张图把这几张图画明白C写起来反而比想象中快得多。如果你还想继续深挖可以把这个最小化DFA导出成词法分析器的状态表配上一个输入字符串匹配函数就相当于自己写了一个小型的正则匹配引擎后续接文本处理工具、做解析器前端这条路都走得通。本文还有配套的精品资源点击获取