正则表达式(应用篇)

一、正则表达式的困境

如果你对正则表达式的基本用法还不了解,请先移步正则表达式(基础篇)

现在我假设你已经掌握了正则表达式的基本语法,也许你已经迫不及待想找个题目练练手,下面就有一个非常简单的题目:

请书写一个能匹配标准IPV4地址的正则表达式。

什么是一个标准的IPV4地址呢?其实描述起来非常简单,就是由三个点.隔开的四个0-255之间的整数。如下面的地址都是合法的IPV4地址:

192.168.1.1
34.76.23.1
255.255.255.255
0.0.0.0

由于当数字前有多余的前缀0时浏览器可能无法正确解析(如021会被解析成17),因此我们约定带前缀0的数字是非法的。如下面的地址是非法的:

192.168.021.1

也许你会很快写出下面的正则表达式:

/[0-255](?:\.[0-255]){3}/

只要你简单学过正则表达式的基础篇,马上就知道这是错的。因为[0-255]并不是用来匹配0-255之间的整数的,它只能匹配0、1、2、5其中的一个字符。

此时你可能会陷入一个困境:我该如何用正则表达式描述一个0-255之间的整数呢?

造成这个困境的根本原因在于,你没有从字符串的角度去理解介于0-255之间的整数这句话。你可能想当然地认为,255是一个整数,而非字符串,这直接导致你面对该问题时无从下手。

在正则表达式基础篇里,我们说过,正则表达式是为字符串处理而生的。它的所有规则都是面向字符串定义的,而你现在想要拿它去匹配一个整数,这当然行不通!

所以不要再认为[0-9]匹配的是0到9这十个数字了,其实它匹配的是ASCII码值介于48 - 570 - 9这十个字符。理解这一点非常重要。

那么我们怎么走出这个困境,写出正确的正则表达式呢?

二、书写正确的表达式

概括起来就一句话:将规则递归拆分,逐个实现,最后一步步进行回溯组合。

比如在面对一个复杂的规则时,我们可能需要先将问题进行第一次拆分,分离出需要单独实现的若干个子规则。然而我们发现,拆分出来的子规则仍然很复杂,于是我们需要将这个子规则进行进一步拆分 。依次类推,直到将规则拆分到足够简单后,再用正则表达式实现它,然后用它们组合出父规则。通过一步步向上组合规则,最终就可以组合到根规则,从而完成整个表达式。整个过程看起来大致是这样的:
在这里插入图片描述
要做到这一点,第一步就是规则拆分。比如上面的例子,我们可以把完整的规则:由三个点隔开的四个0-255之间的整数拆分成两个子规则:

规则1. 匹配xxx.xxx.xxx.xxx这种模式
规则2. 匹配0-255的整数

如果我们能写出符合规则2的正则表达式,那么只需要将其填充到规则1对应的表达式中,就可以得到最终结果了。

比如,对于规则1,我们可以很容易写出下面的表达式(正则1表示它是对规则1的实现,之后的正则2-1等同理):

正则1/^(xxx)(\.xxx){3}$/

毫无疑问,假如这里的xxx可以匹配一个0-255之间的整数,那么我们的正则表达式就写成了。现在的问题就在于,我们如何描述一个0-255之间的整数?

这个问题看上去非常简单,但从字符串的角度来说,却并不简单。由于正则表达式是基于字符匹配的,因此0-255之间的整数可以拆分成以下三种情况:

规则2-1. 一位数,即0-9
规则2-2. 两位数,即10-99
规则2-3. 三位数,即100-255

一位数的情况非常简单,直接用\d描述即可:

正则2-1:\d

两位数的情况可以把十位数和个位数拆开来看,十位数是1-9,个位数是0-9,因此两位数可以写成:

正则2-2[1-9]\d

三位数的情况略复杂,无法直接实现,因此我们把它再拆分成以下三种情况:

规则2-3-1. 100-199,此时百位数是1,十位和个位无限制
规则2-3-2. 200-249,此时百位数是2,十位数是0-4,个位数无限制
规则2-3-3. 250-255,百位数是2,十位数是5,个位数是0-5

之所以要进行这样的拆分,是因为百位数的数字不同,可能对十位数和个位数造成影响。同样,十位数的不同也会对个位数造成影响,正则表达式无法直接描述这种约束关系,因此只能经过拆分后用“或”连接起来。

上面的三种情况已经非常简单了,不需要进一步拆分了,我们分别实现它们:

正则2-3-1. 1\d{2}
正则2-3-2. 2[0-4]\d
正则2-3-3. 25[0-5]

现在让我们用一张图看一下我们得到了什么:
在这里插入图片描述
我们把最初的问题分解成了很多更加简单的规则,一直分解到足够简单,以至于不需要再向下分解。然后我们用最基本的正则表达式单元实现了这些末级规则。下面我们要做的就是向上组合,一步步实现父规则。

首先,规则2-3由三个子规则构成,显然它们之间是逻辑“或”的关系,因此规则2-3看起来是这样的:

正则2-3(1\d{2})|(2[0-4]\d)|(25[0-5])

有了正则2-3,我们就可以合并正则2-1和正则2-2,得到正则2,所以正则2看起来是这样的:

正则2:(\d)|([1-9]\d)|(1\d{2}|2[0-4]\d|25[0-5])

有了正则2,我们就可以将其纳入正则1,这样我们就可以得到最终的表达式了:

最终的正则:/^(\d|([1-9]|\d)|(1\d{2}|2[0-4]\d|25[0-5]))(\.\d|([1-9]|\d)|(1\d{2}|2[0-4]\d|25[0-5])){3}$/

整个回溯过程大致如下:
在这里插入图片描述
最终的正则表达式看上去也许并不优雅,但重要的是,用这种思路书写正则表达式正确性较高,并且不会耗费太多时间。

三、如何让表达式更优雅

必须承认,关于这个问题,我并没有太好的方法,写出优雅的正则表达式需要通过大量的训练习得技巧。不过本文还是希望对如何写出优雅的正则表达式给出一些启发。

摆在面前的第一个问题是,我们上面的正则表达式不优雅在哪?

问题是显然的,规则拆分得过细,导致组合之后得到的表达式过于繁琐。那有什么办法可以解决这个问题呢?

答案就是合并规则。举个例子,如果你做过大量的正则表达式训练,你就会知道,规则2-1和规则2-2是可以归并的。也就是说,只需要一个正则表达式就可以描述一位数和两位数:

合并2-12-2[1-9]?\d

这个表达式既能匹配一位数,又能匹配两位数,因此它完全可以代替规则2-1和2-2组合出的那个表达式。即:

\d|[1-9]\d  => [1-9]?\d

在这里插入图片描述
右边的写法明显比左边的写法更优雅。将右侧的表达式替换到原表达式中即可得到:

/^([1-9]?\d)|(1\d{2}|2[0-4]\d|25[0-5]))(\.[1-9]?\d)|(1\d{2}|2[0-4]\d|25[0-5])){3}$/

如果拆分出的规则中有大量类似的可合并规则,最终得到的表达式将大大简化。不过合并规则是需要经验作支撑的,所以学习正则表达式仍然无法避免大量的训练。

另外,在某些特定的情况下,还有一种写出极其优雅的正则表达式的方法,那就是排除法。举个例子,现在我们要匹配一组人员编码,编码以No为前缀,从001开始:

No001
No002
...
No999

假如No000是合法的,那么我们马上可以写出正则表达式如下:

/No\d{3}/

但是我们规定了No000并不是合法字符串,因此这个正则表达式是错误的。于是我们按照前面的方法,对规则进行拆分,得到下面三种情况:

1. 百位是0,十位是0,个位是1-9
2. 百位是0,十位不是0,个位任意
3. 百位不是0,十位和个位任意

分别实现上面三个规则:

1. No00[1-9]
2. No0[1-9]\d
3. No[1-9]\d{2}

因此最终的正则表达式为(这里我们将公共的字符串No提取出来,以简化表达式):

/^No(00[1-9]|0[1-9]\d|[1-9]\d{2})$/

这个表达式似乎已经不能再简化了,但事实是,它和下面的表达式是等价的:

/^No(?!000)\d{3}$/

No(?!000)使用了负向预查,检查字符串No的后面是不是跟了000,如果是,则认为匹配失败。这就相当于排除了No000这种情况。由于负向预查不会消耗字符串,因此在排除了No000这种情况后,我们可以继续用\d{3}匹配后面的三个数字。

这种书写正则表达式的方法就如同数学中的反证法一样,在使用得当的情况下,可以写出极其优雅的表达式,不过它同样需要大量的训练才能熟练掌握。

四、正则表达式的“回溯陷阱”

“回溯陷阱”是正则表达式最常见的性能问题之一,一旦落入“回溯陷阱”,很容易发生CPU的占用率达到100%,从而造成浏览器卡死。

要理解什么是“回溯陷阱”,需要从正则表达式使用的自动机模型说起。

在《形式语言与自动机》这门课中介绍过两个经典的有穷自动机模型:确定型有限自动机(DFA)和不确定型有限自动机(NFA),两种模型都可以用于实现正则表达式的匹配引擎,但是逻辑上有一定差异。简单来说就是:DFA是用字符串去匹配正则表达式;而NFA是拿正则表达式去匹配字符串。

基于DFA的正则引擎,其时间复杂度是多项式级别的;而基于NFA的时间复杂度在最优情况下是多项式级别的,在最差的情况下则是指数级别(存在大量回溯的情况下)的。然而由于DFA引擎无法支持捕获组和引用,只能简单地检查字符串是否符合某个模式,因此不适合作为通用的正则引擎。所以我们常见的正则引擎(如javascript、java等)都是基于非确定型有限自动机NFA的。

我们说了,NFA是拿正则表达式去匹配字符串。比如,对于正则表达式/abc/,当匹配字符串abababc时,它的匹配过程大致如下:
在这里插入图片描述
注意,这里的每个步骤里,引擎都会进行1-3次匹配,直到匹配成功或者失败才会进行下一步。如第一步中,引擎先对字符a进行检查,发现匹配后又对b进行检查,随后对c进行检查。当发现匹配失败时,引擎又退回到了字符b开始步骤2,以此类推。

我们看到,当字符a、b匹配成功,而字符c匹配失败时,NFA并不是从失败位置字符c处继续向后匹配的,而是回退到了上次匹配位置的下一个字符b进行匹配。也就是说,虽然上次的匹配已经检测到了位置2,但由于匹配失败,不得不退回到位置1处重新进行匹配,这种现象就称为回溯。当正则表达式和字符串非常复杂时,这种回溯会造成大量的运算,从而导致CPU占用率急剧上升。

关于应该回溯到哪个位置,有一个经典的优化算法:KMP算法,它可以大大缩减回溯的距离,从而提升匹配性能。由于与本文所述内容无关,这里不再详述,感兴趣的可以自行查阅。

下面是一个更复杂的例子:

var reg = /(\s*\(.*?=.*?\))+$/;
var s = '(a=b) (a=c) (b=c) (g=i) (h=i)';

console.time('reg');
s.match(reg);
console.timeEnd('reg');  // reg: 0.02001953125ms

该正则表达式检测形如(a=b) (a=c) (b=c)这样的字符串,各个括号之间允许有任意多个空格。我们使用console.time对正则表达式的执行性能进行检测,如图,对字符串检测的耗时约为0.04毫秒(耗时依计算机性能和所用浏览器而异,仅供参考)。

那么如果我们的字符串最后面带了一个字符,导致它不能匹配正则表达式呢?

s = '(a=b) (a=c) (b=c) (g=i) (h=i)m';

现在字符串不是以右括号结尾,显然它无法匹配正则表达式。我们看一下它的性能损耗:

console.time('reg');
s.match(reg);
console.timeEnd('reg');  // reg: 0.185791015625ms

耗时达到了0.18毫秒,耗时上升了超过4倍(仅供参考)。

这是因为,当引擎检测到最后面的子串(h=i)m与正则表达式不匹配时,并不会直接返回匹配失败,而是会进行回溯。

假设没有启用KMP算法,引擎会返回上次匹配位置的下一个字符处,即字符串的第二个字符a。然后发现匹配失败,接着继续向后滑动,从字符=处开始匹配,一直滑动到字符串的最末尾处。

也就是说,引擎会顺着字符串的开始处,一直进行s.length次匹配过程,这使得引擎的执行效率非常低。

我们看一下当字符串非常长时的执行效率:

var reg = /(\s*\(.*?=.*?\))+$/;
var s = '(a=b) (a=c) (b=c) (d=f) (g=i) (h=i) (q=o) (e=0) (p=r) (s=3) (x=3) (h=e) (5=d) (m=n) (g=d) (a=b)m';

console.time('reg');
s.match(reg);
console.timeEnd('reg'); // reg: 4235.3759765625ms

仅仅16个子串,浏览器的执行时间已经达到了4.2秒。实际上经过测试,在没有超过20个子串的情况下,浏览器就已经卡死了(每多一个子串,耗时几乎都会翻倍),“回溯陷阱”的可怕程度可见一斑!

那怎么解决“回溯陷阱”呢?

一般来说,只要能消耗掉已经匹配到的字符串,就可以避免大规模的回溯(字符一旦被消耗,将不会参与到回溯过程中)。这可以通过正向预检和引用来实现。比如下面的正则表达式就可以消除回溯:

var reg = /(?=(\s*\(.*?=.*?\))+)\1$/;
var s = '(a=b) (a=c) (b=c) (d=f) (g=i) (h=i) (q=o) (e=0) (p=r) (s=3) (x=3) (h=e) (5=d) (m=n) (g=d) (a=b)m';

console.time('reg');
s.match(reg);
console.timeEnd('reg'); // reg: 0.248779296875ms

可以看到,同样的字符串,更换了正则表达式的写法后,仅在0.25毫秒内就得到了匹配结果。

这里我们使用了正向预检(?=)模式,它预检测(a=b)这种模式,一旦发现匹配,就通过\1的方式消耗掉它,当发生匹配失败时,引擎不会回溯到该位置重新匹配。这样,每当引擎匹配了一个子串,它就会被消耗掉。当引擎匹配到最后的字符m时,由于前面的子串已经全部被消耗,因此引擎不需要回溯即可判定检测失败,从而解决了“回溯陷阱”。

另外,在某些情况下,使用独占模式也可以解决“回溯陷阱”,可以参考案例正则表达式的回溯

“回溯陷阱”在很多情况下并不易察觉,解决办法也因具体情况而异,因此是正则表达式中一个相当大的难点。如果实际使用中遇到了类似的情况,还需要多总结。

总结

书写优雅且符合要求的正则表达式向来都是一个难题,本文侧重于探索一种可以帮助正确书写正则表达式的标准流程,依照这个流程,可以迅速准确地写出要求较为简单的正则表达式。但是对于复杂的需求,还是需要大量的练习才能熟练掌握。另外,在学习正则表达式的过程中应当特别注意“回溯陷阱”。

发布了49 篇原创文章 · 获赞 110 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_41694291/article/details/104931880
今日推荐