正则引擎入门——正则文法匹配可以简单快捷(四)

前言

  整篇文章是对作者Russ Cox的文章Regular Expression Matching Can Be Simple And Fast的翻译,在我看来,该文章是入门正则引擎的较好的文章之一,读者在阅读之前,最好有一定的正则表达式的基础。翻译内容仅代表作者观点。侵删
  该作者所有的文章的网址在此:https://swtch.com/~rsc/regexp/

正文

性能表现

  前文中我们所提到的C语言的实现并未按照效率优先的方式编写,但即便如此,当我们的输入足够巨大的时候,一个线性时间复杂度的实现算法要远快与拥有指数时间复杂度的实现方法。我们用被称为病态的正则表达式去测试如今几个比较流行的正则引擎后得到的结果证明了上边的观点。
  考虑一下a?nan这一正则表达式。它可以匹配an。当a?选择不去匹配任何的字符的时候,整个字符串都会被后边的an所匹配。拥有回溯功能的正则引擎会在遇到零个或一个的符号 ? 时优先去匹配一个,之后才是零个。对于a?nan来说,便有n个选择,一共就会产生2n种可能性。而只有最后一种可能性——为所有的 ? 均选择为不匹配才可以最终匹配成功。因此采用回溯的正则引擎会需要O(2n)的时间,所以当n大于25的时候,匹配所需的时间就相当可观了。
  相反的,Thompson的算法在匹配长度为n的字符串,并在同时维护一个长度为n的状态列表所需要的匹配时间为O(n2)(整个的时间复杂度是超线性的,因为在输入增加的情况下,正则表达式不一定是恒定的,因此当我们使用长度为m的正则表达式去匹配长度为n的文本的时候,Thompson算法的时间复杂度为O(mn))
  下图展示了在不同的正则引擎下使用a?nan去匹配an所需的时间:
time graph
  注意到该图的y轴经过了对数化的处理,目的是为了让曲线的走势更加的清晰。
  通过上图我们可以知道Perl,PCRE,Python和Ruby均采用了递归的回溯。PCRE在n=23时就无法得出正确的匹配结果,因为它在超出某个最大的数值之后就会放弃回溯的匹配方式。而对于Perl 5.6来说,它的正则引擎据说采用了记忆表的技术,在消耗一些内存的情况下,能有效避免出现指数级的时间复杂度除非匹配时使用了回溯引用的功能。而在上图中看来,这项技术还不够成熟,即便正则表达式中没有回溯引用,它的匹配时间依然是指数级增长的。虽然在图中没有测试数据,但是Java也采用了回溯的实现方式。实际上java.util.regex接口要求使用回溯的实现方式。PHP采用的是PCRE的库。
  蓝色的线条是Thompson算法的C语言实现。Awk,Tcl,GNU grep和GNU awk均采用构造DFA的方式,要么是提前构造DFA,要么是采用下一节会介绍的边构造边使用(on-the-fly)的方式。
  有些同学可能会反对说这种测试对采用回溯的正则引擎不公平,因为我们太过于关注这些罕见的情况。这种反对其实漏掉了一点:当我们在面对两种正则引擎,一种在任何输入下都能快速高效的运行,另一种在大多数情况下能快速运行但在某些情况下会花费大量时间的时候,哪种是我们的首选其实是相当明了的。同时,排开我们这种罕见的情况不说,稍微复杂的情况依然是会出现的,例如我们使用(.*) (.*) (.*) (.*) (.*) (注意括号之间有空格)去拆分出5个由空格区分开的部分,或者当我们在使用 或 时排在前边的不是出现概率最大的情况。而最终的结果就是,码农们需要自己去学习,去记忆哪些是不好的正则表达式从而避开他们,或者他们需要从程序优化的角度去编写程序。而对于Thompson的方法来说,永远不需要注意这些东西,因为并没有所谓的不好的正则表达式。

储存NFA建立DFA

  回忆一下,我们之前说过DFA在运行的时候要比NFA更加高效,因为DFA在同一时间只会处于一个状态,他们永远不会面临多个下一状态的选择。任何一个NFA均可以转换为一个等价的DFA,该DFA中的一个状态对应于NFA的多个状态的集合。
  例如,下图是我们之前使用过的abab|abbb的NFA图,在之上加入了状态的序号:
  
NFA graph
  等价的DFA如下:
  
DFA graph
  DFA中的一个状态对应于NFA的多个状态的集合。
  在一定程度上,Thompson NFA的运行实质上也是对一个等价的DFA的模拟:每一个List都对应着一个DFA的状态,而给出当前的列表(list)以及下一个字符之后,函数step会计算出下一个要进入的DFA状态。Thompson的算法通过在需要时重建每一个DFA的状态来模拟DFA。我们可以将每一步计算出来的Lists保存在空余的内存中而不是在使用之后就丢弃,这样可以避免重复的计算,同时我们计算那些需要进行计算的DFA的状态。文章的这一节展示了这一方法的实现。在前文建立NFA的基础上,我们需要增加不到100行C代码去完成这一实现。
  为了达到存储的目的,我们首先介绍一个新的代表DFA的状态的数据结构:

struct DState
{
    List l;
    DState *next[256];
    DState *left;
    DState *right;
};

  一个DState是对列表(list)l的复制。数组next为每一个可能的输入保存下一个状态,如果现在的状态是d而下一个输入字符是c,那下一个状态便是d->next[c]。如果d->next[c]是NULL,那么下一个状态还未被计算。函数Nextstate根据给出的状态和字符计算,保存,返回下一个状态。
  整个匹配过程便是不断地沿着d->next[c]向前,在需要时调用函数d->next[c]计算新的状态。

int match(DState *start, char *s)
{
    int c;
    DState *d, *next;

    d = start;
    for(; *s; s++)
    {
        c = *s & 0xFF;
        if((next = d->next[c]) == NULL)
            next = nextstate(d, c);
        d = next;
    }
    return ismatch(&d->l);
}

  所有被计算出来的DState都需要被存储到一个结构中,方便我们通过List来查看一个DState。为了达到目的,我们维护一个二叉树来保存状态,以排好序的List来作为键值(Key)。函数dstate根据所给的List给出保存的DState,在必要时开辟内存进行初始化:

DState* dstate(List *l)
{
    int i;
    DState **dp, *d;
    static DState *alldstates;

    qsort(l->s, l->n, sizeof l->s[0], ptrcmp);

    /*在二叉树中查找DState */
    dp = &alldstates;
    while((d = *dp) != NULL)
    {
        i = listcmp(l, &d->l);
        if(i < 0)
            dp = &d->left;
        else if(i > 0)
            dp = &d->right;
        else
            return d;
    }

    /* 开辟内存,初始化DState */
    d = malloc(sizeof *d + l->n*sizeof l->s[0]);
    memset(d, 0, sizeof *d);
    d->l.s = (State**)(d+1);
    memmove(d->l.s, l->s, l->n*sizeof l->s[0]);
    d->l.n = l->n;

    /* 插入树中 */
    *dp = d;
    return d;
}

  函数nextstate调用step函数并返回所对应的DState:

DState* nextstate(DState *d, int c)
{
    step(&d->l, c, &l1);
    return d->next[c] = dstate(&l1);
}

  最后,DFA的初始状态是与NFA的初始列表相对应的DState:

DState* startdstate(State *start)
{
    return dstate(startlist(start, &l1));
}

  DState与DFA的状态所对应,但是我们只在需要的时候建立DFA的一个状态,如果一个DFA的状态没有在搜索中遇到,那它还根本不存在与内存中。另一种实现方式是在匹配之前将整个DFA构建出来,这样做会使得函数match运行得更快,但是会增加引擎初始化的时间以及内存消耗。
  有人可能会担心这种边构造边使用的DFA不断上升的内存消耗。因为DState只是为函数step保存数据,在dstate的实现中,我们可以在存储的数据过大时选择清空所有已经构建好的DFA。这样一种处理方式只需要在dstatenextstate中添加大约50行代码。(Awk使用了相似的内存限制策略,最多不能存储超过32个状态。这也就解释了为什么上图中当n=28时,awk的图线不连续)
  从正则表达式导出的NFA通常表现出良好的局部性,在匹配大多数的文本的时候,它们常常访问同一个状态,也常顺着相同的几条路径向前推进。这也就使得数据的存储显得非常值得,当第一次顺着一个箭头来到新状态时,这个状态必须被计算出来,而以后的同样的路径也只是对同一个内存地址的访问。真正的基于DFA的实现方式可以对匹配进行优化使效率更高。

真正的正则表达式

  在真正的程序中使用的正则表达式要比我们实现的正则引擎能处理的表达式复杂得多。这一节我们简要地介绍一下其中的复杂之处。对以下任何一部分的完整的介绍都超出了本文的范畴。
  字符类(Character classes) 一个字符类,无论是[0-9]还是\w或者是 . (句点),均只是“或”的另一种更加简明的表示形式。字符类可以在编译过程中被还原成“或”的形式,而更有效率的通常是增加一个新的类型的NFA结点去直接表示字符类。POSIX(Portable Operating System Interface)标准还定义了一些像[[: upper :]]这样的会根据当前语境改变自身含义的特殊字符类,而为了实现这些功能最困难的地方不在于怎样将它们融入到NFA中,而是如何准确的确定它们的含义。
  转义字符(Escape sequences) 真正的正则表达式文法需要能够处理转义字符,既要能够匹配元字符又要能够匹配不可见字符如换行符 \n。
  重复匹配(Counted repetition) 许多正则引擎提供了重复匹配的操作符 {n} 去控制正则表达式中某元素的重复匹配次数,{n,m}表示最少重复匹配n次,最多不超过m次。而{n, }表示至少n次。使用递归回溯方式实现的正则引擎可以采用循环去实现这一功能,而基于NFA或DFA的正则引擎则需要将重复匹配扩展一下,例如将e{3}扩展为eee,将e{3,5}扩展为eeee?e?,将e{3, }扩展为eee+。
  分组抽取(Submatch extraction) 当一个正则表达式被用来切分或者解析一个字符串时,如果我们能够知道究竟是字符串的哪一段被正则表达式的某一部分匹配了那将是非常有用的。当一个字符串被正则表达式([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)匹配之后(该正则表达式前一部分匹配日期,后一部分匹配时间),许多正则引擎都可以让我们获得由不同的子表达式所匹配得到的字符串,例如,我们可以在Perl中这样写:

if(/([0-9]+-[0-9]+-[0-9]+) ([0-9]+:[0-9]+)/)
{
    print "date: $1, time: $2\n";
}

  分组抽取已经被计算机科学的理论学家忽略了,而使用回溯实现这个功能也成了争议的焦点。然而,采用Thompson式的算法可以在不牺牲性能的前提下实现这个功能。早在1985年,第八版的Unix regexp库函数就实现了这种算法,虽然在之后这种算法被仔细地解释了,但是也没有被广泛使用,甚至没人注意它。
  非固定的匹配(Unanchored matches) 我们的这篇文章一直都假设正则表达式会去匹配所有的输入字符,而实际上,我们常常希望找到输入的字符串的某一部分是被正则表达式所匹配的。Unix的工具一般只会返回从字符串的左边开始,被正则表达式匹配的最长的部分。对于e的unanchored search其实是分组抽取的特殊情况,例如正则表达式.*(e).*中的 .* 被设定为匹配尽量短的字符串。
  懒惰操作符(Non-greedy operators) 在传统的Unix正则表达式中,重复操作符 ?,*,和+被定义为尽可能匹配最多的字符串,同时还允许整个正则表达式参与字符串的匹配。例如使用(.+)(.+)去匹配abcd时,第一个(.+)匹配到的是abc,第二个匹配到的是d。这些操作符被称作“贪婪的”。Perl引进了??,*?,+?作为?,*,+的非贪婪(懒惰)版本,它们会尽可能少的去匹配字符串从而避免了过度匹配。当使用例如使用(.+?)(.+?)去匹配abcd时,第一个(.+?)只会匹配a,第二个会匹配bcd。根据定义,一个操作符是否是贪婪的并不会影响一个正则表达式是否作为一个整体去匹配字符串,它只会影响到匹配时的分组情况。回溯的算法为懒惰操作符提供了一个简单的实现方式:在采用较长的匹配之前使用较短的匹配。例如,对于e?来说,首先采用e,之后再不使用;而e??则相反。
  断言(Assertions) 传统的正则表达式的元字符 ^ 和 $ 可以被视为对文本周围情况的断言。^ 断定在这之前的那个字符是一个换行符(或者是字符串的开始),而 $ 断定在这之后的字符是换行符(或者字符串的结尾)。Perl引入了更多的断言,例如单词边界 \b ,它断定在这之前的字符是文字或数字,而之后的字符则不是,或者相反的情况。Perl将这个观点推广提出了向前断言,例如(?=re)断言文本当前输入位置的之后两个字符与re匹配,但却并没有将文本的输入向前推进。(?!re)与此类似,但是是断言文本与re不匹配。向后断言 (?<=re)和(?<!re)与此类似但是是对当前文本输入位置之前的断言。简单的断言,例如^ ,$ , \b,比较简单在NFA中实现,将匹配延迟一个字节可以实现向前断言。推广后的断言比较难以实现,但是原则上是可以被融入NFA中的。
  回溯引用(Backreferences) 作为之前多次提到过的技术,没有人知道怎样去高效地实现回溯引用,但是也没有人能证明不存在更好的办法。(更具体地说,该问题是NP完全的,意味着一旦有人找到了更加高效的方法,那将会成为计算机科学的大新闻,这个发明人也会得到百万的奖金。)对于回溯,最简单高效的的策略,也是被最初的awk和egrep所采用的,便是不去实现回溯引用。但是这种策略已经不再适用了,因为越来越多的人开始时不时的依靠于回溯引用所带来的能力,与此同时回溯引用也是POSIX对正则表达式制定的标准的一部分。即便如此,我们也有理由在匹配大多数字符串的时候适用Thompson算法,而在需要的时候才使用回溯的算法。一个聪明的实现方式是将两种算法结合起来,使用回溯算法只是为了实现回溯引用。
  带记忆表的回溯(Backtracking with memoization) Perl采用记忆表来避免回溯过程中指数级的时间复杂度。至少在理论上,它应该使得Perl的正则引擎表现得更像NFA算法而不是回溯算法。但记忆表并没有完全解决问题,记忆表本身要求的内存空间要严格等于匹配文本的大小乘上正则表达式的长度。记忆表也并没有解决回溯过程中栈使用空间的问题,该空间与匹配的文本的大小呈线性相关,匹配过长的文本的时候可能会造成程序用尽栈空间:

$ perl -e '("a" x 100000) =~ /^(ab?)*$/;'
Segmentation fault (core dumped)
$

  字符集 现代的正则引擎需要处理如Unicode这样的非ASCⅡ字符集。Plan 9正则表达式库通过在每一步中使用单个Unicode字符作为NFA的输入来实现对Unicode字符集的支持。这个库通过对输入的字符译码来拆解NFA的运行。这样使用同样的正则表达式匹配代码就可以同时支持UTF-8和宽字符的字符集。

历史介绍

总结

  这两节就不翻译了,想了解的同学自己看看原文吧。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值