改进正则表达式的性能

正则表达式的应用原理

正则表达式应用到目标字符串的过程大致分为下面几步:

  • 编译正则表达式。检查正则表达式的正确性,如果正确,将其编译为内部形式。
  • 开始传动。将正则引擎定位到目标字符串的起始位置。
  • 检测元素。
  1. 引擎开始依次测试表达式的各个元素和目标字符串。
  2. 在检测相连元素时,引擎会在某个元素匹配失败时停止。
  3. 量词修饰的元素,控制权在量词和被限定的元素之间轮换。

控制权在捕获型括号内外切换会带来额外的开销。捕获型括号内表达式匹配的文本必须保留,才能通过$1\1来引用。括号也可能属于某个回溯分支,括号内的状态就是用于回溯的状态的一部分,所以进入和退出捕获型括号时需要修改状态。

  • 寻找匹配结果。若找到匹配结果,传统型NFA立即报告匹配成功(POSIX NFA则在尝试完所有可能的情况之后,返回最长的匹配)。若没有找到匹配,从当前传动位置前进一位,开始下一轮尝试。
  • 匹配彻底失败。在所有可能的尝试都失败之后,报告失败。

全面考察回溯

下面我们通过几个例子来考察回溯在匹配成功和不成功时的各种细节。如表达式".*"对字符串The name "McDonald's" is said "makudonarudo" in Japanese的匹配过程如下:

改进正则表达式的性能_第1张图片
图1

我们知道 ".*"!无法匹配上述字符串,但引擎在报告匹配失败之前仍会进行多次尝试:
改进正则表达式的性能_第2张图片
图2

如果我们将 .换成 [^"][^"]*匹配的内容就能不包括双引号,这减少了匹配和回溯的次数:
改进正则表达式的性能_第3张图片
图3

但是需要注意的是 "[^"]*"".*"在此例中的匹配结果并不一样。

一个简单的例子

假设字符串"2 \"x3\" likeness",为了匹配双引号及之内的字符串,且允许出现转义的双引号。"(\\\\.|[^\\"])*"的匹配结果虽然正确,但在效率方面有所欠缺,通过优化能加快匹配速度。

调整多选结构的顺序

对于一般的双引号字符串而言,普通字符的数量要比转义字符多,将[^\\"]放到\\\\.之前可以有效减少回溯次数。

改进正则表达式的性能_第4张图片
图4

调整分支的顺序必须要保证排序与匹配成功无关。同时,这种改动并不能加快报告失败的速度,因为在报告匹配失败之前,所有可能的匹配都已经被尝试。

目标字符串 "(\\.|[^\\"])*" "([^\\"]|\\.)*"
"2"x3" likeness" 32次测试,14次回溯 22次测试,4次回溯
"... 99 more chars ..." 218次测试,109次回溯 111次测试,2次回溯
"no "match" here 124次测试,86次回溯 124次测试,86次回溯

消除循环

由于*控制着捕获型括号内的多选结构,每次进出括号都意味着状态的切换,为避免这部分的消耗,可以通过消除循环的技巧对表达式进行改进。这项技巧我们将在下文讲到,这里给出当前例子在消除循环之后的表达式是"[^\\"]+(\\\\.[^\\"]+)*"

错误的优化

为了减少*的迭代次数,在[^\\"]后引入+。对于不存在转义字符的字符串而言,这样会一次性读入整个字符串,而不用进行回溯。这改动似乎带来了不错的收益,在匹配成功的时候也确是如此。但在匹配失败时,却会造成指数级的回溯。例如目标字符串是makudonarudo+会对字符串做任意长度的切割,*再在切割的基础上进行多次迭代。长度为n的字符串,回溯的次数是$2^{n+1}$,独立测试的次数为$2^{n+1}+2^n$。如"2\"x3\" likeness and makudonarudo这种长度的目标字符串时就会造成应用程序的未响应。

常见的优化措施

对于正则引擎,各流派有自己的实现和优化措施。实现方案互有差异,优化措施也不尽相同,但通常可以归纳为两类:

  • 加速某些操作。
  • 避免冗余操作。如果引擎认为某些操作对于产生正确的结果是不必要的,或者某些操作能够应用到比之前更少的文本,而忽略这些操作可以节省时间。比如以^$锚定位置的表达式只有在行首(尾)才能匹配,若匹配失败,引擎不会在其它位置进行无谓的尝试。

对某个正则表达式的改动,在某个流派的实现方式中可能带来收益,而在另一个实现方式中却与期望背道而驰。在进行优化时,检测并性能测试实际期望应用的同类型数据,总是有助于判断改动是否有益。

应用正则表达式之前的优化措施

用户通过Pattern.compile在正则表达式应用之前,完成对正则表达式的编译。尤其是循环之前编译正则表达式,可以有效减少构建表达式内部形式的次数。这种方式,被称为“面向对象式处理中的编译缓存” 。此外还有“集成式处理中的编译缓存” 和“程序式处理中的编译缓存”,此处就不做介绍了。

通过传动装置进行优化

行锚点优化。能使用这种优化措施的引擎知道,在锚定位置才能满足表达式的匹配条件,传动装置会直接略过目标字符串中的其它位置的字符。

优化正则表达式本身

  • 消除不必要的括号。对于不需要捕获的分组,用(?: expression)代替(expression)
  • 消除不必要的字符组。没必要对单个字符应用字符组,特别是[.],完全可以用\.来代替。
  • 避免指数级的回溯。对于(.+)*之类的量词结合结构,+*二者分隔目标字符串为任意长度的子字符串,制造出指数级的回溯。
  • 使用占有优先量词/固化分组削减状态。在确定量词限定的元素与其之后的元素不会匹配重叠的情况下,可以使用占有优先量词,或者固化分组减少存储的备用状态。当然这种做法必须建立在引擎支持占有优先量词,或者固化分组的基础上。
  • 用字符组代替多选结构。[uvwxyz]代替u|v|w|x|y|z,因为前者只进行匹配操作,而后者可能需要在目标字符串的每个位置进行6次回溯。由这个例子可以看出多选结构或许是造成回溯的主要原因。
  • 控制多选结构的顺序。将最可能匹配的分支放在前面,减少回溯。
  • 将多选结构中开头相同的字符提取到多选结构之前。this|that就可以改成th(?:is|at)。这样th只需检查一遍,只有在确实需要的时候才会用到代价相对高昂的多选结构功能。
  • 按实际情况选择忽略优先量词和匹配优先量词。忽略优先量词通常要比匹配优先量词要慢(这不是言之确凿的)。另外对大多数引擎而言,排除型字符组的效率要比忽略优先量词快的多。如"[^"]*"".*?"
  • 避免过于复杂的正则表达式。

消除循环

所谓“循环”,指得是(this|that|...)*这类表达式中*代表的意义。消除循环这项技巧对于某些常用的表达式来说,加速效果非常明显。消除循环常用的解法是:

opening normal* (special normal*)* closing

这种解法中最重要的是要区分表达式中的specialnormal部分。一般而言,对于目标字符串中最常见的部分用normal子表达式处理,用special子表达式作为检查点处理其余不常见的部分。为避免(special normal*)*中的无休止匹配(即指数型回溯),需要确保以下三点:

  • special部分和normal部分匹配的开头不能重合;
  • normal部分必须能匹配至少一个字符;
  • special部分是固化的。当存在多种方式匹配同样的文本时,(special normal*)*最终也难免沦为(expression*)*,也就无从避免无休止匹配了。

例1:(?>[^<]*)(?>(?!)<[^<]*)*代替((?!).)*,这里的固化分组不是必须的,但如果只能部分匹配,使用固化分组可以提高速度。
例2:/\*[^*]*\*+([^/*][^*]*\*+)*/匹配java文件中的多行注释。
回过头来看消除循环这项技巧,它对表达式的可读性和可维护性造成了一定影响,但是测试证明其带来的速度收益也同样十分明显。

你可能感兴趣的:(改进正则表达式的性能)