回想起第一次看到正則表達式的時候,眼睛裏大概都是 $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