正则表达式的优化全面详解

Categories: 前端, 后端, 工具语言
Comments: 5 Comments
Published on: 2012 年 02 月 28 日

最近经常接触的数据动不动都是几G的量,所以满脑子都是考虑的优化两个字,不过还好之前是搞算法竞赛的,效率这个问题也一直是做斗争的对象,还算习惯吧,最近写正则也比较多,所以参考参考书、博文、自己的经验写点总结性的优化策略。
个人原创,转载请注明:三江小渡~厚道啊!

就像之前写的mysql全面优化详解一样,就是因为这样工具应用十分广泛,所以对这样的工具全面的进行优化策略总结是非常划算的,因为无论你是PHP、Perl、Python、C++、C#、Java等等语言的程序员,你都是有非常大可能用上Mysql、正则表达式这样的工具的。

先说一下你可能不知道的一点关于正则表达式的知识,这对我们将来的优化是有用的。
大家常见的grep(global regular expression print)算是现在的正则的起源吧(从神经学家提出正则概念到数学家建立模型到被IBM用都没有大规模使用,到最终成为grep独立工具才被更多使用)。现在大家用的正则被POSIX(portable operating system interface)分为两个流派:BREs(base regular expressions)和EREs(extended regular expressions)。POSIX程序必须支持两者之一。这两者有不同特性需要了解。详细内容请看之前的一片文章shell脚本学习指南[一]中的 “三、常见3中类型正则表达式比较” 部分。

正则的匹配引擎主要可以分为两大类:DFA和NFA。前者确定性有限自动机,后者是非确定性有限自动机。编译原理里边有讲,有兴趣的另行wiki。现在正则引擎又分三类:

1、DFA 引擎在线性时状态下执行,因为它们不要求回溯(并因此它们永远不测试相同的字符两次)。DFA 引擎还可以确保匹配最长的可能的字符串。但是,因为 DFA 引擎只包含有限的状态,所以它不能匹配具有反向引用的模式;并且因为它不构造显示扩展,所以它不可以捕获子表达式。
2、传统的 NFA 引擎运行所谓的“贪婪的”匹配回溯算法,以指定顺序测试正则表达式的所有可能的扩展并接受第一个匹配项。因为传统的 NFA 构造正则表达式的特定扩展以获得成功的匹配,所以它可以捕获子表达式匹配和匹配的反向引用。但是,因为传统的 NFA 回溯,所以它可以访问完全相同的状态多次(如果通过不同的路径到达该状态)。因此,在最坏情况下,它的执行速度可能非常慢。因为传统的 NFA 接受它找到的第一个匹配,所以它还可能会导致其他(可能更长)匹配未被发现。
3、POSIX NFA 引擎与传统的 NFA 引擎类似,不同的一点在于:在它们可以确保已找到了可能的最长的匹配之前,它们将继续回溯。因此,POSIX NFA 引擎的速度慢于传统的 NFA 引擎;并且在使用 POSIX NFA 时,您恐怕不会愿意在更改回溯搜索的顺序的情况下来支持较短的匹配搜索,而非较长的匹配搜索。

根据正则引擎的不同,我们能够总结出两条普适的规则:
1、优先选择最左端的匹配结果。
2、标准的匹配量词(* + ? {n,m})是优先匹配的。

这里可以先举些优化的简单例子:
比如'.*[0-9][0-9]' 来匹配字符串"abcd12efghijklmnopqrstuvw",这时候的匹配方式是‘.*’先匹配了整行,但是不能满足之后的两个数字的匹配,所以‘.*’就退还一个字符‘w’,还是无法匹配,继续退还一个‘v’,循环退还字符到‘2’发现匹配了一个,但是还是无法匹配两个数字,所以继续退还‘1’。这样的情况我们了解这一特性后是应该尽量避免的。如果我们希望一个字符串里含有两个数字,直接进行两个数字的匹配就好了,就不要写‘.*’这样的通配符了。对优化的学习其实就是对底层实现的学习,因为优化就是尽量顺着工具的实现方式来实现自己想要的效果,如果你不了解所使用工具的底层,你也无法很好的知道什么情况合适用什么工具高效。

由上边对DFA和NFA的介绍,我们知道他们之间的差异,简单来说就是NFA是表达式主导引擎,DFA是文本主导引擎。一般来说:DFA类引擎只会对目标字符串的每个字符匹配一次,但是NFA则会回溯,文本主导的DFA会比表达式主导的NFA快一些。

细心思考的可能会注意到了,如何尽可能的回避NFA的回溯,将会是我们针对正则NFA引擎的优化的一大问题。这中间还有一个是否忽略优先匹配的问题,也是需要优化的一点。关于优先匹配也做一个简单的解释,因为上边说的普适规则里也说到这个点。如果匹配到一个位置,需要做尝试匹配或者跳过匹配这样的选择的时候,对于量词匹配,引擎会优先作出进行尝试行为,而忽略量词优先的时候则进行跳过尝试匹配。举例来说这两点如何工作的和为什么是需要优化的地方:

用ab?c 来匹配 abc,程序流程类似这样:
先匹配a这没问题,再匹配到b的时候,引擎会因为?号考虑要不要匹配b,默认是量词优先的,所以先做匹配尝试,另一种选择放在备选状态。这样就匹配了ab了,然后又成功匹配到了c,这样程序就结束了,备选状态就放弃了。

如果依然用ab?c来匹配ac,程序运行到b的时候会首先尝试匹配b,发现不行,这时候就会回溯,即回到匹配好a了的状态,然后程序继续运行匹配c,然后成功结束。这个过程就进行了回溯,学过算法的这个过程很好理解。就是类似栈的后进先出,这样总能比较方便的回溯的上一个合法的状态。

再来看忽略优先的匹配,如用ab??c 来匹配ac,程序先匹配a,成功然后到b??,这时候会放弃量词优先,跳过b的匹配先匹配c,这样就匹配成功结束,没有了之前的回溯过程。

再看一下不成功的匹配,让ab?x 来匹配abc,你会发现这次程序匹配a,然后尝试b,b成了然后尝试c,c不行回溯到不匹配b的状态尝试匹配x,依然无法匹配。然后回溯,然后移动起始位置从b开始尝试,不成功再尝试从c开始这样最后得出无法匹配的报告。

总的来看,就是你写的正则需要注意尽量避免回溯和确定你的正则什么地方需要回避优先匹配的原则这两点。上边例子非常简单,但是如果避免回溯就能把程序的时间复杂度从 平方级O(n*n)降到线性的O(n),当然这是理想状态。

*号和+号的回溯类似上述过程,比如x*,就可以看成x?x?x?x?……这样或者(x(x(x...?)?)?)? 这样。试想这样迭代的深入了很多层,突然来一个不能匹配的x,这是需要一层层向前回溯的。还有就是如果匹配.*[0-9],这样的表达式,首先这个匹配会先匹配.*,这使它能匹配完整的整个字符串,然后再一步步回溯,把退回的字符来匹配是否是数字,其实是可以直接匹配一个数字的。所以上边提到.*这样的通配符,如果非必须,就不要写这样的通配符。

另外DFA是不支持忽略优先的,只支持匹配优先。并且.*这一贪婪特点十分容易忽略,使用不当会得到我们未必需要的结果。比如我们想匹配(.*)括号内的内容,目标串是 abcd(aaaa)efg(ggg)h,根据.*的天性,会从匹配到的第一个(开始一直匹配到行尾,这时候再根据)的需求一个字符一个字符的退还以期能匹配),问题就出现了,最终匹配得到的结果是(aaaa)efg(ggg),这却不是我们期望的结果。事实上我们需要的正则表达式是([^()]*)。这种错误尤其小心发生在html标签里,像<b>123</b>456<b>789</b>,如果你要匹配替换的话,你会错的很离谱。但是你过你尝试使用类似([^()]*)这样的方法,拜托,请你思考一下问题,你这样会错的更离谱。比如<b>123/b><b</b>, 使用<b>[^</b>]</b>,很明显了,完全无法匹配。你想到办法了吗?只需要放弃匹配优先这一原则就很好实现了,类似这样:<br /><b>.*?</b>,会放弃.*的优先尝试匹配,会先匹配</b>不行的话才让.*吸收掉。或许你已经发现这样做仍然有问题,因为针对 <b>123<b>456</b>,这匹配结果仍然不会是我们所喜欢的,因为匹配回来的是 <b>123<b>456</b> ,而我们期望得到的是<b>456</b>。比较好的解决办法是使用正则里的环视功能,需要了解的另行google。

上边介绍了优先尝试和跳过尝试两种模式,使用得当是有助于正则优化的,还有一种模式是固化分组(?> expression )。具体说固化分组与正常的匹配没有任何差别,但是expression匹配成功的话,会固化这一结果,放弃任何备选状态。看一个实例:\w+: ,让他尝试匹配helloworld,我们一眼都能看出这是无法匹配的,因为它并不含冒号,为了对比固化匹配,我们还是描述一下这个过程:首先\w会匹配到字符串结束,然后尝试匹配:号,明显的d不能匹配,所以/w需要退回下个字符让:号来匹配,r也不行,最终退到h还是无法匹配然后报告无法匹配这一结果。如果你使用固化分组模式的话(?>\w+):来匹配helloworld的匹配过程:首先会匹配到行尾,然后发现无法匹配冒号,报告匹配不成功。因为我们知道\w是无法匹配符号的,所以如果\w能够匹配的内容,肯定不会是冒号,所以就没必要保留\w产生的备选状态让匹配过程产生回溯,固化分组能很好的消除这些备选状态。你如果想尝试,请确保你的工具是支持正则的固化分组。

还有一种占有量词优先:?+ , *+ , ++ , {m,n}+ 。这种模式匹配,量词会优先匹配,与量词优先匹配不同的是这种模式下的量词匹配的部分不会退回,也就是会移除量词匹配过程中产生的备选模式。

多结构的匹配类似 a|b|c 这样的,传统的NFA都会执行顺序匹配,每一分支都会穷尽所有备选状态。这一有序匹配的特点是能够发掘一点优化方法的,就是让匹配成功可能性大的情况尽量放前边。

上边说了很多,大多多是跟NFA相关的,正则优化的许多工作也就是针对NFA引擎而作的。DFA和NFA在预编译阶段都是把正则表达式转化成各自适合自己算法的规则式,只是DFA需要较多的内存,别且较NFA慢一些,但是正式匹配执行的过程中DFA是快于NFA的,甚至有些时候你正则表达式写的不好,NFA还会陷入无法结束匹配的尴尬境况。但是NFA依然存在依然主流的原因还是它能够提供DFA不能提供的功能的。比如上边刚才提到的种种匹配模式,都是DFA不能提供的。

NFA和DFA并非是不能并存的,有些工具是兼具两种匹配引擎的,来使自身具备DFA的高效和NFA的多功能的。比如GNU的grep和awk,在完成是否匹配的任务的时候使用高效的DFA引擎,完成复杂任务的时候也是尽量使用DFA,如果功能上无法满足需要就切换成NFA引擎。

上边算是比较混乱的介绍了DFA和NFA的正则引擎的一些知识和正则优化的例子。我们也知道了针对DFA引擎的正则式没有太多优化策略的,有的是你在书写正则表达式时的尽可能的准确和尽可能少的匹配尝试。针对NFA引擎的正则表达式我们是有较大优化空间的,但是在这个前边你要区分你所使用的工具是基于传统的NFA还是POSIX NFA。有些问题可能只针对某一引擎存在,对另一种却没太大影响。

避免回溯,更要避免指数级增长的回溯。比如表达式 ([^/]+)*:每次匹配一个字符都要考虑是应该属于+量词还是属于*量词,这样如果匹配一个长度为10的字符串,这样需要回溯1023次,第一次不算回溯,这是2的指数级增长的速度,如果这个字符串增长到20个,就超过了一百万种可能,时常若干秒,如果是30个,就超过十亿中可能,你要跑数小时,如果是字符串长超过40个,那要请你等一年多了。这其实给了我们一种判别自己所使用的工具用的正则引擎的所属:
1、如果某个表达式即便不能匹配,也能给出结果,那么它可能是DFA,只是可能。
2、如果能够匹配才能很快给出结果,那是传统NFA。
3、总是很慢的话,那就是POSIX NFA了。

第一个只是说可能,因为经过高级优化的NFA是能够迅速给出结果的。

再有一个是多选结构的回溯代价很高,比如:a|b|c|d|e|f  和 [a-f]  ,字符数组[a-f]只需要做简单的测试,但是该多选结构在匹配时每个位置都将多出6个备选状态以便回溯。

现在很多的正则编译器会进行许多你不知道的优化,但是常识性优化如果你注意到总是好的,因为你用的工具是否对这块进行了优化是不确定的。

  1. 如果你的正则工具支持,在不需要引用括号内文本的时候使用非捕获型括号:(?:expression) 。
  2. 如果括号是非必须的,请不要加括号。
  3. 不要滥用字符数组,比如[.],请直接用\. 。
  4. 使用锚点^ $ ,这会加速定位。
  5. 从两次中提取必须元素,如:x+写成xx*,a{2,4}写成aa{0,2}。
  6. 提取多选结构开头的相同字符,如the|this 改成th(?:e|is)。(如果你的正则引擎不支持这么使用就改成th(e|is));尤其是锚点,一定要独立出来,这样很多正则编译器会根据锚点进行特别的优化: ^123|^abc 改成^(?:123|abc)。同样的$也尽量独立出来。
  7. 多选结构后边的一个表达式放入多选结构内,这样能够在匹配任何一个多选结构的时候在不退出多选结构的状态下查看后一匹配,匹配失败的更快。这种优化需要谨慎使用。
  8. 忽略优先匹配和优先匹配需要你视情况而定。如果你不确定,请使用匹配优先,它的速度是比忽略优先快的。
  9. 拆分较大正则表达式成一个个小的正则表达式,这是非常有利于提高效率的。
  10. 模拟锚点,使用合适的环视结构来预测合适的开始匹配位置,如匹配十二个月份,可以先预查首字符是否匹配:(?=JFMASOND)(?:Jan|Feb|...|Dec)。这种优化请根据实际情况使用,有时候环视结构开销可能更大。
  11. 很多情况下使用固化分组和占有优先量词能够极大提高速度。
  12. 避免像(this|that)*这样的几乎无尽的匹配。上边提到的 (...+)*也类似。
  13. 如果能简单的匹配大幅缩短目标字符串,可以进行多次正则匹配,经过实践十分有效。

ps:行文可能比较混乱,主要参考[精通正则表达式(第三版)](美)Jeffrey.E.F.Friedl 这本书,另外也看了两篇别人的blog,基本上这本书函盖完了。

我猜你可能也喜欢:

5 Comments - Leave a comment
  1. Jian Xu说道:

    大赞此文,写得很用心诶~ 我以前只是用正则表达式,没有琢磨过实现它的引擎,看完了还是很有收获的~
    还有就算比这篇长得多的也该能正常显示的,诡异了。。。我建议你发一篇试验文然后看看它的html源码看到哪里出了问题。

    • 三江小渡说道:

      html源码里边没有任何内容。不是因为标签嵌套不合理之类的问题,我排查过了这个可能。好纠结啊。而且你上次给的go to moon那个游戏的链接地址下载下来有病毒,玩不了啊~~~周末想玩来着,郁闷啊..........

      • Jian Xu说道:

        那真的很奇怪啊。。。我以前出现类似的问题一般就是html出了错。。。
        应该不是病毒的,可能因为它是破解版的所以那种类似外挂的程序会被报毒。。。反正我是大着胆子给打通了,乃要是实在不放心可以找个破电脑装了玩或者扔到虚拟机里~

  2. 三江小渡说道:

    这篇文章算是搞了几天,加上读书、自己试验和整理。最后竟然发文章的时候出了问题,具体原因不知道为什么,描述一下问题:
    这篇文章一超过某个行数就会全文不显示,没有任何内容输出。但是列表页的more部分是有内容的,数据库里也是有内容的。
    经过试验,确实跟行数有关,即便我删除文章内的特别字符像 [] <> " \ 之类的,也不行。我是有使用wordpress 的add_shortcode函数的,我以为是最后书名[]的问题,但是测试过不是。删除一定行后会显示文章内容。然后再添加行,没行补1这样的方式添加到一定行数后又全文不显示了。。。。 弄了好一会不知道是何原因,希望有知道的能够告诉我。另外我有很多博文比这篇长的多也都显示很正常。

Leave a comment

电子邮件地址不会被公开。 必填项已用*标注

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>


Welcome , today is 星期五, 2017 年 09 月 22 日