Loading

正则表达式相关

左端优先

用正则表达式cat来匹配如下文本,匹配到的是indicates,而非cat

匹配优先的标准量词

标准量词(?、*、+、{min, max}),都是匹配优先。即它们总是匹配上限之内最长的文本。

看这个正则表达式,$1$2都代表什么

^Subject: (.*)(.*)

.*表示匹配任意字符零次或多次,由于匹配优先,第一个.*将匹配整行,第二个将什么都不匹配。

注意,$2什么都不匹配的意思不是它匹配失败了,*不会匹配失败,因为它的下限是匹配0次。

释放字符

标准量词是匹配优先的,那如果它后面还有其他模式,是不是那些模式都没有用了?

文本:about24characters long
正则:^.*([0-9][0-9])

.*匹配了一行的所有字符后,后面的([0-9][0-9])这个量词释放若干字符,让后面的模式匹配。

首先会释放行尾的g,但它没办法让模式匹配,然后会释放n,然后一直向前释放,直到4,它能让[0-9]匹配,但它并不能让两个[0-9]都匹配,所以需要再向前释放。最后的结果是about24$1的值是24。

先来先服务

文本:Copyright 2003.
正则:[^.*[0-9]+]

这里优先满足.*的需求,得到的结果是满足.*的最长匹配和满足[0-9]+的最短匹配。也就是.*匹配Copyright 200[0-9]+匹配3

NFA与DFA

正则表达式解析引擎的实现分为两种,NFA和DFA。

以下对两种引擎的说明使用下面的例子

  • 正则:to(nite|knight|night)
  • 待匹配文本:...tonight...

NFA——表达式主导

  1. 首先NFA会检索表达式的一部分,从t开始,然后再去检索文本。
  2. 当从文本中找到匹配的t时,该部分表达式匹配成功,检查表达式的下一部分,也就是o,然后再次检索文本。
  3. 直到表达式的所有部分都匹配成功,整个表达式匹配成功

当遇到子表达式时,会依次对每一个表达式进行尝试,也就是说当匹配完表达式中的to,走到子表达式时,会分别匹配:

  1. nite
  2. knight
  3. night

DFA——文本主导

引擎会去检索文本的一部分,如果文本的某一部分和表达式匹配,则添加一个潜在的可能。比如当匹配文本到t时,正则表达式刚好能匹配。

有效的匹配可能不仅有一个,当出现子表达式时就会出现多个可能有效的分支。比如tonite分支中和tonight分支中都有的匹配模式t-o-n-i

当再向后匹配一个g时,tonite分支就会被淘汰。

比较NFA与DFA

DFA比NFA快,因为NFA对文本中一个部分可能需要重复检测多次,而DFA对于每一个字符只需要检测一次。

由于NFA是表达式主导,所以正则表达式的编写者可以采用不同的表达式编写方法控制引擎的行为,而DFA同时记录所有选择,编写者无法控制引擎的行为。

DFA——非确定型有穷自动机,NFA——确定型有穷自动机

回溯

NFA的关键算法。

  • 正则:to(nite|knight|night)
  • 待匹配文本:...tonight...

NFA可能会先选择t-o-n-i-t(来自第一个子表达式)这条路径,然而当到第二个t时,NFA会发现已经和待匹配文本不匹配了,所以要进行回溯。

然后选择第二个t-o-k,到此,已经与待匹配文本不匹配了,回溯。

选择第三个t-o-n-i-g-h-t,匹配成功。

NFA引擎最终会尝试表达式的所有可能途径(或者是匹配完成之前所需要的所有途径)

如需要在进行尝试跳过尝试之间做选择,对于匹配优先的量词,选择进行尝试,对于忽略优先(NFA特有的,是在匹配优先量词后加一个?)量词,则选择跳过尝试

示例

约定:下划线_代表正则引擎当前检测的位置,例如a_bc意思是正则已经检测完了a,待检测b

文本:abc
正则:ab?c
待检测
_abc
_ab?c


第一步,匹配a,成功匹配
a_bc
a_b?c

因为表达式中的下一个`b?`是匹配优先量词,保存b?没有匹配时的回溯点
a_bc
ab?_c

第二步,匹配b?,因为是匹配优先量词,先尝试匹配,成功匹配
ab_c
ab?_c


第三步,匹配c,成功匹配,删除保存的回溯点
abc_
abc_

文本:ac
正则:ab?c
_ac
_ab?c

第一步,匹配a
a_c
a_b?c

因为表达式中的下一个`b?`是匹配优先量词,保存b?没有匹配时的回溯点
a_c
ab?_c


第二步,匹配b?,没匹配到
ac_
ab?_c

由于第二步没匹配到(但不代表失败),保存如下的回溯点
a_c
ab?_c

第三步,匹配c,全部成功,清除保存的回溯点
ac_
ab?c_

文本:abX
正则:ab?c
_abX
_ab?c

第一步,匹配a
a_bX
a_b?c


由于是匹配优先量词,保存这个回溯点
a_bX
ab?_c

第二步,匹配b?,匹配到了
ab_X
ab?_c

第三步,匹配c,匹配失败
abX_
ab?c_

由于第三步失败,回溯到之前的点
a_bX
ab?_c

第四步,用c匹配b,失败
ab_X
ab?c_

第五步,跳过文本中的a,匹配bX,后面都会以失败告终。

忽略优先的匹配

文本:abc
正则:ab??c
_abc
_ab??c

第一步
a_bc
a_b??c

第二步之前,保存如下状态,即b存在的状态
a_bc
a_bc

第二步,由于忽略优先,直接跳过b??,即用表达式中的c和文本中的b比较,失败
ab_c
ab??c_

第二步失败,返回保存的状态,这时想要匹配成功,b必然存在
a_bc
a_bc

第三步,匹配b
ab_c
ab_c

第四步,匹配c
abc_
abc_

之前的例子

文本:Copyright 2003.
正则:[^.*[0-9]+]

所以这个。.*匹配了整行,并保存了如下的回溯点
C_o_p_y_r_i_g_h_t_ _2_0_0_3_.

然后[0-9]+要求它回溯,首先它回溯到.前面那个回溯点,显然[0-9]+没有匹配成功,再次回溯,到3,这次匹配成功了。

匹配优先的问题

The name "McDonald's" is said "makudonarudo" in Japanese.

如果用".*"匹配上面的字符串,得到的是

"McDonald's" is said "makudonarudo"

而非引号中的内容。

这是因为匹配优先,.*会一直匹配,直到文本末尾,到了最后,还需要匹配一个末尾的"时,才会向前回溯,所以这个结果是预期中的。这也是我最初使用正则时经常遇到的问题。

解决的办法,可以使用忽略优先量词,也就是.*?。但这样DFA就不支持了。

更加推荐的办法是:"[^"]*"

使用忽略优先量词

上面的例子,如果使用".*?",那么它的匹配过程如下

对于"McDonald's",它先匹配",对于.*?先选择放弃匹配,但保存回溯点

文本匹配位置:"_McDonald's"
正则匹配位置:"_.*?"

然后就需要用正则中的后一个"来匹配文本中的M,显然是失败的。然后回回溯,使用.匹配M,这里能成功匹配,由于*?又是忽略优先的,所以它又被放弃,保存如下回溯点:

文本匹配位置:"M_cDonald's"
正则匹配位置:"_.*?"

然后又是用正则中的最后一个"匹配文本中的c,又失败,然后回溯...一直是这个过程,直到使用正则中的"匹配到文本中的"

忽略优先量词的局限性

文本:<B>Hello World</B>
正则:<B>.*?</B>

看起来,忽略优先量词能胜任此任务,但是如果有如下文本:

<B>Hello <B>World</B>

忽略优先量词会匹配左边的<B>和右边的</B>,而中间的<B>则会被.*?匹配。

可以使用排除环视功能

<B>(.(?!<B>))*?</B></B>

匹配优先、忽略优先和回溯的要旨

无论是匹配优先还是忽略优先,只要引擎报告匹配失败,那么必定已经尝试了所有可能

s/(\.\d\d[1-9]?)\d+/$1/

如上正则希望替换12.12419219这种数字为12.124,也就是保留三位小数,而对于第三位是0的,保留两位小数,如12.14021301,保留12.14

这个正则无法应对12.434这种数字。

原因在于,文本中的.434都先被子表达式(\.\d\d[1-9]?)匹配成功了,但后面还有个\d+,它匹配失败了。编写这个正则的人的原意是到此匹配以失败告终,替换不会发生,因为12.434本就满足要求,不应该发生一次多余的替换。

但,只要引擎报告匹配失败,那么必定已经尝试了所有可能,因为最后的\d+不满足,所以会回溯到前一个状态,这次引擎尝试放弃匹配优先量词[1-9]?的匹配,让最后一个数字给\d+匹配,这样引擎就能报告匹配成功。

所以最后,12.434中的$112.43,是错误的结果。

可以把上面的\d+换成\d*,问题就得到解决了,如下:

s/(\.\d\d[1-9]?)\d*/$1/

新的问题就是,\d*可以什么都不要,所以12.434这种本来就合法的数字也需要一次替换,因为它也能匹配成功。

固化分组

有的正则表达式实现支持固化分组,使用(?>...)元字符序列。

固化分组的意思就是,如果一个可选的模式匹配成功了,那么就将这个模式固化,它之前保留的所有回溯点状态将全部清除,即使后面的匹配使引擎以失败告终,也无法避开该可选模式,回溯到之前的位置。

所以我们可以继续应用之前\d+的写法,但给[1-9]?套上固化分组

s/(\.\d\d(?>[1-9]?))\d+/$1/;

这样,当遇到12.434这种字符串时,\.\d\d(?>[1-9]?)会先匹配.434,因为固化分组匹配成功,所以引擎不会再对[1-9]?进行不匹配的尝试,即使后面的\d+匹配失败。最终,12.434的匹配以失败告终,不会进行替换操作。

在拥有固化分组之前,无论使用何种办法,我们没法影响匹配要测试的路径,最多影响检测路径的顺序。固化分组可以让引擎在某些情况下直接放弃一些路径,这是它的要点

(?>.*?)永远不匹配任何字符,或者说任何忽略优先的量词使用固化分组都不会匹配字符。因为忽略优先量词会先行忽略,然后记录未忽略时的状态,但固化分组会把这个未忽略状态清除,所以永远不会回溯到未忽略该量词的状态。

占有优先量词

占有优先量词就是在匹配优先量词后加+。如?+*+++{m,n}+

它们从不交还已经匹配的字符,它不压根不会创建回溯状态。和固化分组很像,但是感觉更加轻便好用。

环视中的回溯

环视当然也需要保存位置和回溯,它有独立的状态。但由于环视不匹配任何内容,只是匹配位置,所以当环视成功时(或否定环视匹配失败时),它的所有备用状态和位置都会被删除。当环视匹配失败时,它会从自己独立的备用状态中选择最近的回溯,当回溯到达环视表达式的起点时,环视表达式匹配失败(否定环视匹配成功),这时它的所有备用状态和位置也都会删除。

肯定环视模拟固化分组

我刚看到固化分组的时候贼高兴,但当我在很多语言中尝试使用的时候,发现基本都不支持。

但是书上给出了使用肯定环视模拟固化分组的办法,这样我们可以在任何支持肯定环视的地方使用固化分组功能。

原理就是把(?>regex)转换成(?=(regex))\1。第二个的意思是使用环视查看是否有匹配regex的位置,当匹配成功时,(?=(regex))\1不会保存任何状态。

如下是使用perl来重新编写舍弃小数点那个正则表达式。

s/(\.\d\d(?=([1-9]?))\2)\d+/$1/

NFA/DFA/POSIX

POSIX标准规定,如果有多个子表达式能够匹配,返回最左最长的子表达式。

这在传统NFA中是不能实现的,因为传统NFA从左面的子表达式开始尝试匹配,若发现一个子表达式匹配,那么其他子表达式压根儿不会被尝试。

如果让NFA发现匹配的子表达式后不直接报告成功,而是匹配所有的子表达式,选出所有匹配中最左最长的那个,那么就得到了符合POSIX标准的NFA,我们称它POSIX NFA

POSIX NFA需要更多的回溯,而且我们也失去了合理构造子表达式来影响引擎工作流程的权力。

DFA天生支持这些功能,但是它不支持环视、反向引用、忽略优先、占有优先和固化分组这些强大的功能。

如果用图遍历的方式来理解,NFA更像深度优先搜索,DFA更像广度优先搜索。

实现单纯的NFA和DFA引擎都要不了多少代码,它们的原理不难,现在有很多实现为了兼顾效率和高级特性,实现了两种引擎,并且根据需要自动选择NFA和DFA进行匹配。

反正需要知道的就是,使用NFA正则表达式引擎得到的结果可能和使用DFA/POSIX NFA的结果不一样。

posted @ 2021-12-15 13:28  yudoge  阅读(63)  评论(0编辑  收藏  举报