正則表達式和NFA

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

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

前段時間,我試着去查找、學習相關的資料,而後知道了如下的內容:前端

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

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

接下來,我試着用我本身的方式來介紹,但願也能幫助對此感興趣的你。git

NFA

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

有點深奧,我剛看到的時候也很懵,我們慢慢來。正則表達式

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

有限狀態機

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

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

因此有限狀態機的工做過程,就是從開始狀態,根據不一樣的輸入,自動進行狀態轉換的過程。編程

上圖中的狀態機的功能,是檢測二進制數是否含有偶數個 0。從圖上能夠看出,輸入只有 1 和 0 兩種。從 S1 狀態開始,只有輸入 0 纔會轉換到 S2 狀態,一樣 S2 狀態下只有輸入 0 纔會轉換到 S1。因此,二進制數輸入完畢,若是知足最終狀態,也就是最後停在 S1 狀態,那麼輸入的二進制數就含有偶數個 0。

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

正則表達式,能夠認爲是對一組字符串集合的描述。例如 (a+|b)c 對應的字符串集合是:

ac
bc
aac
aaac
aaaac
...
複製代碼

有限狀態機也能夠用來描述字符串集合,一樣是正則表達式所描述的集合,用有限狀態機來表示,能夠是這樣的:

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:

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。

用於建立後綴表達式的是 調度場算法

對於這裏的正則表達式處理的場景,算法的大體描述以下:

代碼在:regex2post() | nfa.js#L14 - luobotang/nfa

- 建立輸出隊列 output 和運算符棧 ops
- 依次讀取輸入字符串中每個字符 ch
  - 若是 ch 是普通字符,追加到 output
  - 若是 ch 是運算符,只要 ops 棧頂的運算符優先級不低於 ch,依次出棧並追加到 output,最後將 ch 入棧 ops
  - 若是 ch 是「(」,入棧 ops
  - 若是 ch 是「)」,只要 ops 棧頂不是「(」,依次出棧並追加到 output
- 將 ops 中運算符依次出棧追加到 output
- 返回 output
複製代碼

具體處理過程當中,因爲原始正則表達式中並無組合運算符,因此須要自行判斷合理的插入位置。

運算符優先級以下(由高到低):

  • * ? +
  • .
  • |
  • (

後綴表達式 -> NFA

基於後綴表達式建立 NFA,是一個由簡單的 NFA 進行不斷組合獲得複雜 NFA 的過程。

用於表示狀態 State 的數據結構爲:

// 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:

// Fragment
{
  start: State,
  out: State
}
複製代碼

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

這一部分代碼在:post2nfa() | nfa.js#L90 - luobotang/nfa

處理的過程大體爲:

- 建立用於記錄 NFA 片斷的棧 stack
- 依次讀取輸入的後綴表達式的每一個字符 ch
  - 若是 ch 是運算符,從 stack 出棧所需數目的 NFA 片斷,構建新的 NFA 片斷後入棧 stack
  - 若是 ch 是普通字符,建立新的狀態,並構建只包含此狀態的 NFA 片斷入棧 stack
- 返回 stack 棧頂的 NFA 片斷,即最終結果
複製代碼

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

const e2 = stack.pop()
const e1 = stack.pop()
e1.out.out = e2.start
stack.push(new Fragment(e1.start, e2.out))
複製代碼

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

其餘處理過程就不詳細介紹了,感興趣能夠看下代碼。

NFA 的執行

NFA 的執行過程就是用當前狀態來比對字符串的當前字符,若是匹配就繼續比對下一個狀態和下一個字符,不然匹配失敗。

不過因爲 NFA 的不肯定性,因此可能會同時有多個匹配的狀態。

我這裏就簡單粗暴了,直接讓當前全部的狀態都進行比對,仍然知足條件的下一個狀態再繼續參與下一輪比對。一次只跟蹤一條路徑,匹配失敗後再回溯確定也是能夠的,不過就要複雜不少了。

代碼在:simulator.js - luobotang/nfa

總結

綜上,正則表達式的執行,能夠經過構建等價的 NFA,而後執行 NFA 來匹配輸入的字符串。真實的 JavaScript 中的正則表達式擁有更多的特性,其正則表達式引擎也更加複雜。

但願經過個人介紹,可以讓你對正則表達式有了更多的瞭解。固然,水平有限,講得不當的地方在所不免,歡迎指正。

最後,感謝閱讀!

參考資料

相關文章
相關標籤/搜索