一、正则表达式的困境
如果你对正则表达式的基本用法还不了解,请先移步正则表达式(基础篇)。
现在我假设你已经掌握了正则表达式的基本语法,也许你已经迫不及待想找个题目练练手,下面就有一个非常简单的题目:
请书写一个能匹配标准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 - 57
的0 - 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-1、2-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
时,由于前面的子串已经全部被消耗,因此引擎不需要回溯即可判定检测失败,从而解决了“回溯陷阱”。
另外,在某些情况下,使用独占模式也可以解决“回溯陷阱”,可以参考案例正则表达式的回溯。
“回溯陷阱”在很多情况下并不易察觉,解决办法也因具体情况而异,因此是正则表达式中一个相当大的难点。如果实际使用中遇到了类似的情况,还需要多总结。
总结
书写优雅且符合要求的正则表达式向来都是一个难题,本文侧重于探索一种可以帮助正确书写正则表达式的标准流程,依照这个流程,可以迅速准确地写出要求较为简单的正则表达式。但是对于复杂的需求,还是需要大量的练习才能熟练掌握。另外,在学习正则表达式的过程中应当特别注意“回溯陷阱”。