阅读 317

面试问你正则原理?你就跟他吹起来

正则表达式 -- 程序员的一个绕不开的话题,一个又爱又恨的神器,一个面试经常被问到的知识点,一个你似懂非懂云山雾罩的痛点。

看完这篇文章,以后如果面试官再问你:“了解正则吗?说说它的匹配原理和机制吧。”

直接跟他开吹吧!

是否认识正则表达式

我先列举一些正则表达式,看大家是否能理解每个正则表达式所匹配的内容?能否拆解表达式最小的组成元素?

let regex1 = /keith/
let regex2 = /tall|fat|skinny|low/
let regex3 = /say(hello)world/
let regex4 = /say(?:hello)world/
let regex5 = /born(?=\d)/
let regex6 = /say[hello]world/
let regex7 = /^Target:(.*)/
let regex8 = /".*"/
let regex9 = /".*?"/
let regex10 = /(\.\d\d\[1-9]?)\d+/
复制代码

如果有些同学有不太清楚的地方,那应该大部分会集中于 () / (?:) / (?=) / *? 上。由于我们的重点是讲正则匹配的原理,所以语法性的知识我只浅显地将其说明白,将焦点集中在后文。

  • []:先说一下,在正则表达式中,[] 符号所匹配的只是一个字符,也就是说,无论 [] 括号中有多少内容,最终匹配的结果长度都是1。那么在明确了这一点之后,你知道 regex6 到底匹配的是什么结果了吗?它可以匹配到'sayhelloworld'吗?

  • () / (?:):想要明白(?:) 等语法的含义,必须要先明白正则表达式中的 () 的作用。
    () 在正则表达式中表示捕获。处于括号中的内容会被存储下来以供之后使用,大家在其他有些地方见到的$1 / $2...就是用来调用被捕获的元素的,第一个括号匹配的内容会放在$1中,第二个括号匹配的内容不会放在$2中,以此类推。
    (?:)表示非捕获括号,也叫非获取匹配。见名知义,也就是说括号中的内容不会被存储下来,也就无法使用$1 / $2等进行调用。

  • (?=) 以及同一系列的(?<=) / (?<!)等都是用来匹配字符串中的位置的。(?=) 表示非获取匹配,叫做顺序环视。名字有些绕,我来为你把它直白地讲清楚。
    首先,它是非获取匹配,所以该()中的结果不会被存储。那么顺序是什么意思呢?(?=pattern)表示在要匹配 pattern 的字符串开始的位置,站起来,向后眺望检查是否可以匹配成功 pattern,该检查过程不消耗字符串。
    顺序:在匹配字符串的位置向后检查。
    环视:只是看一看,检查过程并不消耗字符串。
    相信看到这里,你已经似乎有些朦朦胧胧地了解它的意思了,只是对于所谓的不消耗字符串有些模糊。恭喜你抓到了 (?=) 语法的重点,就是不消耗字符串。我通过和 (?:) 比较来向你解释。
    两者的共同点当然都是不会将匹配结果存储起来。区别则是:(?:pattern) 匹配到的结果中含有 pattern,而 (?=pattern) 所匹配到的结果中是不含有 pattern 的。为什么呢?因为 (?=) 不消耗字符,即在该元素匹配结束后,会继续从预查之前的位置开始继续匹配。

    看过这张图你应该明白了何谓不消耗字符串,也明白了 (?=) 的用法。

  • *?*表示匹配前一字符0次或无限次,且尽可能多地匹配。尽可能多?听起来这个符号很贪心吧,这确实被称为贪婪。后跟一?表示让它改过自新,在保证匹配成功的基础上尽可能少地匹配吞掉字符,这就是非贪婪模式。举个例子:

    let reg1 = /a.*b/;
    let reg2 = /a.*?b/;
    'aabab'.match(reg1); // aabab
    'aabab'.match(reg1); // aab
    复制代码

贪婪模式:我要在保证匹配成功的情况下吃掉尽可能多的字符。
非贪婪模式:我要在保证匹配成功的情况下尽可能减少摄入。

好了,讲了这么多是为了保证你具有简单的正则基础,这样理解起下面要讲到的正则匹配原理来不会一头雾水。至少在面对一个正则表达式时,你要明白它所要匹配的是什么内容,匹配的长度是多少,知道了这些才能继续学习匹配的原理。如果至此你还没有看懂,请先学习完正则的基础语法之后再来继续探究原理。

思考题

带着思考和问题进行学习和阅读是会让你事半功倍的。我先列举三个思考题,请对它们涉及到的原理保持敏感地向下继续阅读。

  1. 使用 /“.*”/ 来匹配 the name "McDonald`s" is said "makudonarudo" in Japanese. 结果是什么呢?如何只匹配到第一对引号中的内容呢?
  2. /^Target:(.*)//^Target:(.*).*/有区别吗?
  3. 需求:获取小数点后的2位或3位。 /(\.\d\d[1-9]?)\d*/ || /(\.\d\d[1-9]?)\d+/ || /(\.\d\d[1-9]??)\d+/ 这三个表达式分别匹配什么,可以互相替换吗?

字符串的组成

我们要理解,字符串是由字符和位置两部分构成的。 为什么要强调字符串中的位置呢?是因为位置也是可以被匹配的,而且位置在正则匹配的过程中至关重要。
当你使用类似于 /Oct/ 这样的表达式进行匹配时,它所匹配的是字符而不是位置,而且匹配到的 Oct 会被保存入最后的结果中。这就是匹配字符,用我们听不懂的概念来讲它叫做:占有字符。
当你使用类似于/Oct(?=upus)/ 进行匹配时,最终得到的结果还是 Oct,此处的 (?=upus) 实际上就是在匹配位置3。也就是说 (?=upus) 这个子表达式是不占用空间的,故被称为零宽度或零宽断言。

测试 & 传动

明确了字符串的组成之后,我们开始正式进入讨论正则匹配原理的阶段。正则匹配的基础:利用传动机制来进行测试。那么什么是测试,什么是传动呢?
说白了,测试就是从当前指针所在位置开始,用正则表达式里的每个子表达式依次向后进行检测,看是否可以匹配成功。传动就是,当指针处于本位置的本次测试不成功时,将指针向后移一位,继续开始测试匹配,直到匹配成功或者整个字符串被消耗完。

举个栗子🌰,使用 /ren/来匹配"reference"的过程:

指针起始位置指向第一个字母 r ,使用正则表达式的子元素依次测试

  1. r 与 r 匹配测试成功;e 与 e 匹配测试成功;n 与 f匹配测试不成功,指针向后移动一位。
  2. r 与 e 匹配测试不成功,指针向后移动一位。
  3. ...
  4. 直到移动到绿色位置,r 与 r 匹配测试成功,e 与 e 匹配测试成功,n 与 n 匹配测试成功,正则表达式匹配成功,匹配结束。

正则表达式就像是在一个车间里的流水线上的模具一样,依次移动,依次操作。传动这个词是不是很形象呢?眼前仿佛出现了一条滚动的皮带。也和滑动窗口的概念很类似。

量词 && 匹配优先 / 忽略优先

量词

我们举例三种量词,分别为:

  • *:前一表达式匹配0次或无限次
  • +:前一表达式匹配1次或无限次
  • ?:前一表达式匹配0次或1次

在正则表达式中,默认情况下量词匹配是优先的。

何谓优先呢?你给翻译翻译。

量词匹配优先就是说,在匹配时,只要符合匹配标准,量词会尽可能多地吃掉字符串中的字符,直到由于吃得太多,正则表达式后面的子表达式无法进行匹配,这时才会进行一次回吐,吐出来一个字符,看是否可以符合其后子表达式的匹配条件,如果无法匹配成功,则再次回吐一个字符并再次进行测试,直到匹配结束(成功或失败)。

忽略优先即前文提过的非贪婪模式,在这个模式下匹配时,量词显得相当谦逊,希望自己尽可能少得吃掉字符来完成匹配。它的匹配过程是:

  1. 匹配指针位于位置I,正则表达式中的与字符串的第一个字符进行测试,测试成功。
  2. 控制权交给子表达式.*?,匹配指针后移到位置II,在字符o处,可以选择匹配或不匹配时的非贪婪模式下,优先选择了不匹配,即忽略子表达式.*?,将控制权交给子表达式"
  3. 子表达式"开始对字符o进行测试,测试失败,将控制权返回给子表达式.*?
  4. 子表达式.*?吃下一个字符,即开始对字符o进行测试,测试成功,匹配指针先后移动一位至位置III。重复步骤2 - 4。
  5. 到位置VIII为止都在重复上述过程。
  6. 当匹配完字符s时,匹配指针向后移动到位置IX,重复步骤2 & 3,在子表达式"对字符"进行测试时,测试成功,匹配成功结束。

(此处CUE一下思考题,带着问题阅读的你一定还没有忘记)

回溯

在上面非贪婪模式中提到的所谓【将控制权返回给子表达式.*?】其实有个专门的词来描述它,叫做回溯。我们来详细聊一下回溯。

正则表达式在匹配的过程中,会遇到并列选择的情况,也就是说在同一位置有多种选择可供选取(例如在使用.*匹配时,可以选择匹配多个也可以选择匹配0个),此时正则引擎会按照规定选择其中的一种,并将其他的几种记在自己的小本本上,明确提示自己,此处有其他岔路口可以走,在当前道路走不通时,可以读取之前的存档,再次选择另一条路出发进行匹配。这样的过程就是回溯。

在匹配流程进行的过程中可能会存入多个不同位置的提醒标识,正则引擎会优先选择距离自己当前位置最近的标识位置进行回溯。

这个逻辑也很容易理解,我举一个让大家一目了然的类比。假设你在玩一款探险解谜游戏,你有一个在遇到岔路口时随手存档的好习惯。当你一口气打到了第9关,发现此路不通时,你会去读取自己在第9关开始时的存档,并再次尝试另一条路。如果仍旧是此路不通,你才会去读档第8关,然后选择一次与前一次自己通过第8关时不同的路径进行尝试。以此类推,直至整个正则表达式匹配成功或失败结束。

无图言D,我们还是看图说话。

我使用/love\s??b(?:erry|anana)/来匹配"I love banana!",它的流程如图所示。

  1. 字符love测试成功,继续向后匹配。
  2. 遇到第一个可回溯的分岔口\s??,因为是非贪婪模式,优先不匹配\s走绿色路径,将匹配\s的路径记录下来。
  3. 遇到第二个可回溯的分叉口erry|anana,走红色路径,将anana路径记录下来。
  4. erry匹配测试不成功,回溯到节点2,走蓝色路径测试ananan。同样测试不成功,回溯到节点1,匹配测试\s
  5. 测试b成功,遇到第三个可回溯到分岔口erry|anana,走黄色路径,将anana路径记录下来。
  6. erry匹配测试不成功,回溯到节点3,走粉色色路径测试ananan。匹配成功。

如此一来,你应该明白了正则的回溯机制了吧。

思考题解谜

我们再次来回顾一下文首的三个思考题。

  1. 使用 /“.*”/ 来匹配 the name "McDonald`s" is said "makudonarudo" in Japanese. 结果是什么呢?如何只匹配到第一对引号中的内容呢?
  2. /^Target:(.*)//^Target:(.*).*/有区别吗?
  3. 需求:获取小数点后的2位或3位。 /(\.\d\d[1-9]?)\d*/ || /(\.\d\d[1-9]?)\d+/ || /(\.\d\d[1-9]??)\d+/ 这三个表达式分别匹配什么,可以互相替换吗?

经过一遍思考一遍阅读完上面的讲解之后,是不是一目了然了呢?

1 => 由于*是贪婪模式匹配优先,会尽可能多地匹配到更多的字符,所以最后匹配到的结果是"McDonald`s" is said "makudonarudo",而想要只匹配到第一对引号中的内容所要做的只是使其转化为非贪婪模式,即/.*?/

2 => 没有区别,同样处于贪婪模式下的(.*)已经吃掉了所有的字符匹配成功,在其后的.*实际上完全没有起到作用。

3 => 明白这道思考题,要先明确一下,我们最后可以获取出来的内容是处于()中的内容。

我们举例匹配一个拥有三位小数的字符串"96.124"来依次对比:

  • 表达式1 vs 表达式2

    区别在于最后面的匹配数字的\d所跟的量词不同,*要匹配0个或多个,+要匹配1个或多个。表达式1中同样是贪婪模式的匹配,在较前的\d\d[1-9]?将小数位全部吞掉,然后在较后的\d*匹配0个数字也可以成功,故在()中捕获到的内容是小数整个后3位124。在表达式2中\d\d[1-9]?吞掉全部小数后,在其后的\d+至少要匹配1个数字,此时无法满足它的要求,则\d\d[1-9]?会再吐出一位小数使之匹配成功,最后()中捕获到的内容则变为了12,无法捕获第3位小数。

  • 表达式3

    表达式3中的\d\d[1-9]??为非贪婪模式,优先不吃字符,在其后的\d+首先吞掉所有后两位小数后,为了使\d[1-9]??匹配成功,吐出一个字符,匹配成功,最终()中捕获到的内容还是仅有两位小数12

课堂测试

通篇读完,相信你已经对正则表达式的简单匹配原理有了一个整体性的认知和理解。跃跃欲试的你已经在摩拳擦掌想要检验一下自己的学习成果了,那好,我再来提出最后一个问题,用来测试一下大家对于正则匹配过程的掌握程度。

用来匹配字符串:"2\"x3\" likeness"

/"(\\.|[^\\"])*"/ & /"([^\\."]|\\.)*"/两个正则表达式各自测试了多少次?回溯了多少次?

答案:

表达式1 => 测试32次,回溯14次

表达式2 => 测试22次,回溯4次

是不是非常惊讶?表达式2较之于表达式1,测试次数减少了31.25%!回溯次数更恐怖,减少了71%??!而引发这些的,仅仅是调换了两个并列的子表达式的顺序。这就说明了,提高正则表达式的效率,很是个技术活儿呢!足够科学的表达式将会极大提高匹配的性能,而这些,我们会在之后的文章中再来继续分析!

在此感谢技术支持:飞哥Philip🙏(segmentfault.com/u/philips/a…)

扫码关注,时不时用说人话的方式给你带来简洁明了的各种解析。

在公众号中回复"正则原理测试"获取详细匹配过程分析。

易企秀工程师 -- Keith