正则表达式中的回溯
在大多数现代正则表达式实现中(包括JavaScript所需的),回溯是匹配过程的基本组成部分。它很大程度上也是正则表达式如此美好和强大的根源。然而,回溯计算代价昂贵,如果你不够小心的话容易失控。虽然回溯是整体性能的唯一因素,理解它的工作原理,以及如何减少使用频率,可能是编写高效正则表达式最重要的关键点。因此后面几节用较长篇幅讨论这个话题。
当一个正则表达式扫描目标字符串时,它从左到右逐个扫描正则表达式的组成部分,在每个位置上测试能不能找到一个匹配。对于每一个量词和分支,都必须决定如何继续进行。如果是一个量词(诸如*,+?,或者{2,}),正则表达式必须决定何时尝试匹配更多的字符;如果遇到分支(通过|操作符),它必须从这些选项中选择一个进行尝试。
每当正则表达式做出这样的决定,如果有必要的话,它会记住另一个选项,以备将来返回后使用。如果所选方案匹配成功,正则表达式将继续扫描正则表达式模板,如果其余部分匹配也成功了,那么匹配就结束了。但是如果所选择的方案未能发现相应匹配,或者后来的匹配也失败了,正则表达式将回溯到最后一个决策点,然后在剩余的选项中选择一个。它继续这样下去,直到找到一个匹配,或者量词和分支选项的所有可能的排列组合都尝试失败了,那么它将放弃这一过程,然后移动到此过程开始位置的下一个字符上,重复此过程。
一、分支和回溯
下面的例子演示了这一过程是如何处理分支的。
/h(ello|appy) hippo/.test("hellothere, happyhippo");
此正则表达式匹配“hellohippo”或“happyhippo”。测试一开始,它要查找一个h,目标字符串的第一个字母恰好就是h,它立刻就被找到了。接下来,子表达式(ello|appy)提供了两个处理选项。正则表达式选择最左边的选项(分支选择总是从左到右进行),检查ello是否匹配字符串的下一个字符。确实匹配,然后正则表达式又匹配了后面的空格。然而在这一点上它走进了死胡同,因为hippo 中的h 不能匹配字符串中的下一个字母t。此时正则表达式还不能放弃,因为它还没有尝试过所有的选择,随后它回溯到最后一个检查点(在它匹配了首字母h 之后的那个位置上)并尝试匹配第二个分支选项。但是没有成功,而且也没有更多的选项了,所以正则表达式认为从字符串的第一个字符开始匹配是不能成功的,因此它从第二个字符开始,重新进行查找。它没有找到h,所以就继续向后找,直到第14 个字母才找到,它匹配happy的那个h。然后它再次进入分支过程。这次ello未能匹配,但是回溯之后第二次分支过程中,它匹配了整个字符串“happyhippo”。匹配成功了。
二、重复与回溯
下一个例子显示了带重复量词的回溯。
var str = "<p>Para 1.</p>" +
"<img src='smiley.jpg'>" +
"<p>Para 2.</p>" +
"<div>Div.</div>";
/<p>.*<\/p>/i.test(str);
正则表达式一上来就匹配了字符串开始的三个字母<p>。然后是.*。点号匹配除换行符以外的任意字符,星号这个贪婪量词表示重复零次或多次——匹配尽量多的次数。因为目标字符串中没有换行符,它将吞噬剩下的全部字符串!不过正则表达式模板中还有更多内容需要匹配,所以正则表达式尝试匹配<。它在字符串末尾匹配不成功,所以它每次回溯一个字符,继续尝试匹配<,直到它回到</div>标签的<位置。然后它尝试匹配\/(转义反斜杠),匹配成功,然后是p,匹配不成功。正则表达式继续回溯,重复此过程,直到第二段末尾时它终于匹配了</p>。匹配返回成功,它从第一段头部一直扫描到最后一个的末尾,这可能不是你想要的结果。
你可以将正则表达式中的贪婪量词*改为懒惰(又名非贪婪)量词*?,以匹配单个段落。懒惰量词的回溯工作以相反方式进行。当正则表达式/<p>.*?<\/p>/推进到.*?时,它首先尝试全部跳过然后继续匹配<\/p>。它这么做是因为*?匹配零次或多次,但尽可能少重复,尽可能少的话那么它就可以重复零次。但是,当随后的<在字符串的这一点上匹配失败时,正则表达式回溯并尝试下一个最小的字符数:一个。它继续像这样向前回溯到第一段的末尾,在那里量词后面的<\/p>得到完全匹配。
如果目标字符串只有一个段落,你可以看到此正则表达式的贪婪版本和懒惰版本是等价的,但是他们尝试匹配的过程不同。
三、回溯失控
当一个正则表达式占用浏览器上秒,上分钟或者更长时间时,问题原因很可能是回溯失控。为说明此问题,考虑下面的正则表达式,它的目标是匹配整个HTML文件。此表达式被拆分成多行是为了适合页面显示。不像其他大多数正则表达式那样,JavaScript没有选项可使点号匹配任意字符,包括换行符,所以此例中以[\s\S]匹配任意字符。
/<html>[\s\S]*?<head>[\s\S]*?<title>[\s\S]*?<\/title>[\s\S]*?<\/head>
[\s\S]*?<body>[\s\S]*?<\/body>[\s\S]*?<\/html>/
此正则表达式匹配正常HTML字符串时工作良好,但是如果目标字符串缺少一个或多个标签时,它就会变得十分糟糕。例如</html>标签缺失,那么最后一个[\s\S]*?将扩展到字符串的末尾,因为在那里没有发现</html>标签,然后并没有放弃,正则表达式将察看此前的[\s\S]*?队列记录的回溯位置,使它们进一步扩大。正则表达式尝试扩展倒数第二个[\s\S]*?——用它匹配</body>标签,就是此前匹配过正则表达式模板<\/body>的那个标签——然后继续查找第二个</body>标签直到字符串的末尾。当所有这些步骤都失败了,倒数第三个[\s\S]*?将被扩展直至字符串的末尾,依此类推。