正则表达式(三)—— 正则的匹配原理

正则的匹配原理

正则引擎的分类

正则引擎主要可以分为三类:DFA、传统NFA(Traditional NFA)和POSIX NFA

用了这么久的正则,js属于哪一种呢?

测试引擎的类型

是否是传统型NFA

首先看看忽略优先量词是否得到支持?如果是,基本就能确定就是NFA。忽略优先量词是DFA不支持的,在POSIX NFA中也没有意义。只需要用/nfa|nfa not/去匹配“nfa not”。如果只有“nfa”匹配了,就是传统型NFA。如果整个“nfa not”都能匹配,则此引擎要么是POSIX NFA,要么是DFA。

匹配优先:尽可能多的匹配

忽略优先:尽可能少的匹配

匹配优先量词: * + ? {min,max}

忽略优先量词:*? +? ?? {min,max}?

例如:

/^\w+?a/g.exec('12a34a56');   // ["12a"]
/^\w+a/g.exec('12a34a56');   // ["12a34a"]
"nfa not".match(/nfa|nfa not/);   // "nfa"

所以js的引擎是传统型NFA。

如果得到的结果是“nfa not”,那么需要进一步判断是POSIX NFA还是DFA。

X(.+)+X去匹配“XX===========================”

如果执行时间长,就是POSIX NFA。

如果执行时间短,就是DFA,或者是支持某些高级优化的NFA。

console.time('/X(.+)+X/ test');
"==XX=============================".match(/X(.+)+X/);
console.timeEnd('/X(.+)+X/ test');
// /X(.+)+X/ test: 12686.867919921875ms

匹配时间这么长,是NFA引擎的回溯失控导致的。

所以,js是Traditional NFA。

匹配执行的实际流程

  1. 优先选择最左端的匹配结果
  2. 标准的匹配量词(*+?{m,n})是匹配优先的

优先选择最左端的匹配结果

根据这条规则,起始位置最靠左的匹配结果总是优先于其他可能的匹配结果。

这条规则的由来是:匹配先从需要查找的字符串的起始位置尝试匹配,在这里,“尝试匹配”的意思是,在当前位置测试整个正则表达式能匹配的每样文本。如果在当前位置测试了所有的可能之后不能找到匹配结果,就需要从字符串的第二个字符之前的位置开始重新尝试。在找到匹配结果之前必须在所有的位置重复此过程。只有在尝试过所有的起始位置都不能匹配结果的情况下,才会报告“匹配失败”。

例如:

var reg = /fat|cat|belly|your/;
var str = 'the gragging belly indicates that your cat is too fat.';
var arr = str.match(reg);  
console.log(arr);
// ["belly"]

标准量词是匹配优先的

标准的匹配量词(*+?{m,n})是匹配优先的。如果用这些量词来约束某个表达式,例如(expr)*中的(expr)a?中的a,在匹配成功之前,进行尝试的次数是存在上限和下限的。

标准匹配量词的结果“可能”并非所有可能中最长的,但它们总是尝试匹配尽可能多的字符,直到匹配上限为止。简而言之,匹配优先量词是以大局为重。

例子?:

  1. .*去匹配字符串"Hey,guys!",将会得到"Hey,guys!"整个字符串。因为*是匹配优先的。

  2. \b\w+s\b来匹配包含“s”的字符串,比如说“dogs”,\w+完全能够匹配整个单词,但如果用\w+来匹配整个单词,s就无法匹配了。为了完成匹配,\w+必须匹配“dogs”中的“dog”,把最后的s\b留出来。\w+表达式会**“强迫”之前匹配优先部分“释放”某些字符**(这里是“s”)。

  3. 再来看看^.*([0-9][0-9])匹配"I was 14 years old 10 years ago"的过程。.*匹配整个字符串以后,第一个[0-9]的匹配要求.*释放一个字符o,但是这并不能让[0-9]匹配,所以.*必须继续释放字符,如此循环,直到.*最终释放0为止。不过即使第一个[0-9]能够匹配0,第二个[0-9]仍然不能匹配,为了匹配整个正则表达式,.*必须再次释放一个字符。即最后匹配了"I was 14 years old 10"

思考?:如果用^.*([0-9]+)来匹配“copyright 2019”,括号会捕获什么呢?

记住,大橘为重!!!

忽略优先量词

忽略优先量词有*?+???{num,num}?,也就是在匹配优先量词上加一个?忽略优先也叫惰性匹配。

例子?,我们把上面第三个例子改为忽略优先:

^.*?([0-9][0-9])匹配"I was 14 years old 10 years ago"。*?会因为是忽略优先而放弃匹配,然后控制权交给[0-9],发现匹配失败,回溯,接着匹配,以此往复当匹配到4时,匹配成功。即最后匹配了"I was 14"

 

我们发现,匹配优先量词会先贪婪的把能匹配的都匹配了,然后最后再以大局为重被迫交出几个字符,这样看好像是从后往前来匹配的,而忽略优先量词会懒惰的不想匹配,然后再以大局为重被迫匹配几个字符,在这样看好像是顺序匹配的。

假如有一篇长篇大论,然而只想匹配开头的子串的话,应该首先选择忽略优先量词,如果只想匹配后面的子串,应该首先选择匹配优先量词

 

 

表达式主导与文本主导

DFA和NFA反映了将正则表达式在应用算法上的根本差异。我们把NFA称为“表达式主导”引擎,把DFA称为“文本主导”。

NFA引擎:表达式主导

看下这个例子:to(nite|knight|night)匹配文本“tonight”。正则中第一个元素是t,它将会重复尝试,直到在目标字符串中找到“t”为止。之后,就检查紧随其后的字符是否能由o匹配,如果能,就检查(nite|knight|night),它的真正含义是niteknightnight。引擎会依次尝试这3种可能。表达式主导的引擎必须完全测试,才能得出结论。

DFA引擎:文本主导

DFA引擎在扫描字符串时,会记录“当前有效”的所有匹配可能。
https://img-blog.csdnimg.cn/20190613192155997.png
继续扫描两个字符之后的情况:
https://img-blog.csdnimg.cn/20190613192401515.png
这时有效的匹配变为两个,当扫描到g时,就只剩下一个可能匹配了。

比较NFA和DFA:

一般情况下,文本主导的DFA引擎会比正则表达式主导的NFA引擎更快。

在NFA的匹配过程中,目标文本中的某个字符可能会被正则表达式中的不同部分重复检测。即使某个子表达式能够匹配,为了检查表达式中剩下的部分,找到匹配,它也可能需要再一次应用。

DFA引擎是确定型的 —— 目标文本中的每个字符只会检查(最多)一次。对于一个已经匹配的字符,引擎同时记录了所有可能的匹配,这个字符只需要检测一次。

因为NFA具有表达式主导的特性,引擎的匹配原理就非常重要。

回溯

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

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

如果正则表达式的余下部分匹配失败,引擎会知道需要回溯到之前做出选择的地方,选择其他的备用分支继续尝试。

我们来看一个简单的?:
to(nite|knight|night)匹配"hot tonic tonight":

正则中第一个元素t从字符串最左端开始匹配,因无法匹配,驱动引擎向后移动。到第三个时,‘t’能匹配,接下来的o无法匹配。本轮匹配失败。

to匹配成功之后,剩下的3个多选分支都成为可能。引擎选择其中之一进行尝试,留下其他的备用。

回溯的两个要点

  • 如果需要在“进行尝试”和“跳过尝试”之间选择,对于匹配优先量词,引擎会优先选择“进行尝试”,而对于忽略优先量词,会选择“跳过尝试”。
  • 距离当前最近存储的选项就是当本地失败强制回溯时返回的。使用的原则是LIFO(后进先出)。

用NFA正则表达式的术语来说,那些备选分支处于“备用状态”。它们用来标记:在需要是时候,匹配可以从这里重新开始尝试。它们保留了两个位置:正则表达式中的位置和未尝试的分支在字符串中的位置。

例如:

ab?c匹配’ac’,a匹配之后,需要决定是否匹配b?,因为?是匹配优先的,它会尝试匹配。但是这时无法匹配。因为有一个备用状态,这里匹配失败不会导致整体匹配失败。引擎会进行回溯,把“当前状态”切换为最近保存的状态。

a▴c—ab?▴c

ab?c匹配’abX’,b能够匹配,但是因为c无法匹配X。于是引擎会回溯到之前的状态,交还b给c来匹配,显然这次也会失败。因为不存在其他状态,字符串中当期位置开始的整个匹配也会宣告失败。

a▴bX — ab?▴c

你以为事情到这里就结束了么?并没有。传动装置会继续在字符串中前行,再次尝试匹配。这里我们可以叫它伪回溯。

匹配重新开始于:a▴bX — ▴ab?c

a▴bX、ab▴X和abX▴都会匹配失败

忽略优先的匹配

ab??c匹配’abc’。a匹配之后:a▴bc — a▴b??c

接下来到b??,因为??是忽略优先的,它会首先尝试忽略,但是为了能从失败的分支中恢复,引擎会保存状态:a▴bc — a▴bc

接下来:a▴bc — ab??▴c

c无法匹配b,回溯到保存的备用状态。a▴bc — a▴bc

此时就能匹配成功了。

有了更详细的了解之后,我们再看看之前^.*([0-9][0-9])匹配"I was 14 years old 10 years ago"的例子。

.*成功匹配到字符串末尾时,保存了许多备用状态。现在我们到了字符串的末尾,并把控制权交给第一个[0-9],这里不能匹配成功,将进行回溯,把当前状态设为最近保存的状态,即.*匹配最后的o的状态。交还这个匹配,于是用[0-9]匹配o,同样会失败。“回溯-尝试”会不断循环,直到引擎交还到0为止,这里第一个[0-9]能匹配成功,但是第二个仍然无法匹配,所以必须继续回溯。当前的回溯会把字符串中的位置设置到1之前,所以现在匹配成功。$1得到20。

有了上面的理解,我们看下之前的一个例子:

需要对小数格式做处理:通常是保留小数点后两位数字,如果第三位不为零,也需要保留,去掉其他的数字。结果就是12.3750000034或者12.375会被修正为12.375,而37.500会被修正为37.50。

var str = '34.345200000234';
str = str.replace(/(.\d\d[1-9]?)\d*/,'$1');
// 34.345

但现在我们想想,如果str的数据本身规范格式,例如str是23.345,最后就相当于用‘.345’替换‘.345’。这个替换是白费功夫的。

那是否存在更有效率的办法呢?即只有当\d*确实匹配到字符时才替换。这时我们会想到把\d*替换为\d+。那这个能解决我们的问题么?

这时再看看匹配23.345。(.\d\d[1-9]?)\d+,括号中的内容在匹配‘.345’之后,\d+无法匹配。但是这并不会影响整个表达式的匹配,因为[1-9]?匹配5只是可选分支之一,还有一个备选状态。它容许[1-9]?匹配一个空字符,而把5留给至少必须匹配一个字符的\d+。并不能得到我们想要的结果。

如果匹配能够进行到(.\d\d[1-9]?)▴\d+中的位置,不进行回溯的话,那就能得到我们想要的结果。固化分组能实现我们的需求。

 

固化分组

固话分组:(?>...)

使用固化分组与正常的匹配并无区别,但是如果匹配进行到此结构之后(即进行到闭括号之后),那么此结构体中的所有备用状态都会被放弃(不能被回溯)。

也就是说,在固话分组匹配结束时,它已经匹配的文本已经固化成为了一个单元,只能作为整体保留或放弃。括号内的子表达式中未尝试过的备用状态都不复存在了,所以回溯也不能选择其中状态。

(.\d\d(?>[1-9]?))\d+,在固话分组(?>[1-9]?)内,如果[1-9]不能匹配,正则表达式会返回?留下的备用状态。然后匹配脱离固化分组,继续前进到\d+。在这种情况下,当控制权离开固化分组时,没有备用状态需要放弃。

如果[1-9]能够匹配。例如‘.345’,因为\d+无法匹配,正则引擎需要回溯,但回溯又无法进行,因为备用状态已经不存在了。所以匹配失败,‘.345’不需要处理。

使用固化分组加快匹配失败的速度

我们一眼就能看出,^\w+:无法匹配’Subject’,但是正则引擎必须经过尝试才能得到这个结论,及此次匹配中所有的回溯都是白费功夫。如果冒号无法匹配最后的字符,那么它当然无法匹配+交换的任何字符。

所以可以改为:^(?>\w+):

但是很遗憾,js引擎不支持固化分组。

用肯定顺序环视模拟固化分组

(?>exp)可以用?=(exp)\1来模拟。这里的关键就是,后面的\1捕获的就是环视结构捕获的单词,而这当然会匹配成功。在这里\1并非多此一举,而是为了把匹配从这个单词结束的位置进行下去。

上面的例子即可改为:^(?=(\w+))\1:

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值