讀古今文學網 > 編寫高質量代碼:改善JavaScript程序的188個建議 > 建議39:正確理解正則表達式回溯 >

建議39:正確理解正則表達式回溯

在正則表達式實現中,回溯是匹配過程的基本組成部分,它是正則表達式如此好用和強大的根源。然而,回溯計算代價很高,如果設計失誤,將導致失控。回溯是影響整體性能的唯一因素,理解它的工作原理,以及如何減小使用頻率,可能是編寫高效正則表達式的關鍵點。

當一個正則表達式掃瞄目標字符串時,從左到右逐個掃瞄正則表達式的組成部分,在每個位置上測試能不能找到一個匹配。對於每一個量詞和分支,都必須確定如何繼續進行。如果是一個量詞(如*、+?或者{2,}),那麼正則表達式必須確定何時嘗試匹配更多的字符;如果遇到分支(通過|操作符),那麼正則表達式必須從這些選項中選擇一個進行嘗試。

當正則表達式做出這樣的決定時,如果有必要,它會記住另一個選項,以備返回後使用。如果所選方案匹配成功,正則表達式將繼續掃瞄正則表達式模板,如果其餘部分匹配也成功了,那麼匹配就結束了。但是,如果所選擇的方案未能發現相應匹配,或者後來的匹配也失敗了,正則表達式將回溯到最後一個決策點,然後在剩餘的選項中選擇一個。繼續這樣,直到找到一個匹配,或者量詞和分支選項的所有可能的排列組合都嘗試失敗後放棄這一過程,然後移動到此過程開始位置的下一個字符上,重複此過程。

例如,下面的代碼演示了這一過程是如何通過回溯處理分支的。


/h(ello|appy)hippo/.test(\"hello there,happy hippo\");


上面一行正則表達式用於匹配「hello hippo」或「happy hippo」。測試一開始要查找一個h,目標字符串的第一個字母恰好就是h,立刻就找到了。接下來,子表達式(ello|appy)提供了兩個處理選項。正則表達式選擇最左邊的選項(分支選擇總是從左到右進行),檢查ello是否匹配字符串的下一個字符,確實匹配,然後正則表達式又匹配了後面的空格。

然而,在接下來的匹配中正則表達式「走進了死胡同」,因為hippo中的h不能匹配字符串中的下一個字母t。此時正則表達式還不能放棄,因為它還沒有嘗試過所有的選擇,隨後它回溯到最後一個檢查點(在匹配了首字母h之後的那個位置上)並嘗試匹配第二個分支選項。但由於匹配沒有成功,而且也沒有更多的選項了,正則表達式認為從字符串的第一個字符開始匹配是不能成功的,因此它從第二個字符開始重新進行查找。正則表達式沒有找到h,繼續向後找,直到第14個字母才找到,它匹配happy的那個h。隨後正則表達式再次進入分支過程,這次ello未能匹配,但在回溯之後的第二次分支中,它匹配了整個字符串「happy hippo」,匹配成功了。

再如,下面代碼演示了帶重複量詞的回溯。


var str=\"<p>Para 1.</p>\"+\"<img src=\'smiley.jpg\'>\"+\"<p>Para 2.</p>\"+\"<p>Div.</p>\";

/<p>.*</p>/i.test(str);


正則表達式先匹配了字符串開始的3個字母<p>,然後是.*。點號表示匹配除換行符以外的任意字符,星號這個「貪婪」量詞表示重複零次或多次,匹配盡量多的次數。因為目標字符串中沒有換行符,正則表達式將匹配剩下的全部字符串!不過由於正則表達式模板中還有更多內容需要匹配,所以正則表達式嘗試匹配<。由於在字符串末尾匹配不成功,因此每次回溯一個字符,繼續嘗試匹配<,直到正則表達式回到</p>標籤的<位置。接下來嘗試匹配/(轉義反斜槓),匹配成功,然後匹配p,匹配不成功。正則表達式繼續回溯,重複此過程,直到第二段末尾時終於匹配了。匹配返回成功需要從第一段頭部一直掃瞄到最後一個的末尾,這可能不是我們想要的結果。</p>

將正則表達式中的「貪婪」量詞*改為「懶惰」(又名「非貪婪」)量詞*?,以匹配單個段落。「懶惰」量詞的回溯工作以相反方式進行。當正則表達式/<p>.*?</p>/推進到.*?時,首先嘗試全部跳過,然後繼續匹配</p>。

這樣做是因為*?匹配零次或多次,盡可能少重複,盡可能少意味著可以重複零次。但是,當隨後的<在字符串的這一點上匹配失敗時,正則表達式回溯並嘗試下一個最小的字符數:1個。正則表達式繼續像這樣向前回溯到第一段的末尾,在那裡量詞後面的</p>得到完全匹配。

如果目標字符串只有一個段落,那麼此正則表達式的「貪婪」版本和「懶惰」版本是等價的,但嘗試匹配的過程不同。

當一個正則表達式佔用瀏覽器幾秒甚至更長時間時,問題原因很可能是回溯失控。為說明此問題,給出下面的正則表達式,它的目標是匹配整個HTML文件。此表達式被拆分成多行是為了適合頁面顯示。與其他正則表達式不同,JavaScript在沒有選項時可使點號匹配任意字符,包括換行符,所以此例中以[sS]匹配任意字符。


/<html>[sS]*?<head>[sS]*?<title>[sS]*?</title>[sS]*?</head>

[sS]*?<body>[sS]*?</body>[sS]*?</html>/


此正則表達式匹配在正常HTML字符串時工作良好,但當目標字符串缺少一個或多個標籤時,就會變得十分糟糕。例如</html>標籤缺失,最後一個[sS]*?將擴展到字符串的末尾,因為在那裡沒有發現</html>標籤,然後正則表達式將查看此前的[sS]*?隊列記錄的回溯位置,使它們進一步擴大。正則表達式嘗試擴展倒數第二個[sS]*?——用它匹配</body>標籤,就是此前匹配過正則表達式模板</body>的那個標籤,然後繼續查找第二個</body>標籤,直到字符串的末尾。當所有這些步驟都失敗時,倒數第三個[sS]*?將被擴展,直至字符串的末尾,依此類推。

此類問題的解決辦法在於盡可能具體地指出分隔符之間的字符匹配形式,如模板「.*?」用於匹配雙引號包圍的一個字符串。用更具體的[^\"rn]*取代過於寬泛的.*?就去除了回溯時可能發生的幾種情況,如嘗試用點號匹配引號,或者擴展搜索超出預期範圍。

在HTML的例子中解決辦法不是那麼簡單。不能使用否定字符類型,如用[^<]替代[sS],因為在搜索過程中可能會遇到其他類型的標籤。但是,可以通過重複一個非捕獲組來達到同樣效果,它包含一個回溯(阻塞下一個所需的標籤)和[sS](任意字符)元序列。這樣可以確保中間位置上查找的每個標籤都會失敗。然後,更重要的是,[sS]模板在回溯過程中阻塞的標籤在被發現之前不能被擴展。應用此方法後對正則表達式的最終修改如下:


/<html>(?:(?!<head>)[sS])*<head>(?:(?!<title>)[sS])*<title>

(?:(?!</title>)[sS])*</title>(?:(?!</head>)[sS])*</head>

(?:(?!<body>)[sS])*<body>(?:(?!</body>)[sS])*</body>

(?:(?!</html>)[sS])*</html>/


雖然這樣做消除了潛在的回溯失控,並允許正則表達式在匹配不完整HTML字符串失敗時的使用時間與文本長度呈線性關係,但是正則表達式的效率並沒有提高。像這樣為每個匹配字符進行多次前瞻,缺乏效率,而且成功匹配過程也相當慢。匹配較短字符串時使用此方法相當不錯,而匹配一個HTML文件可能需要前瞻並測試上千次。