1. 程式人生 > >正則表示式和NFA

正則表示式和NFA

作為前端大佬的你,想必對於 JavaScript 的正則表示式非常熟悉了,甚至隨手就能利用正則表示式寫出一些驚世駭俗的程式碼。只是不知道你是否有和我一樣的疑惑:正則表示式是怎麼執行的呢?

我們寫下這樣的正則表示式 (a+|b)c,然後用它來匹配字串 aacdeabcde,這是怎樣的一個過程呢?

前段時間,我試著去查詢、學習相關的資料,然後知道了以下的內容:

  • 目前正則表示式引擎主要有兩種:NFADFA
  • JavaScript 採用的是 NFA 引擎

那麼 NFA 又是啥,跟 DFA 有什麼不同?NFA 又是怎麼實現正則表示式匹配的呢?

接下來,我試著用我自己的方式來介紹,希望也能幫助對此感興趣的你。

NFA

NFA 是指 Nondeterministic Finite Automaton,非確定有限狀態自動機。

有點深奧,我剛看到的時候也很懵,咱們慢慢來。

先說有限狀態機(Automation),來個示例圖看下:

有限狀態機

狀態機中有這樣一些要素,對照上圖分別說下:

  • 開始狀態:圓圈表示狀態,被一個“沒有起點的箭頭”指向的狀態,是開始狀態,上例中是 S1
  • 最終狀態:也叫接受狀態,圖中用雙圓圈表示,這個例子中也是 S1
  • 輸入:在一個狀態下,向狀態機輸入的符號/訊號,不同輸入導致狀態機產生不同的狀態改變
  • 轉換:在一個狀態下,根據特定輸入,改變到特定狀態的過程,就是轉換

所以有限狀態機的工作過程,就是從開始狀態,根據不同的輸入,自動進行狀態轉換的過程。

上圖中的狀態機的功能,是檢測二進位制數是否含有偶數個 0。從圖上可以看出,輸入只有 1 和 0 兩種。從 S1 狀態開始,只有輸入 0 才會轉換到 S2 狀態,同樣 S2 狀態下只有輸入 0 才會轉換到 S1。所以,二進位制數輸入完畢,如果滿足最終狀態,也就是最後停在 S1 狀態,那麼輸入的二進位制數就含有偶數個 0。

還是有點暈,這個和正則表示式有什麼關係呢?

正則表示式,可以認為是對一組字串集合的描述。例如 (a+|b)c 對應的字串集合是:

123456 acbcaacaaacaaaac...

有限狀態機也可以用來描述字串集合,同樣是正則表示式所描述的集合,用有限狀態機來表示,可以是這樣的:

NFA – (a+|b)c

這裡的 NFA 狀態圖是我用自己寫的頁面繪製出來的,比較簡陋,不過我相信你可以看懂。 你可以在這裡(luobotang/nfa)自己試試看,只支援簡單的正則表示式。

並且,有限狀態機是可以“執行”的,給出如上的狀態機之後,就可以用來對輸入的字串進行檢測。如果最終匹配,也就意味著輸入的字串和正則表示式 (a+|b)c 匹配。

所以,程式語言中的正則表示式,一般是通過有限狀態機來實現。正則表示式匹配字串的過程,可以分解為:

  • 正則表示式轉換為等價的有限狀態機
  • 有限狀態機輸入字串執行

到這裡,我想你大概知道有限狀態機在正則表示式中的作用了,當然,只是具體實現還不清楚。

這裡再講一下 NFA 和 DFA 的區別。DFA 是 Deterministic Finite Automaton,確定有限狀態機。DFA 可以認為是一種特殊的 NFA,它最大的特點,就是確定性。它的確定性在於,在一個狀態下,輸入一個符號,一定是轉換到確定的狀態,沒有其他的可能性。

舉個例子,對於正則表示式 ab|ac,對應 NFA 可以是這樣的:

NFA – ab|ac

可以看到,在狀態 1 這裡,如果輸入 a,其實有兩種可能,如果後面的符號是 b,那麼可以匹配成功,後面符號是 c 也能匹配成功。所以狀態機在執行過程中,可能要嘗試所有的可能性。在嘗試一種可能路徑匹配失敗後,還要回到之前的狀態再嘗試其他的路徑,這就是“回溯”。

但是 DFA 消除了這種不確定性,所以可以想見,其執行效能應該要比 NFA 更好,因為不需要回溯。

NFA 是可以轉換為等價的 DFA 的,也就是說,理論上講,正則表示式可以用 DFA 來實現,從而獲得優於 NFA 的執行效能。但是 NFA 轉換 DFA 的過程,會消耗更多資源,甚至最終得到的 DFA 要佔用大量儲存空間(據有的資料的說法,可能會產生指數級增長)。而且,DFA 相比 NFA,在實現一些正則表示式的特性時會更復雜,成本更高。所以當前的許多程式語言,其正則表示式引擎為 NFA 模式。

可以用如下的正則表示式測試當前程式語言採用的引擎是否 NFA:

1 nfa|nfa not

用上面的正則表示式來測試字串 nfa not,NFA 引擎在檢測滿足 nfa 就返回匹配成功的結果了,而 DFA 則會嘗試繼續查詢,也就是說會得到“最長的匹配結果”。

從正則表示式到 NFA

瞭解了 NFA 在正則表示式中的應用,接下來要介紹的是如何將正則表示式轉換得到對應的 NFA。這一部分會稍微有些枯燥,不過對於深入理解正則表示式和 NFA 還是挺有幫助的。

Thompson 演算法

Thompson 演算法用於轉換正則表示式為NFA,它並非最高效的演算法,但是實用,易於理解。

Thompson 演算法中使用最基本的兩種轉換:

Thompson 演算法基本元素

普通轉換就是在一個狀態下,輸入字元a後轉換至另一個狀態;epsilon轉換則不需要有輸入,就從一個狀態轉換至另一個狀態。

正則表示式中的各種運算,可以通過組合上述兩種轉換實現:

  • 組合運算 RS
RS
  • 替換運算 R|S R|S
  • 重複運算 R* R*

上面圖中的 R、S 是有開始狀態和結束狀態的 NFA。

以正則表示式 ab|c 為例,包括兩個運算:

  • ab 組合
  • ab 的結果,與 c 替換

這樣我們把正則表示式視為一系列輸入和運算,進行分解、組合,就可以得到最終的 NFA。

首先,我們要把正則表示式轉換為方便記錄輸入、運算的方式。

正則表示式 -> 字尾表示式

字尾表示式是一種方便記錄輸入、運算的表示式,本身已包含了運算子的優先順序,也稱為 逆波蘭表示法(Reverse Polish Notation,簡寫為 RPN)。

為方便記錄運算,我們為正則表示式中的組合運算也建立一個運算子“.”(本文只涉及最簡單的正則表示式形式,這裡的“.”不是用於匹配任意字元的特殊符號)。

正則表示式ab|c對應的字尾表示式為 ab.c|

這樣,通過逐個掃描字尾表示式,並識別其中的運算子來執行,就可以對字尾表示式進行求解。對於正則表示式來說,則是在將其變為字尾表示式後,通過“求值”的過程來進一步構建並得到最終的 NFA。

用於建立字尾表示式的是 排程場演算法

對於這裡的正則表示式處理的場景,演算法的大致描述如下:

12345678 -建立輸出佇列output和運算子棧ops-依次讀取輸入字串中每一個字元ch-如果ch是普通字元,追加到output-如果ch是運算子,只要ops棧頂的運算子優先順序不低於ch,依次出棧並追加到output,最後將ch入棧ops-如果ch是“(”,入棧ops-如果ch是“)”,只要ops棧頂不是“(”,依次出棧並追加到output-ops中運算子依次出棧追加到output-返回output

具體處理過程中,由於原始正則表示式中並沒有組合運算子,所以需要自行判斷合理的插入位置。

運算子優先順序如下(由高到低):

  • * ? +
  • .
  • |
  • (

字尾表示式 -> NFA

基於字尾表示式建立 NFA,是一個由簡單的 NFA 進行不斷組合得到複雜 NFA 的過程。

用於表示狀態 State 的資料結構為:

12345678 // State{id:String,type:String,// 'n' - normal, 'e' - epsilon, 'end'symbol:String,// 普通狀態對應的輸入字元out:State,// 允許的下一個狀態out1:State// 允許的下一個狀態}

每個狀態可以對應最多兩個 out 狀態,像 a|b|c 的表示式,會被分解為 (a|b)|c,每次運算子“|”都只處理兩個(子)表示式。

在構造最終 NFA 過程中,每次會建立 NFA 的片段 Fragment:

12345 // Fragment{start:State,out:State}

不管 NFA 片段內部是怎樣複雜,它都只有一個入口(開始狀態),一個出口(最終狀態)。

處理的過程大致為:

12345 -建立用於記錄NFA片段的棧stack-依次讀取輸入的字尾表示式的每個字元ch-如果ch是運算子,從stack出棧所需數目的NFA片段,構建新的NFA片段後入棧stack-如果ch是普通字元,建立新的狀態,並構建只包含此狀態的NFA片段入棧stack-返回stack棧頂的NFA片段,即最終結果

以對組合運算的處理為例:

1234 conste2=stack.pop()conste1=stack.pop()e1.out.out=e2.startstack.push(newFragment(e1.start,e2.out))

從 stack 出棧兩個 NFA 片段,然後將其首尾相連後構建新的 NFA 片段再入棧。

其他處理過程就不詳細介紹了,感興趣可以看下程式碼。

NFA 的執行

NFA 的執行過程就是用當前狀態來比對字串的當前字元,如果匹配就繼續比對下一個狀態和下一個字元,否則匹配失敗。

不過由於 NFA 的不確定性,所以可能會同時有多個匹配的狀態。

我這裡就簡單粗暴了,直接讓當前所有的狀態都進行比對,仍然滿足條件的下一個狀態再繼續參與下一輪比對。一次只跟蹤一條路徑,匹配失敗後再回溯肯定也是可以的,不過就要複雜很多了。

總結

綜上,正則表示式的執行,可以通過構建等價的 NFA,然後執行 NFA 來匹配輸入的字串。真實的 JavaScript 中的正則表示式擁有更多的特性,其正則表示式引擎也更加複雜。

希望通過我的介紹,能夠讓你對正則表示式有了更多的瞭解。當然,水平有限,講得不當的地方在所難免,歡迎指正。

最後,感謝閱讀!

參考資料

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式