深入正規表示式(3):正規表示式工作引擎流程分析與原理釋義

zhoulujun發表於2020-06-06

作為正則的使用者也一樣,不懂正則引擎原理的情況下,同樣可以寫出滿足需求的正則,但是不知道原理,卻很難寫出高效且沒有隱患的正則。所以對於經常使用正則,或是有興趣深入學習正則的人,還是有必要了解一下正則引擎的匹配原理的。

有興趣可以回顧《深入正規表示式(0):正規表示式概述》

正則引擎型別

正則引擎主要可以分為兩大類:一種是DFA(Deterministic Finite Automatons/確定性有限自動機—),一種是NFA(Nondeterministic Finite Automatons/非確定性有限自動機)。總的來說,

  • DFA可以稱為文字主導的正則引擎

  • NFA可以稱為表示式主導的正則引擎

NFA與DFA工作的區別:

我們常常說用正則去匹配文字,這是NFA的思路,DFA本質上其實是用文字去匹配正則

'for tonight's'.match(/to(nite|knite|night)/);
  • 如果是NFA引擎,表示式占主導地位。在字串先查詢字串中的t,然後依次匹配,如果是o,則繼續(以此迴圈)。匹配到to後,到n,就面臨三種選擇,每一種都去嘗試匹配一下(它也不嫌累),第一個分支也是依次匹配,到t這裡停止(nite分到t這裡直接被淘汰);同理,接著第二個分支在k這裡也停止了;終於在第三個分支柳暗花明,找到了自己的歸宿。 NFA 工作方式是以正規表示式為標準,反覆測試字串,這樣同樣一個字串有可能被反覆測試了很多次!

  • 如果是DFA引擎呢,文字占主導地位。從整個字串第一個字元開始f開始查詢t,查詢到t後,定位到t,以知其後為o,則去檢視正規表示式其相應位置後是否為o,如果是,則繼續(以此迴圈),再去查正規表示式o後是否為n(此時淘汰knite分支),再後是否為g(淘汰nite分支),這個時候只剩一個分支,直接匹配到終止即可。

只有正規表示式才有分支和範圍,文字僅僅是一個字元流。這帶來什麼樣的後果?就是NFA引擎在匹配失敗的時候,如果有其他的分支或者範圍,它會返回,記住,返回,去嘗試其他的分支而DFA引擎一旦匹配失敗,就結束了,它沒有退路。

這就是它們之間的本質區別。其他的不同都是這個特性衍生出來的。

NFA VS DFA

首先,正規表示式在計算機看來只是一串符號,正則引擎首先肯定要解析它。NFA引擎只需要編譯就好了;而DFA引擎則比較繁瑣,編譯完還不算,還要遍歷出表示式中所有的可能。因為對DFA引擎來說機會只有一次,它必須得提前知道所有的可能,才能匹配出最優的結果。

所以,在編譯階段,NFA引擎比DFA引擎快

 

其次,DFA引擎在匹配途中一遍過,溜得飛起。相反NFA引擎就比較苦逼了,它得不厭其煩的去嘗試每一種可能性,可能一段文字它得不停返回又匹配,重複好多次。當然運氣好的話也是可以一遍過的。

所以,在執行階段,NFA引擎比DFA引擎慢

 

最後,因為NFA引擎是表示式占主導地位,所以它的表達能力更強,開發者的控制度更高,也就是說開發者更容易寫出效能好又強大的正則來,當然也更容易造成效能的浪費甚至撐爆CPU。DFA引擎下的表示式,只要可能性是一樣的,任何一種寫法都是沒有差別(可能對編譯有細微的差別)的,因為對DFA引擎來說,表示式其實是死的。而NFA引擎下的表示式,高手寫的正則和新手寫的正則,效能可能相差10倍甚至更多。

也正是因為主導權的不同,正則中的很多概念,比如非貪婪模式、反向引用、零寬斷言等只有NFA引擎才有。

所以,在表達能力上,NFA引擎秒殺DFA引擎

 

但是NFA以表示式為主導,因而NFA更容易操縱,因此一般程式設計師更偏愛NFA引擎!

當今市面上大多數正則引擎都是NFA引擎,應該就是勝在表達能力上。

 

總體來說,兩種引擎的工作方式完全不同,一個(NFA)以表示式為主導,一個(DFA)以文字為主導!兩種引擎各有所長,而真正的引用則取決與你的需要以及所使用的語言。

這兩種引擎都有了很久的歷史(至今二十多年),當中也由這兩種引擎產生了很多變體!

因為NFA引擎比較靈活,很多語言在實現上有細微的差別。所以後來大家弄了一個標準,符合這個標準的正則引擎就叫做POSIX NFA引擎,其餘的就只能叫做傳統型NFA引擎咯。

Deterministic finite automaton,Non-deterministic finite automaton,Traditional NFA,Portable Operating System Interface for uniX NFA

於是POSIX的出臺規避了不必要變體的繼續產生。這樣一來,主流的正則引擎又分為3類:DFA,傳統型NFA,POSIX NFA。

正則引擎三國

DFA引擎

DFA引擎線上性時狀態下執行,因為它們不要求回溯(並因此它們永遠不測試相同的字元兩次)。

DFA引擎還可以確保匹配最長的可能的字串。但是,因為 DFA 引擎只包含有限的狀態,所以它不能匹配具有反向引用的模式;並且因為它不構造顯示擴充套件,所以它不可以捕獲子表示式。

DFN不回溯,所以匹配快速,因而不支援捕獲組,支援反向引用和$number引用

傳統的 NFA引擎

傳統的 NFA 引擎執行所謂的“貪婪的”匹配回溯演算法,以指定順序測試正規表示式的所有可能的擴充套件並接受第一個匹配項。因為傳統的 NFA 構造正規表示式的特定擴充套件以獲得成功的匹配,所以它可以捕獲子表示式匹配和匹配的反向引用。但是,因為傳統的 NFA 回溯,所以它可以訪問完全相同的狀態多次(如果通過不同的路徑到達該狀態)。因此,在最壞情況下,它的執行速度可能非常慢。因為傳統的 NFA 接受它找到的第一個匹配,所以它還可能會導致其他(可能更長)匹配未被發現

大多數程式語言和工具使用的是傳統型的NFA引擎,它有一些DFA不支援的特性:

  • 捕獲組、反向引用和$number引用方式;

  • 環視(Lookaround,(?<=…)、(?<!…)、(?=…)、(?!…)),或者有的有文章叫做預搜尋;

  • 忽略優化量詞(??、*?、+?、{m,n}?、{m,}?),或者有的文章叫做非貪婪模式;

  • 佔有優先量詞(?+、*+、++、{m,n}+、{m,}+,目前僅Java和PCRE支援),固化分組(?>…)。

POSIX NFA引擎

POSIX NFA引擎主要指符合POSIX標準的NFA引擎,與傳統的 NFA 引擎類似,不同的一點在於:提供longest-leftmost匹配,也就是在找到最左側最長匹配之前,它將繼續回溯(可以確保已找到了可能的最長的匹配之前它們將繼續回溯)。因此,POSIX NFA 引擎的速度慢於傳統的 NFA 引擎;並且在使用 POSIX NFA 時,您恐怕不會願意在更改回溯搜尋的順序的情況下來支援較短的匹配搜尋,而非較長的匹配搜尋。

同DFA一樣,非貪婪模式或者說忽略優先量詞對於POSIX NFA同樣是沒有意義的。

三種引擎的使用情況

  • 使用傳統型NFA引擎的程式主要有(主流):

    • Java、Emacs(JavaScript/actionScript)、Perl、PHP、Python、Ruby、.NET語言

    • VI,GNU Emacs,PCRE library,sed;

  • 使用POSIX NFA引擎的程式主要有:mawk,Mortice Kern Systems’ utilities,GNU Emacs(使用時可以明確指定);

  • 使用DFA引擎的程式主要有:awk,egrep,flex,lex,MySQL,Procmail等;

  • 也有使用DFA/NFA混合的引擎:GNU awk,GNU grep/egrep,Tcl。

 

《精通正規表示式》書中說POSIX NFA引擎不支援非貪婪模式,很明顯JavaScript不是POSIX NFA引擎。

'123456'.match(/\d{3,6}/);
// ["123456", index: 0, input: "123456", groups: undefined]
'123456'.match(/\d{3,6}?/);
// ["123", index: 0, input: "123456", groups: undefined]

JavaScript的正則引擎是傳統型NFA引擎。

為什麼POSIX NFA引擎不支援也沒有必要支援非貪婪模式?

回溯

現在我們知道,NFA引擎是用表示式去匹配文字,而表示式又有若干分支和範圍,一個分支或者範圍匹配失敗並不意味著最終匹配失敗,正則引擎會去嘗試下一個分支或者範圍。

正是因為這樣的機制,引申出了NFA引擎的核心特點——回溯。

首先我們要區分備選狀態和回溯。

什麼是備選狀態?就是說這一個分支不行,那我就換一個分支,這個範圍不行,那我就換一個範圍。正規表示式中可以商榷的部分就叫做備選狀態。

備選狀態可以實現模糊匹配,是正則表達能力的一方面。

回溯可不是個好東西。想象一下,面前有兩條路,你選擇了一條,走到盡頭發現是條死路,你只好原路返回嘗試另一條路。這個原路返回的過程就叫回溯,它在正則中的含義是吐出已經匹配過的文字。

我們來看兩個例子:

'abbbc'.match(/ab{1,3}c/);
// ["abbbc", index: 0, input: "abbbc", groups: undefined]
'abc'.match(/ab{1,3}c/);
// ["abc", index: 0, input: "abc", groups: undefined]

第一個例子,第一次a匹配a成功,接著碰到貪婪匹配,不巧正好是三個b貪婪得逞,最後用c匹配c成功。

正則文字
/a/ a
/ab{1,3}/ ab
/ab{1,3}/ abb
/ab{1,3}/ abbb
/ab{1,3}c/ abbbc

第二個例子的區別在於文字只有一個b。所以表示式在匹配第一個b成功後繼續嘗試匹配b,然而它見到的只有黃臉婆c。不得已將c吐出來,委屈一下,畢竟貪婪匹配也只是儘量匹配更多嘛,還是要臣服於匹配成功這個目標。最後不負眾望用c匹配c成功。

正則文字
/a/ a
/ab{1,3}/ ab
/ab{1,3}/ abc
/ab{1,3}/ ab
/ab{1,3}c/ abc

請問,第二個例子發生回溯了嗎?

並沒有。

誒,你這樣就不講道理了。不是把c吐出來了嘛,怎麼就不叫回溯了?

回溯是吐出已經匹配過的文字。匹配過程中造成的匹配失敗不算回溯

為了讓大家更好的理解,我舉一個例子:

你和一個女孩子(或者男孩子)談戀愛,接觸了半個月後發現實在不合適,於是提出分手。這不叫回溯,僅僅是不合適而已。

你和一個女孩子(或者男孩子)談戀愛,這段關係維持了兩年,並且已經同居。但由於某些不可描述的原因,疲憊掙扎之後,兩人最終還是和平分手。這才叫回溯。

雖然都是分手,但你們應該能理解它們的區別吧。

為了讓大家更好的理解,我舉一個例子:

你和一個女孩子(或者男孩子)談戀愛,接觸了半個月後發現實在不合適,於是提出分手。這不叫回溯,僅僅是不合適而已。

你和一個女孩子(或者男孩子)談戀愛,這段關係維持了兩年,並且已經同居。但由於某些不可描述的原因,疲憊掙扎之後,兩人最終還是和平分手。這才叫回溯。

雖然都是分手,但你們應該能理解它們的區別吧。

網路上有很多文章都認為上面第二個例子發生了回溯。至少根據我查閱的資料,第二個例子發生的情況不能被稱為回溯。當然也有可能我([馬蹄疾]是錯的,歡迎討論。

我們再來看一個真正的回溯例子:

'ababc'.match(/ab{1,3}c/);
// ["abc", index: 2, input: "ababc", groups: undefined]

匹配文字到ab為止,都沒什麼問題。後面既匹配不到b,也匹配不到c。引擎只好將文字ab吐出來,從下一個位置開始匹配。因為上一次是從第一個字元a開始匹配,所以下一個位置當然就是從第二個字元b開始咯。

正則文字
/a/ a
/ab{1,3}/ ab
/ab{1,3}/ aba
/ab{1,3}/ ab
/ab{1,3}c/ aba
/a/ ab
/a/ aba
/ab{1,3}/ abab
/ab{1,3}/ ababc
/ab{1,3}/ abab
/ab{1,3}c/ ababc

一開始引擎是以為會和最早的ab走完餘生的,然而命運弄人,從此天涯。

這他媽才叫回溯!

還有一個細節。上面例子中的回溯並沒有往回吐呀,吐出來之後不應該往回走嘛,怎麼往後走了?

我們再來看一個例子:

'"abc"def'.match(/".*"/);
// [""abc"", index: 0, input: ""abc"def", groups: undefined]

因為.*是貪婪匹配,所以它把後面的字元都吞進去了。直到發現目標完不成,不得已往回吐,吐到第二個"為止,終於匹配成功。這就好比結了婚還在外面養小三,幾經折騰才發現家庭才是最重要的,自己的行為背離了初衷,於是幡然悔悟。

正則文字
/"/ "
/".*/ "a
/".*/ "ab
/".*/ "abc
/".*/ "abc"
/".*/ "abc"d
/".*/ "abc"de
/".*/ "abc"def
/".*"/ "abc"def
/".*"/ "abc"de
/".*"/ "abc"d
/".*"/ "abc"

我想說的是,不要被回溯的回字迷惑了。它的本質是把已經吞進去的字元吐出來。至於吐出來之後是往回走還是往後走,是要根據情況而定的。

優化正規表示式

現在我們知道了控制回溯是控制正規表示式效能的關鍵。

控制回溯又可以拆分成兩部分:第一是控制備選狀態的數量,第二是控制備選狀態的順序。

備選狀態的數量當然是核心,然而如果備選狀態雖然多,卻早早的匹配成功了,早匹配早下班,也就沒那麼多糟心事了。

傳統NFA工作流程

許多因素影響正規表示式的效率,首先,正規表示式適配的文字千差萬別,部分匹配時比完全不匹配所用的時間要長。上面提到過,JavaScript是傳統NFA引擎,當然每種瀏覽器的正規表示式引擎也有不同的內部優化。

為了有效地使用正規表示式,重要的是理解它們的工作原理。下面是一個正規表示式處理的基本步驟:

第一步:編譯

當你建立了一個正規表示式物件之後(使用一個正規表示式直接量或者RegExp構造器),瀏覽器檢查你的模板有沒有錯誤,然後將它轉換成一個本機程式碼例程,用於執行匹配工作。如果你將正規表示式賦給一個變數,你可以避免重複執行此步驟。

第二步:設定起始位置

當一個正規表示式投入使用時,首先要確定目標字串中開始搜尋的位置。它是字串的起始位置,或由正規表示式的lastIndex屬性指定,但是當它從第四步返回到這裡的時候(因為嘗試匹配失敗),此位置將位於最後一次嘗試起始位置推後一個字元的位置上。

      瀏覽器優化正規表示式引擎的辦法是,在這一階段中通過早期預測跳過一些不必要的工作。例如,如果一個正規表示式以^開頭,IE 和Chrome通常判斷在字串起始位置上是否能夠匹配,然後可避免愚蠢地搜尋後續位置。另一個例子是匹配第三個字母是x的字串,一個聰明的辦法是先找到x,然後再將起始位置回溯兩個字元。

第三步:匹配每個正規表示式的字元

      正規表示式一旦找好起始位置,它將一個一個地掃描目標文字和正規表示式模板。當一個特定字元匹配失敗時,正規表示式將試圖回溯到掃描之前的位置上,然後進入正規表示式其他可能的路徑上。

      第四步:匹配成功或失敗

      如果在字串的當前位置上發現一個完全匹配,那麼正規表示式宣佈成功。如果正規表示式的所有可能路徑都嘗試過了,但是沒有成功地匹配,那麼正規表示式引擎回到第二步,從字串的下一個字元重新嘗試。只有字串中的每個字元(以及最後一個字元後面的位置)都經歷了這樣的過程之後,還沒有成功匹配,那麼正規表示式就宣佈徹底失敗。

      牢記這一過程將有助於您明智地判別那些影響正規表示式效能問題的型別。

 

工具

[ regex101 ]是一個很多人推薦過的工具,可以拆分解釋正則的含義,還可以檢視匹配過程,幫助理解正則引擎。如果只能要一個正則工具,那就是它了。

[ regexper ]是一個能讓正則的備選狀態視覺化的工具,也有助於理解複雜的正則語法。

 

參考文章:

 https://baike.baidu.com/item/正規表示式

正規表示式工作原理 https://www.cnblogs.com/aaronjs/archive/2012/06/30/2570800.html

一次性搞懂JavaScript正規表示式之引擎 https://juejin.im/post/5becc2aef265da6110369c93

 

轉載本站文章《深入正規表示式(3):正規表示式工作引擎流程分析與原理釋義》,
請註明出處:https://www.zhoulujun.cn/html/theory/algorithm/IntroductionAlgorithms/8430.html

相關文章