4. 正则表达式到NFA:Thompson构造法

正则表达式怎么变成自动机?这是编译原理里一个很经典的问题。我刚开始学的时候,总觉得这步很玄乎——明明是一串字符,怎么就变成一张图了?

其实说白了,Thompson构造法就是一套「搭积木」的规则。你给它一个正则表达式,它给你一个NFA。而且这个NFA有个特点:只有一个开始状态,一个接受状态。这个特性在后面做子集构造时会特别方便。

4.1 为什么需要Thompson构造法?

你想想看,正则表达式是人写的,但计算机不认识。计算机只认识状态和跳转。所以我们需要一个桥梁——把正则表达式翻译成NFA。

Thompson构造法就是这座桥。它由Ken Thompson在1968年提出,后来成了lex等词法分析器的核心算法。我在做编译器前端时,就亲手实现过这个算法。嗯,踩过不少坑,后面会讲到。

4.2 基本构造规则

Thompson构造法对每种正则表达式操作都有对应的NFA模板。我们一个一个来看。

4.2.1 空串 ε

最简单的正则表达式就是空串。它的NFA长这样:

开始状态 --ε--> 接受状态

就两个状态,一条ε边。没什么好说的。

4.2.2 单个字符 a

对于单个字符,比如 'a',NFA就是:

开始状态 --a--> 接受状态

也是两个状态,一条标有 'a' 的边。

4.2.3 连接操作 r1r2

连接操作就是把两个NFA串起来。假设r1的NFA是N1,r2的NFA是N2。那么r1r2的NFA就是:

N1的开始状态 --...--> N1的接受状态 --ε--> N2的开始状态 --...--> N2的接受状态

说白了,就是把N1的接受状态和N2的开始状态用ε边连起来。N1的接受状态不再是接受状态了,N2的接受状态才是最终的接受状态。

我的习惯:实现连接操作时,我会把N1的接受状态标记为「非接受」,然后加一条ε边到N2的开始状态。这样逻辑清晰,不容易出错。

4.2.4 选择操作 r1|r2

选择操作表示「要么r1,要么r2」。它的NFA结构是:

         --ε--> N1的开始状态 --...--> N1的接受状态
        /                                        \
新的开始状态                                        --ε--> 新的接受状态
        \                                        /
         --ε--> N2的开始状态 --...--> N2的接受状态

这里需要引入一个新的开始状态和一个新的接受状态。从新开始状态有两条ε边,分别指向N1和N2的开始状态。N1和N2的接受状态各有一条ε边指向新的接受状态。

我曾经在实现选择操作时犯过一个错误:忘了加新的接受状态,直接把N1和N2的接受状态合并了。结果导致某些路径走不通。嗯,这个坑我替你们踩过了。

4.2.5 重复操作 r*

重复操作表示「零次或多次」。它的NFA结构是:

        --ε--> N的开始状态 --...--> N的接受状态 --ε--
       /                                              \
新的开始状态                                            --> 新的接受状态
       \                                              /
        --------------------ε--------------------------

这里的关键是两条ε边:一条从新的开始状态直接到新的接受状态(表示零次),另一条从N的接受状态回到N的开始状态(表示多次)。

注意:重复操作的NFA中,从N的接受状态到N的开始状态的ε边是必须的。没有它,就无法实现「多次重复」。我见过有人漏掉这条边,结果r*只能匹配零次或一次。

4.3 构造步骤总结

好了,有了上面的基本模板,构造任意正则表达式的NFA就变成了一个递归过程:

  1. 解析正则表达式:把它拆成基本操作(字符、连接、选择、重复)
  2. 对每个基本操作:用对应的NFA模板构造
  3. 组合子NFA:按照正则表达式的结构,把子NFA组合起来
  4. 得到最终NFA:只有一个开始状态和一个接受状态

这个过程可以用语法分析树来驱动。每个节点对应一个操作,递归地构造子NFA,然后组合。

4.4 完整示例:构造 (a|b)*abb 的NFA

我们来走一遍完整的例子。这个正则表达式匹配的是:任意多个a或b,后面跟abb。

第一步:解析结构

  • 整体是连接操作:(a|b)* 和 abb
  • (a|b)* 是重复操作,里面是选择操作 a|b
  • abb 是三个字符的连接

第二步:从内向外构造

先构造 a|b 的NFA:

         --ε--> [1] --a--> [2] --ε--
        /                            \
[0]                                      [5]
        \                            /
         --ε--> [3] --b--> [4] --ε--

然后构造 (a|b)* 的NFA:

        --ε--> [1] --a--> [2] --ε--
       /        \                /        \
[0]               --ε--> [5] --               [6]
       \        /                \        /
        --ε--> [3] --b--> [4] --ε--
              \                /
               ------ε--------

最后构造整个 (a|b)*abb 的NFA:

(a|b)* 的NFA --ε--> [7] --a--> [8] --b--> [9] --b--> [10]

其中[10]是最终的接受状态。

关键点:这个NFA有10个状态。虽然看起来复杂,但每个部分都是按照模板搭出来的。你只要记住模板,就能构造任意复杂的正则表达式。

4.5 我踩过的坑

做Thompson构造法时,有几个地方特别容易出错:

  • ε边太多:每个操作都引入ε边,导致NFA状态数爆炸。其实有些ε边可以优化掉,但Thompson构造法为了保持结构统一,故意保留了所有ε边。
  • 接受状态搞混:组合NFA时,子NFA的接受状态要重新标记。我刚开始实现时,忘了把子NFA的接受状态改为非接受,结果导致匹配提前结束。
  • 重复操作的边界:r*的NFA中,从新开始状态到新接受状态的ε边表示「零次」。这条边不能少,否则r*就无法匹配空串。

4.6 知识体系图

下面这张图展示了Thompson构造法的核心逻辑:

Thompson构造法知识体系 正则表达式 步骤1:解析正则表达式结构 步骤2:对每个基本操作应用NFA模板 字符 a 连接 r1r2 选择 r1|r2 重复 r* 步骤3:组合子NFA得到最终NFA NFA(一个开始,一个接受)

这张图展示了Thompson构造法的三个核心步骤:解析结构、应用模板、组合子NFA。每一步都有明确的输入输出,你照着这个流程走,就不会乱。

4.7 小结

Thompson构造法其实不复杂。记住四个模板,然后递归地搭积木就行。我当年实现这个算法时,花了大概两天时间。第一天理解模板,第二天写代码。调试时遇到几个bug,但都是上面提到的那些坑。

你如果自己动手实现一遍,会发现对NFA的理解会深很多。别怕,试试看。

实用建议:实现Thompson构造法时,建议用面向对象的方式。每个NFA节点是一个对象,边是对象之间的引用。这样组合NFA时,只需要修改引用关系,不需要复制整个状态图。

可可精品资料库