正则表达式
正则是什么,能做什么?
正则,就是正则表达式,英文是 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;
- 第 2 位可能是 3,4,5,6,7,8,9;
- 第 3 位到第 11 位我们认为可能是 0-9 任意数字。

量词与贪婪
量词
用{m,n}来表示(*) (+) (?)这3种元字符:
| 元字符 | 同一表示方法 | 示例 |
|---|---|---|
| * | {0,} | ab* 可以匹配a或abbb |
| + | {1,} | ab+ 可以匹配ab或abbb,但不能匹配a |
| ? | {0,1} | (\+86-)?\d{11}可以匹配+86-13800001111或13800001111 |
1 | import re |
可以看出(+)和(*)的区别,(*)的结果匹配到了四次空字符串。为什么会匹配到空字符串呢?因为星号(*)代表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 | import re |


独占模式(Possessive)
独占模式不会交还已经匹配上的字符。
不管是贪婪模式,还是非贪婪模式,都需要发生回溯才能完成相应的功能。
什么是回溯呢?如下的例子,首先是贪婪模式:
- regex = “xy{1,3}z”
- text = “xyyz”
在匹配时,y{1,3}会尽可能长地去匹配,当匹配完 xyy 后,由于 y 要尽可能匹配最长,即三个,但字符串中后面是个 z 就会导致匹配不上,这时候正则就会向前回溯,吐出当前字符 z,接着用正则中的 z 去匹配。
1 | import re |
匹配不上,回溯(即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 | import re |
正则z匹配不上,回溯,重新尝试匹配两个y的情况
但是在一些场景下,我们不需要回溯,匹配不上返回失败就好了,因此正则中还有另外一种模式,独占模式,它类似贪婪匹配,但匹配过程不会发生回溯,因此在一些场合下性能会更好。
独占模式和贪婪模式很像,独占模式会尽可能多地去匹配,如果匹配失败就结束,不会进行回溯,这样的话就比较节省时间。具体的方法就是在量词后面加上加号(+)。
正则为独占模式:
- regex = “xy{1,3}+z”
- text = “xyyz”
需要注意的是 Python 和 Go 的标准库目前都不支持独占模式,会报错,如下所示:
1 | import re |
报错显示,加号(+)被认为是重复次数的元字符了。如果要测试这个功能,我们可以安装 PyPI 上的 regex 模块。
注意:需要先安装regex模块,python3 -m pip install regex
1 | import regex |
总结
| 模式 | 正则 | 文本 | 结果 |
|---|---|---|---|
| 贪婪模式 | 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 需要看成一个单词 |

分组与引用
分组与编号
括号在正则中可以用于分组,被括号括起来的部分“子表达式”会被保存成一个子组。
分组和编号的规则:第几个括号就是第几个分组,如下的例子,这里有个时间格式 2020-05-10 20:23:05。假设我们想要使用正则提取出里面的日期和时间。

正则表达式(\d{4}-\d{2}-\{2})(\d{2}:\d{2}:\{2}),将日期和时间都括号括起来。这个正则中一共有两个分组,日期是第 1 个,时间是第 2 个。
不保存子组
在括号里面的会保存成子组,但有些情况下,你可能只想用括号将某些部分看成一个整体,后续不用再用它,类似这种情况,在实际使用时,是没必要保存子组的。这时我们可以在括号里面使用?:不保存子组。
如果正则中出现了括号,那么我们就认为,这个子表达式在后续可能会再次被引用,所以不保存子组可以提高正则的性能。除此之外呢,这么做还有一些好处,由于子组变少了,正则性能会更好,在子组计数时也更不容易出错。
不保存子组是什么?
- 可以理解成,括号只用于归组,把某个部分当成“单个元素”,不分配编号,后面不会再进行这部分的引用。
| 正则 | 示例 | |
|---|---|---|
| 保存子组 | (正则) | \d{15}(\d{3}) |
| 不保存子组 | (?:正则) | \d{15}(?:\d{3}) |


括号嵌套
在括号嵌套的情况里,我们要看某个括号里面的内容是第几个分组怎么办?不要担心,其实方法很简单,我们只需要数左括号(开括号)是第几个,就可以确定是第几个子组。
例子:在阿里云简单日志系统中,我们可以使用正则来匹配一行日志的行首。假设时间格式是 2020-05-10 20:23:05 。

日期分组编号是 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 这个正则表达式了。

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

Python3的实现
1 | import re |
练习题
有一篇英文文章,里面有一些单词连续出现了多次,我们认为连续出现多次的单词应该是一次,比如:
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. |

匹配模式
所谓匹配模式,指的是正则中一些改变元字符匹配行为的方式,比如匹配时不区分英文字母大小写。常见的匹配模式有 4 种,分别是不区分大小写模式、点号通配模式、多行模式和注释模式。
不区分大小写模式(Case-Insensitive)
一个例子:在进行文本匹配时,我们要关心单词本身的意义。比如要查找单词 cat,我们并不需要关心单词是 CAT、Cat,还是 cat。根据之前我们学到的知识,你可能会把正则写成这样:[Cc][Aa][Tt],这样写虽然可以达到目的,但不够直观,如果单词比较长,写起来容易出错,阅读起来也比较困难。

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

(?i)cat相比[Cc][Aa][Tt]清晰简洁的很多了。
也可以用它来尝试匹配两个连续出现的 cat,如下图所示,你会发现,即便是第一个cat和第二个cat大小写不一致,也可以匹配上。

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

需要注意的是,这里正则写成了((?i)cat) \1,而不是((?i)(cat)) \1。也就是说,我们给修饰符和 cat 整体加了个括号,而原来 cat 部分的括号去掉了。如果 cat 保留原来的括号,即((?i)(cat)) \1,这样正则中就会有两个子组,虽然结果也是对的,但这其实没必要。
如果用正则匹配,实现部分区分大小写,另一部分不区分大小写,这该如何操作呢?就比如说我现在想要,the cat 中的 the 不区分大小写,cat 区分大小写。

有一点需要你注意一下,上面讲到的通过修饰符指定匹配模式的方式,在大部分编程语言中都是可以直接使用的,但在 JS 中我们需要使用 /regex/i 来指定匹配模式。在编程语言中通常会提供一些预定义的常量,来进行匹配模式的指定。比如 Python 中可以使用 re.IGNORECASE 或 re.I ,来传入正则函数中来表示不区分大小写。
1 | >> import re |
总结一下不区分大小写模式的要点:
不区分大小写模式的指定方式,使用模式修饰符 (?i);
修饰符如果在括号内,作用范围是这个括号内的正则,而不是整个正则;
使用编程语言时可以使用预定义好的常量来指定匹配模式。
点号通配模式(Dot All)
.任意字符(换行除外)可以匹配上任何符号,但不能匹配换行。当我们需要匹配真正的“任意”符号的时候,可以使用 [\s\S] 或 [\d\D] 或 [\w\W] 等。但这么写不够简洁自然,所以正则中提供了一种模式,让英文的点(.)可以匹配上包括换行的任何字符。
模式就是点号通配模式,有很多地方把它称作单行匹配模式
单行的英文表示是 Single Line,单行模式对应的修饰符是(?s)

点可以匹配上换行
需要注意的是,JavasScript 不支持此模式,那么我们就可以使用前面说的[\s\S]等方式替代。在 Ruby 中则是用 Multiline,来表示点号通配模式(单行匹配模式)。
多行匹配模式(Multiline)
通常情况下,^匹配整个字符串的开头,$ 匹配整个字符串的结尾。多行匹配模式改变的就是 ^ 和 $ 的匹配行为。

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

和以下好像没有差

这个模式有什么用呢?在处理日志时,如果日志以时间开头,有一些日志打印了堆栈信息,占用了多行,我们就可以使用多行匹配模式,在日志中匹配到以时间开头的每一行日志。
值得一提的是,正则中还有 \A 和 \z(Python 中是 \Z) 这两个元字符容易混淆,\A 仅匹配整个字符串的开始,\z 仅匹配整个字符串的结束,在多行匹配模式下,它们的匹配行为不会改变,如果只想匹配整个字符串,而不是匹配每一行,用这个更严谨一些。
注释模式(Comment)
在实际工作中,正则可能会很复杂,这就导致编写、阅读和维护正则都会很困难。我们在写代码的时候,通常会在一些关键的地方加上注释,让代码更易于理解。很多语言也支持在正则中添加注释,让正则更容易阅读和维护,这就是正则的注释模式。正则中注释模式是使用 (?#comment) 来表示。
比如我们可以把单词重复出现一次的正则 (\w+) \1 写成下面这样,这样的话,就算不是很懂正则的人也可以通过注释看懂正则的意思。
1 | (\w+)(?#word) \1(?#word repeat again) |

在很多编程语言中也提供了 x 模式来书写正则,也可以起到注释的作用。Python3的例子:
1 | import re |
需要注意的是在 x 模式下,所有的换行和空格都会被忽略。为了换行和空格的正确使用,我们可以通过把空格放入字符组中,或将空格转义来解决换行和空格的忽略问题。
1 | import re |
总结
正则中常见的四种匹配模式,分别是:不区分大小写、点号通配模式、多行模式和注释模式。
- 不区分大小写模式,它可以让整个正则或正则中某一部分进行不区分大小写的匹配。
- 点号通配模式也叫单行匹配,改变的是点号的匹配行为,让其可以匹配任何字符,包括换行。
- 多行匹配说的是 ^ 和 $ 的匹配行为,让其可以匹配上每行的开头或结尾。
- 注释模式则可以在正则中添加注释,让正则变得更容易阅读和维护。
应用篇
断言
如何用断言更好地实现替换重复出现的单词?
什么是断言呢?断言是指匹配到文本位置有要求。
举例讲解。例如\d{11}能匹配上11位数字,但这11位数字可能是18位身份证号中的一部分。再例如,我们要查找tom,但其它单词,比如tomorrow中也包含了tom。
从上面的例子看到,在有些情况下,我们对需要匹配的文本的位置是有一定要求的。为了解决这个问题,正则中提供了一些结构,只用于匹配位置,而不是文本内容本身,这种结构就是断言。
常见的断言有三种:单词边界、行的开始或结束、环视。
单词边界(Word Boundary)
举例,我们需要将下面文本中的tom替换成jerry。
1 | tom asked me if I would go fishing with him tomorrow.(Tom 问我明天能否和他一同去钓鱼) |
注意一下,在文本中出现了 tomorrow 这个单词,tomorrow 也是以 tom 开头的。
如果直接替换,就会出现如下的结果
1 | import re |
这显然是错误的,因为明天这个英语单词里面的 tom 也被替换了。
那正则是如何解决这个问题的呢?单词的组成一般可以用元字符 \w+ 来表示,\w 包括了大小写字母、下划线和数字(即 [A-Za-z0-9_])。那如果我们能找出单词的边界,也就是当出现了\w 表示的范围以外的字符,比如引号、空格、标点、换行等这些符号,我们就可以在正则中使用\b 来表示单词的边界。 \b 中的 b 可以理解为是边界(Boundary)这个单词的首字母。
| tom单词包含tom | \btom以tom开头的单词 |
tom\b以tom结尾的单词 |
\btom\b只能是tom |
|
|---|---|---|---|---|
| tom | ✅ | ✅ | ✅ | ✅ |
| tomorrow | ✅ | ✅ | ❎ | ❎ |
| atom | ✅ | ❎ | ✅ | ❎ |
| atomic | ✅ | ❎ | ❎ | ❎ |
根据刚刚学到的内容,在准确匹配单词时,我们使用 \b\w+\b 就可以实现了。
经过改进后的结果
1 | import re |
行的开始或结束
和单词的边界类似,在正则中还有文本每行的开始和结束,如果我们要求匹配的内容要出现在一行文本开头或结尾,就可以使用^ 和 $来进行位置界定。
我们先说一下行的结尾是如何判断的。你应该知道换行符号。在计算机中,回车(\r)和换行(\n)其实是两个概念,并且在不同的平台上,换行的表示也是不一样的。看下 Windows、Linux、macOS 平台上换行的表示方式。
| 平台 | 换行符号 |
|---|---|
| Windows | \r\n |
| Linux | \n |
| macOS | \n |
匹配行的开始和结束有什么用呢?
日志的起始行判断
最常见的例子就是日志收集,我们在收集日志的时候,通常可以指定日志行的开始规则,比如以时间开头,那些不是以时间开头的可能就是打印的堆栈信息。我来给你一个以日期开头,下面每一行都属于同一篇日志的例子。
输入数据校验
在 Web 服务中,我们常常需要对输入的内容进行校验,比如要求输入 6 位数字,我们可以使用\d{6}来校验。但你需要注意到,如果用户输入的是 6 位以上的数字呢?在这种情况下,如果不去要求用户录入的 6 位数字必须是行的开头或结尾,就算验证通过了,结果也可能不对。比如下面的示例,在不加行开始和结束符号时,用户输入了 7 位数字,也是能校验通过的:
1 | import re |
在多行模式下,^和 $ 符号可以匹配每一行的开头或结尾。大部分实现默认不是多行匹配模式,但也有例外,比如 Ruby 中默认是多行模式。所以对于校验输入数据来说,一种更严谨的做法是,使用 \A 和 \z (Python 中使用 \Z) 来匹配整个文本的开头或结尾。
解决这个问题还有一种做法,我们可以在使用正则校验前,先判断一下字符串的长度,如果不满足长度要求,那就不需要再用正则去判断了。相当于你用正则解决主要的问题,而不是所有问题,这也是说使用正则要克制。
环视( Look Around)
环视就是要求匹配部分的前面或后面要满足(或不满足)某种规则,有些地方也称环视为零宽断言。
那具体什么时候我们会用到环视呢?我来举个例子。邮政编码的规则是由 6 位数字组成。现在要求你写出一个正则,提取文本中的邮政编码。根据规则,我们很容易就可以写出邮编的组成\d{6}。我们可以使用下面的文本进行测试:
1 | 130400 满足要求 |
使用\d{6},会发现7位数的前6位也能匹配上,12位数匹配上了两次,这显然是不符合要求的。

也就是说,除了文本本身组成符合这 6 位数的规则外,这 6 位数左边或右边都不能是数字。
正则是通过环视来解决这个问题的。解决这个问题的正则有四种。
| 正则 | 名称 | 含义 | 示例 |
|---|---|---|---|
(?<=Y) |
肯定逆序环视postive-lookbehind | 左边是Y | (?<=\d)th左边是数字的th,能匹配9th |
(?<!Y) |
否定逆序环视negative-lookbehind | 左边不是Y | (?<=!\d)th左边不是数字的th,能匹配health |
(?=Y) |
肯定顺序环视postive-lookahead | 右边是Y | six(?=\d)右边是数字的six,能匹配six6 |
(?!Y) |
否定顺序环视negative-lookahead | 右边不是Y | hi(?!\d)右边不是数字的hi,能匹配high |
便于记忆的小口诀,左尖括号代表看左边,没有尖括号是看右边,感叹号是非的意思。
因此,针对刚刚邮编的问题,就可以写成左边不是数字,右边也不是数字的 6 位数的正则。即(?<!\d)\d{6}(?!\d)。这样就能符合要求了。

单词边界用环视表示
表示单词边界的 \b 如果用环视的方式来写,应该是怎么写呢?
这个问题其实比较简单,单词可以用 \w+ 来表示,单词的边界其实就是那些不能组成单词的字符,即左边和右边都不能是组成单词的字符。比如下面这句话:
1 | the little cat is in the hat |
the 左侧是行首,右侧是空格,hat 右侧是行尾,左侧是空格,其它单词左右都是空格。所有单词左右都不是\w。
(?<!\w)表示左边不能是单词组成字符,(?!\w)右边不能是单词组成字符,即\b\w+\b也可以写成(?<!\w)\w+(?!\w)。
也可以根据非\w也可以用\W来表示。那单词的正则可以写成(?<=\W)\w+(?=\W)。
这个例子只是加强思考,并不在日常工作中这么来表示单词的边界,因为\b明显更简洁,也更容易阅读和书写。
环视和子组
之前有讲过”分组与引用”相关的内容,环视中虽然也有括号,但不会保存子组。保存成子组的一般是匹配到的文本内容,后续用于替换等操作,而环视是表示对文本左右环境的要求,即环视只匹配位置,不匹配文本内容。
转义
正则中转义需要注意哪些问题?