實現簡單的正規表示式引擎

2021-09-24 07:08:38 字數 4861 閱讀 1279

回想起第一次看到正規表示式的時候,眼睛裡大概都是$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 分為以下幾個部分:

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

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

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

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

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

除了能到達多個狀態之外,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|tn(r)為 連線

r = stn(r)為 閉包

r = s*n(r)

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

如果你寫過計算器應該知道,中綴表示式不利於分析運算子的優先順序,在這裡也是一樣,我們需要將表示式從中綴表示式轉為字尾表示式。

如果遇到字母,將其輸出。

如果遇到左括號,將其入棧。

如果遇到右括號,將棧元素彈出並輸出直到遇到左括號為止。左括號只彈出不輸出。

如果遇到限定符,依次彈出棧頂優先順序大於或等於該限定符的限定符,然後將其入棧。

如果讀到了輸入的末尾,則將棧中所有元素依次彈出。

在本文實現範圍中,優先順序從小到大分別為

實現如下:

(a|b)*·c轉為字尾表示式ab|*c·

由字尾表示式構建 nfa 就容易多了,從左到右讀入表示式內容:

**見 automata.ts

有了 nfa 之後,可以將其轉為 dfa。nfa 轉 dfa 的方法可以使用子集構造法,nfa 構建出的 dfa 的每乙個狀態,都是包含原始 nfa 多個狀態的乙個集合,比如原始 nfa 為

這裡我們需要使用到乙個操作ε-closure(s),這個操作代表能夠從 nfa 的狀態 s 開始只通過ε轉換到達的 nfa 的狀態集合,比如ε-closure(q0) =,我們把這個集合作為 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 實現簡單的正規表示式引擎,我們一共經過了這麼幾步:

新增連線符

轉換為字尾表示式

構建 nfa

判斷 nfa 是否接受輸入串

完整**見 github

正規表示式學習 引擎

目錄傳統nfa優化 優化2 將文字獨立出來 優化3 將錨點獨立出來 優化4 模擬開頭字元識別 優化5 使用固化分組和占有優先量詞 優化6 消除迴圈 傳統型nfa支援忽略優先量詞 dfa不支援捕獲型括號和回溯 優先選擇最左端的匹配結果 標準的匹配量詞是匹配優先的 多選結構按序排列,合理安排次序,減少回...

實現最簡單的正規表示式

如 j smi?可以匹配 john smith 請用c語言實現如下函式 void scan const char psztext,const char pszname 以下並未完全按要求實現,但意思到了。gcc 4.5.2 include include using namespace std ch...

正規表示式簡單語法及常用正規表示式

基本符號 表示匹配字串的開始位置 例外 用在中括號中 時,可以理解為取反,表示不匹配括號中字串 表示匹配字串的結束位置 表示匹配 零次到多次 表示匹配 一次到多次 至少有一次 表示匹配零次或一次 表示匹配單個字元 表示為或者,兩項中取一項 小括號表示匹配括號中全部字元 中括號表示匹配括號中乙個字元 ...