正则的匹配原理
正则引擎的分类
正则引擎主要可以分为三类: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。
匹配执行的实际流程
- 优先选择最左端的匹配结果
- 标准的匹配量词(
*
、+
、?
和{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
,在匹配成功之前,进行尝试的次数是存在上限和下限的。
标准匹配量词的结果“可能”并非所有可能中最长的,但它们总是尝试匹配尽可能多的字符,直到匹配上限为止。简而言之,匹配优先量词是以大局为重。
例子?:
-
用
.*
去匹配字符串"Hey,guys!",将会得到"Hey,guys!"整个字符串。因为*
是匹配优先的。 -
用
\b\w+s\b
来匹配包含“s”的字符串,比如说“dogs”,\w+
完全能够匹配整个单词,但如果用\w+
来匹配整个单词,s
就无法匹配了。为了完成匹配,\w+
必须匹配“dogs”中的“dog”,把最后的s\b
留出来。\w+
表达式会**“强迫”之前匹配优先部分“释放”某些字符**(这里是“s”)。 -
再来看看
^.*([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)
,它的真正含义是nite
或knight
或night
。引擎会依次尝试这3种可能。表达式主导的引擎必须完全测试,才能得出结论。
DFA引擎:文本主导
DFA引擎在扫描字符串时,会记录“当前有效”的所有匹配可能。
继续扫描两个字符之后的情况:
这时有效的匹配变为两个,当扫描到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:
。