DFA&NFA

如果读者根据上面介绍的知识比较NFA和DFA,可能会得出结论:一般情况下,文本主导的DFA引擎要快一些。正则表达式主导的NFA引擎,因为需要对同样的文本尝试不同的子表达式匹配,可能会浪费时间(就好像上面例子中的3个分支)。

这个结论是对的。在NFA的匹配过程中,目标文本中的某个字符可能会被正则表达式中的不同部分重复检测(甚至有可能被同一部分反复检测)。即使某个字表达式能够匹配,为了检查表达式中剩下的部分,找到匹配,它也可能需要再一次应用(甚至可能反复多次)。单独的子表达式可能匹配成功,也可能失败,但是,直到抵达正则表达式的末尾之前,我们都无法确知全局匹配成功与否(也就是说“不到最后关头不能分胜负(It’s not over until the fat lady sings)”,但这句话又不符合本段的语境)。相反,DFA引擎则是确定型的(deterministic)——目标文本中的每个字符只会检查(最多)一遍。对于一个已经匹配的字符,你无法知道它是否属于最终匹配(它可能属于最终会失败的匹配),但因为引擎同时记录了所有可能的匹配,这个字符只需要检测一次,如此而已。

正则表达式引擎所使用的两种基本技术,都对应有正式的名字:非确定型有穷自动机(NFA)和确定型有穷自动机(DFA)。这两个名字实在是太饶舌,所以我坚持只用DFA和NFA。下文中不会出现它们的全称了(注4)。

用户需要面对的结果

因为NFA具有表达式主导的特性,引擎的匹配原理就非常重要。我已经说过,通过改变表达式的编写方式,用户可以对表达式进行多方面的控制。拿tonight的例子来说,如果改变表达式的编写方式,可能会节省很多工夫,比如下面这3种方式:

「to(ni(ght|te)|knight)」
「tonite|toknight|tonight」
「to(k?night|nite)」

给出任意文本,这3个表达式都可以捕获相同的结果,但是它们以不同的方式控制引擎。现在,我们还无法分辨这3者的优劣,不过接下来会看到。

DFA的情况相反——引擎会同时记录所有的匹配选择,因为这3个表达式最终能够捕获的文本相同,在写法上的差异并无意义。取得一个结果可能有上百种途径,但因为DFA能够同时记录它们(有点神奇,待稍后详述),选择哪一个表达式并无区别。对纯粹的DFA来说,即使「abc」和「 [aa-a](b|b{1}|b)c」看来相差巨大,但其实是一样的。
如果要描述DFA,我能想到的特征有:

DFA匹配很迅速。
DFA匹配很一致。
谈论DFA匹配很恼人。
最终我会展开这3点。

因为NFA是表达式主导的,谈论它是件很有意思的事情。NFA为创造性思维提供了丰富的施展空间。调校好一个表达式能带来许多收益,调校不好则会带来严重后果。这就好比发动机的熄火和点不着火,他们并不只是汽油发动机的专利。为了彻底弄明白这个问题,我们来看NFA最重要的部分:回溯(backtracking)。

回溯
Backtracking

NFA引擎最重要的性质是,它会依次处理各个子表达式或组成元素,遇到需要在两个可能成功的可能中进行选择的时候,它会选择其一,同时记住另一个,以备稍后可能的需要。

需要做出选择的情形包括量词(决定是否尝试另一次匹配)和多选结构(决定选择哪个多选分支,留下哪个稍后尝试)。

不论选择那一种途径,如果它能匹配成功,而且正则表达式的余下部分也成功了,匹配即告完成。如果正则表达式中余下的部分最终匹配失败,引擎会知道需要回溯到之前做出选择的地方,选择其他的备用分支继续尝试。这样,引擎最终会尝试表达式的所有可能途径(或者是匹配完成之前需要的所有途径)。

真实世界中的例子:面包屑
A Really Crummy Analogy

回溯就像是在道路的每个分岔口留下一小堆面包屑。如果走了死路,就可以照原路返回,直到遇见面包屑标示的尚未尝试过的道路。如果那条路也走不通,你可以继续返回,找到下一堆面包屑,如此重复,直到找到出路,或者走完所有没有尝试过的路。

在许多情况下,正则引擎必须在两个(或更多)选项中做出选择——我们之前看到的分支的情况就是一例。另一个例子是,在遇到「…x?…」时,引擎必须决定是否尝试匹配「x」。对于「…x+…」的情况,毫无疑问,「x」至少尝试匹配一次——因为加号要求必须匹配至少一次。第一个「x」匹配之后,此要求已经满足,需要决定是否尝试下一个「x」。如果决定进行,还要决定是否匹配第三个「x」,第四个「x」,如此继续。每次选择,其实就是洒下一堆“面包屑”,用于提示此处还有另一个可能的选择(目前还不能确定它能否匹配),保留起来以备用。

一个简单的例子

现在来看个完整的例子,用先前的「to(nite|knight|night)」匹配字符串‘hottonic tonight! ’(看起来有点无聊,但是个好例子)。第一个元素「t」从字符串的最左端开始尝试,因为无法匹配‘h’,所以在这个位置匹配失败。传动装置于是驱动引擎向后移动,从第二个位置开始匹配(同样也会失败),然后是第三个。这时候「t」能够匹配,接下来的「o」无法匹配,因为字符串中对应位置是一个空格。至此,本轮尝试宣告失败。

继续下去,从…tonic…开始的尝试则很有意思。to匹配成功之后,剩下的3个多选分支都成为可能。引擎选取其中之一进行尝试,留下其他的备用(也就是洒下一些面包屑)。在讨论中,我们假定引擎首先选择的是「nite」。这个表达式被分解为“「n」+「i」+「t」+「e」”,在…tonic…遭遇失败。但此时的情况与之前不同,这种失败并不意味着整个表达式匹配失败——因为仍然存在没有尝试过的多选分支(就好像是,我们仍然可以找到先前留下的面包屑)。假设引擎然后选择「knight」,那么马上就会遭遇失败,因为「k」不能匹配‘n’。现在只剩下最后的选项「night」,但它不能失败。因为「night」是最后尝试的选项,它的失败也就意味着整个表达式在…tonic…的位置匹配失败,所以传动机构会驱动引擎继续前进。

直到引擎开始从…tonight!处开始匹配,情况又变得有趣了。这一次,多选分支「night」终于可以匹配字符串的结尾部分了(于是整体匹配成功,现在引擎可以报告匹配成功了)。

回溯的两个要点
Two Important Points on Backtracking

回溯机制的基本原理并不难理解,还是有些细节对实际应用很重要。它们是,面对众多选择时,哪个分支应当首先选择?回溯进行时,应该选取哪个保存的状态?第一个问题的答案是下面这条重要原则:

如果需要在“进行尝试”和“跳过尝试”之间选择,对于匹配优先量词,引擎会优先选择“进行尝试”,而对于忽略优先量词,会选择“跳过尝试”。

此原则影响深远。对于新手来说,它有助于解释为什么匹配优先的量词是“匹配优先”的,但还不完整。要想彻底弄清楚这一点,我们需要了解回溯时使用的是哪个(或者是哪些个)之前保存的分支,答案是:
距离当前最近储存的选项就是当本地失败强制回溯时返回的。使用的原则是LIFO(last in first out,后进先出)。

用面包屑比喻就很好理解——如果前面是死路,你只需要沿原路返回,直到找到一堆面包屑为止。你会遇到的第一堆面包屑就是最近洒下的。传统的LIFO比喻也是这样:就像堆叠盘子一样,最后叠上去的盘子肯定是最先拿下来的。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值