正規表示式效能優化的探究

碼猿手發表於2020-10-29

一.背景

  前文的String字串效能優化的探究中的第3點講述了Split() 方法使用了正規表示式實現了其強大的分割功能,而正規表示式的效能是非常不穩定的,使用不恰當會引起回溯問題。那麼今天詳細探討下正規表示式。

  正規表示式是電腦科學的一個概念,很多語言都實現了它。正規表示式使用一些特定的元字元來檢索、匹配以及替換符合規定的字串。

  構造正規表示式語法的元字元,由普通字元、標準字元、限定字元(量詞)、定位符(邊界字元)組成,詳情如下圖:                                                             

二.正規表示式引擎

  正規表示式是一個用正則符號寫出的公式,程式對這個公式進行語法分析,建立一個語法分析樹,再根據這個分析樹結合正規表示式的引擎生成執行程式(這個執行程式我們把它稱作狀態機,也叫狀態自動機),用於字元匹配。

  而這裡的正規表示式引擎就是一套核心演算法,用於建立狀態機。

  目前實現正規表示式引擎的方式有兩種:DFA自動機(Deterministic Final Automata 確定有限狀態自動機)和 NFA(Non deterministic Finite Automaton 非確定有限狀態自動機)。

  對比來看,構造 DFA 自動機的代價遠大於 NFA 自動機,但 DFA 自動機的執行效率高於 NFA 自動機。

  假設一個字串的長度是 n,如果用 DFA 自動機作為正規表示式引擎,則匹配的時間複雜度為 O(n);如果用 NFA 自動機作為正規表示式引擎,由於 NFA 自動機在匹配過程中存在大量的分支和回溯,假設 NFA 的狀態數為 s,則該匹配演算法的時間複雜度為 O(ns)。

  NFA 自動機的優勢是支援更多功能。例如:捕獲 group、環視、佔有優先量詞等高階功能。這些功能都是基於子表示式獨立進行匹配,因此在程式語言裡,使用的正規表示式庫都是基於 NFA 實現的。

  那麼 NFA 自動機到底是怎麼進行匹配的呢?接下來以下面的例子來進行說明: 

text = "aabcab"
regex = "bc"

   NFA 自動機會讀取正規表示式的每一個字元,拿去和目標字串匹配,匹配成功就換正規表示式的下一個字元,反之就繼續和目標字串的下一個字元進行匹配。

  分解一下過程:

  1)讀取正規表示式的第一個匹配符和字串的第一個字元進行比較,b 對 a,不匹配;繼續換字串的下一個字元,也就是 a,不匹配;繼續換下一個,是 b,匹配;

  

  2)同理,讀取正規表示式的第二個匹配符和字串的第四個字元進行比較,c 對 c,匹配;繼續讀取正規表示式的下一個字元,然而後面已經沒有可匹配的字元了,結束。

  這就是 NFA 自動機的匹配過程,雖然在實際應用中,碰到的正規表示式都要比這複雜,但匹配方法是一樣的。

三.NFA自動機的回溯

  用 NFA 自動機實現的比較複雜的正規表示式,在匹配過程中經常會引起回溯問題。大量的回溯會長時間地佔用 CPU,從而帶來系統效能開銷。如下面例子: 

text = "abbc"
regex = "ab{1,3}c"

  上面例子,匹配目的比較簡單。匹配以 a 開頭,以 c 結尾,中間有 1-3 個 b 字元的字串。NFA 自動機對其解析的過程是這樣的:

  1)讀取正規表示式第一個匹配符 a 和字串第一個字元 a 進行比較,a 對 a,匹配;

  2)讀取正規表示式第一個匹配符 b{1,3} 和字串的第二個字元 b 進行比較,匹配。但因為 b{1,3} 表示 1-3 個 b 字串,NFA 自動機又具有貪婪特性,所以此時不會繼續讀取正規表示式的下一個匹配符,而是依舊使用 b{1,3} 和字串的第三個字元 b 進行比較,結果還是匹配。

  3)繼續使用 b{1,3} 和字串的第四個字元 c 進行比較,發現不匹配了,此時就會發生回溯,已經讀取的字串第四個字元 c 將被吐出去,指標回到第三個字元 b 的位置。

  4)那麼發生回溯以後,匹配過程怎麼繼續呢?程式會讀取正規表示式的下一個匹配符 c,和字串中的第四個字元 c 進行比較,結果匹配,結束。 

 

四.如何避免回溯問題?

  既然回溯會給系統帶來效能開銷,那我們如何應對呢?如果你有仔細看上面那個案例的話,你會發現 NFA 自動機的貪婪特性就是導火索,這和正規表示式的匹配模式息息相關。

  1.貪婪模式(Greedy)

  顧名思義,就是在數量匹配中,如果單獨使用 +、?、*或(min,max)等量詞,正規表示式會匹配儘可能多的內容。

  例如,上面那個例子:

text = "abbc"
regex = "ab{1,3}c"

  就是在貪婪模式下,NFA自動機讀取了最大的匹配範圍,即匹配 3 個 b 字元。匹配發生了一次失敗,就引起了一次回溯。如果匹配結果是“abbbc”,就會匹配成功。

text = "abbbc"
regex = "ab{1,3}c"

  2.懶惰模式(Reluctant)

  在該模式下,正規表示式會盡可能少地重複匹配字元,如果匹配成功,它會繼續匹配剩餘的字串。

  例如,上面的例子的字元後面加一個“?”,就可以開啟懶惰模式。

text = "abc"
regex = "ab{1,3}?c"

   匹配結果是“abc”,該模式下 NFA 自動機首先選擇最小的匹配範圍,即匹配 1 個 b 字元,因此就避免了回溯問題。

  3.獨佔模式(Possessive)

   同貪婪模式一樣,獨佔模式一樣會最大限度地匹配更多內容;不同的是,在獨佔模式下,匹配失敗就會結束匹配,不會發生回溯問題。

  還是上面的例子,在字元後面加一個“+”,就可以開啟獨佔模式。

text = "abbc"
regex = "ab{1,3}+c"

  結果是不匹配,結束匹配,不會發生回溯問題。

  所以綜上所述,避免回溯的方法就是:使用懶惰模式或獨佔模式。

  前面講述了“Split() 方法使用了正規表示式實現了其強大的分割功能,而正規表示式的效能是非常不穩定的,使用不恰當會引起回溯問題。”,比如使用了 split 方法提取域名,並檢查請求引數是否符合規定。split 在匹配分組時遇到特殊字元產生了大量回溯,解決辦法就是在正規表示式後加一個需要匹配的字元和“+”解決了回溯問題:

\\?(([A-Za-z0-9-~_=%]++\\&{0,1})+)

五.正規表示式的優化

  1.少用貪婪模式:多用貪婪模式會引起回溯問題,可以使用獨佔模式來避免回溯。

  2.減少分支選擇:分支選擇型別 “(X|Y|Z)” 的正規表示式會降低效能,在開發的時候要儘量減少使用。如果一定要用,可以通過以下幾種方式來優化:

    1)考慮選擇的順序,將比較常用的選擇項放在前面,使他們可以較快地被匹配;

    2)可以嘗試提取共用模式,例如,將 “(abcd|abef)” 替換為 “ab(cd|ef)” ,後者匹配速度較快,因為 NFA 自動機會嘗試匹配 ab,如果沒有找到,就不會再嘗試任何選項;

    3)如果是簡單的分支選擇型別,可以用三次 index 代替 “(X|Y|Z)” ,如果測試話,你就會發現三次 index 的效率要比 “(X|Y|Z)” 高一些。

  3.減少捕獲巢狀 :

    捕獲組是指把正規表示式中,子表示式匹配的內容儲存到以數字編號或顯式命名的陣列中,方便後面引用。一般一個()就是一個捕獲組,捕獲組可以進行巢狀。

    非捕獲組則是指參與匹配卻不進行分組編號的捕獲組,其表示式一般由(?:exp)組成。

    在正規表示式中,每個捕獲組都有一個編號,編號 0 代表整個匹配到的內容。可以看看下面的例子:

    public static void main(String[] args) {
        String text = "<input high=\"20\" weight=\"70\">test</input>";
        String reg = "(<input.*?>)(.*?)(</input>)";
        Pattern p = Pattern.compile(reg);
        Matcher m = p.matcher(text);
        while (m.find()){
            System.out.println(m.group(0));//整個匹配到的內容
            System.out.println(m.group(1));//<input.*?>
            System.out.println(m.group(2));//(.*?)
            System.out.println(m.group(3));//(</input>)
        }

    }
=====執行結果=====
<input high="20" weight="70">test</input>
<input high="20" weight="70">
test
</input>

  如果你並不需要獲取某一個分組內的文字,那麼就使用非捕獲組,例如,使用 “(?:x)” 代替 “(X)” ,例如下面的例子:

    public static void main(String[] args) {
        String text = "<input high=\"20\" weight=\"70\">test</input>";
        String reg = "(?:<input.*?>)(.*?)(?:</input>)";
        Pattern p = Pattern.compile(reg);
        Matcher m = p.matcher(text);
        while (m.find()) {
            System.out.println(m.group(0));//整個匹配到的內容
            System.out.println(m.group(1));//(.*?)
        }

    }
=====執行結果=====
<input high="20" weight="70">test</input>
test