正则表达式(RE)、有限自动机(FA)和词法分析(LA)
正则表达式(RE, Regular Expression)是一种用于描述语言特性的方法,使用 RE 描述或者生成的语言被称为正则语言(RL, Regular Language),而与 RE 等价的被称为有限自动机(FA,Finite Automata)的方法则适合识别正则语言。
RE 和 FA 可以用于编译器的词法分析器(LA, Lexical Analysis)。词法分析的任务是将字符流转为特定语言的单词流,并且将每个单词根据当前语言的特性归类到到某个语法范畴,也即词类,例如 new 是关键字、_subset 是标识符等。正则表达式可以用于程序语言中有效单词的符号表示,RE 这种符号表示还需要转换为可以使用计算机程序实现的形式化方法,有限自动机就是这样的方法。词法分析器实现的流图如下:
正则表达式 RE
语言(language)是由字符串(string)组成的集合,字符串是符号(symbol)的有限序列,符号来自有限字母表(alphabet)。可以使用 RE 来描述语言的字符串的集合。一个 RE 是由三个基本操作构建而成:
- 可选(alternation):对于两个正则表达式 M M M 和 N N N,可选操作( | )形成一个新的正则表达式 M ∣ N M | N M∣N。
- 联接(concatenation):对于两个正则表达式 M M M 和 N N N,联接操作( ⋅ \cdot ⋅)形成一个新的正则表达式 M ⋅ N M \cdot N M⋅N,也可以写成 M N MN MN。
- 重复(repeatition):对于给定正则表达式 M M M,它的克林闭包(Kleene closure)是 M ∗ M^* M∗,表示一个字符串是由 M 中字符串经零次或多次的联接运算而来。
另外,还有一个特殊的 RE:空字符串( ϵ \epsilon ϵ)。通过使用符号和可选、连结、重复、克林闭包运算以及空串 ϵ \epsilon ϵ,构建出新的可以用于表示程序语言中单词的正则表达式。正则表达式还提供了语法糖可以简化正则表达式。例如:
- [ a b c d ] [abcd] [abcd]表示 ( a ∣ b ∣ c ∣ d ) ( a | b | c | d ) (a∣b∣c∣d)。
- [ b − g ] [b-g] [b−g] 表示 [ b c d e f g ] [bcdefg] [bcdefg], [ a − z A − Z ] [a-zA-Z] [a−zA−Z] 表示字符集。
- M ? M? M? 表示 ( M ∣ ϵ M | \epsilon M∣ϵ), M + M^+ M+ 表示( M ⋅ M ∗ M \cdot M^* M⋅M∗)。
我们再来看下 C 语言中常见单词的正则表达式。例如:
-
C 语言的标识符,一般可以是字母或者下划线开头,然后紧跟字母或者数字,可以表示为 ( _ ∣ [ a − z A − Z ] ) ( _ ∣ [ a − z A − Z ] ∣ [ 0 − 9 ] ) ∗ ( \_ | [a-zA-Z] )( \_ | [a-zA-Z] | [0-9] )^* (_∣[a−zA−Z])(_∣[a−zA−Z]∣[0−9])∗,如果限定字符串长度为10,则可以表示为 ( _ ∣ [ a − z A − Z ] ) ( _ ∣ [ a − z A − Z ] ∣ [ 0 − 9 ] ) 9 (\_ | [a-zA-Z] )( \_ | [a-zA-Z] | [0-9] )^9 (_∣[a−zA−Z])(_∣[a−zA−Z]∣[0−9])9。
-
无符号整数,正则表达式为 0 ∣ [ 1 − 9 ] [ 0 − 9 ] ∗ 0 | [1-9][0-9]^* 0∣[1−9][0−9]∗。
有限自动机 FA
正则表达式可以很方便地表示程序语言中的单词,但还需要一种可以使用计算机实现的形式化表示方法——有限自动机(FA,Finite Automata)。
例如我们有这么一段程序用于识别关键字 new:
代码的右侧给出其状态转移图,是有限自动机(或称有限状态机)的图形化表示,其形式化表示可以采用一个五元组
(
S
,
Σ
,
δ
,
s
0
,
S
A
)
(S, \Sigma, \delta, s_0, S_A)
(S,Σ,δ,s0,SA):
- S S S 是状态机中有限状态的集合。
- Σ \Sigma Σ 是状态机中有限字母表。转移图的边上字母的集合。
- δ \delta δ 是转移函数。
- s 0 s_0 s0 是初始状态。 s 0 ∈ S s_0 \in S s0∈S。
- S A S_A SA 是接受状态。 S A ∈ S S_A \in S SA∈S。在状态转移图上用双层圆圈表示。
例如:

S
=
{
s
0
,
s
1
,
s
2
,
s
3
}
S = \{s_0, s_1, s_2, s_3\}
S={s0,s1,s2,s3}
Σ
=
{
n
,
e
,
w
}
\Sigma = \{n, e, w \}
Σ={n,e,w}
δ
=
{
s
0
→
n
s
1
,
s
1
→
e
s
2
,
s
2
→
w
s
3
}
\delta = \{s_0 \stackrel{n} {\rightarrow} s_1, s_1 \stackrel{e} {\rightarrow} s_2, s_2 \stackrel{w} {\rightarrow} s_3 \}
δ={s0→ns1,s1→es2,s2→ws3}
s
0
=
s
0
s_0 = s_0
s0=s0
S
A
=
s
3
S_A = s_3
SA=s3
FA 分为确定有限自动机(DFA, Deterministic Finite Automata)和非确定有限状态机(NFA, Non-Deterministic Finite Automata)。
- DFA:FA 中每个状态对于任一输入字符都只有唯一可能的转移。或者说转移函数为单值。DFA 不允许 ϵ \epsilon ϵ 转移。 ϵ \epsilon ϵ 转移是指针对空串输入 ϵ \epsilon ϵ 的转移,不会改变输入流中的读写位置。
- DFA:FA 中状态对同一字符输入可能有多种转移,并且允许 ϵ \epsilon ϵ 转移。
例如, a ∗ a^* a∗ 和 a b ab ab 的 DFA 分别为:

使用 ϵ \epsilon ϵ 合并两个 DFA,形成一个 NFA,也即 a ∗ a b a^{*}ab a∗ab:

NFA 和 DFA 在表达能力上是等价的。任何 DFA 都是 NFA 的一个特例,或者说任何 NFA 都可以通过一个 DFA 来模拟。例如上述 NFA 可以通过下面的 DFA 来模拟:

RE 转换为 NFA:Tompson 算法
Tompson 算法是一种将 RE 转换为 NFA 的简单方法,它模拟了 RE 的联接、选择和克林闭包等基本运算的效果。例如:

这种转换方法适用于任意的 NFA。用同样的方法构造 a ( b ∣ c ) ∗ a(b | c)^* a(b∣c)∗ 如下:

其实,对于简单的 RE,我们可以直接人工构造出其 NFA。例如 a ( b ∣ c ) ∗ a(b | c)^* a(b∣c)∗,我们可以很简单的画出其 NFA 如下:

可见,通过 Thompson 算法构建的 NFA 比我们人工构建 NFA 多了很多不必要的 ϵ \epsilon ϵ 转移,在下面的阶段会消除他们。
NFA 转换为 DFA:子集构造法
与 NFA 相比,计算机模拟 DFA 的执行要简单很多,因而从 NFA 构造 DFA 是必要的。从前面介绍的 NFA 和 DFA 的区别可以知道,要想将 NFA 转换为 DFA,需要解决以下两种情况:
- 消除 ϵ \epsilon ϵ 闭包

状态 s 1 s_1 s1、 s 2 s_2 s2 和 s 3 s_3 s3 完全可以合并,这样就可以消除 ϵ \epsilon ϵ 边。这里定义 ϵ \epsilon ϵ 闭包:
ϵ − c l o s u r e { S } \epsilon-closure\{S\} ϵ−closure{S} 为从集合 S 中的状态出发,无需接收任何输入字符,即只通过 ϵ \epsilon ϵ 边就可达到的状态组成的集合。
- 消除不确定的状态

状态 s 1 s_1 s1、 s 2 s_2 s2 和 s 3 s_3 s3 合并到一起,状态 s 0 s_0 s0 接收输入 n 0 n_0 n0 只有一条确定的转移状态。
综合以上,这里介绍一种被称为子集构造的算法,它以 NFA
(
N
,
Σ
,
δ
N
,
n
0
,
N
A
)
(N,\Sigma, \delta_N, n_0, N_A)
(N,Σ,δN,n0,NA) 为输入,得到一个 DFA
(
D
,
Σ
,
δ
D
,
d
0
,
D
A
)
(D, \Sigma, \delta_D, d_0, D_A)
(D,Σ,δD,d0,DA)。其算法如下图所示:
这里,
D
e
l
t
a
(
q
,
c
)
Delta(q,c)
Delta(q,c) 表示从状态
q
q
q 读入字符
c
c
c 能够到达的边的集合。下面给出算法迭代的例子:
对于 a ( b ∣ c ) ∗ a(b | c)^* a(b∣c)∗ 的NFA:

使用子集构造算法迭代如下:
初始化:
ϵ
−
c
l
o
s
u
r
e
{
s
0
}
=
{
s
0
}
\epsilon-closure\{s_0\} = \{s_0\}
ϵ−closure{s0}={s0}
q
0
←
s
0
q_0 \leftarrow s_0
q0←s0
Q
←
s
0
Q \leftarrow s_0
Q←s0
w
l
←
s
0
wl \leftarrow s_0
wl←s0
第一轮迭代:
将
{
s
0
}
\{s_0\}
{s0} 从
w
l
wl
wl 中去除,此时
w
l
wl
wl 为空。
对于输入字符 a a a, D e l t a ( { s 0 } , a ) = { s 1 } Delta(\{s_0\}, a) = \{s_1\} Delta({s0},a)={s1},则 t ← ϵ − c l o s u r e { D e l t a ( { s 0 } , a ) } = { s 1 , s 8 , s 6 , s 2 , s 4 , s 9 } t\leftarrow\epsilon-closure\{Delta(\{s_0\}, a) \}=\{s_1, s_8,s_6,s_2,s_4,s_9\} t←ϵ−closure{Delta({s0},a)}={s1,s8,s6,s2,s4,s9}。记 d 0 = { s 0 } , d 1 = { s 1 , s 8 , s 6 , s 2 , s 4 , s 9 } d_0= \{s_0\},d_1=\{s_1, s_8,s_6,s_2,s_4,s_9\} d0={s0},d1={s1,s8,s6,s2,s4,s9}。此时,得到部分 DFA 如下。由于 t t t 中包括了原 NFA 的接受状态 s 9 s_9 s9,因而 d 1 d_1 d1 也是一个接受状态。 t = d 1 t=d_1 t=d1 不在 Q 中,将 d 1 d_1 d1 加入 Q Q Q 和 w l wl wl 中,则 Q = { d 0 , d 1 } Q=\{d_0,d_1\} Q={d0,d1}, w l = { d 1 } wl=\{d_1\} wl={d1}。

对于输入字符 b b b 和 c c c,状态 s 0 s_0 s0 不会发生转移状态。
第二轮迭代:
将
d
1
d_1
d1 从
w
l
wl
wl 中删除,此时
w
l
wl
wl 为空。
对于输入字符 a a a, d 1 d_1 d1 中没有状态可以读入 a a a 发生转移。
对于输入字符 b b b, d 1 d_1 d1 中只有 s 2 s_2 s2 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 2 } , b ) = { s 3 , s 7 , s 6 , s 2 , s 4 , s 9 } } t\leftarrow\epsilon-closure\{Delta(\{s_2\}, b)=\{s_3,s_7,s_6,s_2,s_4,s_9\}\} t←ϵ−closure{Delta({s2},b)={s3,s7,s6,s2,s4,s9}},记 d 2 = { s 3 , s 7 , s 6 , s 2 , s 4 , s 9 } d_2=\{s_3,s_7,s_6,s_2,s_4,s_9\} d2={s3,s7,s6,s2,s4,s9}。此时,得到的DFA如下图。 t = d 2 t=d_2 t=d2 不在 Q Q Q 中,将 d 2 d_2 d2 加入 Q Q Q 和 w l wl wl 中。此时 Q = { d 0 , d 1 , d 2 } Q=\{d_0,d_1,d_2\} Q={d0,d1,d2}, w l = { d 2 } wl=\{d_2\} wl={d2}。

对于输入字符 c c c, d 1 d_1 d1 中只有 s 4 s_4 s4 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 4 } , c ) = { s 5 , s 7 , s 6 , s 2 , s 4 , s 9 } } t\leftarrow\epsilon-closure\{Delta(\{s_4\}, c)=\{s_5,s_7,s_6,s_2,s_4,s_9\}\} t←ϵ−closure{Delta({s4},c)={s5,s7,s6,s2,s4,s9}},记 d 3 = { s 5 , s 7 , s 6 , s 2 , s 4 , s 9 } d_3=\{s_5,s_7,s_6,s_2,s_4,s_9\} d3={s5,s7,s6,s2,s4,s9}。此时,得到的DFA如下图。 t = d 3 t=d_3 t=d3 不在 Q 中,将 d 3 d_3 d3 加入 Q Q Q 和 w l wl wl 中。此时 Q = { d 0 , d 1 , d 2 , d 3 } Q=\{d_0,d_1,d_2,d_3\} Q={d0,d1,d2,d3}, w l = { d 2 , d 3 } wl=\{d_2,d_3\} wl={d2,d3}。

第三轮迭代:
将
d
2
d_2
d2 从
w
l
wl
wl 中删除,此时
w
l
=
{
d
3
}
wl=\{d_3\}
wl={d3}。
对于输入字符 a a a, d 2 d_2 d2 中没有状态可以发生转移。
对于输入字符 b b b, d 2 d_2 d2 中只有 s 2 s_2 s2 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 2 } , b ) = d 2 t\leftarrow\epsilon-closure\{Delta(\{s_2\}, b)=d_2 t←ϵ−closure{Delta({s2},b)=d2,此时,得到的DFA如下图。 t = d 2 t=d_2 t=d2 已经在 Q Q Q 中。

对于输入字符 c c c, d 2 d_2 d2 中只有 s 4 s_4 s4 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 4 } , c ) = d 3 t\leftarrow\epsilon-closure\{Delta(\{s_4\}, c)=d_3 t←ϵ−closure{Delta({s4},c)=d3,此时,得到的DFA如下图。 t = d 3 t=d_3 t=d3 已经在 Q Q Q 中。

第四论迭代:
将
d
3
d_3
d3 从
w
l
wl
wl 中删除,此时
w
l
wl
wl 为空。
对于输入字符 a a a, d 3 d_3 d3 中没有状态可以发生转移。
对于输入字符 b b b, d 3 d_3 d3 中只有 s 2 s_2 s2 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 2 } , b ) = d 2 t\leftarrow\epsilon-closure\{Delta(\{s_2\}, b)=d_2 t←ϵ−closure{Delta({s2},b)=d2,此时,得到的DFA如下图。 t = d 2 t=d_2 t=d2 已经在 Q Q Q 中。

对于输入字符 c c c, d 3 d_3 d3 中只有 s 4 s_4 s4 可以发生状态转移,则 t ← ϵ − c l o s u r e { D e l t a ( { s 4 } , c ) = d 3 t\leftarrow\epsilon-closure\{Delta(\{s_4\}, c)=d_3 t←ϵ−closure{Delta({s4},c)=d3,此时,得到的DFA如下图。 t = d 3 t=d_3 t=d3 已经在 Q Q Q 中。

至此。算法迭代结束。NFA 转换为了 DFA。
最小 DFA: Hopcroft 算法
到这里我们得到的 DFA 可能还存在冗余的状态,需要进一步处理得到最小化的 DFA。我们用到等价类划分的思想,如果两个状态等价,则他们属于同一个等价类,只需要保留其中之一即可。而两个状态对于任意输入字符串都产生同样的行为则二者等价。最小化 DFA 的 Hopcroft 算法如下图:
该算法的核心思想为:假设
d
i
d_i
di 和
d
j
d_j
dj 都在集合
p
s
p_s
ps 中,对
∀
c
∈
Σ
\forall c \in \Sigma
∀c∈Σ,
d
i
→
c
d
x
d_i \stackrel{c} {\rightarrow} d_x
di→cdx 和
d
j
→
c
d
y
d_j \stackrel{c} {\rightarrow} d_y
dj→cdy,如果
d
x
,
d
y
∈
p
t
d_x,d_y \in p_t
dx,dy∈pt,则二者属于等价类。如果有任一状态
d
k
∈
p
s
d_k \in p_s
dk∈ps,
d
k
→
c
d
z
d_k \stackrel{c} {\rightarrow} d_z
dk→cdz,而
d
z
∉
p
t
d_z \notin p_t
dz∈/pt,则
d
k
d_k
dk 和
d
i
、
d
j
d_i、d_j
di、dj 不属于等价类,也就需要将
d
k
d_k
dk 从
p
t
p_t
pt 中分离出来。
该算法初始化将所有状态划分为两个状态集合,一个是所有的接收状态集合,另一个是它的补集。
对于 a ( b ∣ c ) ∗ a(b | c)^* a(b∣c)∗ 的 DFA:

初始划分为 p 1 = { { d 0 } } p_1=\{\{d_0\}\} p1={{d0}} 和 p 2 = { { d 1 , d 2 , d 3 } } p_2=\{\{d_1,d_2,d_3\}\} p2={{d1,d2,d3}}, p 1 p_1 p1 只有一个状态,没法再拆分。对于 p 2 p_2 p2,输入字符 a a a, p 2 p_2 p2 的状态都不会发生转移;输入字符 b b b 和 c c c, p 2 p_2 p2 中的状态都转回 p 2 p_2 p2;因此, Σ \Sigma Σ 中任意符号都不会导致 p 2 p_2 p2 发生拆分。

词法分析器实现方案
到目前为止,已经实现了从 RE 构建了一个 NFA,构建了一个 DFA 模拟 NFA,并且最小化了 DFA。需要进一步将最小 DFA 转换为可执行代码,即产生一个词法分析器。
从概念上将,DFA 是一个有向图,可以有多种实现策略,比如转移表(类似邻接矩阵)、哈希表、跳转表等。这里 有一个转移表实现的实例:

对于从 RE 转化为最小 DFA,当前已经有工具(Lex,Flex)可以自动化来完成。也就是说,编译器开发者先为词法分析器构建语法范畴的 RE,并将这些 RE 输入给词法分析器生成器,生成器会为每个 RE 构建一个 NFA,并使用 ϵ \epsilon ϵ 将各个 NFA 联接合并,再转换为 DFA,并最小化 DFA。最后,词法分析器生成器再将 DFA 转换为可执行代码。
当前商用(GCC、LLVM)的编译器很多是使用手工实现的词法分析器,精心编写的手工词法分析器会比词法分析器生成器生成的词法分析器更加高效,并且可以控制细节。
参考:
- Engineering a compiler, Second Edition
- Modern Compiler Implementation in C
- https://mooc.study.163.com/learn/1000002001?tid=1000003000#/learn/content