基於 NFA 實現正則表達式引擎

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

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

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

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

NFA

一個 NFA 分爲以下幾個部分:

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

clipboard.png

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

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

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

那爲什麼叫做 NFA 呢,因爲 對於同一個狀態與同一個輸入符號,NFA 可以到達不同的狀態,如下圖:

clipboard.png

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

ε-NFA

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

clipboard.png

這是一個接受 (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 很容易構造和理解:

clipboard.png

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

clipboard.png

所以 NFA 容易構造,但是因爲其不確定性很難用程序實現狀態轉移邏輯;NFA 不容易構造,但是因爲其確定性很容易用程序來實現狀態轉移邏輯,怎麼辦呢?

神奇的是每一個 NFA 都有對應的 DFA,所以我們一般會先根據正則表達式構建 NFA,然後可以轉化成對應的 DFA,最後進行識別。

McMaughton-Yamada-Thompson 算法

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

基本規則

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

歸納規則

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

r = s|tN(r)

clipboard.png

連接

r = stN(r)

clipboard.png

閉包

r = s*N(r)

clipboard.png

其他的 +? 等限定符可以類似實現。本文所需關於自動機的知識到此就結束了,接下來就可以開始構建 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,實現如下:

clipboard.png

中綴表達式轉後綴表達式

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

在本文的具體過程如下:

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

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

  • 連接符 ·
  • 閉包 *
  • |

實現如下:

clipboard.png

(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 爲

clipboard.png

這裏我們需要使用到一個操作 ε-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 爲

clipboard.png

DFA 的開始狀態即包含 NFA 開始狀態的狀態,終止狀態亦是如此。

搜索

其實我們並不用顯式構建 DFA,而是用這種思想去遍歷 NFA,這本質上是一個圖的搜索,實現代碼如下:

clipboard.png

getClosure 代碼如下:

clipboard.png

總結

總的來說,基於 NFA 實現簡單的正則表達式引擎,我們一共經過了這麼幾步:

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

完整代碼見 github

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章