回想起第一次看到正則表達式的時候,眼睛裏大概都是 $7^(0^=]W-\^*d+
,心裏我是拒絕的。不過在後面的日常工作裏,越來越多地開始使用到正則表達式,正則表達式也逐漸成爲一個很常用的工具。
要掌握一種工具除了瞭解它的用法,瞭解它的原理也是同樣重要的,一般來說,正則引擎可以粗略地分爲兩類:DFA(Deterministic Finite Automata)確定性有窮自動機和 NFA (Nondeterministic Finite Automata)不確定性有窮自動機。
使用 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
一個 NFA 分爲以下幾個部分:
- 一個初始狀態
- 一個或多個終結狀態
- 狀態轉移函數
上圖是一個具有兩個狀態 q0
和 q1
的 NFA,初始狀態爲 q0
(沒有前序狀態),終結狀態爲 q1
(兩層圓圈標識)。在 q0
上有一根箭頭指向 q1
,這代表當 NFA 處在 q0
狀態時,接受輸入 a
,會轉移到狀態 q1
。
當要接受一個串時,我們會將 NFA 初始化爲初始狀態,然後根據輸入來進行狀態轉移,如果輸入結束後 NFA 處在結束狀態,那就意味着接受成功,如果輸入的符號沒有對應的狀態轉移,或輸入結束後 NFA 沒有處在結束狀態,則意味着接受失敗。
由上可知這個 NFA 能接受且僅能接受字符串 a
。
那爲什麼叫做 NFA 呢,因爲 對於同一個狀態與同一個輸入符號,NFA 可以到達不同的狀態,如下圖:
在 q0
上時,當輸入爲 a
,該 NFA 可以繼續回到 q0
或者到達 q1
,所以該 NFA 可以接受 abb
(q0 -> q1 -> q2 -> q3
),也可以接受 aabb
(q0 -> q0 -> q1 -> q2 -> q3
),同樣接受 ababb
、aaabbbabababb
等等,你可能已經發現了,這個 NFA 表示的正則表達式正是 (a|b)*abb
ε-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。它分爲兩個規則:
基本規則
- 對於表達式
ε
,構造下面的 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 了。
基於 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
由後綴表達式構建 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 也能接受 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 實現簡單的正則表達式引擎,我們一共經過了這麼幾步:
- 添加連接符
- 轉換爲後綴表達式
- 構建 NFA
- 判斷 NFA 是否接受輸入串
完整代碼見 github