做爲前端大佬的你,想必對於 JavaScript 的正則表達式很是熟悉了,甚至隨手就能利用正則表達式寫出一些驚世駭俗的代碼。只是不知道你是否有和我同樣的疑惑:正則表達式是怎麼執行的呢?javascript
咱們寫下這樣的正則表達式 (a+|b)c
,而後用它來匹配字符串 aacde
、abcde
,這是怎樣的一個過程呢?html
前段時間,我試着去查找、學習相關的資料,而後知道了如下的內容:前端
那麼 NFA 又是啥,跟 DFA 有什麼不一樣?NFA 又是怎麼實現正則表達式匹配的呢?java
接下來,我試着用我本身的方式來介紹,但願也能幫助對此感興趣的你。git
NFA 是指 Nondeterministic Finite Automaton,非肯定有限狀態自動機。github
有點深奧,我剛看到的時候也很懵,我們慢慢來。正則表達式
先說有限狀態機(Automation),來個示例圖看下:算法
狀態機中有這樣一些要素,對照上圖分別說下:express
因此有限狀態機的工做過程,就是從開始狀態,根據不一樣的輸入,自動進行狀態轉換的過程。編程
上圖中的狀態機的功能,是檢測二進制數是否含有偶數個 0。從圖上能夠看出,輸入只有 1 和 0 兩種。從 S1 狀態開始,只有輸入 0 纔會轉換到 S2 狀態,一樣 S2 狀態下只有輸入 0 纔會轉換到 S1。因此,二進制數輸入完畢,若是知足最終狀態,也就是最後停在 S1 狀態,那麼輸入的二進制數就含有偶數個 0。
仍是有點暈,這個和正則表達式有什麼關係呢?
正則表達式,能夠認爲是對一組字符串集合的描述。例如 (a+|b)c
對應的字符串集合是:
ac
bc
aac
aaac
aaaac
...
複製代碼
有限狀態機也能夠用來描述字符串集合,一樣是正則表達式所描述的集合,用有限狀態機來表示,能夠是這樣的:
這裏的 NFA 狀態圖是我用本身寫的頁面繪製出來的,比較簡陋,不過我相信你能夠看懂。 你能夠在這裏(luobotang/nfa)本身試試看,只支持簡單的正則表達式。
而且,有限狀態機是能夠「執行」的,給出如上的狀態機以後,就能夠用來對輸入的字符串進行檢測。若是最終匹配,也就意味着輸入的字符串和正則表達式 (a+|b)c
匹配。
因此,編程語言中的正則表達式,通常是經過有限狀態機來實現。正則表達式匹配字符串的過程,能夠分解爲:
到這裏,我想你大概知道有限狀態機在正則表達式中的做用了,固然,只是具體實現還不清楚。
這裏再講一下 NFA 和 DFA 的區別。DFA 是 Deterministic Finite Automaton,肯定有限狀態機。DFA 能夠認爲是一種特殊的 NFA,它最大的特色,就是肯定性。它的肯定性在於,在一個狀態下,輸入一個符號,必定是轉換到肯定的狀態,沒有其餘的可能性。
舉個例子,對於正則表達式 ab|ac
,對應 NFA 能夠是這樣的:
能夠看到,在狀態 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 仍是挺有幫助的。
Thompson 算法用於轉換正則表達式爲NFA,它並不是最高效的算法,可是實用,易於理解。
Thompson 算法中使用最基本的兩種轉換:
普通轉換就是在一個狀態下,輸入字符a後轉換至另外一個狀態;epsilon轉換則不須要有輸入,就從一個狀態轉換至另外一個狀態。
正則表達式中的各類運算,能夠經過組合上述兩種轉換實現:
RS
:替換運算 R|S
:
重複運算 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 的過程。
用於表示狀態 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 的不肯定性,因此可能會同時有多個匹配的狀態。
我這裏就簡單粗暴了,直接讓當前全部的狀態都進行比對,仍然知足條件的下一個狀態再繼續參與下一輪比對。一次只跟蹤一條路徑,匹配失敗後再回溯確定也是能夠的,不過就要複雜不少了。
代碼在:simulator.js - luobotang/nfa
綜上,正則表達式的執行,能夠經過構建等價的 NFA,而後執行 NFA 來匹配輸入的字符串。真實的 JavaScript 中的正則表達式擁有更多的特性,其正則表達式引擎也更加複雜。
但願經過個人介紹,可以讓你對正則表達式有了更多的瞭解。固然,水平有限,講得不當的地方在所不免,歡迎指正。
最後,感謝閱讀!