首页 > 代码库 > 正则表达式基础、原理及优化
正则表达式基础、原理及优化
前言
正则表达式使用单个字符串来描述、匹配一系列匹配某个句法规则的字符串。在很多文本编辑器里,正则表达式通常被用来检索、替换那些匹配某个模式的文本。简单说就是一个特殊的字符串去匹配一个字符串。定义了一些规则,用于匹配满足这些规则的字符串。
对于正则表达式应该很多人第一感觉就是很难,完全没有规律,看不懂。
我觉得可能有以下几个原因:
1、读不懂。
各种不同的字符组合一起,难以理解。确实,对于熟悉正则表达式的人来说,一个稍微长点的正则表达式想要理解起来,可能也要花费一定的功夫。可读性差是正则表达式的一个很大的缺点。
如:[\w!#$%&‘*+/=?^_`{|}~-]+(?:\.[\w!#$%&‘*+/=?^_`{|}~-]+)*@(?:[\w](?:[\w-]*[\w])?\.)+[\w](?:[\w-]*[\w])?
2、写不出来
各种标点符合,完全不知道什么意思,没法写。
3、很多工具,很多编程语言都有正则表达式,而往往这些正则表达式存在细微的差别。
4、写出来的正则表达式有问题,匹配了非期望的字符串,甚至把期望的字符串给遗漏了。
另外通常情况下正则的速度确实相对慢一些。但是很多情况下正则表达式太慢是由于写的正则表达式有问题,没有优化。我并不推荐大量使用正则表达式,有些情况下又非用不可。(想检测一个字符串中是否含有the, \bthe\b)
关于匹配
本教程所说的匹配成功,并不是指正则表达式完全匹配目标字符串,指正则表达式能匹配目标字符串的部分。无特殊说明,仅匹配一次
推荐的书籍和网站:
精通正则表达式(第三版)
http://rubular.com/ 测试正则表达式
基本概念
如果想找出当前目录所有的.txt文件。用过windows或者linux命令行的应该知道使用“dir *.txt 、ls*.txt”去查找。为什么这个字符串能找到所有的txt文件呢?因为*这个字符有特殊的含义,表示任意文本。那么.txt有什么特殊含义呢?没有什么含义,就是表示他们自身。所有*.txt表示任意文本开通,但是以.txt结尾就行。
正则表达式有两种字符组成(是不是很简单),如上面例子中的*,属于特殊字符,也称之为元字符,表示的不是字面上的含义;还有一种就是上面说的.txt,表示普通文本字符。
正则表达式之所以强大,靠的就是元字符提供了强大的描述能力。下面介绍一些常用的元字符。
1、行的起始^和结束$(字符串的起始和结束)
^表示一行的起始,$表示一行的结束。这是最简单的两个元字符了。
例如cat可以匹配所有cat单词,以及包含cat字符的单词。^cat只能找到行首的cat,同样cat$表示行尾的cat。简单来说^和$匹配的是一个位置,并不匹配任何字符。元字符不仅可以匹配字符,还可以匹配特定位置。
关于正则表达式的理解
1、^cat匹配以cat开头的行
2、^cat匹配的是以c作为一行的第一个字符,紧接着一个a,接着一个t的文本
这两种结果并没有什么差异,但是第二种更符合正则表达式的逻辑,对后面分析会有帮助。
^cat$ 匹配的是行开头,然后是一个c,接着一个a,接着一个t,然后是行尾。
^$ 行开头,然后行结尾(空行)
^ 行开头 (没有特殊意义,每行都有开头,当然可以用来统计行数)
注意,对于一个字符串来说,如java或者python中^和$表示字符串的起始和结束,如^cat$不能匹配"cat\ncat"
2、字符组[]
比如我们搜索单词grey(灰色),但是也有可能写作gray。我们怎么用正则表达式同时能匹配上grey和gray呢?
这时可以考虑使用字符组[],用中括号将某处期望出现的字符括起来。[ea]表示匹配e或者a,gr[ea]p的意思是:先找到g,跟着一个r,然后是一个a或者e,最后是一个y。
可以看出在字符组外,普通字符都是(接下来是)的意思,首先匹配g,接下来是r等等。但是在字符组内是完全不一样的,表示的意思是或,就是指必须匹配字符组中的一个字符,仅只能匹配其中一个。如这个表达式并不能匹配greay
<H[123456]>可以用来匹配<H1>、<H2>等一直到<H6>,注意<>并不是元字符。
字符组元字符
连字符-
上面的<H[123456]>可以写成<H[1-6]>,是等效的。-连字符表示一个范围,表示字符组内的字符是1到6,包括1和6(和通常编程里面的范围不太一样)。就是指这个字符在字符组内才是元字符,否则就是普通字符或者其他意义的元字符。同样的,反过来,字符组外的元字符在字符组内就不是元字符了,或者是不同意义的元字符。
[0-9A-Za-z] 支持多重范围,表示可以匹配0-9,A-Z,a-z中的任意一个字符
[0-9a-zA-Z] 顺序也可以颠倒(0-9不能颠倒)
[0-9a-zA-Z_!.?] 可以和普通文本结合起来。
注意:-连号只有出现在字符组中字符中间才算连字符,出现在开头就不算了。(出现在结尾呢?当然也不是字符组元字符了)
排除型字符 [^...]
[^...],字符组中的^如果出现在第一个位置,也是一个字符组元字符。匹配的是所有没有被字符组列出来的任何字符。简单说就是表示排除的意思,不希望匹配的字符。
例如:例如英文单词中,q后面通常跟的是字母是u,很奇怪,我想找出q后面不是u的单词怎么办? q[^u].表示的就是匹配的是一个,q然后一个非u的字符。我们发现q和Iraq没有被匹配上。
注意:匹配一个没有列出的字符,而不是不要匹配列出的字符。看起来差不多,但是有些细微差别。重点在于q后面需要匹配一个字符,排除型字符组也需要匹配一个字符。
3、用点号匹配任意字符
例如:对于这三个日期,2017-07-12、2017.07.12、2017/07/12,利用上面学过的元字符,可以表示为:2017[-./]07[-./]12。注意:虽然可以匹配上面的三个字符串,但也会匹配一些非法的日期如2017/07.12等。
注意:编写正则表达式一定要和实际应用环境结合起来。比如只是想相对精确的找出2017-07-12这个日期,那么用这个表达式就可以了。如果你的应用中只接受上面三种格式,其他格式都是错误的,你需要精确的匹配日期。那么上面的正则表达式就不能满足要求了。第一种,只是相对精确的匹配目标字符串,只要目标字符串使我们想要的,那么一定匹配到,可以容许少量的非法字符串。第二种就是精确匹配期望的字符串,不要匹配不期望的字符串。理想情况下正则表达式都是第二种,但是这种正则表达式会更难写一点,有时候也不一定能写出来。需要在复杂性和完整性之前取得平衡。
再稍微不精确一点,可以使用2017.07.12来进行匹配。这里的.点号就不是字面上的意思了,它是一个元字符,表示匹配任意单个字符(通常不匹配换行符\n)
4、多选结构 |
多选结构是用来匹配任意子表达式,用这个符号“|”表示,是或的意思。它可以把不同的子表达式组合成一个总的表达式,而这个总的表达式可以匹配任意的子表达式。如上面的日期的例子:如果想精确匹配,正则表达式可以这么写2017-07-12|2017\.07\.12|2017/07/12。它是把三个子表达式组合起来了,可以匹配任意一个表达式。注意.之前有反斜杠,因为.号是元字符,反斜杠可以把元字符变成原始的含义。
这个和字符组是类似的,都是表示或的意思,字符组只能选择单个字符,多选结构可以选择一个字符串。gr[ea]y也可以用多选结构来写,grey|gray。还可以简写成gr(e|a)y。可以用括号来限定多选的范围,括号也是元字符。
注意:多选结构与其他元字符一起使用,一定要注意他的使用范围。必要时用括号括起来。
^From|Subject|Date:,我们可能期望的是行起始跟着From或者Subject或者Date,然后后面接着一个:。^(From|Subject|Date):才能完全匹配我们的期望字符串。
5、单词分界符 \b
b本身不是元字符,但是通过反斜杠转义后变成了元字符。表示的是一个单词的分界符。它匹配的也是一个位置。\bcat\b就能匹配It is a cat.中cat单词了,cats之列的就匹配不上。\ba通常用来表示a的前面不是字母和数字。
6、可选项元素 ?
对于colour(颜色, 英式),同样存在color(美式)。我们需要匹配这两种格式。我们使用了colo(r|ur),但是我们还可以更简单点。
colou?r可以解决这个问题。?表示的意思就是可选项,它加在一个字符后面,表示他允许出现这个字符,当然也可以不出现。简单来说就是匹配0个或者1个字符,优先匹配1个字符。如果它不是跟着字符后面,可能表示其他含义。
它只作用于紧邻的字符。上述正则表达式的意思是匹配一个 c,然后是o,然后是l,然后是o,然后是u?,然后是r。其中u?总是可以匹配成功的,如果出现u,就匹配u,如果没有,就什么都不匹配。也就是说,不管u存不存在,u?都是匹配成功的。例如对于这个例子,semicolon,首先是一个c,接着一个o,接着一个l,接着一个u?,虽然单词这里没有u,u?也是匹配成功的。到目前为止colou?已经匹配了colo这四个字符了,接着后面跟着一个r,但是r不能匹配n,所以最终匹配失败了。
字符串July 4th和July 4可以用这个正则July 4(th)?来匹配,括号可以限定?的作用范围,其实括号的一个主要作用就是限制所以范围。
7、其他量词* + 区间{min,max}
加好+ ,星号 * 和?很类似。并不单独使用,而是作用在其他字符后面,限定个数使用。
* 出现0次或更多次。下限0次,无上限。含义是匹配尽可能多的次数,如果实在无法匹配,那也没有关系。
+ 出现1次或更多次。下限1次,无上限。匹配尽可能多的次数,但如果一次都匹配不了,那么久匹配失败。
例如:a* a? a+分别匹配下面这三个字符串
aaaab:a*匹配了aaaa;a?匹配了a;a+匹配了aaaa。(a?只匹配第一个a,因为我们这里讨论的是匹配一次就结束匹配。)
ab: a*匹配了a;a?匹配了a;a+匹配了a。
b:a*选择匹配0个字符串,匹配成功了。a?同样选择匹配0个字符,也匹配成功了;a+只要需要匹配一个a,这里没有a,所以匹配失败了。
注意 :*和?一样是永远不会匹配失败的,只是匹配的内容不一样。
? * +可以统称为量词,他们的作用是限定所所用元素的匹配次数。量词是贪婪的,他会匹配尽可能多的字符,直到无法匹配。
规定出现的次数的范围:区间{min,max}
{min,max},至少出现min次,最多出现max次。
{min, } max可以省略,表示最少出现minci,无上限。
{num} 正好出现num次。
例如: a{1,5},a至少出现1次,最多出现5次。
注意:量词和区间范围都是匹配优先的。
8、环视(又称 零宽断言)
(?=...) 顺序环视,表示的含义是,从左到右顺序查看文本,如果能够匹配就返回匹配成功。
如a(?=def),表示的含义,首先有一个a,接着一个d,接着一个e,接着一个f,如果成功,则返回成功。匹配adef就可能成功。
顺序环视匹配的也是一个位置,并不占用字符
(?=Jeffrey) 去匹配Jeffrey Fried。匹配的就是J前面的位置
(?=Jeffrey)Jeff可以获得更精确的结果。
同样还有逆序环视
(?<=...),表示从右到左环视字符串。(?<=Jeffrey)匹配的是y后面的位置。
上面又称肯定环视
将等号换成!就表示否定环视
如(?!...) 顺序否定环视
(?<!...)逆序否定环视
re.split(r‘(?<=a)b(?=c)‘, ‘123b321abc123‘) [‘123b321a‘, ‘c123‘]
re.split(r‘(?<!a)b(?!c)‘, ‘123b321abc123‘) [‘123‘, ‘321abc123‘]
注意:环视的限制,环视中可以出现什么表达式通常有限制,一般顺序环视没有限制,逆序环视往往限制匹配的长度。(?<=books?)往往是不合法的正则表达式,因为匹配文本长度是不确定的。
括号的作用
1、限制作用域
如前面限制| ? {min, max} + *等作用域
2、捕获期望字符串。可以进行分组和反向引用。可以有多个括号,捕获的顺序是(出现的次序。括号能够记住自己匹配上的内容,这样可以进场分组和反向引用。
2.1、分组,大部分语言都提供分组的功能。如python中:
import re #2017.07.12 re.match(r‘([0-9]{4}).([0-9]{2}).[0-9]{2}‘, ‘2017-07-12‘).group(1)
2.2、反向引用,反斜杠加上数字,表示当前位置匹配第几个分组中的内容。从1开始
例如:
([a-z])([1-9])\2\1,其中\1匹配的是[a-z]匹配的内容,\2匹配的是[1-9]匹配的内容。能够匹配a11a等对称的字符串。
(([a-z])([1-9]))\3\2去匹配a11a,第一个括号捕获的是a11a,第二个括号匹配的是a,第三个是1
还是之前日期的例子,正则表达式2017([-./])07(\1)12,可以精确匹配这三个‘2017-07-12‘, ‘2017.07.12‘, ‘2017/07/12‘中的一个,不会匹配其他字符串。注意\1加了括号,因为1后面还有数字,避免 混淆。同时加了括号,那么又会多出一个捕获分组。
3、组合成其他元字符
例如环视
4、命名捕获
(P?<Name>...)
python中:
print re.match(r‘(?P<word>\w+)(?P=word)‘, ‘pythonpython‘).group(‘word‘)
java中:
Pattern pattern = Pattern.compile("(?<word>\\w+)\\k<word>"); Matcher matcher = pattern.matcher("javajava"); if(matcher.matches()){ System.out.println(matcher.group("word")); }
注意:一个字符是否是元字符,取决于所属环境,如在字符组内.号就不是字符组。部分元字符加上反斜杠转义成普通字符,部分普通字符加上反斜杠变成元字符。
其他常用元字符
\t制表符,tap
\n换行符
\s 任何空白字符(包括制表符,换行,空格等)常用
\S除了\s以外的字符
\w [a-zA-Z0-9],通常用\w+匹配单词,注意的是部分工具中\w可以匹配unicode中的“字母”如汉字,notepad++
\d [0-9]
\D 除了\d意外的字符,[^0-9]
大多数正则表达式都提供对unicode的支持。
\u4e2d匹配中文的中字。
[\u4e00-\u9fa5]可以用这个来表示中文字符。
忽略优先量词 *? +? {m,n}?,前面提到的匹配优先量词加上?,就变成忽略优先量词,优先匹配下限次数。如a+?去匹配aaaab,只会匹配第一个a。
占有优先量词 *+ ?+ ++ {m,n}+,匹配优先量词加上+,不会交还已匹配的字符。目前主要是java.util.regex这个包提供这个功能。\d+0 匹配123450是可以匹配成功的。但是\d++0是无法匹配成功的。
匹配原理
正则匹配引擎主要分为两类,DFA和NFA,发展的过程也产生了一些变体
DFA引擎 主要有awk MySQL
传统型NFA引擎 主要有python java PHP Ruby sed .NET
其他引擎等
DFA不支持忽略优先量词,基本上就是传统型NFA。本教程只关注传统型NFA引擎的正则表达式。
有两条普遍的规则,适用于大多数NFA和DFA引擎。
规则一 优先选择最左端的匹配结果
起始位置最靠左的匹配结果总是优先其他可能的结果。匹配先从需要查找的字符串的起始位置尝试匹配。尝试匹配的意思是,在当前位置测试整个表达式能匹配的文本。如果当前位置测试了所有可能之后不能找到匹配结果,那么就会从第二个字符之前的位置开始重新尝试匹配。在找到匹配前会一直重复这个过程,如果尝试了所有位置,都找不到匹配结果的情况下,才会报告匹配失败。
例如:使用ORA来匹配FLORAL,首先第一轮从F前面开始匹配,用O去尝试匹配,尝试会失败;然后从F后面L前面开始匹配,仍然用O去尝试匹配,失败;第三次尝试成功。
规则二 标准量词是匹配优先的* ? + {m,n}
标准量词并非是所有可能中最长的,但它们匹配尽可能多的字符,直到上限。\b\w+s\b来匹配结尾是s的单词。比如匹配regexes,\w+是可以匹配整个单词的,但是如果\w+匹配了整个单词,s\b就无法匹配了,所有\w+只能匹配regexe,最后的字符让s\b去匹配
^Subject:(.*),如果^Subject:部分匹配成功了,那么整个正则表达式都会匹配成功。.*的目的是*是匹配优先的,会把剩下的所有字符都匹配上。这是我们期望的。
过度的优先匹配
^.*\d\d能够匹配一行中最后的两个数字。对于这个字符串,about 24 characters long,首先.*匹配了整个字符串,这时候第一个\d需要匹配,为了避免匹配失败,.*需要交出最后一个字符g,显然\d无法匹配g,.*继续交出下一个字符n,这样循环15次,最终交出4时候,\d终于可以匹配上了,但是第二个\d仍然无法匹配,所以.*必须再交出一个字符2。这样.*匹配了“about ”,\d\d匹配了24。
NFA引擎:表达式主导
例如:to(nite|knight|night) 去匹配‘...tonight...‘。正则表达式第一个需要匹配的是t,它去字符串中寻找t,从第一个位置开始寻找,找到后停下,接下来是o,检查o能否匹配下一个字符,如果能匹配,继续检查下面的元素。下面的元素指的是(nite|knight|night)它们是或的关系。引擎会尝试这三种可能。在尝试nite的过程和之前一样,尝试匹配n,接着是i,最后是e。
NFA具有表达式主导的特性,引擎的匹配原理就很重要,如果我们改变表达式,可以节省很多时间。
to(ni(ght|te)|knight)
tonite|toknight|tonight
to(k?night|nite)
回溯
NFA最重要的性质是,它会一次处理各个子表达式或组成的元素,遇到需要在两个可能的成功的可能中进行选择的时候,它会选择其一,同时记住另一个,以备稍后可能的需要。
需要作出选择的情形主要包括量词和多选结构。无论选择哪一个途径,如果匹配成功了,其余下的也匹配成功了,那么匹配就结束了,如果余下的匹配失败了,引擎会回溯到之前做出选择的地方,选择其他的备用分支继续尝试。这样,引擎会尝试表达式的所有可能途径,直到匹配成功,或者尝试完所有路径并失败。
例如:对于to(nite|knight|night)去匹配hot tonic tonight
首先用正则表达式的t,从字符串的起始位置开始匹配,首先t无法匹配h,第一轮匹配失败,第二轮,t匹配o,同样失败,第三轮,t匹配成功,但是接下来o不匹配空格,导致本轮失败。进行第四轮匹配,to匹配成功,进入3个多选分支。三个分支都有可能。假设选择的是nite进行匹配,首先是n匹配成功,接着是i匹配成功,但是到c的时候匹配失败。这个失败并不会导致这一轮失败,也就是这个位置的匹配失败,因为还有其他分支没有尝试。假设接下来尝试knight,这时候回溯到to和nic之间的位置,显然第一次匹配,k与n匹配失败,那么只剩下night继续匹配了,显然这一次也失败了,这导致这个位置的匹配全部失败。这样会进入下一个位置,重复上面的操作。最终到tonight的前的位置时,选择night这个分支匹配成功了。
回溯的两个要点。
在匹配时,当出现多个选择时,应该首先选择哪个?
1、对于匹配优先量词,会优先进行匹配。
2、对于忽略优先量词,会选择跳过尝试。
3、对于多选结构,会选择按顺序进行尝试。
当需要回溯时,应该选择哪个备用状态。
选择当前最近存储的选项。如果前面是死路,你只需要沿路返回,找到上一次做出选择的地方。
关于备用状态
ab?c 匹配 abc 首先a匹配a,成功,接着b?去匹配b,这时候有可能失败,所以必须记下备用状态ab? · c和a · bc,b?匹配b成功,接着c匹配c成功。最终匹配成功,备用状态也丢弃了。
ab?c 匹配 ac 首先a匹配a,成功,接着b?去匹配c,这时候有可能失败,所以必须记下备用状态ab? · c和a · c,b?匹配c失败,选用回溯到备用状态,接着c匹配c成功。最终匹配成功,备用状态也丢弃了。
ab?c 匹配 abx 首先a匹配a,成功,接着b?去匹配b,这时候有可能失败,所以必须记下备用状态ab?·c和a·bx,b?匹配b成功,接着c匹配x失败,选用回溯到备用状态,接着c匹配b失败。但是整个匹配还没有结束。还会继续匹配下去。
*和+的回溯
\d+ 去匹配 a 1234 num,在用\d去匹配空格的时候,失败了,那么它会回溯。
a 1`234 num
a 12`34 num
a 123`4 num
a 1234` num
\d+`去匹配空格,但是此时表达式已经匹配完成,并且已经匹配到数据了,那么整个匹配结束。
a `1234 num这个位置并不在备用选项中,\d*来匹配整个字符串,会有这个备用选项吗?
一些问题
.*的问题 ".*"去匹配双引号文本
<input name="id" value="http://www.mamicode.com/1">,结果发现匹配的是"id" value="http://www.mamicode.com/1"这个文本,"[^"]*"就可以了。
<B>name</B>and<B>age</B> 使用<B>.*</B>去匹配,同样出现上面的问题,但是我们不能使用<B>[^</B>]*</B>。
<B>.*?</B> 使用忽略优先量词
占有优先量词,java中提供了占有优先量词,它不会交回已匹配的字符,可以避免备用状态。
\d++元 去匹配金额,1231231美元。
\d++\d永远无法匹配任何字符。
多选结构的陷阱
Jan 1到 Jan 31,需要匹配合法的日期
Jan [0-3][0-9] 无法匹配Jan 1 却能匹配Jan 00
Jan (0?[1-9]|[12][0-9]|3[01])看起来没有任何问题。可以匹配Jan 2,Jan 01,但是匹配Jan 21的时候匹配的是Jan 2。
Jan ([12][0-9]|3[01]|0?[1-9])
正则表达式的实用技巧
理想情况中,好的正则表达式必须在下面这些方面取得平衡。
1、只匹配期望的文本,排除不期望的文本
2、易于控制和理解
3、必须保证效率(如果能够匹配,必须很快返回结果,如果不能匹配,应该尽可能快的报告匹配失败)
现实情况,好的正则表达式与具体问题结合。在完整性和复杂性之间取得平衡。
避免优化过度
为提高效率而改动正则表达式最需要考虑的问题是,改动是否会影响准确性。
必要时才进行优化,优化需要考虑使用的环境和语言,优化后需要测试
正则表达式的匹配过程
正则表达式编译,检测语法,编译成内部形式
开始匹配,定位到字符串的起始位置
元素检测 依次测试正则表达式的各个元素。
相连元素会依次尝试 Subject
量词修饰的元素,控制权会在量词和被限定的匀速直接轮换
控制权在捕获型括号内外切换回带来一些开销。
寻找匹配结果 如果遭到匹配结果,就会报告匹配成功。
继续匹配 如果没有找到匹配,就会从下一个字符继续匹配。
匹配彻底失败,所有尝试都失败了,就会报告匹配失败
简单优化措施
1、消除不必要的括号,必须使用括号时,请尽量使用非捕获型括号
例如:.* 和 (?:.)*
2、消除不必要的字符组
例如:[.] 和 \.
3、使用.*开头的表达式应该在最前面加^
例如:.*abc vs ^.*abc
4、在多选结构中提取开头的必需元素
例如:th(?:is|at) 替代 (?:this|that)
5、多选结构的代价
例如:u|v|w|x|y|z VS [uvwxyz],尽量避免多选结构
6、改变多选结构的顺序
例如:去匹配引号中的字符,并且能够匹配转义的引号。"(\\.|[^"\\])*"去匹配"2\"x3\" likeness" 。可以优化"([^"\\]|\\.)*"
正则表达式基础、原理及优化