• home > theory > algorithm > Introduction >

    深入正则表达式(3):正则表达式工作引擎流程分析与原理释义

    Author:zhoulujun Date:

    正则表达式引擎正则引擎主要可以分为两大类:一种是DFA(是确定性有限自动机),一种是NFA(非确定性有限自动机)。总的来说,DFA可以称为文本

    作为正则的使用者也一样,不懂正则引擎原理的情况下,同样可以写出满足需求的正则,但是不知道原理,却很难写出高效且没有隐患的正则。所以对于经常使用正则,或是有兴趣深入学习正则的人,还是有必要了解一下正则引擎的匹配原理的。

    有兴趣可以回顾《深入正则表达式(0):正则表达式概述》

    正则引擎类型

    正则引擎主要可以分为两大类:一种是DFA(Deterministic Finite Automatons/确定性有限自动机—),一种是NFA(Nondeterministic Finite Automatons/非确定性有限自动机)。总的来说,

    • DFA可以称为文本主导的正则引擎

    • NFA可以称为表达式主导的正则引擎

    NFA与DFA工作的区别:

    我们常常说用正则去匹配文本,这是NFA的思路,DFA本质上其实是用文本去匹配正则

    'for tonight's'.match(/to(nite|knite|night)/);
    • 如果是NFA引擎,表达式占主导地位。在字符串先查找字符串中的t然后依次匹配如果是o,则继续(以此循环)。匹配到to后,到n,就面临三种选择,每一种都去尝试匹配一下(它也不嫌累),第一个分支也是依次匹配,到t这里停止(nite分到t这里直接被淘汰);同理,接着第二个分支在k这里也停止了;终于在第三个分支柳暗花明,找到了自己的归宿。 NFA 工作方式是以正则表达式为标准,反复测试字符串,这样同样一个字符串有可能被反复测试了很多次!

    • 如果是DFA引擎呢,文本占主导地位。从整个字符串第一个字符开始f开始查找t,查找到t后,定位到t以知其后为o,则去查看正则表达式其相应位置后是否为o,如果是,则继续(以此循环),再去查正则表达式o后是否为n(此时淘汰knite分支),再后是否为g(淘汰nite分支),这个时候只剩一个分支,直接匹配到终止即可。

    只有正则表达式才有分支和范围,文本仅仅是一个字符流。这带来什么样的后果?就是NFA引擎在匹配失败的时候,如果有其他的分支或者范围,它会返回,记住,返回,去尝试其他的分支而DFA引擎一旦匹配失败,就结束了,它没有退路。

    这就是它们之间的本质区别。其他的不同都是这个特性衍生出来的。

    NFA VS DFA

    首先,正则表达式在计算机看来只是一串符号,正则引擎首先肯定要解析它。NFA引擎只需要编译就好了;而DFA引擎则比较繁琐,编译完还不算,还要遍历出表达式中所有的可能。因为对DFA引擎来说机会只有一次,它必须得提前知道所有的可能,才能匹配出最优的结果。

    所以,在编译阶段,NFA引擎比DFA引擎快


    其次,DFA引擎在匹配途中一遍过,溜得飞起。相反NFA引擎就比较苦逼了,它得不厌其烦的去尝试每一种可能性,可能一段文本它得不停返回又匹配,重复好多次。当然运气好的话也是可以一遍过的。

    所以,在运行阶段,NFA引擎比DFA引擎慢


    最后,因为NFA引擎是表达式占主导地位,所以它的表达能力更强,开发者的控制度更高,也就是说开发者更容易写出性能好又强大的正则来,当然也更容易造成性能的浪费甚至撑爆CPU。DFA引擎下的表达式,只要可能性是一样的,任何一种写法都是没有差别(可能对编译有细微的差别)的,因为对DFA引擎来说,表达式其实是死的。而NFA引擎下的表达式,高手写的正则和新手写的正则,性能可能相差10倍甚至更多。

    也正是因为主导权的不同,正则中的很多概念,比如非贪婪模式、反向引用、零宽断言等只有NFA引擎才有。

    所以,在表达能力上,NFA引擎秒杀DFA引擎


    但是NFA以表达式为主导,因而NFA更容易操纵,因此一般程序员更偏爱NFA引擎!

    当今市面上大多数正则引擎都是NFA引擎,应该就是胜在表达能力上


    总体来说,两种引擎的工作方式完全不同,一个(NFA)以表达式为主导,一个(DFA)以文本为主导!两种引擎各有所长,而真正的引用则取决与你的需要以及所使用的语言。

    这两种引擎都有了很久的历史(至今二十多年),当中也由这两种引擎产生了很多变体!

    因为NFA引擎比较灵活,很多语言在实现上有细微的差别。所以后来大家弄了一个标准,符合这个标准的正则引擎就叫做POSIX NFA引擎,其余的就只能叫做传统型NFA引擎咯。

    Deterministic finite automaton,Non-deterministic finite automaton,Traditional NFA,Portable Operating System Interface for uniX NFA

    于是POSIX的出台规避了不必要变体的继续产生。这样一来,主流的正则引擎又分为3类:DFA,传统型NFA,POSIX NFA。

    正则引擎三国

    DFA引擎

    DFA引擎在线性时状态下执行,因为它们不要求回溯(并因此它们永远不测试相同的字符两次)。

    DFA引擎还可以确保匹配最长的可能的字符串。但是,因为 DFA 引擎只包含有限的状态,所以它不能匹配具有反向引用的模式;并且因为它不构造显示扩展,所以它不可以捕获子表达式。

    DFN不回溯,所以匹配快速,因而不支持捕获组,支持反向引用和$number引用

    传统的 NFA引擎

    传统的 NFA 引擎运行所谓的“贪婪的”匹配回溯算法,以指定顺序测试正则表达式的所有可能的扩展并接受第一个匹配项。因为传统的 NFA 构造正则表达式的特定扩展以获得成功的匹配,所以它可以捕获子表达式匹配和匹配的反向引用。但是,因为传统的 NFA 回溯,所以它可以访问完全相同的状态多次(如果通过不同的路径到达该状态)。因此,在最坏情况下,它的执行速度可能非常慢。因为传统的 NFA 接受它找到的第一个匹配,所以它还可能会导致其他(可能更长)匹配未被发现

    大多数编程语言和工具使用的是传统型的NFA引擎,它有一些DFA不支持的特性:

    • 捕获组、反向引用和$number引用方式;

    • 环视(Lookaround,(?<=…)、(?<!…)、(?=…)、(?!…)),或者有的有文章叫做预搜索;

    • 忽略优化量词(??、*?、+?、{m,n}?、{m,}?),或者有的文章叫做非贪婪模式;

    • 占有优先量词(?+、*+、++、{m,n}+、{m,}+,目前仅Java和PCRE支持),固化分组(?>…)。

    POSIX NFA引擎

    POSIX NFA引擎主要指符合POSIX标准的NFA引擎,与传统的 NFA 引擎类似,不同的一点在于:提供longest-leftmost匹配,也就是在找到最左侧最长匹配之前,它将继续回溯(可以确保已找到了可能的最长的匹配之前它们将继续回溯)。因此,POSIX NFA 引擎的速度慢于传统的 NFA 引擎;并且在使用 POSIX NFA 时,您恐怕不会愿意在更改回溯搜索的顺序的情况下来支持较短的匹配搜索,而非较长的匹配搜索。

    同DFA一样,非贪婪模式或者说忽略优先量词对于POSIX NFA同样是没有意义的。

    三种引擎的使用情况

    • 使用传统型NFA引擎的程序主要有(主流):

      • Java、Emacs(JavaScript/actionScript)、Perl、PHP、Python、Ruby、.NET语言

      • VI,GNU Emacs,PCRE library,sed;

    • 使用POSIX NFA引擎的程序主要有:mawk,Mortice Kern Systems’ utilities,GNU Emacs(使用时可以明确指定);

    • 使用DFA引擎的程序主要有:awk,egrep,flex,lex,MySQL,Procmail等;

    • 也有使用DFA/NFA混合的引擎:GNU awk,GNU grep/egrep,Tcl。


    《精通正则表达式》书中说POSIX NFA引擎不支持非贪婪模式,很明显JavaScript不是POSIX NFA引擎。

    '123456'.match(/\d{3,6}/);
    // ["123456", index: 0, input: "123456", groups: undefined]
    '123456'.match(/\d{3,6}?/);
    // ["123", index: 0, input: "123456", groups: undefined]

    JavaScript的正则引擎是传统型NFA引擎。

    为什么POSIX NFA引擎不支持也没有必要支持非贪婪模式?

    回溯

    现在我们知道,NFA引擎是用表达式去匹配文本,而表达式又有若干分支和范围,一个分支或者范围匹配失败并不意味着最终匹配失败,正则引擎会去尝试下一个分支或者范围。

    正是因为这样的机制,引申出了NFA引擎的核心特点——回溯。

    首先我们要区分备选状态和回溯。

    什么是备选状态?就是说这一个分支不行,那我就换一个分支,这个范围不行,那我就换一个范围。正则表达式中可以商榷的部分就叫做备选状态。

    备选状态可以实现模糊匹配,是正则表达能力的一方面。

    回溯可不是个好东西。想象一下,面前有两条路,你选择了一条,走到尽头发现是条死路,你只好原路返回尝试另一条路。这个原路返回的过程就叫回溯,它在正则中的含义是吐出已经匹配过的文本。

    我们来看两个例子:

    'abbbc'.match(/ab{1,3}c/);
    // ["abbbc", index: 0, input: "abbbc", groups: undefined]
    'abc'.match(/ab{1,3}c/);
    // ["abc", index: 0, input: "abc", groups: undefined]

    第一个例子,第一次a匹配a成功,接着碰到贪婪匹配,不巧正好是三个b贪婪得逞,最后用c匹配c成功。

    正则文本
    /a/a
    /ab{1,3}/ab
    /ab{1,3}/abb
    /ab{1,3}/abbb
    /ab{1,3}c/abbbc

    第二个例子的区别在于文本只有一个b。所以表达式在匹配第一个b成功后继续尝试匹配b,然而它见到的只有黄脸婆c。不得已将c吐出来,委屈一下,毕竟贪婪匹配也只是尽量匹配更多嘛,还是要臣服于匹配成功这个目标。最后不负众望用c匹配c成功。

    正则文本
    /a/a
    /ab{1,3}/ab
    /ab{1,3}/abc
    /ab{1,3}/ab
    /ab{1,3}c/abc

    请问,第二个例子发生回溯了吗?

    并没有。

    诶,你这样就不讲道理了。不是把c吐出来了嘛,怎么就不叫回溯了?

    回溯是吐出已经匹配过的文本。匹配过程中造成的匹配失败不算回溯

    为了让大家更好的理解,我举一个例子:

    你和一个女孩子(或者男孩子)谈恋爱,接触了半个月后发现实在不合适,于是提出分手。这不叫回溯,仅仅是不合适而已。

    你和一个女孩子(或者男孩子)谈恋爱,这段关系维持了两年,并且已经同居。但由于某些不可描述的原因,疲惫挣扎之后,两人最终还是和平分手。这才叫回溯。

    虽然都是分手,但你们应该能理解它们的区别吧。

    为了让大家更好的理解,我举一个例子:

    你和一个女孩子(或者男孩子)谈恋爱,接触了半个月后发现实在不合适,于是提出分手。这不叫回溯,仅仅是不合适而已。

    你和一个女孩子(或者男孩子)谈恋爱,这段关系维持了两年,并且已经同居。但由于某些不可描述的原因,疲惫挣扎之后,两人最终还是和平分手。这才叫回溯。

    虽然都是分手,但你们应该能理解它们的区别吧。

    网络上有很多文章都认为上面第二个例子发生了回溯。至少根据我查阅的资料,第二个例子发生的情况不能被称为回溯。当然也有可能我([马蹄疾]是错的,欢迎讨论。

    我们再来看一个真正的回溯例子:

    'ababc'.match(/ab{1,3}c/);
    // ["abc", index: 2, input: "ababc", groups: undefined]

    匹配文本到ab为止,都没什么问题。后面既匹配不到b,也匹配不到c。引擎只好将文本ab吐出来,从下一个位置开始匹配。因为上一次是从第一个字符a开始匹配,所以下一个位置当然就是从第二个字符b开始咯。

    正则文本
    /a/a
    /ab{1,3}/ab
    /ab{1,3}/aba
    /ab{1,3}/ab
    /ab{1,3}c/aba
    /a/ab
    /a/aba
    /ab{1,3}/abab
    /ab{1,3}/ababc
    /ab{1,3}/abab
    /ab{1,3}c/ababc

    一开始引擎是以为会和最早的ab走完余生的,然而命运弄人,从此天涯。

    这他妈才叫回溯!

    还有一个细节。上面例子中的回溯并没有往回吐呀,吐出来之后不应该往回走嘛,怎么往后走了?

    我们再来看一个例子:

    '"abc"def'.match(/".*"/);
    // [""abc"", index: 0, input: ""abc"def", groups: undefined]

    因为.*是贪婪匹配,所以它把后面的字符都吞进去了。直到发现目标完不成,不得已往回吐,吐到第二个"为止,终于匹配成功。这就好比结了婚还在外面养小三,几经折腾才发现家庭才是最重要的,自己的行为背离了初衷,于是幡然悔悟。

    正则文本
    /"/"
    /".*/"a
    /".*/"ab
    /".*/"abc
    /".*/"abc"
    /".*/"abc"d
    /".*/"abc"de
    /".*/"abc"def
    /".*"/"abc"def
    /".*"/"abc"de
    /".*"/"abc"d
    /".*"/"abc"

    我想说的是,不要被回溯的回字迷惑了。它的本质是把已经吞进去的字符吐出来。至于吐出来之后是往回走还是往后走,是要根据情况而定的。

    优化正则表达式

    现在我们知道了控制回溯是控制正则表达式性能的关键。

    控制回溯又可以拆分成两部分:第一是控制备选状态的数量,第二是控制备选状态的顺序。

    备选状态的数量当然是核心,然而如果备选状态虽然多,却早早的匹配成功了,早匹配早下班,也就没那么多糟心事了。

    传统NFA工作流程

    许多因素影响正则表达式的效率,首先,正则表达式适配的文本千差万别,部分匹配时比完全不匹配所用的时间要长。上面提到过,JavaScript是传统NFA引擎,当然每种浏览器的正则表达式引擎也有不同的内部优化。

    为了有效地使用正则表达式,重要的是理解它们的工作原理。下面是一个正则表达式处理的基本步骤:

    第一步:编译

    当你创建了一个正则表达式对象之后(使用一个正则表达式直接量或者RegExp构造器),浏览器检查你的模板有没有错误,然后将它转换成一个本机代码例程,用于执行匹配工作。如果你将正则表达式赋给一个变量,你可以避免重复执行此步骤。

    第二步:设置起始位置

    当一个正则表达式投入使用时,首先要确定目标字符串中开始搜索的位置。它是字符串的起始位置,或由正则表达式的lastIndex属性指定,但是当它从第四步返回到这里的时候(因为尝试匹配失败),此位置将位于最后一次尝试起始位置推后一个字符的位置上。

          浏览器优化正则表达式引擎的办法是,在这一阶段中通过早期预测跳过一些不必要的工作。例如,如果一个正则表达式以^开头,IE 和Chrome通常判断在字符串起始位置上是否能够匹配,然后可避免愚蠢地搜索后续位置。另一个例子是匹配第三个字母是x的字符串,一个聪明的办法是先找到x,然后再将起始位置回溯两个字符。

    第三步:匹配每个正则表达式的字元

          正则表达式一旦找好起始位置,它将一个一个地扫描目标文本和正则表达式模板。当一个特定字元匹配失败时,正则表达式将试图回溯到扫描之前的位置上,然后进入正则表达式其他可能的路径上。

          第四步:匹配成功或失败

          如果在字符串的当前位置上发现一个完全匹配,那么正则表达式宣布成功。如果正则表达式的所有可能路径都尝试过了,但是没有成功地匹配,那么正则表达式引擎回到第二步,从字符串的下一个字符重新尝试。只有字符串中的每个字符(以及最后一个字符后面的位置)都经历了这样的过程之后,还没有成功匹配,那么正则表达式就宣布彻底失败。

          牢记这一过程将有助于您明智地判别那些影响正则表达式性能问题的类型。


    工具

    [ regex101 ]是一个很多人推荐过的工具,可以拆分解释正则的含义,还可以查看匹配过程,帮助理解正则引擎。如果只能要一个正则工具,那就是它了。

    [ regexper ]是一个能让正则的备选状态可视化的工具,也有助于理解复杂的正则语法。


    参考文章:

     https://baike.baidu.com/item/正则表达式

    正则表达式工作原理 https://www.cnblogs.com/aaronjs/archive/2012/06/30/2570800.html

    一次性搞懂JavaScript正则表达式之引擎 https://juejin.im/post/5becc2aef265da6110369c93


    转载本站文章《深入正则表达式(3):正则表达式工作引擎流程分析与原理释义》,
    请注明出处:https://www.zhoulujun.cn/html/theory/algorithm/IntroductionAlgorithms/8430.html