想寫出效率更高的正規表示式?試試固化分組和佔有優先匹配吧

dreamapplehappy發表於2020-07-28

20200719 (1).png

上次我們講解了正規表示式量詞匹配方式的貪婪匹配和懶惰匹配之後,一些同學給我的公眾號留言說希望能夠快點把量詞匹配方式的下篇也寫了。那麼,這次我們就來學習一下量詞的另外一種匹配方式,那就是佔有優先的匹配方式。當然我們這篇文章還講解了跟佔有優先匹配功能一樣的固化分組,以及使用肯定的順序環視來模擬佔有優先的匹配方式以及固化分組。準備好了嗎,快來給自己的技能樹上再新增一些技能吧。

我們如果可以掌握這種匹配方式的原理的話,那麼我們就有能力寫出效率更高的正規表示式。在進行深入的學習之前,希望你至少對正規表示式的貪婪匹配有所瞭解,如果還不怎麼了解的話,可以花費幾分鐘的時間看一下我上一篇關於貪婪匹配和懶惰匹配的文章。

佔有優先的匹配方式:(表示式)*+

首先,佔有優先的匹配方式跟貪婪匹配的方式很像。它們之間的區別就是佔有優先的匹配方式,不會歸還已經匹配的字元,這點很重要。這也是為什麼佔有優先的匹配方式效率比較高的原因。因為它作用的表示式在匹配結束之後,不會保留備用的狀態,也就是不會進行回溯。但是,貪婪匹配會保留備用的狀態,如果之前的匹配沒有成功的話,它會回退到最近一次的保留狀態,也就是進行回溯,以便正規表示式整體能夠匹配成功
那麼佔有優先的表示方式是怎樣的?佔有優先匹配就是在原來的量詞的後面新增一個+,像下面展示的這樣。

.?+
.*+
.++
.{3, 6}+
.{3,}+

因為正規表示式有很多流派,有一些流派是不支援佔有優先這種匹配方式的,比如JavaScript就不支援這種匹配的方式(前端同學表示不是很開心?),但是我們可以使用肯定的順序環視來模擬佔有優先的匹配。PHP就支援這種匹配方式。所以接下來我們一些正則的演示,就會選擇使用PHP流派進行演示。

我們來寫一個簡單的例子來加深一下大家對於佔有優先匹配方式的瞭解。有這麼一個需求,你需要寫一個正規表示式來匹配以數字9結尾的數字。你會怎麼寫呢?當然,對於已經有正規表示式基礎的同學來說,這應該是很容易的事情。我們會寫出這麼一個正規表示式\d*9,這樣就滿足了上面所說的需求了。

d*9

讓我們把上面的貪婪匹配方式修改為佔有優先的匹配方式,你覺得我們還能夠匹配相同的結果嗎?來讓我們看一下修改後的匹配結果。

d*+9

答案是不能,你也許會好奇,為什麼就不可以了。讓我來好好給大家解釋一下為什麼不能夠匹配了。

我們知道正規表示式是從左向右匹配的,對於\d*+這個整體,我們在進行匹配的時候可以先把\d*+看作是\d*進行匹配。對於\d*+這部分表示式來說它在開始匹配的時候會匹配儘可能多的數字,對於我們給出的測試用例,\d*+都是可以匹配的,所以\d*+直接匹配到了每一行數字的結尾處。然後因為\d*+是一個整體,表示佔有優先的匹配。所以\d*+匹配完成之後,這個整體便不再歸還已經匹配的字元了。但是我們正規表示式的後面還需要匹配一個字元9,但是前面已經匹配到字串的結尾了,再沒有字元給9去匹配,所以上面的測試用例都匹配失敗了。

在開始匹配的過程中我們可以把佔有優先當做貪婪匹配來進行匹配,但是一旦匹配完成就跟貪婪匹配不一樣了,因為它不再歸還匹配到的字元。所以對於佔有優先的匹配方式,我們只需要牢記佔有優先匹配方式匹配到的字元不再歸還就可以了。

固化分組:(?>表示式)

我們瞭解了佔有優先的匹配之後,再來看看跟佔有優先匹配作用一樣的固化分組。那什麼是固化分組呢?固化分組的意思是這樣的,當固化分組裡面的表示式匹配完成之後,不再歸還已經匹配到的字元。固話分組的表示方式是(?>表示式),其中裡面的表示式就是我們要進行匹配的表示式。比如(?>\d*)裡面的表示式就是\d*,表示的意思就是當\d*這部分匹配完成之後,不再歸還\d*已經匹配到的字元。

所以,對於\d*+來說,我們如果使用固化分組的話可以表示為(?>\d*)。其實,佔有優先固化分組的一種簡便的表示方式,如果固化分組裡面的表示式是一個很簡單的表示式的話。那麼使用佔有優先量詞,比使用固化分組更加的直觀。

我們將上面使用佔有優先的表示式替換為使用固化分組的方式表示,下面兩張圖片展示了使用固化分組後的匹配結果。

(?>d*)

(?>d*)9

還有一些需要注意的是,支援佔有優先量詞的正則流派也支援固化分組,但是對於支援固化分組的正則流派來說,不一定支援佔有優先量詞。所以在使用佔有優先量詞的時候,要確保你使用的那個流派是支援的。

使用肯定順序環視模擬固化分組:(?=(表示式))1

對於不支援固化分組的流派來說,如果這些流派支援肯定的順序環視捕獲的括號的話,我們可以使用肯定的順序環視來模擬固化分組。如果對於正規表示式的環視還不熟悉的話,可以花幾分鐘的時間看一下我之前寫的這篇文章距離弄懂正則的環視,你只差這一篇文章,保證你可以快速的理解正則的環視。

看到這裡,你可能要問,為什麼肯定的順序環視可以模擬固化分組呢?我們要知道固化分組的特性就是匹配完成之後,丟棄了固化分組內表示式的備用狀態,然後不會進行回溯。又因為環視一旦匹配成功之後也是不會進行回溯的,所以我們可以利用肯定的順序環視來模擬固化分組。

我們可以使用(?=(表示式))\1這個表示式來模擬固化分組(?>表示式)。我來解釋一下上面這個模擬的正規表示式,首先是一個肯定的順序環視,需要在當前位置的後面找到滿足表示式的匹配,如果找到的話,接下來\1會匹配環視中已經匹配到的那部分字元。因為在順序環視中的正規表示式不會受到順序環視後面表示式的影響,所以順序環視內部的表示式在匹配完成之後不會進行回溯。然後後面的\1再次匹配環視裡面表示式匹配到的內容,這樣就模擬了一個固化分組。

我們再將上面的表示式替換為使用模擬固化分組的方式表示,下面兩張圖片展示了使用模擬固化分組後的匹配結果。

(?=(d*))1

(?=(d*))19

模擬的固化分組在效率上要比真正的固化分組慢一些,因為\1的匹配也是需要花費時間的。不過對於貪婪匹配所造成的的回溯來說,這點匹配的時間一般還是很短的。

貪婪匹配和佔有優先效率的比較

我們上面說過,因為佔有優先不會回溯,所以在一些情況下,使用佔有優先的匹配要比使用匹配優先的匹配效率高很多。那麼下面我們就使用程式碼來驗證一下貪婪匹配和佔有優先匹配的效率是怎樣的。

程式碼如下所示:

// 匹配優先(貪婪匹配)匹配一行中的數字,後面緊跟著字元b
const greedy_reg = /\d*b/;
// 佔有優先(使用肯定順序環視模擬)
const possessive_reg = /(?=(\d*))\1b/;
// 測試的字串 000...(共有1000個0)...000a
const str = `${new Array(1000).fill(0).join('')}a`;

console.time('匹配優先');
greedy_reg.test(str);
console.timeEnd('匹配優先');

console.time('模擬的佔有優先');
possessive_reg.test(str);
console.timeEnd('模擬的佔有優先');

在上面的測試程式碼中,我們生成了一個長度為1001的字串,最後一位是一個小寫字母a。因為貪婪匹配在匹配到最後一個數字後,發現最後一個字元是a,不能夠滿足b的匹配,所以開始進行回溯。雖然我們知道就算進行了回溯也不會匹配成功了,但是執行的程式是不知道的,所以程式會不斷的回溯,一直回溯到\d*什麼也不匹配,然後再次檢查b,發現還是不可以匹配。最終報告匹配失敗。中間進行了大量的回溯,所以匹配的效率降低了。

對於佔有優先的匹配,在第一次\d*匹配成功後,發現後面的a不能夠滿足b的匹配,所以立即報告失敗,匹配效率比較高。但是因為JavaScript不支援佔有優先固化分組,所以我們使用了肯定的順序環視來替代,但是因為\1需要進行接下來的匹配,也會消耗一些時間。所以這個測試的結果不能夠嚴格意義上表明佔有優先貪婪匹配在這種情況下的效率高,但是如果模擬的佔有優先消耗的時間比較短,那就可以說明佔有優先確實比貪婪匹配在這種情況下的效率高。

我首先在node.js環境中執行,我本地的node.js版本為v12.16.1,系統為macOS。程式執行的結果如下:

匹配優先: 1.080ms
模擬的佔有優先: 0.702ms

這個結果只是其中一次的執行結果,執行很多次後發現匹配優先的耗時要比我們模擬的佔有優先多一些,但也有幾次的執行時間是小於模擬的佔有優先的。我把相同的程式碼也放在了Chrome瀏覽器中執行了多次,發現匹配優先的耗時有時比模擬佔有優先高,有時比模擬佔有優先低,不是很好做判斷。

JavaScript中不能夠很好地反應這兩種匹配方式的效率高低,所以我們需要在PHP中再次進行試驗。因為PHP是原生的支援佔有優先匹配的,所以比較的結果是有說服力的。我們使用PHP的程式碼實現上面相同的邏輯,程式碼如下:

// 貪婪匹配
$greedy_reg     = '/\d*b/';
// 佔有優先
$possessive_reg = '/\d*+b/';

// 待測試字串
$str = implode(array_fill(0, 1000, 0)) . 'a';

// 計算貪婪匹配花費的時間
$t1 = microtime(true);
preg_match($greedy_reg, $str);
$t2 = microtime(true);
echo '貪婪匹配執行的時間:' . ($t2 - $t1) * 1e3 . 'ms';

echo PHP_EOL;

// 計算佔有優先匹配花費的時間
$t3 = microtime(true);
preg_match($possessive_reg, $str);
$t4 = microtime(true);
echo '佔有優先匹配執行的時間:' . ($t4 - $t3) * 1e3 . 'ms';

可以看到執行的結果如下:

貪婪匹配執行的時間:0.025033950805664ms
佔有優先匹配執行的時間:0.0071525573730469ms

如果你將這段程式碼執行多次的話,可以看到佔有優先匹配所花費的時間的確是比貪婪匹配要少一些的,所以上面的程式碼可以說明,在這種情況下佔有優先匹配的效率是比貪婪匹配的效率高的。

關於正規表示式的佔有優先匹配和固化分組的講解到這裡就結束啦,如果大家有什麼疑問和建議都可以在這裡提出來。歡迎大家關注我的公眾號關山不難越,我們一起學習更多有用的正則知識,一起進步。

參考資料:

相關文章