實現簡單的正則表達式引擎

回想起第一次看到正則表達式的時候,眼睛裏大概都是 $7^(0^=]W-\^*d+,內心我是拒絕的。不過在後面的平常工做裏,愈來愈多地開始使用到正則表達式,正則表達式也逐漸成爲一個很經常使用的工具。git

要掌握一種工具除了瞭解它的用法,瞭解它的原理也是一樣重要的,通常來講,正則引擎能夠粗略地分爲兩類:DFA(Deterministic Finite Automata)肯定性有窮自動機和 NFA (Nondeterministic Finite Automata)不肯定性有窮自動機。github

使用 NFA 的工具包括 .NETPHPRubyPerlPythonGNU Emacsedsecvigrep 的多數版本,甚至還有某些版本的 egrepawk。而採用 DFA 的工具主要有 egrepawklexflex。也有些系統採用了混合引擎,它們會根據任務的不一樣選擇合適的引擎(甚至對同一表達式中的不一樣部分採用不一樣的引擎,以求得功能與速度之間的最佳平衡)。—— Jeffrey E.F. Friedl《精通正則表達式》正則表達式

DFA 與 NFA 都稱爲有窮自動機,二者有不少類似的地方,自動機本質上是與狀態轉換圖相似的圖。(注:本文不會嚴格給自動機下定義,深刻理解自動機能夠閱讀《自動機理論、語言和計算導論》。)算法

NFA

一個 NFA 分爲如下幾個部分:express

  • 一個初始狀態
  • 一個或多個終結狀態
  • 狀態轉移函數

上圖是一個具備兩個狀態 q0q1 的 NFA,初始狀態爲 q0(沒有前序狀態),終結狀態爲 q1(兩層圓圈標識)。在 q0 上有一根箭頭指向 q1,這表明當 NFA 處在 q0 狀態時,接受輸入 a,會轉移到狀態 q1閉包

當要接受一個串時,咱們會將 NFA 初始化爲初始狀態,而後根據輸入來進行狀態轉移,若是輸入結束後 NFA 處在結束狀態,那就意味着接受成功,若是輸入的符號沒有對應的狀態轉移,或輸入結束後 NFA 沒有處在結束狀態,則意味着接受失敗。函數

由上可知這個 NFA 能接受且僅能接受字符串 a工具

那爲何叫作 NFA 呢,由於 對於同一個狀態與同一個輸入符號,NFA 能夠到達不一樣的狀態,以下圖:flex

q0 上時,當輸入爲 a,該 NFA 能夠繼續回到 q0 或者到達 q1,因此該 NFA 能夠接受 abbq0 -> q1 -> q2 -> q3),也能夠接受 aabbq0 -> q0 -> q1 -> q2 -> q3),一樣接受 ababbaaabbbabababb 等等,你可能已經發現了,這個 NFA 表示的正則表達式正是 (a|b)*abb3d

ε-NFA

除了能到達多個狀態以外,NFA 還能接受空符號 ε,以下圖:

這是一個接受 (a+|b+) 的 NFA,由於存在路徑 q0 -ε-> q1 -a-> q2 -a-> q2ε 表明空串,在鏈接時移除,因此這個路徑即表明接受 aa

你可能會以爲爲何不直接使用 q0 經過 a 鏈接 q2,經過 b 鏈接到 q4,這是由於 ε 主要起鏈接的做用,介紹到後面會感覺到這點。

DFA

介紹完了不肯定性有窮自動機,肯定性有窮自動機就容易理解了,DFA 和 NFA 的不一樣之處就在於:

  • 沒有 ε 轉移
  • 對於同一狀態和同一輸入,只會有一個轉移

那麼 DFA 要比 NFA 簡單地多,爲何不直接使用 DFA 實現呢?這是由於對於正則語言的描述,構造 NFA 每每要比構造 DFA 容易得多,好比上文提到的 (a|b)*abb,NFA 很容易構造和理解:

但直接構造與之對應的 DFA 就沒那麼容易了,你能夠先嚐試構造一下,結果大概就是這樣:

因此 NFA 容易構造,可是由於其不肯定性很難用程序實現狀態轉移邏輯;NFA 不容易構造,可是由於其肯定性很容易用程序來實現狀態轉移邏輯,怎麼辦呢?

神奇的是每個 NFA 都有對應的 DFA,因此咱們通常會先根據正則表達式構建 NFA,而後能夠轉化成對應的 DFA,最後進行識別。

McMaughton-Yamada-Thompson 算法

McMaughton-Yamada-Thompson 算法能夠將任何正則表達式轉變爲接受相同語言的 NFA。它分爲兩個規則:

基本規則

  1. 對於表達式 ε,構造下面的 NFA:

  2. 對於非 ε,構造下面的 NFA:

概括規則

假設正則表達式 s 和 t 的 NFA 分別爲 N(s)N(t),那麼對於一個新的正則表達式 r,則以下構造 N(r)

r = s|tN(r)

鏈接

r = stN(r)

閉包

r = s*N(r)

其餘的 +? 等限定符能夠相似實現。本文所需關於自動機的知識到此就結束了,接下來就能夠開始構建 NFA 了。

基於 NFA 實現

1968 年 Ken Thompson 發表了一篇論文 Regular Expression Search Algorithm,在這篇文章裏,他描述了一種正則表達式編譯器,並催生出了後來的 qededgrepegrep。論文相對來講比較難懂,implementing-a-regular-expression-engine 這篇文章一樣也是借鑑 Thompson 的論文進行實現,本文必定程度也參考了該文章的實現思路。

添加鏈接符

在構建 NFA 以前,咱們須要對正則表達式進行處理,以 (a|b)*abb 爲例,在正則表達式裏是沒有鏈接符號的,那咱們就無法知道要鏈接哪兩個 NFA 了。

因此首先咱們須要顯式地給表達式添加鏈接符,好比 ·,能夠列出添加規則:

左邊符號 / 右邊符號 * ( ) | 字母
*
(
)
|
字母

(a|b)*abb 添加完則爲 (a|b)*·a·b·b,實現以下:

中綴表達式轉後綴表達式

若是你寫過計算器應該知道,中綴表達式不利於分析運算符的優先級,在這裏也是同樣,咱們須要將表達式從中綴表達式轉爲後綴表達式。

在本文的具體過程以下:

  1. 若是遇到字母,將其輸出。
  2. 若是遇到左括號,將其入棧。
  3. 若是遇到右括號,將棧元素彈出並輸出直到遇到左括號爲止。左括號只彈出不輸出。
  4. 若是遇到限定符,依次彈出棧頂優先級大於或等於該限定符的限定符,而後將其入棧。
  5. 若是讀到了輸入的末尾,則將棧中全部元素依次彈出。

在本文實現範圍中,優先級從小到大分別爲

  • 鏈接符 ·
  • 閉包 *
  • |

實現以下:

(a|b)*·c 轉爲後綴表達式 ab|*c·

構建 NFA

由後綴表達式構建 NFA 就容易多了,從左到右讀入表達式內容:

  • 若是爲字母 s,構建基本 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

構建 DFA

有了 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 也能接受 ab。當 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 實現簡單的正則表達式引擎,咱們一共通過了這麼幾步:

  1. 添加鏈接符
  2. 轉換爲後綴表達式
  3. 構建 NFA
  4. 判斷 NFA 是否接受輸入串

完整代碼見 github

相關文章
相關標籤/搜索