正则表达式

正则是什么,能做什么?

正则,就是正则表达式,英文是 Regular Expression,简称 RE。顾名思义,正则其实就是一种描述文本内容组成规律的表示方式

  • 在编程语言中,正则常常用来简化文本处理的逻辑
  • 在 Linux 命令中,它也可以帮助我们轻松地查找或编辑文件的内容,甚至实现整个文件夹中所有文件的内容替换
  • 在各种文本编辑器中,比如 Atom,Sublime Text 或 VS Code 等,在查找或替换的时候也会使用到它

正则功能:

  • 校验数的的有效性,比如校验手机号、邮箱
  • 查找符合要求的文本内容,比如查找符合某规则的号码
  • 对文本进行切割、替换等操作,比如用连续的空白符切割

正则练习地址

基础篇

元字符

元字符的概念

所谓元字符就是指那些在正则表达式中具有特殊意义的专用字符,元字符是构成正则表达式的基本元件。正则就是由一系列的元字符组成的。

正则元字符

特殊单字符
字符 说明
. 任意字符(换行除外)
\d 任意数字
\D 任意非数字
\w 任意字母数字下划线
\W 任意非字母数字下划线
\s 任意空白符
\S 任意非空白符
空白符
字符 说明
\r 回车符
\n 换行符
\f 换页符
\t 制表符
\v 垂直制表符
\s 任意空白符
量词
字符 说明
* 0到多次
+ 1到多次
? 0到1次
{m} 出现m次
{m,} 出现至少m次
{m,n} m到n次
范围
字符 说明
| 或,如ab|cd代表ab或bc
[…] 多选一,括号中任意单个元素
[a-z] 匹配a到z之间任意单个元素(按ASCII表,包含a,z)
[^…] 取反,不能是括号中的任意单个元素

练习题

  1. 第 1 位固定为数字 1;
  2. 第 2 位可能是 3,4,5,6,7,8,9;
  3. 第 3 位到第 11 位我们认为可能是 0-9 任意数字。

image-20200814152954058

量词与贪婪

量词

用{m,n}来表示(*) (+) (?)这3种元字符:

元字符 同一表示方法 示例
* {0,} ab*
可以匹配a或abbb
+ {1,} ab+
可以匹配ab或abbb,但不能匹配a
? {0,1} (\+86-)?\d{11}
可以匹配+86-13800001111或13800001111
1
2
3
4
5
>>> import re
>>> re.findall(r'a+', 'aaaabbb')
['aaaa']
>>> re.findall(r'a*', 'aaaabbb')
['aaaa', '', '', '', '']

可以看出(+)(*)的区别,(*)的结果匹配到了四次空字符串。为什么会匹配到空字符串呢?因为星号(*)代表0到多次,匹配0次就是空字符串。查看上面的结果,会发现疑问,aaaa这部分也是有空字符串的,为什么没匹配上呢?

对于以上问题,就引出贪婪与非贪婪模式,这两种模式都必须满足匹配次数的:

  • 贪婪模式,简单说就是尽可能进行最长匹配
  • 非贪婪模式呢,则会尽可能进行最短匹配

正是这两种模式产生了不同的匹配结果。

贪婪、非贪婪与独占模式

贪婪匹配(Greedy)

在正则中,表示次数的量词默认是贪婪的,在贪婪模式下,会尝试尽可能最大长度去匹配

示例:字符串aaabb 中使用正则 a* 的匹配过程

aaabb
下标 012345
匹配 开始 结束 说明 匹配内容
第1次 0 3 到第一个字母b发现不满足,输出aaa aaa
第2次 3 3 匹配剩下的bb,发现匹配不上,输出空字符串 空字符串
第3次 4 4 匹配剩下的b,发现匹配不上,输出空字符串 空字符串
第4次 5 5 匹配剩下的空字符串,输出空字符串 空字符串

a* 在匹配开头的 a 时,会尝试尽量匹配更多的 a,直到第一个字母 b 不满足要求为止,匹配上三个 a,后面每次匹配时都得到了空字符串。

贪婪模式的特点就是尽可能进行最大长度匹配。所以要不要使用贪婪模式是根据需求场景来定的。如果我们想尽可能最短匹配呢?那就要用到非贪婪匹配模式了。

非贪婪匹配(Lazy)

非贪婪模式会尽可能短地去匹配。那么如何将贪婪模式变成非贪婪模式呢?我们可以在量词后面加上英文的问号 (?),正则就变成了 a*?。

1
2
3
4
5
>>> import re
>>> re.findall(r'a*', 'aaabb') # 贪婪模式
['aaa', '', '', '']
>>> re.findall(r'a*?', 'aaabb') # 非贪婪模式
['', 'a', '', 'a', '', 'a', '', '', '']

image-20200814155311923

image-20200814155354334

独占模式(Possessive)

独占模式不会交还已经匹配上的字符。

不管是贪婪模式,还是非贪婪模式,都需要发生回溯才能完成相应的功能。

什么是回溯呢?如下的例子,首先是贪婪模式:

  • regex = “xy{1,3}z”
  • text = “xyyz”

在匹配时,y{1,3}会尽可能长地去匹配,当匹配完 xyy 后,由于 y 要尽可能匹配最长,即三个,但字符串中后面是个 z 就会导致匹配不上,这时候正则就会向前回溯,吐出当前字符 z,接着用正则中的 z 去匹配。

1
2
3
>>> import re
>>> re.findall(r'xy{1,3}z', 'xyyz')
['xyyz']

匹配不上,回溯(即z会吐出来),在用z去匹配

正则为非贪婪模式:

  • regex = “xy{1,3}?z”
  • text = “xyyz”

由于 y{1,3}? 代表匹配 1 到 3 个 y,尽可能少地匹配。匹配上一个 y 之后,也就是在匹配上 text 中的 xy 后,正则会使用 z 和 text 中的 xy 后面的 y 比较,发现正则 z 和 y 不匹配,这时正则就会向前回溯,重新查看 y 匹配两个的情况,匹配上正则中的 xyy,然后再用 z 去匹配 text 中的 z,匹配成功。

1
2
3
>>> import re
>>> re.findall(r'xy{1,3}?z', 'xyyz')
['xyyz']

正则z匹配不上,回溯,重新尝试匹配两个y的情况

但是在一些场景下,我们不需要回溯,匹配不上返回失败就好了,因此正则中还有另外一种模式,独占模式,它类似贪婪匹配,但匹配过程不会发生回溯,因此在一些场合下性能会更好。

独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯,这样的话就比较节省时间。具体的方法就是在量词后面加上加号(+)。

正则为独占模式:

  • regex = “xy{1,3}+z”
  • text = “xyyz”

需要注意的是 Python 和 Go 的标准库目前都不支持独占模式,会报错,如下所示:

1
2
3
>>> import re
>>> re.findall(r'xy{1,3}+yz', 'xyyz')
error: multiple repeat at position 7

报错显示,加号(+)被认为是重复次数的元字符了。如果要测试这个功能,我们可以安装 PyPI 上的 regex 模块。

注意:需要先安装regex模块,python3 -m pip install regex

1
2
3
4
>>> import regex
>>> regex.findall(r'xy{1,3}z', 'xyyz') # 贪婪模式['xyyz']
>>> regex.findall(r'xy{1,3}+z', 'xyyz') # 独占模式['xyyz']
>>> regex.findall(r'xy{1,2}+yz', 'xyyz') # 独占模式[]
总结
模式 正则 文本 结果
贪婪模式 a{1,3}ab aaab 匹配
非贪婪模式 a{1,3}?ab aaab 匹配
独占模式 a{1,3}+ab aaab 不匹配

独占模式性能比较好,可以节约匹配的时间和 CPU 资源,但有些情况下并不能满足需求,要想使用这个模式还要看具体需求,另外还得看你当前使用的语言或库的支持程度

正则中量词默认是贪婪匹配,如果想要进行非贪婪匹配需要在量词后面加上问号。贪婪和非贪婪匹配都可能会进行回溯,独占模式也是进行贪婪匹配,但不进行回溯,因此在一些场景下,可以提高匹配的效率,具体能不能用独占模式需要看使用的编程语言的类库的支持情况,以及独占模式能不能满足需求。

练习题

有一篇英文文章,里面有很多单词,单词和单词之间是用空格隔开的,在引号里面的一到多个单词表示特殊含义,即引号里面的多个单词要看成一个单词。现在你需要提取出文章中所有的单词。我们可以假设文章中除了引号没有其它的标点符号,有什么方法可以解决这个问题呢?如果用正则来解决,你能不能写出一个正则,提取出文章中所有的单词呢(不要求结果去重)?

1
we found “the little cat” is in the hat, we like “the little cat”

注意:引号是中文情况下的

1
其中 the little cat 需要看成一个单词

image-20200814160635021

分组与引用

分组与编号

括号在正则中可以用于分组,被括号括起来的部分“子表达式”会被保存成一个子组。

分组和编号的规则:第几个括号就是第几个分组,如下的例子,这里有个时间格式 2020-05-10 20:23:05。假设我们想要使用正则提取出里面的日期和时间。

img

正则表达式(\d{4}-\d{2}-\{2})(\d{2}:\d{2}:\{2}),将日期和时间都括号括起来。这个正则中一共有两个分组,日期是第 1 个,时间是第 2 个。

不保存子组

在括号里面的会保存成子组,但有些情况下,你可能只想用括号将某些部分看成一个整体,后续不用再用它,类似这种情况,在实际使用时,是没必要保存子组的。这时我们可以在括号里面使用?:不保存子组。

如果正则中出现了括号,那么我们就认为,这个子表达式在后续可能会再次被引用,所以不保存子组可以提高正则的性能。除此之外呢,这么做还有一些好处,由于子组变少了,正则性能会更好,在子组计数时也更不容易出错。

不保存子组是什么?

  • 可以理解成,括号只用于归组,把某个部分当成“单个元素”,不分配编号,后面不会再进行这部分的引用。
正则 示例
保存子组 (正则) \d{15}(\d{3})
不保存子组 (?:正则) \d{15}(?:\d{3})

image-20200818112711499

image-20200818112819142

括号嵌套

在括号嵌套的情况里,我们要看某个括号里面的内容是第几个分组怎么办?不要担心,其实方法很简单,我们只需要数左括号(开括号)是第几个,就可以确定是第几个子组。

例子:在阿里云简单日志系统中,我们可以使用正则来匹配一行日志的行首。假设时间格式是 2020-05-10 20:23:05 。

img

日期分组编号是 1,时间分组编号是 5,年月日对应的分组编号分别是 2,3,4,时分秒的分组编号分别是 6,7,8。

命名分组

由于编号得数在第几个位置,后续如果发现正则有问题,改动了括号的个数,还可能导致编号发生变化,因此一些编程语言提供了命名分组(named grouping),这样和数字相比更容易辨识,不容易出错。命名分组的格式为(?P<分组名>正则)。

比如在 Django 的路由中,命名分组示例如下:

1
url(r'^profile/(?P\w+)/$', view_func)

需要注意的是,刚刚提到的方式命名分组和前面一样,给这个分组分配一个编号,不过你可以使用名称,不用编号,实际上命名分组的编号已经分配好了。不过命名分组并不是所有语言都支持的,在使用时,你需要查阅所用语言正则说明文档,如果支持,那你才可以使用。

分组引用

在知道了分组引用的编号 (number)后,大部分情况下,我们就可以使用 “反斜扛 + 编号”,即 \number 的方式来进行引用,而 JavaScript 中是通过$编号来引用,如$1

常见的编程语言中,分组查找和替换的引用方式:

编程语言 查找时引用方式 替换时引用方式
Python \number 如\1 \number 如\1
Go 官方包不支持 官方包不支持
Java \number 如\1 $number,如$1
JavaScript \number,如\1 $number,如$1
PHP \number 如\1 \number 如\1
Ruby \number 如\1 \number 如\1

分组引用在查找中使用

正则查找时如何使用分组引用?。比如我们要找重复出现的单词,我们使用正则可以很方便地使“前面出现的单词再次出现”,具体要怎么操作呢?我们可以使用 \w+ 来表示一个单词,针对刚刚的问题,我们就可以很容易写出 (\w+) \1 这个正则表达式了。

image-20200818115846181

分组引用在替换中使用

和查找类似,我们可以使用反向引用,在得到的结果中,去拼出来我们想要的结果。

image-20200818120820114

Python3的实现

1
2
3
4
5
6
>>> import re
>>> test_str="2020-08-18 12:02:05"
>>> regex=r"((\d{4})-(\d{2})-(\d{2})) ((\d{2}):(\d{2}):(\d{2}))"
>>> subst=r"日期\1 时间\5 \2年\3月\4日 \6时\7分\8秒"
>>> re.sub(regex, subst, test_str)
'日期2020-08-18 时间12:02:05 2020年08月18日 12时02分05秒'

练习题

有一篇英文文章,里面有一些单词连续出现了多次,我们认为连续出现多次的单词应该是一次,比如:

1
the little cat cat is in the hat hat hat, we like it.

其中 cat 和 hat 连接出现多次,要求处理后结果是

1
the little cat is in the hat, we like it.

image-20200818150059610

匹配模式

所谓匹配模式,指的是正则中一些改变元字符匹配行为的方式,比如匹配时不区分英文字母大小写。常见的匹配模式有 4 种,分别是不区分大小写模式、点号通配模式、多行模式和注释模式。

不区分大小写模式(Case-Insensitive)

一个例子:在进行文本匹配时,我们要关心单词本身的意义。比如要查找单词 cat,我们并不需要关心单词是 CAT、Cat,还是 cat。根据之前我们学到的知识,你可能会把正则写成这样:[Cc][Aa][Tt],这样写虽然可以达到目的,但不够直观,如果单词比较长,写起来容易出错,阅读起来也比较困难。

image-20200814111923877

不区分大小写是匹配模式的一种。当我们把模式修饰符放在整个正则前面时,就表示整个正则表达式都是不区分大小写的。模式修饰符是通过 (? 模式标识) 的方式来表示的。 我们只需要把模式修饰符放在对应的正则前,就可以使用指定的模式了。在不区分大小写模式中,由于不分大小写的英文是 Case-Insensitive,那么对应的模式标识就是 I 的小写字母 i,所以不区分大小写的 cat 就可以写成 (?i)cat。

image-20200814112024019

(?i)cat相比[Cc][Aa][Tt]清晰简洁的很多了。

也可以用它来尝试匹配两个连续出现的 cat,如下图所示,你会发现,即便是第一个cat和第二个cat大小写不一致,也可以匹配上。

image-20200814112317956

如果想要第一次和第二次重复时的大小写一致,只需要用括号把修饰符和正则cat部分括起来,加括号相当于作用范围的限定,让不区分大小写只作用于这个括号里的内容。

image-20200814112450898

需要注意的是,这里正则写成了 ((?i)cat) \1,而不是 ((?i)(cat)) \1。也就是说,我们给修饰符和 cat 整体加了个括号,而原来 cat 部分的括号去掉了。如果 cat 保留原来的括号,即 ((?i)(cat)) \1,这样正则中就会有两个子组,虽然结果也是对的,但这其实没必要。

如果用正则匹配,实现部分区分大小写,另一部分不区分大小写,这该如何操作呢?就比如说我现在想要,the cat 中的 the 不区分大小写,cat 区分大小写。

image-20200814115240865

有一点需要你注意一下,上面讲到的通过修饰符指定匹配模式的方式,在大部分编程语言中都是可以直接使用的,但在 JS 中我们需要使用 /regex/i 来指定匹配模式。在编程语言中通常会提供一些预定义的常量,来进行匹配模式的指定。比如 Python 中可以使用 re.IGNORECASE 或 re.I ,来传入正则函数中来表示不区分大小写。

1
2
3
>>> import re
>>> re.findall(r"cat", "CAT Cat cat", re.IGNORECASE)
['CAT', 'Cat', 'cat']

总结一下不区分大小写模式的要点:

  1. 不区分大小写模式的指定方式,使用模式修饰符 (?i);

  2. 修饰符如果在括号内,作用范围是这个括号内的正则,而不是整个正则;

  3. 使用编程语言时可以使用预定义好的常量来指定匹配模式。

点号通配模式(Dot All)

.任意字符(换行除外)可以匹配上任何符号,但不能匹配换行。当我们需要匹配真正的“任意”符号的时候,可以使用 [\s\S] 或 [\d\D] 或 [\w\W] 等。但这么写不够简洁自然,所以正则中提供了一种模式,让英文的点(.)可以匹配上包括换行的任何字符。

模式就是点号通配模式,有很多地方把它称作单行匹配模式

单行的英文表示是 Single Line,单行模式对应的修饰符是 (?s)

image-20200814120017449

点可以匹配上换行

需要注意的是,JavasScript 不支持此模式,那么我们就可以使用前面说的[\s\S]等方式替代。在 Ruby 中则是用 Multiline,来表示点号通配模式(单行匹配模式)。

多行匹配模式(Multiline)

通常情况下,^匹配整个字符串的开头,$ 匹配整个字符串的结尾。多行匹配模式改变的就是 ^ 和 $ 的匹配行为

image-20200814140244516

多行模式的作用在于,使 ^ 和 $ 能匹配上每行的开头或结尾,我们可以使用模式修饰符号(?m)来指定这个模式

image-20200814140704882

和以下好像没有差

image-20200814140807207

这个模式有什么用呢?在处理日志时,如果日志以时间开头,有一些日志打印了堆栈信息,占用了多行,我们就可以使用多行匹配模式,在日志中匹配到以时间开头的每一行日志。

值得一提的是,正则中还有 \A 和 \z(Python 中是 \Z) 这两个元字符容易混淆,\A 仅匹配整个字符串的开始,\z 仅匹配整个字符串的结束,在多行匹配模式下,它们的匹配行为不会改变,如果只想匹配整个字符串,而不是匹配每一行,用这个更严谨一些。

注释模式(Comment)

在实际工作中,正则可能会很复杂,这就导致编写、阅读和维护正则都会很困难。我们在写代码的时候,通常会在一些关键的地方加上注释,让代码更易于理解。很多语言也支持在正则中添加注释,让正则更容易阅读和维护,这就是正则的注释模式。正则中注释模式是使用 (?#comment) 来表示。

比如我们可以把单词重复出现一次的正则 (\w+) \1 写成下面这样,这样的话,就算不是很懂正则的人也可以通过注释看懂正则的意思。

1
(\w+)(?#word) \1(?#word repeat again)

image-20200814141622842

在很多编程语言中也提供了 x 模式来书写正则,也可以起到注释的作用。Python3的例子:

1
2
3
4
5
6
7
8
9
10
11
12
import re

regex = r'''(?mx) #使用多行模式和x模式
^ # 开头
(\d{4}) # 年
(\d{2}) # 月
$ # 结尾
'''

print(re.findall(regex, '202007\n202008'))

# 输出结果:[('2020', '07'), ('2020', '08')]

需要注意的是在 x 模式下,所有的换行和空格都会被忽略。为了换行和空格的正确使用,我们可以通过把空格放入字符组中,或将空格转义来解决换行和空格的忽略问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
import re

regex = r'''(?mx) #使用多行模式和x模式
^ # 开头
(\d{4}) # 年
[ ] # 空格
(\d{2}) # 月
$ # 结尾
'''

print(re.findall(regex, '2020 07\n2020 08'))

# 输出结果:[('2020', '07'), ('2020', '08')]

总结

正则中常见的四种匹配模式,分别是:不区分大小写、点号通配模式、多行模式和注释模式。

  1. 不区分大小写模式,它可以让整个正则或正则中某一部分进行不区分大小写的匹配。
  2. 点号通配模式也叫单行匹配,改变的是点号的匹配行为,让其可以匹配任何字符,包括换行。
  3. 多行匹配说的是 ^ 和 $ 的匹配行为,让其可以匹配上每行的开头或结尾。
  4. 注释模式则可以在正则中添加注释,让正则变得更容易阅读和维护。

应用篇

断言

如何用断言更好地实现替换重复出现的单词?