正则表达式与回溯

Posted Web手艺人

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了正则表达式与回溯相关的知识,希望对你有一定的参考价值。

一、工作原理

正则表达式是一个描述字符模式的对象。 javascript 正则表达式语法是 Perl5 正则表达式语法的大型子集。比如:

正则表达式与回溯

想了解正则表达式,就要先了解正则表达式引擎。

实现一个正则表达式引擎,实际上就类似与实现一个简单语言的编译器。一个正则表达式就是用正则符号写出的程序,我们要对这个式子进行语法分析,建立一个语法分析树,根据这个树生成 NFA ,如果采用 NFA 匹配的话,然后需要写出 NFA 模拟执行的程序,用来进行匹配。

正则表达式引擎分成两类,一类称为 DFA(确定性有穷自动机),另一类称为 NFA(非确定性有穷自动机)。NFA 引擎是以正则表达式为主导的引擎,而 DFA 引擎是以文本为主导的引擎。NFA 引擎和 DFA 引擎在机制上有一些不同:

(1)NFA 引擎需要对字符串进行反复地消耗和释放,速度慢,但是特性丰富。DFA 引擎对字符串里的每一个字符只需扫描一次,比较快,但特性较少。

(2)只有 NFA 引擎才支持惰性量词和反向引用等特性。

(3)NFA 引擎中最左侧的分支一旦匹配成功,其他分支将无法参与匹配。DFA 引擎中则是最长的分支优先匹配。

(4)NFA 缺省采用贪婪量词。

(5)NFA 可能会陷入回溯失控的陷阱而表现得性能极差。

下面,我们共同思考一个问题:JavaScript 的正则表达式引擎是哪一种?

这里可以做一个小实验:

正则表达式与回溯

我们发现:匹配结果是'nfa',可以判断 JavaScript 采用 NFA 引擎(对应上面说的第3点)。

对正则表达式引擎有了一定了解之后,我们再看看 JavaScript 正则表达式的工作原理。共分为 4 步:

(1)编译。当用户创建一个 RegExp 对象(正则表达式直接量或者 RegExp 构造函数),javaScript 引擎会对其进行验证,并将其转化为一个原生代码程序,用于执行匹配工作。将 RegExp 对象赋值给一个变量,可以避免重复执行这一步操作。

(2)设置起始搜索位置。目标字符串的起始搜索位置是由起始字符或者 lastIndex 属性指定。如果从第 4 步返回到这里,起始搜索位置就是上一轮匹配起始位置的下一位字符。

(3)匹配每个正则表达式字元。一旦正则表达式找到开始匹配的位置,会对字符串进行逐个检查。当一个特定的字元匹配失败后,正则表达式会试着回溯到最近的决策点,尝试其他可能的路径。

(4)匹配成功或失败。如果在字符串当前位置发现了一个完全匹配,则匹配成功。如果正则表达式所有可能路径都没有匹配到,正则表达式引擎会回退到第 2 步。如果字符串每个字符(以及最后一个字符后面的位置)都经历这个过程还未匹配成功,则匹配失败。

lastIndex 是正则表达式开始下一次查找的索引位置.第一次总是为 0 的,第一次查找完后会把 lastIndex 的值设为匹配到得字符串的最后一个字符的索引位置加 1 ,第二次查找的时候会从 lastIndex 这个位置开始,后面的以此类推。如果没有找到,则会把 lastIndex 重置为 0。要注意的是,lastIndex 属性只有在有全局标志的正则表达式中才有作用,否则永远为 0。

正则表达式与回溯

二、回溯

回溯是正则表达式匹配过程的基础组成部分,却也会产生昂贵的计算消耗。我们先举 2 个栗子来理解回溯。

栗子一:分支与回溯

正则表达式与回溯

(1)首先会查找一个 h,目标字符串首字母恰好是h。

(2)选择子表达式中最左侧的选项(分支选择总是从左到右进行),发现 ello 目标字符串中 h 之后的字符成功匹配。

(3)继续匹配随后的空格,由于 hippo 中的 h 无法匹配目标字符串中的 t,匹配无法继续。

(4)正则表达式回溯到最近的决策点(匹配完目标字符串首字符 h 后面的位置),尝试匹配子表达式中的 appy,匹配失败。

(5)正则表达式从目标字符串的第 2 个字符重新尝试,一直搜索到第 14 个字符才找到 h,再次进入分支过程,匹配ello失败后,回溯匹配 appy ,成功匹配整个字符串“happy hippo”。

栗子二:重复与回溯

贪婪量词是尽可能多的匹配,惰性量词是尽可能少的匹配。在语法上,贪婪量词后加一个?就形成惰性量词,比如“??”,“+?”,“*?”,“{1,5}?”。

正则表达式与回溯

正则表达式与回溯

我们看一下贪婪量词和惰性量词的回溯情况。

正则表达式与回溯

正则表达式与回溯

三、回溯失控

当正则表达式导致浏览器假死数秒、数分钟,甚至更长时间时,问题很有可能是因为回溯失控。下面我们来一道思考题:写一个可以匹配整个 html 文件的正则表达式。

如果你写成了这个样子,就会踩到一个大坑。

正则表达式与回溯

上述正则表达式在匹配常规的 HTML 字符时运行正常,但是当目标字符串缺少一个或多个必要标签时会变得很糟糕。比如</html>标签的缺失,就会引发回溯失控。

为了避免回溯失控,除了使字符串匹配模式更加具体之外,还可以依赖一些小技巧。一些正则表达式引擎,包括:.NET、Java、Ongiguruma、PCRE、Perl,都支持一种名为“原子组”的特性。原子组的写法是(?>...),省略号表示任意正则表达式的模式,是一种具有特殊反转性的非捕获组。一旦原子组中存在一个正则表达式,该组的任何回溯位置都会被丢弃。

深究其原因:位于(?>)之间的所有正则表达式都会被认为是一个单一的正则符号,一旦匹配失败,正则引擎将会回溯到原子组前面的正则表达式部分。这就为 HTML 字符匹配提供了一个更好的解决方案:将 [\s\S]*? 序列和它后面的HTML标签放在一个原子组中,每当所需要的HTML 标签被发现一次,这次匹配基本上就被锁定了。如果该正则表达式的后续部分匹配失败,原子组中的量词不会记录回溯点,因此 [\s\S]*? 序列已经匹配的部分不会再被展开。

说了这么多,让我们把关注点再次聚焦在 JavaScript 上。JavaScript 不支持原子组,却可以通过预查模拟原子组。

预查作为全局匹配的一部分,并不消耗任何字符,只检查自己包含的正则符号在当前字符串位置是否匹配。

正则表达式与回溯

把预查的表达式封装在捕获组中并添加反向引用,可以解决预查不消耗任何字符。

反向引用 \n 和第 n 个分组第一次匹配的字符相匹配,组是圆括号中的子表达式,组索引是从左到右的左括号数,“(?:”形式的分组不编码。

正则表达式与回溯

由此,我们得到一个适用于 JavaScript 的原子组表达式:

正则表达式与回溯

匹配整个 HTML 文件的正则表达式可以写成:

正则表达式与回溯

这样如果目标字符串缺少</html>标签,最后一个[\s\S]*? 会展开至目标字符串末尾,立刻匹配失败(没有可返回的回溯点)。

四、是否使用正则表达式?

正则表达式的确非常强大,带给开发者很多便利,但是正则表达式适用于所有场景吗?

下面我们再思考一个问题:去除字符串的首尾空白。

利用正则表达式,我们可以轻松实现。

正则表达式与回溯

正则表达式可以很好地去除字符串头部的空白,却不能同样快速地去除长字符串尾部的空白(无法直接跳到字符串末尾而不考虑沿途的字符)。这时,我们可以利用非正则表达式的方式进行处理。

由此可见,在基于正则表达式的方案中,字符串的总长度比修剪掉的字符数量更影响性能;而非正则表达式方案从字符串末尾反向查找,不受字符串总长度的影响,却受到修剪空格数量的影响。故将二者混合可以说是最周全的解决方案。


以上是关于正则表达式与回溯的主要内容,如果未能解决你的问题,请参考以下文章

一起学习正则表达式回溯陷阱

一起学习正则表达式回溯陷阱

一起学习正则表达式回溯陷阱

一起学习正则表达式回溯陷阱

什么时候应该使用正则表达式回溯控件,例如 (*PRUNE)?

Go 正则表达式中没有灾难性的回溯吗?