正則表達式匹配原理

匹配基礎

對于正則表達式,有兩條普適原則:

  • 優先選擇最左端的匹配結果;
  • 標準的匹配量詞(*、+、?{min, max})是優先匹配的。

規則1:優先選擇最左端的匹配結果

正則引擎在目標文本的某一位置檢測整個正則表達式能匹配的每樣文本,若在所有可能的匹配嘗試失敗之后,則從當前位置的下一位置開始重新開始檢查。在嘗試過所有的起始位置都不能找到匹配結果的情況下,報告匹配失敗,反之則報告匹配成功。
例如,用cat來匹配The dragging belly indicates that your cat is too fat.在非全局匹配模式(/g)下,匹配的結果是indicates中的cat,而非后來的cat。若不了解這條規則,在執行文本替換操作時,會產生令人困惑的結果。

規則2:標準量詞是優先匹配的

標準匹配量詞都是匹配優先的,它們總是嘗試匹配盡可能多的字符,直至匹配上限。以\b\w+s\b為例,\w+完全能夠匹配regexes整個單詞,但為了表達式的余下部分能夠成功匹配,\w+被迫“交還”之前匹配的字符s,該過程被稱作回溯,將在下文講到。
當正則表達式中存在多個標準量詞,這條規則總是優先保證前面量詞限定的部分不受后面元素的影響。如^.*(\d+)匹配CA 95472 USA時,\d+只能匹配一個數字2
實際上,對表達式.*而言,存在濫用情況。因為.可以匹配除換行符以外的所有字符,它總是引起過多不必要的回溯。比如用^.*(\d\d)來匹配about 24 characters long,在.*匹配整個字符串之后,必須循環回溯17次,直到它釋放字符24來滿足余下的表達式\d\d匹配成功。

正則引擎

正則引擎主要可以分為兩大類:DFA和NFA。兩類引擎經過多年發展,產生了多種不必要的變體,為規范這種現狀,出臺了POSIX標準。POSIX標準規定了引擎應該支持的元字符和特性,以及使用者期望由表達式獲得的準確結果。而傳統NFA引擎根據該標準衍生出新的引擎類型,POSIX NFA。
在主流的程序中egrep、awk、MySQL使用DFA引擎,而JAVA、grep、.Net、PHP、Python、Ruby等使用傳統NFA引擎。

NFA引擎

NFA(非確定型有窮自動機)引擎是以正則表達式為主導的引擎。NFA引擎每次檢查表達式的一部分,同時檢查當前文本是否匹配表達式的當前部分(這里用部分表示需要匹配的可能是一個字符,或一個子表達式),若匹配,則繼續表達式的下一部分,直至所有部分都能匹配,即表達式匹配成功。以to(nite|knight|night)匹配文本... tonight ...為例,引擎從t開始,在目標文本找到t為止,然后檢查緊隨其后的字符是否能匹配o。當碰到分支結構時,引擎會依次選擇分支所列的多種匹配模式,直至匹配成功。
由于傳統NFA引擎使用的是順序結構的多選分支,在安排分支的先后順序時需格外小心,以免寫出無意義的多選結構。如a((ab)*|b*),因為第一條分支(ab)*永遠不會匹配失敗,所以第二條分支毫無意義。
NFA引擎匹配的過程中,每一個子表達式都是獨立的,子表達式之間不存在內在聯系,而只是整個表達式的各個部分。

DFA引擎

DFA(確定型有窮自動機)引擎是以文本為主導的引擎。DFA引擎在掃描字符串時,會記錄當前有效的所有匹配可能。具體到... tonight ...例子,引擎每掃描一個字符,都會更新當前的可能匹配序列,直至引擎發現匹配已經完成,則報告匹配成功。若在掃描過程中,引擎發現目標文本中的某個字符會令所有處理中的匹配失效,則返回某個之前保留的完整匹配,若不存在這樣的完整匹配,則報告在當前位置無法匹配。在多選分支結構中,DFA引擎總是優先匹配所有分支中匹配最多文本的那條分支。

字符串中的位置 正則表達式中的位置
... t|onight ... 可能匹配的位置:t|o(nite|knight|night)
... toni|ght ... 可能匹配的位置:to(ni|te|knight|ni|ght)

<small>注:此處用|作為引擎當前進行匹配的位置,下同</small>。
值得一提,DFA引擎不支持捕獲型括號、反向引用、忽略優先量詞這些特性。

在使用正則表達式進行檢索文本之前,兩種引擎都會編譯表達式,得到一套內化形式,適應各自的匹配算法。NFA的編譯過程通常要更快一些,所需內存也更小。而在NFA匹配過程中,目標文本的某個字符可能會被正則表達式重復檢測,在DFA中,目標文本中的字符至多只會被檢測一次,所以,在一般情況下,DFA是要比NFA快一些(若只是簡單文本的匹配測試,兩者速度倒是相差無幾)。NFA是表達式主導的,能提供一些DFA不支持的功能,相對而言它具有更開闊的施展空間。

回溯

NFA引擎最重要的性質是,在遇到多個可能成功的可能(包括量詞、多選結構)中進行選擇時,它會選擇其一,并記住其它,當匹配失敗時,引擎會回溯到之前記錄的位置繼續嘗試匹配。記錄的位置包含兩個位置信息:正則表達式中的位置,和未嘗試的分支在字符串中的位置。在NFA正則表達式中,這些記錄的位置被稱為備用狀態
回溯機制有兩個要點:

  1. 如果需要在“進行嘗試”和“跳過嘗試”之間選擇,對于匹配優先量詞,引擎會優先選擇“進行嘗試”,而對于忽略優先量詞,會選擇“跳過嘗試”。
  2. 距離當前最近存儲的選項就是當本地失敗強制回溯時返回的,使用的原則是LIFO。

未進行回溯的匹配

ab?c匹配abc

  1. 匹配a,當前狀態為a|bca|b?c
  2. 記錄備用狀態a|bcab?|c,當前狀態仍同1所示;
  3. 匹配b,當前狀態為ab|cab?|c
  4. 匹配c,匹配完成,丟棄之前保存的備用狀態。

進行了回溯的匹配

ab?c匹配ac

  1. 匹配a,當前狀態為a|ca|b?c;
  2. 記錄備用狀態a|cab?|c
  3. 匹配b失敗,返回之前記錄的備用狀態;
  4. 匹配c,匹配完成。

不成功的匹配

ab?c匹配abd

  1. 1-3步匹配過程同例1,但在匹配c時失敗,而返回備用狀態記錄位置仍失敗,由于不存在記錄的備用狀態,本次匹配失敗,故字符串前進,再次嘗試正則表達式。當前狀態為a|bc|ab?c;
  2. 重新開始的整個匹配失敗,字符串繼續前進,直至隨后的ab|cabc|都失敗,引擎宣告匹配失敗。

忽略優先的匹配

ab??c匹配abc

  1. 匹配a,當前狀態為a|bca|b??c
  2. 忽略b??,記錄備用狀態a|bca|b??c,當前狀態為a|bcab??|c;
  3. c無法匹配b,回溯到狀態a|bca|b??c;
  4. 匹配b,當前狀態為ab|cab??|c;
  5. 匹配c,匹配完成。

同理,每次測試*+作用的元素之前,引擎都會保存一個狀態,若測試失敗,便回退到之前保存的狀態開始匹配。這個過程會不斷重復,直到所有的嘗試完全失敗為止。

匹配優先與回溯

有一種常見的錯誤是,當我們希望用".*"來檢索“雙引號之間的文本”,而在匹配The name "McDonald's" is said "makudonarudo" in Japanese.這種帶有多對雙引號的文本時,總是輸出如"McDonald's" is said "makudonarudo"這種錯誤。這也是之前提到關于.*表達式濫用的情況之一。正確的答案是用"[^"]*"代替".*"。盡管".*?"也能達到相同的效果,但是較之[^"]*,.*?存在許多不必要的回溯,效率方面有所欠缺。
但不幸的是,對于... <B>Billions</B> and <B>Zillions</B> of ...,并不能用<B>[^</B>]*</B>匹配。字符組只能代表單個字符,而這里需要的</B>是一組字符,事實是[^</B>][^<>B/]并無本質區別。此時,使用忽略優先量詞*?就派上了用場。但通常情況下,忽略優先量詞并不是排除類的完美替身。若用<B>[^</B>]*?</B>來匹配... <B>Billions and <B>Zillions</B> of ...,我并不認為輸出的<B>Billions and <B>Zillions</B>就是用戶所期望的。在支持零寬斷言的程序中,把表達式改為<B>((?!</?B>).)*</B>,就能準確匹配我們期望的內容。因為斷言禁止了表達式主體匹配<B>和</B>之外的內容。
無論是匹配優先,還是忽略優先,都是為全局匹配服務的,只要引擎報告匹配失敗,則必然嘗試了所有可能的匹配。若只存在一條可能的匹配路徑,兩種模式就都能找到這個結果,區別只在于找出這個結果的效率而已。若最后可能的匹配結果不唯一,則需要根據實際情況自行選擇。
在零寬斷言的子表達式結構中,它會保存自己的備用狀態,進行必要的回溯。但只要零寬斷言的匹配嘗試結束,它就不會留下任何備用狀態,所有備用狀態會在斷言成功時被放棄(斷言失敗時意味著所有可能的匹配路徑都已經被嘗試,也就無所謂放棄)。在不支持固化分組的流派中,通??梢允褂昧銓挃嘌詠砟M。比如用(?=(regex))\1來模擬(?>regex),用^(?=(\w+))\1:來模擬(?>\w+):

占有優先量詞與固化分組

如果流派支持固化分組或者占有優先量詞,我們可以選擇在某個可選元素已經成功匹配的情況下,拋棄此元素的備用狀態來阻止回溯。

固化分組

使用固化分組,(?>expression),的匹配和正常的匹配并無差別,但若匹配進行到次結構之后,那么此結構內的所有備用狀態都會被放棄。即,在固化分組匹配結束時,它已經匹配的文本已經固化為一個單元,只能作為整體而保留或放棄。放棄備用狀態可能對匹配結果毫無影響,也可能導致匹配失敗,或者改變匹配結果。比如\b(?>\S+)ing\b就無法匹配listening a song中的listening
如果當我們確定保留的備用狀態毫無作用時,那么存在可以匹配的文本時,固化分組不會有任何影響,但若不存在能夠匹配的文本,放棄這些備用狀態會讓引擎更快地得出無法匹配的結論。比如用^(?>\w+):來代替^\w+:匹配Subject,可以加快報告匹配失敗的速度。

占有優先量詞

占有優先量詞與匹配優先量詞相似,只是占有優先量詞從不交還已經匹配的字符。占有優先量詞不會創造已經匹配字符的備用狀態。

匹配優先量詞 占有優先量詞
* *+
+ ++
? ?+
{min, max} {min, max}+
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容