简介:这份资源面向学习编译原理、自动机理论或需要深入理解正则表达式底层实现机制的开发者,提供一套用C++完整实现「正则表达式→NFA→DFA→最小化DFA」全流程的编程代码。压缩包内共1个文件,为单个cpp源码,整体约7KB,代码量逾千行,涵盖子集构造法、状态集合划分与等价类合并等核心算法,并借助图、状态表、栈等数据结构完成自动机的表示与操作。已有191人学习,适合作为课程设计、编译原理实验或模式匹配相关项目的参考实现。读者可从中获得从正则表达式解析到最小化DFA生成的完整可运行代码,理解非确定性与确定性自动机之间的转换思路,以及DFA最小化中删除不可达与等价状态的优化方法,为后续编写词法分析器或文本匹配工具打下基础。
1. 正则表达式到最小化 DFA:为什么“多走一步”反而更快
很多人第一次写正则引擎,直觉是“边匹配边回溯”:遇到a*就先贪心吃满,匹配失败再吐回来。这个思路写起来顺手,跑起来却可能指数级翻车——一个嵌套量词就能让匹配时间从毫秒涨到分钟。真正让正则“稳”下来的做法,是先把模式编译成自动机,再把自动机压到最小。正则表达式转最小化 DFA 这条链路,解决的正是“匹配行为可预测”这件事:模式一旦编译完成,每次匹配的步数只与输入长度线性相关,跟模式写得多复杂无关。搞懂它,你才能明白为什么工业级正则库敢在日志过滤、词法分析、协议解析这些高频场景里放心用,也才能看懂那些“为什么我的正则突然卡死”的血泪经验到底卡在哪一层。
2. 从正则到 NFA:Thompson 构造为什么先“铺开”再“收敛”
正则表达式本身是一串描述语言集合的符号,计算机没法直接“理解”它。第一步必须把它翻译成某种状态机。这里有个关键取舍:直接构造 DFA 很难,因为 DFA 的每个状态必须对每个输入符号都有唯一确定的转移;而正则里的选择、闭包天然带“不确定性”。所以常规做法是先构造 NFA,允许一个状态对同一输入有多条出路,甚至不读输入就能跳转(ε 转移)。Thompson 构造的价值就在于:它给每种正则运算规定了固定的拼接模板,让“翻译”变成机械的图拼接,不需要任何全局推理。
2.1 五种基本运算的拼接模板
Thompson 构造的核心思想是“每个子表达式对应一个带唯一入口和唯一出口的 NFA 片段”。字符a就是一个入口状态经a边到出口状态;连接RS就是把 R 的出口用 ε 边接到 S 的入口;选择R|S是新建入口,用两条 ε 边分别指向 R、S 的入口,再把两者出口用 ε 边汇到新出口;闭包R*是新建入口和出口,入口 ε 到 R 入口、ε 到出口,R 出口 ε 回 R 入口、ε 到出口。这套模板的好处是片段之间只通过 ε 边耦合,构造过程可以纯递归完成。
// NFA 片段:入口与出口状态编号 struct Frag { int start; int accept; }; // 为字符 c 构造单边片段 Frag makeChar(char c) { int s = newState(); int a = newState(); addEdge(s, a, c); // 读入 c 才转移 return {s, a}; } // 连接:R 的出口 ε 接到 S 的入口 Frag concat(Frag r, Frag s) { addEdge(r.accept, s.start, EPS); // EPS 表示 ε 转移 return {r.start, s.accept}; } // 选择:新入口分两路,两出口汇合 Frag alternate(Frag r, Frag s) { int in = newState(); int out = newState(); addEdge(in, r.start, EPS); addEdge(in, s.start, EPS); addEdge(r.accept, out, EPS); addEdge(s.accept, out, EPS); return {in, out}; }上面代码里EPS是一个特殊符号,代表“不消耗输入也能走”。newState()每次分配全局唯一状态号,保证片段拼接后不会撞号。concat只加一条 ε 边,是因为 R 和 S 各自已经是完整片段,出口到入口的衔接不需要额外状态。alternate新建入口出口,是为了让选择结构对外仍然表现为“单入口单出口”,这样上层再做连接或闭包时不用关心内部有几个分支。闭包R*的写法类似,只是多两条回边和两条跳过边,这里不重复贴,关键是理解“所有不确定性都被收进 ε 边”。
2.2 ε 闭包:NFA 模拟匹配的发动机
NFA 有了,怎么用它匹配?不能像 DFA 那样“当前状态唯一”,而要维护一个状态集合。每读一个字符,先把集合里所有状态沿 ε 边走到底(求 ε 闭包),再对集合中每个状态找匹配该字符的边,得到新集合,再求一次 ε 闭包。这个“集合 + 闭包”的循环就是 NFA 模拟。它的时间复杂度是 O(输入长度 × 状态数),虽然比 DFA 慢一个常数因子,但构造简单、内存小,很多轻量正则库就停在这一步。
提示:ε 闭包必须用栈或队列做传递闭包,不能只走一层。漏掉多层 ε 跳转是 NFA 模拟最常见的翻车点,表现为“明明该匹配却不匹配”。
3. 子集构造:把“状态集合”压成“确定状态”
NFA 的不确定性对匹配器不友好,因为每次都要维护集合。子集构造(subset construction)的思路很直接:既然 NFA 在某一时刻可能处于一组状态,那就把“这一组状态”整体当成 DFA 的一个状态。这样 DFA 的每个状态天然对应 NFA 的一个状态子集,读入一个字符后,新子集是确定的,转移也就确定了。这一步把“不确定性”从运行时搬到了构造时,代价是 DFA 状态数可能指数膨胀。
3.1 核心循环与状态映射
子集构造用一个工作队列驱动:初始 DFA 状态是 NFA 起点的 ε 闭包;每次取出一个未处理的 DFA 状态(本质是状态集合),对字母表中每个字符,算出集合中所有状态沿该字符转移后再求 ε 闭包的结果;如果这个结果集合没出现过,就分配新 DFA 状态号并入队;最后记录一条从当前 DFA 状态到目标 DFA 状态的边。
// DFA 状态用 NFA 状态集合表示,用排序后的 vector 做 key using StateSet = std::vector<int>; StateSet moveAndClosure(const StateSet& cur, char c) { StateSet moved; for (int s : cur) { for (auto& e : nfaEdges[s]) { if (e.label == c) moved.push_back(e.to); // 只走字符边 } } return epsilonClosure(moved); // 再求 ε 闭包 } void subsetConstruction() { StateSet start = epsilonClosure({nfaStart}); std::map<StateSet, int> id; // 集合 -> DFA 状态号 std::queue<StateSet> work; id[start] = newDfaState(); work.push(start); while (!work.empty()) { StateSet cur = work.front(); work.pop(); for (char c : alphabet) { StateSet nxt = moveAndClosure(cur, c); if (nxt.empty()) continue; // 死状态可省略 if (!id.count(nxt)) { id[nxt] = newDfaState(); work.push(nxt); } addDfaEdge(id[cur], id[nxt], c); } } }moveAndClosure先只沿字符边收集目标,再统一求闭包,顺序不能反——先闭包再走字符会多出不该有的状态。id用有序集合做键,保证相同集合映射到同一 DFA 状态,这是“确定性”的来源。nxt.empty()直接跳过,意味着不建死状态,匹配时遇到无出边即失败,省状态也省内存。字母表alphabet要提前从正则里收集所有出现过的字符,漏字符会导致某些转移缺失。
3.2 指数膨胀不是传说
子集构造最坏情况能把 n 个 NFA 状态炸成 2^n 个 DFA 状态。经典构造(a|b)*a(a|b)^n这类模式时,DFA 需要记住“最近 n 位里有没有出现过 a”,状态数随 n 指数增长。这不是实现 bug,而是语言本身决定的。所以工程上常见两种策略:一是限制模式复杂度,二是干脆不建完整 DFA,回到 NFA 模拟或惰性构造(用到哪个子集才算哪个)。理解这一点,你就不会盲目相信“DFA 一定更快”。
4. 最小化 DFA:Hopcroft 划分为什么能砍掉冗余
子集构造出来的 DFA 能跑,但往往臃肿:不同状态可能行为完全一样,只是历史路径不同。最小化 DFA 的目标是合并这些“等价状态”,得到状态数最少的等价自动机。判定两个状态等价的标准是:对任意输入串,从它们出发要么都接受、要么都拒绝。直接按这个定义两两比较是 O(n²) 起步,Hopcroft 算法用“划分细化”把它压到接近线性。
4.1 划分细化的直觉
初始把状态分成两组:接受态一组、非接受态一组。然后反复检查:如果某一组里两个状态对某个字符转移到了不同的组,它们就不该待在一起,必须拆开。每拆一次,划分就更细,直到不再变化。最终每个划分块就是一个最小 DFA 状态。这个过程的本质是“用已发现的区别去发现更多区别”,类似不断追问“你俩读同一个字符后还一样吗”。
// 简化版划分细化:按转移目标所在块号反复分裂 std::vector<int> minimizeDfa(int n, const std::vector<std::array<int, 128>>& trans, const std::vector<bool>& accept) { std::vector<int> block(n, 0); for (int i = 0; i < n; ++i) block[i] = accept[i] ? 1 : 0; // 初始两块 bool changed = true; while (changed) { changed = false; std::map<std::vector<int>, int> sig; // 转移签名 -> 新块号 std::vector<int> next(n); for (int i = 0; i < n; ++i) { std::vector<int> key; key.push_back(block[i]); // 自身块号参与签名 for (int c = 0; c < 128; ++c) key.push_back(block[trans[i][c]]); // 每个字符去向的块号 if (!sig.count(key)) sig[key] = sig.size(); next[i] = sig[key]; } if (next != block) { block = next; changed = true; } } return block; // 同块号即同一最小化状态 }trans[i][c]是状态 i 读字符 c 的目标状态,accept标记接受态。签名里同时放“自身是否接受”和“每个字符去向的块号”,保证只有行为完全一致的状态才共享签名。next != block判断划分是否还在变,不变即收敛。这段是教学版,复杂度偏高;Hopcroft 原版用“较小侧优先分裂”和待处理块队列把均摊代价降下来,但判定逻辑与上面一致。实际写的时候,字母表通常不止 128,要按真实字符集建表。
4.2 不可达状态要先删
最小化前必须做可达性分析:从初态出发走不到的 DFA 状态直接删掉。子集构造一般不会产生不可达状态,但如果你手工改过转移表、或者做了某些优化合并,就可能留下孤岛。带着不可达状态做划分,虽然结果仍等价,但状态数不是最小,白费功夫。常见做法是 BFS 一遍标记可达,再只对可达状态建转移表。
5. 避坑与排查:这条链路上最容易翻车的几处
5.1 匹配结果对但状态数爆炸
现象:正则能正确匹配,但编译出的 DFA 状态数远超预期,内存吃紧。原因多半是子集构造时把 ε 闭包算重了,或者字母表里混入了大量无关字符导致每个状态都生成一堆空转移。解决:先确认epsilonClosure对已访问状态做了去重;再检查字母表是否只收集正则中真实出现的字符,而不是直接拿 0–127 全量建表。
5.2 最小化后行为变了
现象:最小化前能匹配的串,最小化后拒绝。原因通常是初始划分只分了接受/非接受,却忘了把“死状态”单独处理,或者签名里漏了某个字符的转移。解决:最小化前先补全转移表(缺失转移指向显式死状态),签名必须覆盖字母表全部字符,不能只挑有边的字符。
5.3 ε 闭包漏层导致偶发不匹配
现象:简单模式正常,带嵌套闭包的模式偶尔漏匹配。原因:闭包实现只走了一层 ε 边,没有做传递闭包。解决:用栈迭代直到没有新状态加入,并在加入前查重;单元测试专门构造(a*)*这类多层 ε 路径。
5.4 字符类与转义没在解析层归一
现象:[a-c]或\d在 NFA 里被当成多个字符字面量,导致状态数虚高或匹配错误。原因:词法分析阶段没把字符类展开成单字符集合,直接塞给了 Thompson 构造。解决:在解析阶段就把字符类、预定义类统一展开为字符集合,构造层只认单字符边。
5.5 用递归下降解析深嵌套模式栈溢出
现象:超长正则或深嵌套括号导致编译期崩溃。原因:递归下降解析器每层括号占一个栈帧。解决:给嵌套深度设上限并报错,或改用显式栈的迭代解析;工程上更常见的是限制模式长度,把复杂度挡在编译前。
6. 设计取舍:为什么工业实现不总是“一路最小化到底”
把正则编译成最小化 DFA,理论上匹配最快、行为最确定,但这条链路每一步都有代价。Thompson 构造简单但 NFA 状态多;子集构造可能指数膨胀;Hopcroft 最小化虽然状态最少,但构造期时间和内存都不低。真实系统往往在这三者之间做取舍:词法分析器生成工具偏爱完整 DFA,因为模式固定、编译一次跑很久,膨胀可以接受;通用正则库更常见“惰性 DFA”或“NFA 模拟 + 缓存”,避免为冷门模式付出爆炸代价;对匹配延迟敏感的场景,甚至会先跑 NFA,命中后再决定要不要建 DFA。
我一般会这么理解:最小化不是目的,而是“把运行时不确定性提前消灭”的手段。如果你的模式集合小、输入短,NFA 模拟完全够用,硬上最小化 DFA 反而让编译期变重;如果模式要反复匹配海量日志,那多花点编译时间换线性匹配就值。验证方法也很直接:拿真实输入跑一遍,分别记录编译耗时、匹配耗时和峰值内存,三者一起看,而不是只盯状态数。
| 方案 | 编译代价 | 匹配代价 | 适用场景 |
|---|---|---|---|
| NFA 模拟 | 低 | O(长度×状态数) | 模式少、输入短、编译频繁 |
| 子集构造 DFA | 中到高 | O(长度) | 模式固定、反复匹配 |
| 最小化 DFA | 高 | O(长度),状态最少 | 内存敏感、需长期驻留 |
从那以后我每次做自动机相关的优化,都强制先跑一遍“编译耗时 / 匹配耗时 / 内存”三列对比,再决定停在哪一层,而不是默认最小化就是终点。希望帮到你。
本文还有配套的精品资源,点击获取