持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第2天,点击查看活动详情
回溯
本文主要补充上段正则表达式的工作原理中的回溯继续展开。正则表达式的工作原理(一)
重复与回溯
下面例子显示了带有重复量词时的回溯过程,这个正则表达式匹配 "abyz" 中间包含任意字符(0或多个)
const str = "cab abcyz abcdyz abcyd";
/ab.*yz/i.test(str);
- 匹配过程开始时,首先会查找一个 a,锁定起始位置,向后匹配到 ab。
- 字符串的末尾并未匹配到yz,于是正则表达式每次回溯一个字符继续尝试匹配 y,直到回溯至 "abcyd"的y。
- 接下来尝试匹配 z,"abcyd" 的 y 后面不是z,匹配失败。
- 正则表达式继续回溯并重复这一过程,直到匹配到 "abcyz" 的 yz 才匹配成功。
扫描的范围是从第一段头部开始直到最后一段的尾部,这可能并不是效率高的方式。可以通过把量词 * 换成 *?,它变成先尝试全部跳过并匹配接下来的 yz。这样做是因为 *? 会重复匹配前一个字符零次或多次,次数尽可能的少,而最小的重复次数就是零次。但是紧接着 y 无法匹配当前字符,正则表达式会回溯并尝试匹配匹配下一个最小值:1。它像这样继续向前回溯,直到 "abcyz" 后面的 yz 能够完全匹配。
回溯失控
当正则表达式导致你的浏览器假死数秒、数分钟、甚至更长时间,问题很可能是因为回溯失控。为了说明这个问题,考虑如下正则表达式它用来匹配整个 HTML 文件。该正则表达式被拆分成多行是为了适应页面显示。\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]*?将被扩展至字符串末尾,以此类推。
解决方案:具体化
类似问题的解决方案是,尽可能具体化分隔符之间的字符串匹配模式。比如模式 ".*?",它用来匹配一个由双引号 "" 包括的字符。通过把宽泛的 .*? 替换成更为具体的 [^"\r\n]*,就去除了回溯时可能发生的几种情况,如尝试用点号匹配引号或者扩展搜索超出预期范围。
在 HTML 的示例中,解决方法不是那么简单。你不能使用一个取反字符集如 [^<] 来替代 [\s\S],因为搜索过程中可能存在其他标签。但是你可以通过重复一个非捕获组来达到同样的效果,它包含了否定性预查和 [\s\S] 元序列。这确保了中间位置上你查找的每一个标签都会失败,更重要的是表达式 [\s\S] 在你预查过程中阻塞的标签被发现之前不能被拓展。修改后:
/<html>(?:(?!<head>)[\s\S]*?)*<head>(?:(?!<title>)[\s\S])*<title>(?:(?!</title>)[\s\S])*</title>(?:(?!</head>)[\s\S])*</head>(?:(?!<body>)[\s\S])*<body>(?:(?!</body>)[\s\S])*</body>(?:(?!</html>)[\s\S])*</html>
虽然这样做消除了潜在的回溯失控,并允许正则表达式匹配不完整的 HTML 字符串失败时所需要时间同字符串长度成线性关系,但是它的效率并没有提高,这种方法在匹配短字符串时运行良好,但是本例中为匹配 HTML 文件向前查看可能需要测试上千次。使用预查和反向引用成为更好的解决方案。
预查和反向引用
一些正则表达式引擎都支持一种名为 原子组 的特性。原子组的写法是(?>...),省略号表示任意正则表达式模式,它是一种具有特殊反转性的非捕获组。预查和原子组的区别是:预查作为全局匹配的一部分,并不消耗任何字符;而只是检查自己包含的正则符合在当前字符串位置是否匹配。
原子组的目的是使得正则引擎回溯结束的更快一点,因此可以有效的阻止海量回溯。
一旦原子组中存在一个正则表达式,该组的任何回溯位置都会被丢弃。这为 HTML 正则表达式的回溯问题提供了一个更好的解决方案:
如果你将 [\s\S]*? 序列和它后面 HTMl 标签放在一个原子组中,每当所需要的 HTML 标签被发现一次,这次匹配基本就被锁定了。如果该正则表达式的后续部分匹配失败,原子组中的量词不会记录回溯点,因此 [\s\S]*? 序列已经匹配的部分不会再被展开。
但是在 JS 中是不支持原子组的,也没有提供其他方法消除不必要的回溯。那么可以利用预查过程来模拟原子组。
(?=(pattern to make atomic))\1
如果你的正则表达式包含了多个捕获组,那么需要使用适当的反向引用次数。对之前的 HTML 正则应用此方法:
/<html>(?=([\s\S]*?<head>))\1(?=([\s\S]*?<title>))\2(?=([\s\S]*?</title>))\3(?=([\s\S]*?</head>))\4(?=([\s\S]*?<body>))\5(?=([\s\S]*?</body>))\6[\s\S]*?</html>/
现在如果字符串中缺少了结尾的 </html> 标签,那么最后一个 [\s\S]*? 会展开至字符串末尾,于是立刻就会判断匹配失败,因为没有可返回的回溯点。正则表达式每次找到一个中间的标签就会退出一个预查,它在预查过程中丢弃所有回溯位置。接下来的反向引用简单地重复匹配预查中发现的字符串,并将它作为实际匹配的一部分。经过与之前的正则表达式的匹配方法对比,经过优化过后效率明显提升。
嵌套量词与回溯
嵌套量词是指量词出现在一个自身被重复量词修饰的组中,例如(x+)*。嵌套量词并不会造成性能损耗但在使用嵌套量词需要额外关心潜在的回溯失控,它很容易在尝试匹配字符串过程中内部量词与外部量词之间产生一大堆文本拆解路径。
举个例子:匹配 <html> 标签
/<(?:[^>"']|"[^"]*"|'[^']*')*>/
它不能正确辨别出所有有效和无效字元,但如果只用来处理有效的 HTML 片段是没问题的。与更简单的 /<[^>]*>/ 相比它的优势在于涵盖了可能会出现在属性值中的 > 符号。它使用非捕获组中的第二个和第三个分支,这些分支一次性匹配所有具有单引号和双引号的属性值,并允许除各自的引号类型外的所有字符出现。