回想起第一次看到正则表达式的时候,眼睛里大概都是 $7^(0^=]W-\^*d+
,内心我是拒绝的。不过在后面的平常工做里,愈来愈多地开始使用到正则表达式,正则表达式也逐渐成为一个很经常使用的工具。git
要掌握一种工具除了了解它的用法,了解它的原理也是一样重要的,通常来讲,正则引擎能够粗略地分为两类:DFA(Deterministic Finite Automata)肯定性有穷自动机和 NFA (Nondeterministic Finite Automata)不肯定性有穷自动机。github
使用 NFA 的工具包括
.NET
、PHP
、Ruby
、Perl
、Python
、GNU Emacs
、ed
、sec
、vi
、grep
的多数版本,甚至还有某些版本的egrep
和awk
。而采用 DFA 的工具主要有egrep
、awk
、lex
和flex
。也有些系统采用了混合引擎,它们会根据任务的不一样选择合适的引擎(甚至对同一表达式中的不一样部分采用不一样的引擎,以求得功能与速度之间的最佳平衡)。—— Jeffrey E.F. Friedl《精通正则表达式》正则表达式
DFA 与 NFA 都称为有穷自动机,二者有不少类似的地方,自动机本质上是与状态转换图相似的图。(注:本文不会严格给自动机下定义,深刻理解自动机能够阅读《自动机理论、语言和计算导论》。)算法
一个 NFA 分为如下几个部分:express
上图是一个具备两个状态 q0
和 q1
的 NFA,初始状态为 q0
(没有前序状态),终结状态为 q1
(两层圆圈标识)。在 q0
上有一根箭头指向 q1
,这表明当 NFA 处在 q0
状态时,接受输入 a
,会转移到状态 q1
。闭包
当要接受一个串时,咱们会将 NFA 初始化为初始状态,而后根据输入来进行状态转移,若是输入结束后 NFA 处在结束状态,那就意味着接受成功,若是输入的符号没有对应的状态转移,或输入结束后 NFA 没有处在结束状态,则意味着接受失败。函数
由上可知这个 NFA 能接受且仅能接受字符串 a
。工具
那为何叫作 NFA 呢,由于 对于同一个状态与同一个输入符号,NFA 能够到达不一样的状态,以下图:flex
在 q0
上时,当输入为 a
,该 NFA 能够继续回到 q0
或者到达 q1
,因此该 NFA 能够接受 abb
(q0 -> q1 -> q2 -> q3
),也能够接受 aabb
(q0 -> q0 -> q1 -> q2 -> q3
),一样接受 ababb
、aaabbbabababb
等等,你可能已经发现了,这个 NFA 表示的正则表达式正是 (a|b)*abb
3d
除了能到达多个状态以外,NFA 还能接受空符号 ε
,以下图:
这是一个接受 (a+|b+)
的 NFA,由于存在路径 q0 -ε-> q1 -a-> q2 -a-> q2
,ε
表明空串,在链接时移除,因此这个路径即表明接受 aa
。
你可能会以为为何不直接使用 q0
经过 a
链接 q2
,经过 b
链接到 q4
,这是由于 ε
主要起链接的做用,介绍到后面会感觉到这点。
介绍完了不肯定性有穷自动机,肯定性有穷自动机就容易理解了,DFA 和 NFA 的不一样之处就在于:
ε
转移那么 DFA 要比 NFA 简单地多,为何不直接使用 DFA 实现呢?这是由于对于正则语言的描述,构造 NFA 每每要比构造 DFA 容易得多,好比上文提到的 (a|b)*abb
,NFA 很容易构造和理解:
但直接构造与之对应的 DFA 就没那么容易了,你能够先尝试构造一下,结果大概就是这样:
因此 NFA 容易构造,可是由于其不肯定性很难用程序实现状态转移逻辑;NFA 不容易构造,可是由于其肯定性很容易用程序来实现状态转移逻辑,怎么办呢?
神奇的是每个 NFA 都有对应的 DFA,因此咱们通常会先根据正则表达式构建 NFA,而后能够转化成对应的 DFA,最后进行识别。
McMaughton-Yamada-Thompson 算法能够将任何正则表达式转变为接受相同语言的 NFA。它分为两个规则:
对于表达式 ε
,构造下面的 NFA:
对于非 ε
,构造下面的 NFA:
假设正则表达式 s 和 t 的 NFA 分别为 N(s)
和 N(t)
,那么对于一个新的正则表达式 r,则以下构造 N(r)
:
当 r = s|t
,N(r)
为
当 r = st
,N(r)
为
当 r = s*
,N(r)
为
其余的 +
,?
等限定符能够相似实现。本文所需关于自动机的知识到此就结束了,接下来就能够开始构建 NFA 了。
1968 年 Ken Thompson 发表了一篇论文 Regular Expression Search Algorithm,在这篇文章里,他描述了一种正则表达式编译器,并催生出了后来的 qed
、ed
、grep
和 egrep
。论文相对来讲比较难懂,implementing-a-regular-expression-engine 这篇文章一样也是借鉴 Thompson 的论文进行实现,本文必定程度也参考了该文章的实现思路。
在构建 NFA 以前,咱们须要对正则表达式进行处理,以 (a|b)*abb
为例,在正则表达式里是没有链接符号的,那咱们就无法知道要链接哪两个 NFA 了。
因此首先咱们须要显式地给表达式添加链接符,好比 ·
,能够列出添加规则:
左边符号 / 右边符号 | * | ( | ) | | | 字母 |
---|---|---|---|---|---|
* | ❌ | ✅ | ❌ | ❌ | ✅ |
( | ❌ | ❌ | ❌ | ❌ | ❌ |
) | ❌ | ✅ | ❌ | ❌ | ✅ |
| | ❌ | ❌ | ❌ | ❌ | ❌ |
字母 | ❌ | ✅ | ❌ | ❌ | ✅ |
(a|b)*abb
添加完则为 (a|b)*·a·b·b
,实现以下:
若是你写过计算器应该知道,中缀表达式不利于分析运算符的优先级,在这里也是同样,咱们须要将表达式从中缀表达式转为后缀表达式。
在本文的具体过程以下:
在本文实现范围中,优先级从小到大分别为
·
*
|
实现以下:
如 (a|b)*·c
转为后缀表达式 ab|*c·
由后缀表达式构建 NFA 就容易多了,从左到右读入表达式内容:
N(s)
,并将其入栈|
,弹出栈内两个元素 N(s)
、N(t)
,构建 N(r)
将其入栈(r = s|t
)·
,弹出栈内两个元素 N(s)
、N(t)
,构建 N(r)
将其入栈(r = st
)*
,弹出栈内一个元素 N(s)
,构建 N(r)
将其入栈(r = s*
)代码见 automata.ts
有了 NFA 以后,能够将其转为 DFA。NFA 转 DFA 的方法可使用 子集构造法,NFA 构建出的 DFA 的每个状态,都是包含原始 NFA 多个状态的一个集合,好比原始 NFA 为
这里咱们须要使用到一个操做 ε-closure(s)
,这个操做表明可以从 NFA 的状态 s 开始只经过 ε
转换到达的 NFA 的状态集合,好比 ε-closure(q0) = {q0, q1, q3}
,咱们把这个集合做为 DFA 的开始状态 A
。
那么 A 状态有哪些转换呢?A 集合里有 q1
能够接受 a
,有 q3
能够接受 b
,因此 A 也能接受 a
和 b
。当 A 接受 a
时,获得 q2
, 那么 ε-closure(q2)
则做为 **A 状态接受 a
后到达的状态 B。**同理,A 状态接受 b
后到达的 ε-closure(q4)
为状态 C。
而状态 B 还能够接受 a
,到达的一样是 ε-closure(q2)
,那咱们说状态 B 接受 a
仍是到达了状态 B。一样,状态 C 接受 b
也会回到状态 C。这样,构造出的 DFA 为
DFA 的开始状态即包含 NFA 开始状态的状态,终止状态亦是如此。
其实咱们并不用显式构建 DFA,而是用这种思想去遍历 NFA,这本质上是一个图的搜索,实现代码以下:
getClosure
代码以下:
总的来讲,基于 NFA 实现简单的正则表达式引擎,咱们一共通过了这么几步:
完整代码见 github