本文已参与新人创作礼活动,一起开启掘金创作之路。
前言
正则表达式在几乎所有语言中都可以使用,无论是前端的JavaScript、还是后端的Java、c#。他们都提供相应的接口/函数支持正则表达式。
正则表达式( Regular expression)是一组由字母和符号组成的特殊文本, 它可以用来从文本中找出满足你想要的格式的句子。
\
一、起源与发展
我们在学习一门技术的时候有必要了解其起源与发展过程,这对我们去理解技术本身有一定的帮助!
20世纪40年代:正则表达式最初的想法来自两位神经学家:沃尔特·皮茨与麦卡洛克,他们研究出了一种用数学方式来描述神经网络的模型。
1956年:一位名叫Stephen Kleene的数学科学家发表了一篇题目是《神经网事件的表示法》的论文,利用称之为正则集合的数学符号来描述此模型,引入了正则表达式的概念。正则表达式被作为用来描述其称之为“正则集的代数”的一种表达式,因而采用了“正则表达式”这个术语。
1968年:C语言之父、UNIX之父肯·汤普森把这个“正则表达式”的理论成果用于做一些搜索算法的研究,他描述了一种正则表达式的编译器,于是出现了应该算是最早的正则表达式的编译器qed(这也就成为后来的grep编辑器)。
Unix使用正则之后,正则表达式不断的发展壮大,然后大规模应用于各种领域,根据这些领域各自的条件需要,又发展出了许多版本的正则表达式,出现了许多的分支。我们把这些分支叫做“流派”。
1987年:Perl语言诞生了,它综合了其他的语言,用正则表达式作为基础,开创了一个新的流派,Perl流派。之后很多编程语言如:Python、Java、Ruby、.Net、PHP等等在设计正则式支持的时候都参考Perl正则表达式。
到这里我们也就知道为什么众多编程语言的正则表达式基本一样,因为他们都师从Perl。
注:Perl语言是一种擅长处理文本的语言,但因晦涩语法和古怪符号不利于理解和记忆导致很多开发者并不喜欢。
二、正则表达式入门
正则在线测试网站:
元字符
我们先来记几个常用的元字符:
| 元字符 | 说明 |
|---|---|
| . | 匹配除换行符以外的任意字符 |
| \w | 匹配字母或数字或下划线 |
| \W | 匹配非单词字符 |
| \s | 匹配任意的空白符(包含换行符、Tab) |
| \S | 匹配非空白字符 |
| \d | 匹配数字 |
| \D | 匹配非数字字符 |
| \b | 匹配单词的开始或结束 |
| 匹配字符串的开始 | |
| $ | 匹配字符串的结束 |
有了元字符之后,我们就可以利用这些元字符来写一些简单的正则表达式了,
比如:
匹配有abc开头的字符串:
\babc或者^abc
匹配8位数字的QQ号码:
^\d\d\d\d\d\d\d\d$\
匹配1开头11位数字的手机号码:
^1\d\d\d\d\d\d\d\d\d\d$\
有了元字符就可以写不少的正则表达式了,但细心的你们可能会发现:别人写的正则简洁明了,而上面的正则一堆乱七八糟而且重复的元字符组成的。正则没提供办法处理这些重复的元字符吗?
答案是有的!
为了处理这些重复问题,正则表达式中一些重复限定符,把重复部分用合适的限定符替代,下面我们来看一些限定符:
限定符
| 语法 | 说明 |
|---|---|
| * | 重复零次或更多次( a*表示a出现0次或多次) |
| + | 重复一次或更多次( a+表示a出现1次或多次) |
| ? | 重复零次或一次( a?表示a出现0次或1次) |
| {n} | 重复n次 ( a{n}表示a出现n次) |
| {n,} | 重复n次或更多次 ( a{n,}表示a出现n次或多次) |
| {n,m} | 重复n到m次( a{2,6}表示a出现2-6次) |
区间
| 语法 | 说明 |
|---|---|
| [abc] | 匹配a或者b或者c |
| [a-fA-F0-9] | 匹配小写+大写英文字符以及数字 |
| [^0-9] | 匹配非数字字符 |
有了这些限定符之后,我们就可以对之前的正则表达式进行改造了,比如:
匹配8位数字的QQ号码:
^\d{8}$\
匹配1开头11位数字的手机号码:
^1\d{10}$\
匹配银行卡号是14~18位的数字:
^\d{14,18}$\
匹配以a开头的,0个或多个b结尾的字符串
^ab*$\
或运算符|
回到我们刚才的手机号匹配,我们都知道:国内号码都来自三大网,它们都有属于自己的号段,比如联通有130/131/132/155/156/185/186/145/176等号段,假如让我们匹配一个联通的号码,那按照我们目前所学到的正则,应该无从下手的,因为这里包含了一些并列的条件,也就是“或”,那么在正则中是如何表示“或”的呢?
正则用符号 | 来表示或,也叫做分支条件,当满足正则里的分支条件的任何一种条件时,都会当成是匹配成功。
那么我们就可以用或条件来处理这个问题
^(130|131|132|155|156|185|186|145|176)\d{8}$
分组()
从上面的例子(4)中看到,限定符是作用在与他左边最近的一个字符,那么问题来了,如果我想要ab同时被限定那怎么办呢?
正则表达式中用小括号()来做分组,也就是括号中的内容作为一个整体。
因此当我们要匹配多个ab时,我们可以这样
如:匹配字符串中包含0到多个ab开头:
^(ab)*\
1.捕获分组()与反向引用 \N
分组有一个非常重要的功能——捕获数据。所以()被称为捕获分组,用来捕获数据
(\d{4})-(\d{7})
张三:0731-8825951
\1 表示的就是第一个分组,在这里第一个分组匹配的是 font 所以\1 就代表font
0123提示abcd
0123提示abcd
<\w+>.*?</\w+>
<(\w+)>.*?</\1>
2.非捕获分组(?:表达式)
转义\
我们看到正则表达式用小括号来做分组,那么问题来了:
如果要匹配的字符串中本身就包含小括号,那是不是冲突?应该怎么办?
针对这种情况,正则提供了转义的方式,也就是要把这些元字符、限定符或者关键字转义成普通的字符,做法很简答,就是在要转义的字符前面加个斜杠,也就是\即可。
如:要匹配以(ab)开头:
^((ab))*\
\
三、正则表达式进阶
贪婪模式、懒惰模式、独占模式
我们都知道,贪婪就是不满足,尽可能多的要。
在正则中,贪婪也是差不多的意思:
限定符就是贪婪量词
比如表达式:\d { 3 , 6 }
\
懒惰量词是在贪婪量词后面加个“?”
| 代码 | 说明 |
|---|---|
| *? | 重复任意次,但尽可能少重复 |
| +? | 重复1次或更多次,但尽可能少重复 |
| ?? | 重复0次或1次,但尽可能少重复 |
| {n,m}? | 重复n到m次,但尽可能少重复 |
| {n,}? | 重复n次以上,但尽可能少重复 |
独占模式是在贪婪量词后面加个“+”
独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯。
\
零宽断言
无论是零宽还是断言,听起来都古古怪怪的,
那先解释一下这两个词。
- 断言:俗话的断言就是“我断定什么什么”,而正则中的断言,就是说正则可以指明在指定的内容的前面或后面会出现满足指定规则的内容,
- 零宽:就是没有宽度,在正则中,断言只是匹配位置,不占字符,也就是说,匹配结果里是不会返回断言本身。
意思是讲明白了,那他有什么用呢?
我们来举个栗子:
假设我们要用爬虫抓取csdn里的文章阅读量。通过查看源代码可以看到文章阅读量这个内容是这样的结构
"阅读数:641"
其中也就‘641’这个是变量,也就是说不同文章不同的值,当我们拿到这个字符串时,需要获得这里边的‘641’有很多种办法,但如果正则应该怎么匹配呢?
下面先来讲几种类型的断言:
1.正向先行断言
- 语法:(?=pattern)
- 作用:匹配pattern表达式的前面内容,不返回本身。
.+(?=</span>)
//匹配结果:
阅读数:641
我们要的只是前面的数字呀,那也简单咯,匹配数字 \d,那可以改成:
\d+(?=</span>)
//匹配结果:
641
\
2. 负向先行断言
- 语法:(?!pattern)
- 作用:匹配非pattern表达式的前面内容,不返回本身。
有正向也有负向,负向在这里其实就是非的意思。
举个栗子:比如有一句 “我爱祖国,我是祖国的花朵”
现在要找到不是'的花朵'前面的祖国
用正则就可以这样写:
祖国(?! 的花朵 )
3. 正向后行断言
- 语法:(?<=pattern)
- 作用:匹配pattern表达式的后面的内容,不返回本身。
(?<=<span class="read-count">阅读数:)\d+
//匹配结果:
641
4. 负向后行断言
- 语法:(?<!pattern)
- 作用:匹配非pattern表达式的后面内容,不返回本身。
总结
| 语法 | 说明 |
|---|---|
| a(?=)b | 正向先行断言,a只有在b前面才匹配 |
| a(?<)b | 负向先行断言,a只有不在b前面才匹配 |
| (?<=b)a | 正向后行断言,a只有在b后面才匹配 |
| (?<!b)a | 负向后行断言,a只有不在b后面才匹配 |
\
四、正则引擎执行原理
``机制
正则引擎主要可以分为基本不同的两大类:
- DFA (Deterministic finite automaton) 确定型有穷自动机
- NFA (Non-deterministic finite automaton) 非确定型有穷自动机
\
两类引擎要顺利工作,都必须有一个正则式和一个文本串,一个捏在手里,一个吃下去。
DFA捏着文本串去比较正则式,看到一个子正则式,就把可能的匹配串全标注出来,然后再看正则式的下一个部分,根据新的匹配结果更新标注。
而NFA是捏着正则式去比文本,吃掉一个字符,就把它跟正则式比较,匹配就记下来:“某年某月某日在某处匹配上了!”,然后接着往下干。一旦不匹配,就把刚吃的这个字符吐出来,一个个的吐,直到回到上一次匹配的地方。
\
DFA引擎执行原理
NFA引擎执行原理
text="Today is Saturday."
regex="day"
NFA 是以正则表达式为基准去匹配的。也就是说,NFA 自动机会读取正则表达式的一个一个字符,然后拿去和目标字符串匹配,匹配成功就换正则表达式的下一个字符,否则继续和目标字符串的下一个字符比较。或许你们听不太懂,没事,接下来我们以上面的例子一步步解析。
- 首先,拿到正则表达式的第一个匹配符:d。于是那去和字符串的字符进行比较,字符串的第一个字符是 T,不匹配,换下一个。第二个是 o,也不匹配,再换下一个。第三个是 d,匹配了,那么就读取正则表达式的第二个字符:a。
- 读取到正则表达式的第二个匹配符:a。那着继续和字符串的第四个字符 a 比较,又匹配了。那么接着读取正则表达式的第三个字符:y。
- 读取到正则表达式的第三个匹配符:y。那着继续和字符串的第五个字符 y 比较,又匹配了。尝试读取正则表达式的下一个字符,发现没有了,那么匹配结束。
回溯
正则表达式匹配字符串的这种方式,有个学名,叫回溯法。
回溯法也称试探法,它的基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有“状态”,当一条路走到“尽头”的时候(不能再前进),再后退一步或若干步,从另一种可能“状态”出发,继续搜索,直到所有的“路径”(状态)都试探过。这种不断“前进”、不断“回溯”寻找解的方法,就称作“回溯法”。
本质上就是深度优先搜索算法。其中退到之前的某一步这一过程,我们称为“回溯” 。从上面的描述过程中,可以看出,路走不通时,就会发生“回溯”。即,尝试匹配失败时,接下来的一步通常就是回溯。
正则回溯的几种常见形式
1.贪婪量词导致回溯
text="abbc"
regex="ab{1,3}c"
上面的这个例子的目的比较简单,匹配以 a 开头,以 c 结尾,中间有 1-3 个 b 字符的字符串。NFA 对其解析的过程是这样子的:
- 首先,读取正则表达式第一个匹配符 a 和 字符串第一个字符 a 比较,匹配了。于是读取正则表达式第二个字符。
- 读取正则表达式第二个匹配符 b{1,3} 和字符串的第二个字符 b 比较,匹配了。但因为 b{1,3} 表示 1-3 个 b 字符串,以及 NFA 自动机的贪婪特性(也就是说要尽可能多地匹配),所以此时并不会再去读取下一个正则表达式的匹配符,而是依旧使用 b{1,3} 和字符串的第三个字符 b 比较,发现还是匹配。于是继续使用 b{1,3} 和字符串的第四个字符 c 比较,发现不匹配了。此时就会发生回溯。
- 发生回溯是怎么操作呢?发生回溯后,我们已经读取的字符串第四个字符 c 将被吐出去,指针回到第三个字符串的位置。之后,程序读取正则表达式的下一个操作符 c,读取当前指针的下一个字符 c 进行对比,发现匹配。于是读取下一个操作符,但这里已经结束了。
\
2.惰性量词导致回溯
贪婪是导致回溯的重要原因,那我们尽量以懒惰匹配的方式去匹配文本,是否就能避免回溯了呢?答案是否定的。
text="abbc"
regex="ab{1,3}?c"
\
- 正则引擎先匹配 a。
- 正则引擎尽可能少地(懒惰)匹配 b{1,3}中的 b。
- 正则引擎去匹配 c,糟糕!怎么有个 b 挡着,匹配不了 c 啊!赶紧回溯!
- 返回 b{1,3}这一步,不能这么懒惰,多匹配个 b。
- 正则引擎再去匹配 c,匹配成功,棒棒哒!
3.分组导致回溯
分支的匹配规则是:按照分支的顺序逐个匹配,当前面的分支满足要求了,则舍弃后面的分支。
text="abc"
regex="abcde|abc"
- 正则引擎匹配 a。
- 正则引擎匹配 b。
- 正则引擎匹配 c。
- 正则引擎匹配 d,糟糕!下一个并不是 d,赶紧回溯!
- 上一个分支走不通,切换分支,第二个分支正则引擎匹配 a。
- 第二个分支正则引擎匹配 b。
- 第二个分支正则引擎匹配 c,匹配成功!
\
参考文章:回溯法原理
回溯陷阱
讲到回溯必须提到回溯陷阱,它导致的结果就是机器CPU使用率爆满(超100%),机器就卡死了。
举个例子:text=aaaaa,pattern=/^(a*)b$/,匹配过程大致是
- (a*):匹配到了文本中的aaaaa
- 匹配正则中的b,但是失败,因为(a*)已经把text都吃了
- 这时候引擎会要求(a*)吐出最后一个字符(a),但是无法匹配b
- 第二次是吐出倒数第二个字符(还是a),依然无法匹配
- 就这样引擎会要求(a*)逐个将吃进去的字符都吐出来
- 但是到最后都无法匹配b
这里的重点就在于 引擎会要求*匹配的东西一点一点吐回,我们假设如果文本长度为几万,那引擎就要回溯几万次,这对机器的CPU来说简直是灾难。
有些复杂的正则表达式可能有多个部分都要回溯,那回溯次数就是指数型。如果文本长度为500,一个表达式有两部分都要回溯,那次数可能是500^2=25万次
五、正则优化
正则是个很好用的利器,如果使用得当,如有神助,能省掉大量代码。当如果使用不当,则是处处埋坑。所以,所以我们需要学习一些NFA引擎的一些优化技巧,以减少引擎回溯次数以及更直接的匹配到结果!
1. 避免量词嵌套
举个简单的例子对比:
我们使用正则表达式/a*b/去匹配字符串 aaaaa,看下图 RegexBuddy 的执行过程:
我们将以上正则修改成/(a*)*b/去匹配字符串 aaaaa,再看看 RegexBuddy 的执行结果过程:
以上两个正则的基本执行步骤可以简单认为是:
- 贪婪匹配
- 回溯
- 直至发现匹配失败
但令人惊奇的是,第一个正则的从开始匹配到匹配失败这个过程只有 14 步。而第二个正则却有 128 步之多。可想而知,嵌套量词会大大增加正则的执行过程。因为这其中进行了两层回溯,这个执行步骤增加的过程就如同算法复杂度从 O(n)上升到 O(n^2)的过程一般。
所以,面对量词嵌套,我们需作出适当的转化消除这些嵌套:
(a*)* <=> (a+)* <=> (a*)+ <=> a*
(a+)+ <=> a+
2.根据情况选择独占模式
不管是贪婪模式,还是非贪婪模式,都可能发生回溯才能完成相应的功能。
但是在一些场景下,我们不需要回溯,匹配不上返回失败就好了。
因此正则中还有另外一种模式,独占模式,其匹配过程不会发生回溯,因此在一些场合下性能会更好。
独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯,这样的话就比较节省时间。具体的方法就是在量词后面加上加号(+)。
如果你用 a{1,3}+ab 去匹配 aaab 字符串,a{1,3}+ 会把前面三个 a 都用掉,并且不会回溯,这样字符串中内容只剩下 b 了,导致正则中加号后面的 a 匹配不到符合要求的内容,匹配失败。如果是贪婪模式 a{1,3} 或非贪婪模式 a{1,3}? 都可以匹配上。
\
具体能不能用独占模式需要看使用的编程语言的类库的支持情况,以及独占模式能不能满足需求。
3. 使用非捕获组
NFA 正则引擎中的括号主要有两个作用:
- 主流功能,提升括号中内容的运算优先级
- 反向引用
反向引用这个功能很强大,强大的代价是消耗性能。所以,当我们如果不需要用到括号反向引用的功能时,我们应该尽量使用非捕获组,也就是:
捕获组与非捕获组 () => (?:)
4. 分支优化
分支也是导致正则回溯的重要原因,所以,针对正则分支,我们也需要作出必要的优化。
4.1 减少分支数量
首先,需要减少分支数量。比如不少正则在匹配 http 和 https 的时候喜欢写成:
/^http|https/
其实上面完全可以优化成:
/^https?/
这样就能减少没必要的分支回溯
4.2 缩小分支内的内容
缩小分支中的内容也是很有必要的,例如我们需要匹配 this 和 that ,我们也许会写成:
/this|that/
但上面其实完全可以优化成
/th(?:is|at)/
有人可能认为以上没啥区别,实践出真知,让我们用以上两个正则表达式去匹配一下 that。
我们会发现第一个正则的执行步骤比第一个正则多两步,那是因为第一个正则的回溯路径比第二个正则的回溯路径更长了,最终导致执行步骤变长。
5. 锚点优化
在能使用锚点的情况下尽量使用锚点。大部分正则引擎会在编译阶段做些额外分析, 判断是否存在成功匹配必须的字符或者字符串。类似^、$ 这类锚点匹配能给正则引擎更多的优化信息。
例如正则表达式 hello(hi)?$ 在匹配过程中只可能从字符串末尾倒数第 7 个字符开始, 所以正则引擎能够分析跳到那个位置, 略过目标字符串中许多可能的字符, 大大提升匹配速度。
6.拆分表达式
多个小正则表达式比一个大的正则表达式要快很多
参考文章:zhuanlan.zhihu.com/p/107836267
六、常用案例
- Email地址: ^\w+([-+.]\w+)@\w+([-.]\w+).\w+([-.]\w+)*$
- 域名: [a-zA-Z0-9][-a-zA-Z0-9]{0,62}(.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+.?
- InternetURL: [a-zA-z]+://[^\s] 或 ^http://([\w-]+.)+[\w-]+(/[\w-./?%&=])?$**
- 手机号码: ^(13[0-9]|14[5|7]|15[0|1|2|3|4|5|6|7|8|9]|18[0|1|2|3|5|6|7|8|9])\d{8}$
- 电话号码("XXX-XXXXXXX"、"XXXX-XXXXXXXX"、"XXX-XXXXXXX"、"XXX-XXXXXXXX"、"XXXXXXX"和"XXXXXXXX): ^((\d{3,4}-)|\d{3.4}-)?\d{7,8}$****
- 国内电话号码(0511-4405222、021-87888822):\d{3}-\d{8}|\d{4}-\d{7}
- 电话号码正则表达式(支持手机号码,3-4位区号,7-8位直播号码,1-4位分机号): ((\d{11})|^((\d{7,8})|(\d{4}|\d{3})-(\d{7,8})|(\d{4}|\d{3})-(\d{7,8})-(\d{4}|\d{3}|\d{2}|\d{1})|(\d{7,8})-(\d{4}|\d{3}|\d{2}|\d{1}))$)
- 身份证号(15位、18位数字),最后一位是校验位,可能为数字或字符X: (^\d{15})|(^\d{17}(\d|X|x)$)
- 帐号是否合法(字母开头,允许5-16字节,允许字母数字下划线): ^[a-zA-Z][a-zA-Z0-9_]{4,15}$
- 密码(以字母开头,长度在6~18之间,只能包含字母、数字和下划线): ^[a-zA-Z]\w{5,17}$
- 强密码(必须包含大小写字母和数字的组合,不能使用特殊字符,长度在 8-10 之间): ^(?=.\d)(?=.[a-z])(?=.[A-Z])[a-zA-Z0-9]{8,10}$***
- 强密码(必须包含大小写字母和数字的组合,可以使用特殊字符,长度在8-10之间): ^(?=.\d)(?=.[a-z])(?=.[A-Z]).{8,10}$***
- 日期格式: ^\d{4}-\d{1,2}-\d{1,2}
- 一年的12个月(01~09和1~12): ^(0?[1-9]|1[0-2])$
- 一个月的31天(01~09和1~31): ^((0?[1-9])|((1|2)[0-9])|30|31)$****
- 钱的输入格式:
-
- 有四种钱的表示形式我们可以接受:"10000.00" 和 "10,000.00", 和没有 "分" 的 "10000" 和 "10,000": ^[1-9][0-9]$***
- 这表示任意一个不以0开头的数字,但是,这也意味着一个字符"0"不通过,所以我们采用下面的形式: ^(0|[1-9][0-9])$***
- 一个0或者一个不以0开头的数字.我们还可以允许开头有一个负号: ^(0|-?[1-9][0-9])$***
- 这表示一个0或者一个可能为负的开头不为0的数字.让用户以0开头好了.把负号的也去掉,因为钱总不能是负的吧。下面我们要加的是说明可能的小数部分: ^[0-9]+(.[0-9]+)?$****
- 必须说明的是,小数点后面至少应该有1位数,所以"10."是不通过的,但是 "10" 和 "10.2" 是通过的: ^[0-9]+(.[0-9]{2})?$****
- 这样我们规定小数点后面必须有两位,如果你认为太苛刻了,可以这样: ^[0-9]+(.[0-9]{1,2})?$****
- 这样就允许用户只写一位小数.下面我们该考虑数字中的逗号了,我们可以这样: ^[0-9]{1,3}(,[0-9]{3})(.[0-9]{1,2})?$***
- 1到3个数字,后面跟着任意个 逗号+3个数字,逗号成为可选,而不是必须: ^([0-9]+|[0-9]{1,3}(,[0-9]{3}))(.[0-9]{1,2})?$***
- 备注:这就是最终结果了,别忘了"+"可以用"*"替代如果你觉得空字符串也可以接受的话(奇怪,为什么?)最后,别忘了在用函数时去掉去掉那个反斜杠,一般的错误都在这里
- xml文件: ^([a-zA-Z]+-?)+[a-zA-Z0-9]+\.[x|X][m|M][l|L]$
- 中文字符的正则表达式: [\u4e00-\u9fa5]
- 双字节字符: [^\x00-\xff] (包括汉字在内,可以用来计算字符串的长度(一个双字节字符长度计2,ASCII字符计1))
- 空白行的正则表达式:\n\s*\r (可以用来删除空白行)
- HTML标记的正则表达式: <(\S?)[^>]>.?|<.? /> ( 首尾空白字符的正则表达式:^\s*|\s*) (可以用来删除行首行尾的空白字符(包括空格、制表符、换页符等等),非常有用的表达式)**
- 腾讯QQ号: [1-9][0-9]{4,} (腾讯QQ号从10000开始)
- 中国邮政编码: [1-9]\d{5}(?!\d) (中国邮政编码为6位数字)
- IPv4地址: ((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}