正则表达式这门手艺,写业务代码的人几乎天天在用,但真要问一句“它底层凭什么能识别那些模式”,不少人就卡壳了。我当年第一次被问到“正则表达式和自动机是什么关系”的时候,脑子里只有re.match的用法,完全答不上来原理层面的东西。后来啃编译原理和形式语言那部分内容,才把这条线捋顺:正则表达式不是凭空冒出来的字符串匹配魔法,它背后站着一个数学模型——有限自动机,尤其是非确定有限自动机(NFA)。这篇就围绕这个核心,把 NFA 与正则表达式的对应关系、Python 里怎么落地实现、以及实操中容易踩的坑,一次讲透。适合已经会用正则但想搞懂原理的开发者,也适合正在学自然语言处理、文本处理方向、需要补形式语言基础的读者。读完你至少能做到两件事:一是能手写一个简易 NFA 并用它跑匹配,二是能理解为什么有些正则写法性能会突然崩掉。
1. 为什么正则表达式需要自动机这套理论撑腰
1.1 从“字符串匹配”到“状态转移”的思维转换
大多数人学正则的路径是这样的:先背元字符,\d是数字,*是零次或多次,+是一次或多次,然后拿一堆例子去试。这条路能走通,但走不远。一旦遇到嵌套量词、回溯爆炸、或者需要自己实现一个简易的模式匹配引擎,光靠背元字符就彻底没辙了。
自动机提供的是另一套视角:把“匹配一个模式”看成“在一个状态图里走路径”。你可以想象自己站在一个迷宫的入口,每读入一个字符,就沿着当前状态下允许的边走到下一个状态。如果读完整个字符串后,你恰好站在“接受状态”上,那这个字符串就匹配成功。这个类比我觉得特别贴切——正则表达式描述的是“什么样的路径算合法”,自动机则是那张实际的地图。
这个思维转换的价值在于:它把“字符串处理”这种看起来偏工程的问题,转化成了“图上的路径可达性”这种有严格数学定义的问题。一旦转化完成,很多模糊的直觉就变成了可证明的结论。比如“为什么正则表达式不能匹配任意深度的嵌套括号”,用自动机的语言一句话就能说清:有限状态机只有有限个状态,记不住任意深的嵌套层数。
1.2 确定与非确定的本质区别
有限自动机分两种:确定有限自动机(DFA)和非确定有限自动机(NFA)。这两个词听起来吓人,其实区别就一句话。
DFA 的特点是:在任何一个状态上,读入任何一个字符,下一步去哪个状态是唯一确定的。就像一条单行道,你站在路口,看到红灯就只能停,没有第二种选择。
NFA 则允许“不确定性”,具体体现在两个地方:第一,同一个状态读入同一个字符,可以转移到多个不同的下一状态;第二,允许存在“空转移”(ε-转移),也就是不读入任何字符就能跳过去。这就像路口同时有好几条岔路都通向不同方向,你可以“同时”走所有岔路。
这里有个关键结论必须先摆出来:NFA 和 DFA 在表达能力上是等价的,任何 NFA 都能转换成等价的 DFA,任何正则表达式都能构造出对应的 NFA。这个等价性是整个理论的基石。那既然等价,为什么还要区分?因为 NFA 更贴近正则表达式的书写直觉,构造起来自然;DFA 执行效率高,每个字符只需一次状态转移。工程上的常见做法就是:先用 NFA 把正则“翻译”出来,再转成 DFA 去跑,这就是经典的正则引擎架构。
1.3 三种描述方式的等价链条
形式语言里有一条非常漂亮的等价链:正则表达式、NFA、DFA,三者描述的语言类完全一致,都叫正则语言。这条链的意义在于,你可以在这三种表示之间自由转换,选最方便的那种来思考或实现。
- 写给人看、给人维护:用正则表达式,紧凑直观。
- 构造和证明:用 NFA,因为从正则到 NFA 的构造过程(Thompson 构造法)是机械的、可递归的。
- 高效执行:用 DFA,因为匹配时无需回溯,线性时间。
我在实际做文本处理工具时,深刻体会到这个链条的实用价值。比如要写一个日志字段提取器,先用正则把需求描述清楚,然后心里清楚它对应的 NFA 长什么样,就能预判哪些写法会导致状态爆炸。这种“看得见底层”的感觉,是单纯背 API 给不了的。
2. NFA 的核心机制拆解与正则的对应关系
2.1 NFA 的形式化定义,用人话讲一遍
教科书上的定义是这样的:一个 NFA 是一个五元组 (Q, Σ, δ, q0, F),其中 Q 是状态集合,Σ 是字母表,δ 是转移函数,q0 是起始状态,F 是接受状态集合。第一次看这套符号确实头大,但拆开看每一项都很朴素。
- Q(状态集合):就是迷宫里的所有路口,有限个。
- Σ(字母表):允许出现的字符集合,比如
{a, b}或者全部 ASCII。 - δ(转移函数):核心中的核心。它接收“当前状态 + 输入字符(或空)”,返回“下一状态集合”。注意返回的是集合,这正是“非确定”的体现。
- q0(起始状态):入口。
- F(接受状态集合):出口集合,走到其中任意一个就算成功。
我习惯把 δ 理解成一张“路线表”:给定当前位置和脚下踩到的字符,查表得到所有可能的下一站。NFA 的匹配过程本质上就是:从 q0 出发,把所有可能的路径同时探索一遍,只要有一条路径在读完字符串后落在 F 里,就算匹配。
2.2 空转移 ε 到底在解决什么问题
空转移是 NFA 里最容易被误解的部分。很多人会问:不读字符就能跳状态,这不是作弊吗?其实它解决的是“拼接”和“选择”的构造问题。
考虑正则ab,它要求先匹配 a 再匹配 b。用自动机表示,就是 a 的结束状态要“无缝”接到 b 的起始状态。如果没有空转移,你得手工把两个子自动机的状态合并,构造过程会变得很别扭。有了 ε,你可以让 a 的接受状态通过一条 ε 边指向 b 的起始状态,两个子结构各自独立构造,最后用 ε 边“粘”起来。这就是 Thompson 构造法能机械递归的根本原因。
再考虑选择a|b,你需要一个分支点,读入字符前先“同时”进入两条支路。这个分支同样靠 ε 边实现:新起始状态通过 ε 分别指向 a 子机和 b 子机的起始状态。所以 ε 不是可有可无的装饰,它是让构造过程模块化的关键工具。
注意:ε 转移在匹配时意味着“零成本跳转”,实现时必须用闭包运算把所有能通过 ε 到达的状态一次性收集起来,否则会漏掉合法路径。这是手写 NFA 引擎最容易出错的地方。
2.3 从正则到 NFA:Thompson 构造法逐条拆解
Thompson 构造法的精髓是:对正则表达式的每一种语法结构,给出一个固定的自动机拼装模板。因为正则表达式本身是递归定义的,所以这套模板可以递归套用,最终拼出整个 NFA。下面把几种基本结构的构造方式列清楚。
| 正则结构 | 构造方式 | 状态数变化 |
|---|---|---|
| 单字符 a | 两个状态,一条标 a 的边 | 新增 2 个状态 |
| 连接 ab | 左子机接受态 ε 连右子机起始态 | 子机状态数相加 |
| 选择 a|b | 新起始态 ε 分叉到两子机,两子机接受态 ε 汇合到新接受态 | 子机之和 + 2 |
| 闭包 a* | 新起始态 ε 到子机起始和接受态,子机接受态 ε 回子机起始和新接受态 | 子机 + 2 |
| 正闭包 a+ | 等价于 aa*,或单独构造 | 视实现而定 |
| 可选 a? | 等价于 a|ε | 视实现而定 |
这套模板的妙处在于,每个模板都只关心“入口”和“出口”两个接口,内部结构对上层透明。你写代码实现时,可以定义一个NFAFragment结构,只暴露 start 和 accept 两个状态,拼接时把 accept 用 ε 连到下一个 fragment 的 start 即可。这种“接口化”的构造思路,和软件工程里的组合模式如出一辙。
2.4 一个具体例子:手推(a|b)*abb的 NFA
光看模板不够直观,拿一个经典例子走一遍。正则(a|b)*abb的意思是:任意个 a 或 b,最后以 abb 结尾。这个例子在编译原理教材里出现频率极高,因为它同时用到了闭包、选择、连接三种结构。
构造顺序建议自底向上:先做a|b,再做它的闭包(a|b)*,然后依次连接a、b、b。每一步都套用上面的模板,用 ε 边把子结构粘起来。手工推的时候,我建议在纸上画,每个 fragment 用方框圈起来,标好入口出口,最后把方框按顺序连起来。推完之后你会发现,状态数比想象的多,但结构非常规整,没有任何“聪明”的地方——这正是 Thompson 构造法的优点:笨,但绝对正确。
推完之后可以拿几个字符串验证:abb应该匹配,aabb应该匹配,abab不匹配(结尾不是 abb),babb匹配。验证的时候要模拟 NFA 的“多路径并行”语义,把所有可能的当前状态集合都维护住,而不是只跟一条路径。
3. Python 实现一个能跑的 NFA 匹配器
3.1 数据结构设计:状态、边、片段
动手写代码前,先把数据结构定清楚。我的习惯是用整数表示状态编号,用一个全局计数器分配新状态,转移关系用字典存。核心结构大概是这样:
class NFA: def __init__(self): self.states = set() self.transitions = {} # (state, char) -> set of states self.epsilon = {} # state -> set of states self.start = None self.accept = None self._counter = 0 def new_state(self): s = self._counter self._counter += 1 self.states.add(s) return s def add_transition(self, src, char, dst): self.transitions.setdefault((src, char), set()).add(dst) def add_epsilon(self, src, dst): self.epsilon.setdefault(src, set()).add(dst)把 ε 转移和普通字符转移分开存,是我踩过坑之后的决定。早期我把 ε 当成一个特殊字符混在 transitions 里,结果做闭包运算时总要特判,代码又臭又长。分开存之后,闭包运算就是纯粹的图遍历,逻辑干净很多。
片段(fragment)结构则用来承载 Thompson 构造的中间结果:
class Fragment: def __init__(self, start, accept): self.start = start self.accept = accept只暴露入口和出口,内部细节全部藏在 NFA 对象里。这个设计让后续的拼接代码非常短。
3.2 递归下降解析正则并构造 NFA
要构造 NFA,先得把正则表达式解析成语法结构。这里用一个简单的递归下降解析器,支持连接、选择、闭包、括号和单字符。解析和构造可以合并进行,边解析边返回 Fragment。
class RegexParser: def __init__(self, pattern): self.pattern = pattern self.pos = 0 self.nfa = NFA() def peek(self): return self.pattern[self.pos] if self.pos < len(self.pattern) else None def parse(self): frag = self.parse_alternation() self.nfa.start = frag.start self.nfa.accept = frag.accept return self.nfa def parse_alternation(self): left = self.parse_concat() while self.peek() == '|': self.pos += 1 right = self.parse_concat() left = self.make_alternation(left, right) return left def parse_concat(self): frag = self.parse_repeat() while self.peek() not in (None, '|', ')'): nxt = self.parse_repeat() frag = self.make_concat(frag, nxt) return frag def parse_repeat(self): frag = self.parse_atom() while self.peek() in ('*', '+', '?'): op = self.peek() self.pos += 1 frag = self.make_repeat(frag, op) return frag def parse_atom(self): ch = self.peek() if ch == '(': self.pos += 1 frag = self.parse_alternation() assert self.peek() == ')' self.pos += 1 return frag self.pos += 1 return self.make_char(ch)这段代码的关键在于优先级:选择最低,连接居中,闭包最高,括号改变结合顺序。递归下降天然贴合这个层次,每个函数负责一层,读起来和语法定义一一对应。make_char、make_concat、make_alternation、make_repeat这几个方法就是 Thompson 模板的代码化,每个方法内部只做“新建状态 + 连 ε 边”这两件事。
3.3 ε 闭包与多状态并行匹配
NFA 匹配的核心是维护一个“当前状态集合”,而不是单个状态。每读一个字符,先对当前集合做 ε 闭包扩展,再按字符转移,再对新集合做闭包。代码不长,但每一步都不能省。
def epsilon_closure(nfa, states): stack = list(states) closure = set(states) while stack: s = stack.pop() for nxt in nfa.epsilon.get(s, ()): if nxt not in closure: closure.add(nxt) stack.append(nxt) return closure def match(nfa, text): current = epsilon_closure(nfa, {nfa.start}) for ch in text: nxt_states = set() for s in current: nxt_states |= nfa.transitions.get((s, ch), set()) if not nxt_states: return False current = epsilon_closure(nfa, nxt_states) return nfa.accept in currentepsilon_closure用显式栈做深度优先遍历,避免递归深度问题。match函数里那个“如果下一状态集合为空就提前返回 False”的剪枝很重要,它能在明显不匹配时立刻退出,不用把整个字符串读完。
提示:闭包运算一定要在“读字符之前”和“读字符之后”都做。只做一次是新手最常见的错误,会导致某些依赖 ε 跳转的路径被漏掉,表现为“明明该匹配却匹配不上”。
3.4 实测验证:几个边界用例
代码写完必须拿用例打。我一般准备三类:正常匹配、应当拒绝、边界情况。拿(a|b)*abb这个 NFA 来测:
| 输入字符串 | 期望结果 | 说明 |
|---|---|---|
| abb | 匹配 | 最短合法串 |
| aabb | 匹配 | 前缀有额外字符 |
| babb | 匹配 | 前缀为 b |
| abab | 不匹配 | 结尾不是 abb |
| ab | 不匹配 | 长度不足 |
| 空串 | 不匹配 | 闭包可空但后面要求 abb |
| abbabb | 匹配 | 中间可以再出现 abb |
实测下来这套实现对这些用例全部通过。特别值得一提的是空串那个用例,它验证了闭包结构本身允许零次重复,但后续连接部分强制要求 abb,所以整体不接受空串——这个细节如果构造时 ε 边连错,很容易误判。
4. 实操中的坑与性能问题排查
4.1 NFA 状态爆炸与子集构造的必要性
NFA 跑匹配时,最坏情况下当前状态集合会膨胀得很大。考虑(a|b)*a(a|b)(a|b)...这种模式,每多一个可选分支,并行路径就多一批。虽然单次匹配仍是多项式时间,但常数因子可能大到无法接受。这就是为什么真实的正则引擎不会直接拿 NFA 跑,而是先做子集构造,把 NFA 转成 DFA。
子集构造的思路很直接:DFA 的每个状态,对应 NFA 的一个状态集合。从 NFA 起始状态的 ε 闭包出发,对字母表里每个字符,算出转移后的闭包集合,作为一个新的 DFA 状态,不断扩展直到不再产生新集合。转换完成后,DFA 每个状态对每个字符只有一条出边,匹配时就是纯粹的查表,每个字符 O(1)。
代价是 DFA 的状态数可能指数级增长。所以工程上常见两种策略:状态数可控时预转 DFA 求速度;状态数可能爆炸时保留 NFA 做惰性转换(按需生成 DFA 状态并缓存)。理解这个权衡,你就能明白为什么有些正则库对某些模式会突然变慢——不是库写得差,是模式本身触发了状态膨胀。
4.2 回溯型引擎与自动机引擎的差异
这里必须澄清一个常见误解:Python 标准库re用的不是纯自动机引擎,而是回溯型引擎。回溯引擎的实现更接近“带记忆的深度优先搜索”,它按正则的书写顺序尝试匹配,失败就回退重试。这种引擎支持反向引用、环视等 NFA/DFA 理论模型之外的高级特性,但代价是最坏情况下的指数级时间复杂度。
自动机引擎(DFA 类)则是线性时间,但不支持反向引用这类需要“记忆”的特性。所以选型时要清楚:需要高级特性就用回溯引擎,接受潜在的性能风险;追求稳定线性性能就用自动机思路,放弃部分表达力。我在处理大批量日志时,会刻意把正则写得“自动机友好”——避免嵌套量词、避免大量可选分支,就是为了不给回溯引擎制造爆炸条件。
4.3 常见问题速查表
把实操中反复遇到的问题整理成表,方便对照排查。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 该匹配却匹配不上 | ε 闭包漏做或只做一次 | 检查读字符前后是否都做了闭包 |
| 匹配结果多出意外路径 | 状态集合未去重 | 用 set 存状态,转移时取并集 |
| 构造过程状态编号混乱 | 全局计数器与子结构未隔离 | 每个 fragment 独立分配,拼接时只连边 |
| 递归解析栈溢出 | 深层嵌套括号 | 改用显式栈或限制嵌套深度 |
| 匹配速度突然变慢 | 回溯爆炸或 NFA 状态膨胀 | 简化正则结构,或考虑子集构造 |
| 空串处理异常 | 闭包模板 ε 边连错 | 单独测空串用例,核对闭包构造 |
这张表里前两条是我早期最常犯的错,尤其是闭包只做一次那个,调试了大半天才发现问题出在“读字符之后忘了再闭包”。后来我养成习惯,match函数里闭包调用固定出现两次,位置写死,就不容易漏了。
4.4 从理论到工程的几点心得
第一,先画图再写码。Thompson 构造的每个模板都很小,但拼起来状态多,直接写代码容易乱。我现在的习惯是先在纸上把 fragment 的入口出口标清楚,确认拼接顺序,再动手。
第二,测试用例要覆盖 ε 路径。很多 bug 只在依赖空转移的输入上才暴露,比如a*匹配空串、(a|b)*匹配单个字符。专门为这些情况准备用例,能提前抓出闭包实现的错误。
第三,别急着优化。NFA 直接跑虽然慢,但正确性容易保证。等确认逻辑无误,再考虑子集构造或惰性 DFA。过早优化会让调试难度成倍上升,这是我交过学费的教训。
第四,理解引擎边界。知道re是回溯引擎之后,我写正则时会下意识避开(a+)+b这种经典爆炸模式,改用更扁平的结构。这种“知道底层在干什么”的自觉,比记住一百个元字符都有用。
最后分享一个小技巧:如果你也想验证自己对 NFA 的理解,不妨拿一个中等复杂度的正则,手工推出它的 NFA,再用本文的代码跑一遍,对比结果。推的过程会逼你把每个 ε 边都想清楚,跑通那一刻,正则和自动机之间的那层窗户纸就彻底捅破了。这个内容后续还可以往子集构造的代码实现、以及 DFA 最小化方向继续扩展,那又是另一段有意思的探索了。