本文作者:時淺
寫在前面
在日常的開發工作當中,我們必不可免的會碰到需要使用正則的情況。
正則在很多時候透過不同的組合方式最後都可以達到既定的目標結果。比如我們有一個需要匹配的字串:<p>hello</p>
,我們可以透過 /<p>.*<\/p>/
以及 /<p>.*?<\/p>/
來匹配,兩種方式就像就像中文語法中的陳述句以及倒裝句,不同的語序往往不影響我們的理解,但是他們的運作方式卻完全不一樣。
為了讓大家有一個更加直觀的感受,這裡將 <p>hello</p>
分三次存到一份百萬字元文件的最前面,中間以及最後面,然後分別使用上面的 2 個正規表示式進行匹配,並測算匹配到對應字元所需要的時間,結果如下(實驗結果透過 https://regexr.com/ 得出):
最前面 | 中間 | 最後面 | |
---|---|---|---|
/<p>.*<\/p>/ | 1.1ms | 0.7ms | 0.2ms |
/<p>.*?<\/p>/ | 0.1ms | 0.2ms | 0.3ms |
由此我們可以明顯地看出不同的撰寫方式,匹配的效率也有很大的不同。
撰寫正則是一個不斷思考與抉擇的過程。就像一道數學題中往往包含一種或幾種最優解的可能,想要趨近這個結果,就需要我們理清題意,掌握正則的表達方式以及加強對正則運作方式的理解。
那麼,我們應該如何趨近最優解,養成良好的撰寫習慣,鍛鍊撰寫健壯的正規表示式的能力呢?
知己知彼,百戰不殆,要寫好正規表示式就需要做到知其然還要知其所以然。因此,本文將嘗試從正則原理的角度出發,介紹正則匹配的規則,梳理正則與有限自動機的關係、及有限自動機之間的轉化邏輯,並解釋回溯等相關概念。希望能夠幫助大家更好地理解及使用正則。
正規表示式與有限自動機(FA)
正規表示式是建立在 有限自動機 ( Finite Automaton ) 的理論基礎上的,是自動機理論的應用。當我們寫完相關的表示式之後,正則引擎會按照我們所寫的表示式構建相應的自動機,若該自動機接受輸入的文字並抵達最終狀態,則表示輸入的文字可以被我們所寫的正規表示式匹配到。
有限自動機的圖示
自動機的圖形化包含以下元素:
- 箭頭:表示路徑,可以在上面標註字母,表示狀態 1 經過該字母可以轉換到狀態 2 (也可以是標註 ε,即空串,表示無需經過轉換就可以從狀態 1 過渡到狀態 2),
- 單圓圈:表示非結束的中間狀態
- 雙圓圈:表示結束狀態
- 由自身指向自身的箭頭:用於表示 Kleene 閉包(也就是正規表示式中的
*
),指可以走任意次數的路徑。
以 ab*c
這條正規表示式為例,其有限自動機的圖示如下,表示狀態 1 經過 a 可以過渡到狀態 2 ,在狀態 2 可以經過 ε 再迴圈經過 b 也可以不經過 b 而直接透過透過 ε 再經由 c 過渡到最終的結束狀態。
不確定有限自動機(NFA)及確定有限自動機(DFA)
有限自動機又可以分為 NFA:不確定有限自動機(Nondeterministic Finite Automaton )及 DFA:確定有限自動機(Deterministic Finite Automaton ),NFA 的不確定性表現在其狀態可以透過 ε 轉換 以及對同一個字元可以有不同的路徑。而 DFA 可以看作是一種特殊的 NFA,其最大的特點就是確定性,即輸入一個字元,一定會轉換到確定的狀態,而不會有其他的可能。
以 ab|ac
這條正規表示式為例子,我們可以得到以下 NFA 及 DFA 兩種自動機。
可以看到自動機 1 中我們有兩條路徑都是 a,經由 a 可以到達下一個狀態,而自動機 2 中只有一條路徑,對於圖一來說,經由相同的 a 路徑卻可以導致不同的結果,這即是 NFA,具有不確定性。而對圖二來說,其路徑都是通往明確的結果,具有確定唯一性,所以是 DFA。
正則替換成 NFA
從上面的圖中我們可以看出 DFA 的匹配路徑比 NFA 要少且匹配的速度要更快,但是目前大多數的語言中的正則引擎使用的卻是 NFA,為什麼不直接使用 DFA 而要使用 NFA?為了解答這個問題,我們需要知道如何透過正則轉化成 NFA,而 NFA 又可以怎樣轉成 DFA。
正規表示式轉 NFA 主要基於以下 3 條規則(R 表示正規表示式)
- 連線 R = AB
- 選擇 R = A|B
- 重複 R = A*
其他的運算基本都可以透過以上三種運算轉換得到,比如 A+
可以用 AA*
來表示
Thompson 演算法
為了更好地理解上面的 3 條轉換規則,這裡介紹比較實用且容易理解由 C 語言 & Unix 之父之一的 Ken Thompson 提出的 Thompson 演算法。其思想主要就是透過簡單的表示式組合成複雜的表示式。
Thompson演算法 中兩種最基本的單元(或者說兩種最基本的 NFA):
表示經過字元 a 過渡的下一個狀態以及不需要任何字元輸入的 ε 轉換 過渡到下一個狀態。
- 對於連線運算
R = AB
,我們將 AB 拆解成 NFA(A) 與 NFA(B) 兩個單元,然後進行組合:
- 對於選擇運算
R = A|B
,我們同樣將 A 與 B 拆解成NFA(A)
與NFA(B)
兩個單元,然後進行組合:
- 對於重複運算
R = A*
,其表示可能不需要經過轉換,可能經過 1 次或者多次,所以拆解成單一 NFA 後,我們可以這樣表示:
由此,我們就可以根據上面的 3 條轉換規則,將正規表示式進行拆分重組,最後得出相應的 NFA。
NFA 轉換成 DFA 及其簡化
DFA 實際上是一個特殊的 NFA,轉 DFA,就是要將 NFA 中將所有等價的狀態合併,消除不確定性。
這裡以《編譯原理》一書中的一道例題來完整地講解一下如何從正則轉 NFA 再轉成相應的 DFA,並對其進行簡化。
eg: (a|b)*(aa|bb)(a|b)*
這裡我們依據正則轉 NFA 的三條規則以及 Thompson 演算法 的理念,將上述的表示式進行拆分與組合:
- 首先我們將該表示式以括號為分隔,視為 3 個正規表示式子透過連線符連線,以此拆分成 3 個表示式並將其組合
- 然後根據每個表示式括號內的內容繼續拆分成更細的單元,碰到運算子號,則按照規則進行轉換,以此類推直到 NFA 變成變成由最小單元組合而成。
子集構造演算法
正如前面所說,NFA 轉 DFA 本質是將等價的狀態合併,消除不確定性。要找出等價的狀態,我們需要先找出各個狀態的集合。
上面的表示式只有 a 跟 b 兩個字元,所以我們要得出各個狀態經過 a 以及經過 b 的所有集合,然後再將等價的集合合併。這裡先畫出所有集合構成的轉換表單,結合圖示將更有助於我們的的理解。
I | Ia | Ib |
---|---|---|
{i,1,2} | {1,2,3} | {1,2,4} |
{1,2,3} | {1,2,3,5,6,f} | {1,2,4} |
{1,2,4} | {1,2,3} | {1,2,4,5,6,f} |
{1,2,3,5,6,f} | {1,2,3,5,6,f} | {1,2,4,6,f} |
{1,2,4,5,6,f} | {1,2,3,6,f} | {1,2,4,5,6,f} |
{1,2,4,6,f} | {1,2,3,6,f} | {1,2,4,5,6,f} |
{1,2,3,6,f} | {1,2,3,5,6,f} | {1,2,4,6,f} |
圖示第一列主要是放置所有不重複的集合,第二列表示經過 a 的集合,第三列表示經過 b 的集合
子集構造法 尋找集合的規則為碰到一個字元,如果這個字元後面有可以透過 空串(ε轉換)到達下一個狀態,則下一個狀態包含在該集合中,一直往後直到碰到一個明確的字元
- 從上面構造的NFA的初始狀態 i 開始,其經過 2 個 ε轉換 轉換可以到達 2 狀態,此後必須經過 a 或者 b,由此我們可以得到第一個狀態集合
{i,1,2}
- 從第一個集合開始,分析集合內分別經過 a 和 b 可以達到什麼狀態,可以看到初始集合中 i 只經過空串,不經過 a、b, 狀態 1 經過 a 可以到達它自身,也可以再經過 ε 到達狀態 2,2 經過 a 只能到達狀態 3
- 據此我們得到初始集合經過 a 的集合為
{1,2,3}
,同樣的,初始集合經過 b 只在狀態 2 與經過 a 不同,所以我們可以得到經過 b 的集合為{1,2,4}
- 因為
{1,2,3}
,{1,2,4}
都沒有出現過,所以這裡我們將其記到第一列的第二與第三行,並分析它們經過 a 與 b 的集合。 - 以此類推直到獲得上述所有集合構成的轉換表單(完整文字版推導過程附於文末附錄)
可以看到上面的轉換表的第一列中一共有 7 個集合,這裡我們給第一列的集合進行排序(為方便與 NFA 對比,這裡將序號 0 改為 i),並對右邊經過 a 跟經過 b 的所有集合根據左邊的序號進行標記,可以得到相應轉換矩陣:
I | Ia | Ib |
---|---|---|
{i,1,2} i | {1,2,3} 1 | {1,2,4} 2 |
{1,2,3} 1 | {1,2,3,5,6,f} 3 | {1,2,4} 2 |
{1,2,4} 2 | {1,2,3} 1 | {1,2,4,5,6,f} 4 |
{1,2,3,5,6,f} 3 | {1,2,3,5,6,f} 3 | {1,2,4,6,f} 5 |
{1,2,4,5,6,f} 4 | {1,2,3,6,f} 6 | {1,2,4,5,6,f} 4 |
{1,2,4,6,f} 5 | {1,2,3,6,f} 6 | {1,2,4,5,6,f} 4 |
{1,2,3,6,f} 6 | {1,2,3,5,6,f} 3 | {1,2,4,6,f} 5 |
轉換矩陣
S | a | b |
---|---|---|
i | 1 | 2 |
1 | 3 | 2 |
2 | 1 | 4 |
3 | 3 | 5 |
4 | 6 | 4 |
5 | 6 | 4 |
6 | 3 | 5 |
依據轉換矩陣,我們把第一列的資料作為每一個單獨狀態,並以 i 為初始狀態,由矩陣可得,其經過 a 可以到達狀態 1,經過 b 可以到達狀態 2,同時我們將包含 f 的集合作為終態(即序號 3,4,5,6),以此類推,我們可以得到如下的 NFA:
因為該 NFA 不包含空串,也沒有由一個狀態分出兩條相同的分支路徑,所以該 NFA 就是上述表示式的 DFA。但該 DFA 看起來還比較複雜,所以我們還需要對其進一步簡化。
Hopcroft 演算法化簡
Hopcroft 演算法 是1986年圖靈獎獲得者 John Hopcroft 所提出的,其本質思想跟子集構造法相似,都是對等價狀態的合併。
Hopcroft 演算法 首先將未化簡的 DFA 劃分成 終態集 和 非終態集(因為這兩種狀態一定不等價),之後不斷進行劃分,直到不再發生變化。每輪劃分對所有子集進行。對一個子集的劃分中,若每個輸入符號都能把狀態轉換到等價的狀態,則兩個狀態等價。
這裡依據 Hopcroft 演算法 將上述 DFA 以終態以及非終態進行劃分,可以得到 {i,1,2}
以及 {3,4,5,6}
兩個集合。然後分別分析兩個集合是否能夠進一步劃分。
- 集合
{i,1,2}
經過 a 可以得到狀態 1 和 3,3 不在集合{i,1,2}
中,依據矩陣圖,我們可以看到 i 和 2 經過 a 都到狀態 1,1 經過 a 可以達到狀態 3,於是我們將{i,1,2}
劃分成{i,2}
和{1}
兩個集合
- 因為
{1}
已經是最小集合,所以無法繼續劃分,所以分析{i,2}
集合經過 b 的情況,{i,2}
經過 b 可以達到狀態 2 和 4,4 同樣不在集合中,所以需要對{i,2}
進行劃分,依據矩陣表,我們可以劃分成{i}
,{2}
,至此,非終態已經無法往下拆分,所以分析結束,我們得到的拆分集合為{i}
,{1}
,{2}
- 終態集合
{3,4,5,6}
經過 a 可以達到狀態 3 和 6,3 和 6 都在集合內部,所以無需往下拆分,經過 b 可以達到狀態 4 和 5,4 和 5 同樣都在集合內,無需拆分。所以我們可以將{3,4,5,6}
當作是一個整體。
將集合 {3,4,5,6}
當作一個整體,記成狀態 3,重新梳理上面的矩陣,並將所有指向 3,4,5,6 的路徑都指向新的狀態 3,我們可以得到新的也是最簡單的 DFA:
從上面的轉換過程可以看到,實際上,NFA 轉 DFA 是一個繁瑣的過程,如果正則採用 DFA 引擎,勢必會消耗部分效能在 NFA 的轉換上,而這個轉換的效益很多時候遠不比直接用使用 NFA 高效,同時 DFA 相對來說沒有 NFA 直觀,可操作空間也要比 NFA 少,所以大多數語言的採用 NFA 作為正則的引擎。
回溯
正則採用的是 NFA 引擎,那麼我們就必須面對它的不確定性,體現在正則上就是 回溯 的發生。
遇到一個字串,NFA 拿著正規表示式去比對文字,拿到一個字元,就把它與字串做比較,如果匹配就記住並繼續往下拿下一個字元,如果後面拿到的字元與字串不匹配,則將之前記下的字元一個個往回退直到上一次出現岔路的地方。
假設現在有一個正規表示式 ab|ac
,需要匹配字串 ac。則 NFA 會先拿到正則的字元 a,然後去比較字串 ac,發現匹配中了 a,則會去拿下一個字元 b,然後再匹配字串,發現不匹配,則回溯,吐出字元 b,回到字元 a,取字元 c 去匹配字串,發現匹配,完成比對。
文字或許比較枯燥,這裡用下面得圖示來表示上述的過程,藍色圓圈表示NFA拿到正則的字元後去匹配字串看是否可以過渡到下一個狀態,同時字元的顏色變化表示是否可以被匹配中:
<img src="https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/22507732391/7081/2788/a30a/a08de81e0d5b3d5ffcfdc471a65a44ae.gif" width="400" height="220" alt="回溯" />
貪婪模式與惰性模式對比
回到一開始講的透過 /<p>.*<\/p>/
以及 /<p>.*?<\/p>/
來匹配 <p>hello</p>
的問題,因為量詞的特殊性以及 回溯 的存在,所以2種方式的匹配效率也不一樣。
*
表示重複零次或多次,一般情況下會以 貪婪模式 儘可能地匹配多次,因此在上面的匹配過程中,它會在匹配到 <p>
之後一口氣把之後的字元也吞併掉,然後透過回溯匹配 <
字元直到匹配到完整的 <\/p>
,而當我們給 *
加上 ?
之後,它就變成非貪婪的 惰性模式,當匹配到 <p>
之後它就會透過回溯逐步去匹配 <\/p>
直到匹配中完整的字串。
目標字串:<p>hello</p>
在兩個表示式下的匹配過程:
表示式:/<p>.*<\/p>/ | 表示式:/<p>.*?<\/p>/ | ||
---|---|---|---|
< | 匹配 < | < | 匹配 < |
<p | 匹配 p | <p | 匹配 p |
<p> | 匹配 > | <p> | 匹配 > |
<p>hello</p> | 匹配 .* | <p> | 最短 0字元匹配 .*? |
<p>hello</p> | 匹配 < | <p> | 匹配 < |
<p>hello</p | 回溯 | <p>h | 回溯 1字元匹配.*? |
<p>hello</p | 匹配 < | <p>h | 匹配 < |
<p>hello</ | 回溯 | <p>he | 回溯 |
<p>hello</ | 匹配 < | <p>he | 匹配 < |
<p>hello< | 回溯 | <p>hel | 回溯 |
<p>hello< | 匹配 < | <p>hel | 匹配 < |
<p>hello | 回溯 | <p>hell | 回溯 |
<p>hello< | 匹配 < | <p>hell | 匹配 < |
<p>hello</ | 匹配 \/ | <p>hello | 回溯 |
<p>hello</p | 匹配 p | <p>hello< | 匹配 < |
<p>hello</p> | 匹配 > | <p>hello</ | 匹配 \/ |
<p>hello</p | 匹配 p | ||
<p>hello</p> | 匹配 > |
回溯失控
有一種回溯的情況比較特殊,就是不管如何回溯都匹配不到相應的字串,被稱為 回溯失控,舉個簡單的例子,假設我們有一個字串 aaa
,以及正規表示式 a*b
,則我們會有下面的匹配過程:
表示式:a*b | |
---|---|
aaa | 匹配 a* |
aaa | 匹配 b |
aa | 回溯 |
aa | 匹配 b |
a | 回溯 |
a | 匹配 b |
回溯 |
這個正規表示式回溯到最後也沒有匹配到對應的字串,而通常我們所寫的正則並不單單像例子中只回溯這一小部分,設想一下,一個需要多處回溯的正規表示式子,去匹配成百上千甚至上萬個字元,那對機器來說是多麼可怕的一件事情。
最後
上面的所寫的例子都比較簡單,可能有些還不是特別嚴謹,不過主要是為了給大家演示正則的一些匹配過程。在真實的開發環境中些微不同的寫法,往往會讓正則的匹配效率有很大的變化。希望本文章能夠起到拋磚引玉的作用,讓大家對正規表示式有一個更加具體,立體的認知。同時也希望大家能夠對自己所寫的表示式有更進一步的瞭解,之後能夠寫出更加健壯與高效的正規表示式。
參考文件
- 編譯原理 中南大學 徐德智教授
- 如何實現一個簡單的正規表示式引擎
- 編譯原理根據正規表示式構造有限自動機(包含DFA化簡)
- 《高效能Javascript》 -- Nicbolas C. Zakas
附錄
NFA子集構造法轉換表完整轉換推導過程
待轉NFA
轉換表
I | Ia | Ib |
---|---|---|
{i,1,2} | {1,2,3} | {1,2,4} |
{1,2,3} | {1,2,3,5,6,f} | {1,2,4} |
{1,2,4} | {1,2,3} | {1,2,4,5,6,f} |
{1,2,3,5,6,f} | {1,2,3,5,6,f} | {1,2,4,6,f} |
{1,2,4,5,6,f} | {1,2,3,6,f} | {1,2,4,5,6,f} |
{1,2,4,6,f} | {1,2,3,6,f} | {1,2,4,5,6,f} |
{1,2,3,6,f} | {1,2,3,5,6,f} | {1,2,4,6,f} |
推導過程
- 從的 NFA 的初始狀態 i 開始,其經過兩個 ε轉換 轉換可以到達 2 狀態,此後必須經過 a 或者 b,由此我們可以得到第一個狀態集合
{i,1,2}
- 從第一個集合開始,分析其中的各種狀態分別經過 a 和 b 可以達到什麼狀態,可以看到初始集合中 i 只經過空串,不經過 a、b, 狀態 1 經過 a 可以到達它自身,也可以再經過 ε 到達狀態 2,2 經過 a 只能到達狀態 3,據此我們得到初始集合經過 a 的集合為
{1,2,3}
,同樣的,初始集合經過 b 只在狀態 2 與經過 a 不同,所以我們可以得到經過 b 的集合為{1,2,4}
- 因為
{1,2,3}
,{1,2,4}
都沒有出現過,所以這裡我們將其記到第一列的第二與第三行,並分析它們經過 a 與 b 的集合。 - 前面已經分析過狀態 1 跟 2 經過 a 的情況,所以針對
{1,2,3}
跟{1,2,4}
,這裡只分析 3 跟 4 就可以,3 經過 a 可以到達 5,之後可以經過 ε 到達 6 以及到達終點 f,所以{1,2,3}
經過 a 的集合為{1,2,3,5,6,f}
,3 經過 b 沒有後續狀態,所以保留 1 跟 2 經過 b 的集合,即{1,2,4}
,同理,4 不經過 a,所以保留 1 跟 2 經過 a 的情況,即{1,2,3}
,4 經過 b 可以到達 5,之後經過 ε 到達 6 以及 f,所以{1,2,4}
經過 a 的集合為{1,2,3}
,經過 b 的集合為{1,2,3,5,6,f}
- 第二第三行中集合
{1,2,3,5,6,f}
跟{1,2,4,5,6,f}
未曾出現過,所以同樣將其放到第一列第四以及第五行,然後分析其中字元分別經過 a,b 的情況。 - 集合
{1,2,3,5,6,f}
中 1,2,3 已經分析過,所以只需要分析 5 跟 6 的情況,5 沒有經過 a,6 經過 a 可以到達自身也可以經由 ε 到達 f,所以 5,6 經過 a 的集合為{6,f}
,前面的{1,2,3}
經過 a 的集合為{1,2,3,5,6,f}
,涵蓋了 6 跟 f,所以{1,2,3,5,6,f}
經過 a 的集合為它自身,5 跟 6 經過 b 的集合跟經過 a 的集合一樣(即 5 不經過 b,6 可以經過 b 到達自身以及經過 ε 到達 f),都是{6,f}
,而{1,2,3}
經過 b 的集合為{1,2,4}
,所以{1,2,3,5,6,f}
經過 b 的集合就是將{6,f}
跟{1,2,4}
合併起來的{1,2,4,6,f}
。 - 集合
{1,2,4,5,6,f}
中 1,2,4 已經分析過,所以一樣只需要分析 5 跟 6 的情況,透過前面我們可以知道 5 跟 6 經過 a 或者 b 都是集合{6,f}
,而{1,2,4}
經過 a 的集合為{1,2,3}
,經過 b 的集合為{1,2,4,5,6,f}
,將其分別與{6,f}
合併,我們得出{1,2,4,5,6,f}
經過 a 的集合為{1,2,3,6,f}
,經過 b 的集合為{1,2,4,5,6,f}
。 - 在第四以及第五行中,集合
{1,2,4,6,f}
與集合{1,2,3,6,f}
未曾出現過,所以我們把他們寫到第一列的第六第七行,分析它們經過 a 與 b 的情況。 - 這裡我們可以把
{1,2,4,6,f}
跟{1,2,3,6,f}
分別拆成{1,2,4}
,{1,2,3}
與{6,f}
等集合,這些都是前面分析過的,我們可以直接拿來組合。{1,2,4}
經過 a 的集合為{1,2,3}
,{6,f}
經過 a 的集合為{6,f}
,{1,2,4,6,f}
經過 a 的集合就是其組合起來的{1,2,3,6,f}
,{1,2,3}
經過 b 的集合{1,2,4}
,所以{1,2,3,6,f}
經過 b 的集合為其與{6,f}
組合起來的{1,2,4,6,f}
。 - 第六,第七行中已經沒有新的集合出現,至此,推導結束。
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!