Python 教学 | “小白”友好型正则表达式教学(二)
2.贪婪模式和懒惰模式
总结Python教学系列
本文共7116个字,阅读大约需要18分钟,欢迎指正!
引言
这里是 Python 正则表达式教学系列的第二期文章。在上一期文章Python 教学 | “小白”友好型正则表达式教学(一) 中,我们向大家介绍了一些关于正则表达式的基础知识。在 Python 中,正则表达式的实现是标准库re
。为了让大家快速入门正则表达式,我们以re
库中的findall
函数为切入点,介绍了正则表达式中的几个基本的元字符
以及它们的匹配原理,不过这些基本元字符只能匹配单一字符,仅使用他们无法进行稍复杂的文本处理。所以本期文章我们将在基本元字符的基础上,学习实用性非常高的量词元字符。掌握基本元字符和量词元字符的使用方法之后,我们就有能力使用正则表达式去处理基本的数据需求了。
一、基础元字符回顾
介绍新内容之前,我们先回顾一下上期介绍的基本元字符及其含义。
元字符 | 含义 |
---|---|
. | 匹配一个任意字符(不包括换行符 \n) |
| | 逻辑“或”操作符,可以用于连接多个子正则表达式 |
[ ] | 匹配任意一个字符集合/区间中的字符 |
[^] | 匹配任意一个不在该字符集合/区间中的字符 |
\ | 转义符,对正则表达式中的下一个字符进行转义 |
[0-9] | 匹配0-9之间的任意单个数字 |
[a-z] | 匹配a-z之间的任意单个小写字母 |
[A-Z] | 匹配A-Z之间的任意单个大写字母 |
[\u4e00-\u9fa5] | 匹配一个任意中文汉字,等价于[一-龥] |
[^0-9] | 匹配非0-9之间的任意单个数字 |
\d | 匹配任意单个0-9之间的数字 |
\D | \d的反义,匹配任意单个非0-9之间的数字 |
\s | 匹配任意单个空白字符,包括空格符 、换行符\n、横向制表符\t、纵向制表符\v、回车符\r、换页符\f、退格符[\b]等。 |
\S | \s的反义,匹配任意单个非空字符 |
\w | 匹配任意单个数字,英文字母或下划线_中的一个,实际上也可以匹配中文汉字 |
\W | \w的反义,匹配任意单个非数字,英文字母,下划线_或中文汉字 |
本期文章将以上表中的部分基本元字符为基础,去学习实用的量词元字符。
二、量词元字符有什么用?
为了能够让大家更深刻地了解量词元字符的作用和用法,下面我们引入一个现实中的数据处理场景。
《国际专利分类表》(IPC分类表)是国际通用的专利分类标准和检索工具,该标准将全世界各类专利归入八个领域(也可称为“部”),每个“部”内还向下分有大类、小类、大组以及含有多个层级的小组,据统计,包含“部”在内,专利最多被细分为15个具体的层级。这其中所有不同类别的专利都有对应的编码,这个编码就是专利分类号(IPC分类号),下图描述的是IPC分类号各部分对应的层级。
国家知识产权局每年都会在其官网上发布一版最新的《国际专利分类表》(word 文档)。为了得到详细的 IPC 层级表,需要对国家知识产权局提供的专利分类表进行合并处理,由于文档分页等原因,导致一部分 IPC 分类号不全,所以处理过程中需要使用正则表达式验证 IPC 分类号是否合规。从上图中我们知道,分类号中一部分字符的数量是不确定的,例如大组对应的编码可以是1位数字,也可以是2位数字;小类后面可能有一个空格符,也可能没有。那么我们如何去匹配这些局部位置字符数量不确定的字符串呢?
如果我们只知道如何使用基本元字符,那么我们可以使用下面的正则表达式。
re.findall('^[A-H]\d\d[A-Z]\d\d/\d\d$', 'A23L33/00')
# 得到:['A23L33/00']
# 如果待匹配的分类号是 'A23L1/00',则完全匹配不到
在上面的正则表达式中,我们使用\d\d
去匹配连续两位数字,不过这个表达式只能匹配大组编码和小组编码都是两位数字的 IPC 分类号,根本无法在实际场景中使用。
而当我们学会使用量词元字符后,就可以使用下面的正则表达式去匹配(验证)所有情况的 IPC 分类号。
re.findall('^[A-H]\d{2}[A-Z] ?\d{1,2}/\d{2,5}$', 'A23L33/165')
# 得到:['A23L33/165']
虽然这个正则表达式看起来很长,但是一点一点分析的话,其实一点都不难,下面我们来剖析这个正则表达式。
'^[A-H]\d{2}[A-Z] ?\d{1,2}/\d{2,5}$'
首先,位置元字符
^
是用来匹配文本的开头的,这个我们在上一期文章中介绍过,为什么在这里使用^
呢?请先往下看。[A-H]
是正则表达式中的一个字符范围,可以匹配单个 A-H (含本身)之间的大写字母,为什么是 A-H 呢?因为专利分为 8 个“部”,每个“部”的编码是一个大写字母,正好是 ABCDEFGH,所以[A-H]
的作用是匹配分类号的“部”对应的编码。[A-H]
后面跟着\d
,\d
是用来匹配数字的,而\d
后面还跟着一组元字符{2}
,这就是一个量词元字符,用于限制前一个字符的匹配次数。即{2}
是用来修饰\d
的,那么\d{2}
就表示匹配\d
(数字)两次,也就是匹配两位数字,这等价于\d\d
。在这个场景中,\d{2}
的作用是匹配分类号中的大类编号。紧接着后面的
[A-Z]
可以匹配 A-Z 之间任意一个的大写字母,在此场景中,用于匹配分类号中的小类编码。[A-Z]
后面的一个部分是?
,注意这是两个字符,一个是空格符,一个是半角问号。在正则表达式中,半角问号?
也是一个量词元字符,表示匹配前一个字符 0 次或 1 次。也就是说?
的前一个字符是可有可无的,如果有,也只能出现一次。所以?
就表示这个位置可以有一个空格,也可以没有空格。这恰好对应着分类号小类后面可能出现空格的情况。接下来的
\d{1,2}
是一个整体。在匹配大类编码的时候,我们使用了\d{2}
,表示匹配两个数字。而在分类号中,大组对应的编码可能是一位数字,也可能是两位数字,于是这里就使用了\d{1,2}
,其中元字符{1,2}
的作用是限制数字\d
的匹配次数,表示匹配数字的次数在 1 到 2 之间。紧接着后面的字符是
/
,这是一个普通字符,代表匹配斜杠/
本身一次。注意它不是转义符,转义符是反斜杠\
,这里的斜杠是正斜杠。再后面的的
\d{2,5}
是用来匹配分类号中小组部分的,表示匹配 2 到 5 个数字。其含义可以参考匹配大组的部分(\d{1,2}
)。上述正则表达式中最后一个字符
$
也是一个元字符,而且是与元字符^
很相似的位置元字符,不过^
表示从文本的开头处匹配,用在正则表达式中的开头处;$
则表示匹配文本的结尾处,用在正则表达式中的末尾。在上面的正则表达式中,我们同时使用了位置元字符^
和$
,这代表待匹配的文本必须与我们书写的正则表达式从头到尾一 一吻合,前后不能出现其他不相关的内容。
三、量词元字符的用法
在上面这个场景中,我们用到了正则表达式中量词元字符的几种用法。例如{2}
、{1,2}
、?
等,相信大家已经从这个场景中了解到了正则表达式中量词元字符的重要性。下面我们来正式介绍它们,基本的量词元字符如下表所示。
量词元字符 | 描述 |
---|---|
{n} | 表示匹配前一个字符(或元字符代表的字符)n 次 |
{n,} | 表示匹配前一个字符(或元字符代表的字符)最少 n 次 |
{,n} | 表示匹配前一个字符(或元字符代表的字符)0 到 n 次 |
{m,n} | 表示匹配前一个字符(或元字符代表的字符)m 次到 n 次,m 必须小于等于 n |
? | 表示匹配前一个字符(或元字符代表的字符)0 次或 1 次;等价于{,1}或{0,1} |
+ | 表示匹配前一个字符(或元字符代表的字符)至少一次,即 1 次或多次;等价于{1,} |
* | 表示匹配前一个字符(或元字符代表的字符)任意次,即 0 次到多次;等价于{0,}或{,} |
{n,}? | {n,}的懒惰(非贪婪)模式 |
+? | +的懒惰(非贪婪)模式 |
*? | *的懒惰(非贪婪)模式 |
1. 量词元字符的使用规则
量词元字符大致可以分为两类,一类是使用花括号(例如{2}
、{2,5}
、{,5}
等),另一类是使用特定字符(包括?
、*
、+
)。前者的含义相对容易理解和记忆,如果花括号中只有一个数字,那么这个数字就表示前一个字符的匹配次数,既不能多也不能少。例如正则表达式\d{4}
就表示匹配\d4
次,由于正则表达式中\d
代表数字,所以它的含义就是匹配任意数字四次(即四位数字),所以这个正则表达式常用来匹配文本中的年份。如果花括号中出现半角逗号,
,则表示其中包含两个数字参数,分别在逗号两侧,左侧数字表示最小匹配次数,右侧数字表示最大匹配次数,两侧数字都可以省略不写,左侧数字省略表示最小匹配次数为 0,右侧数字省略表示最大匹配次数为无穷大。因此使用花括号已经能够表示几乎所有情况,而特定字符表示的量词元字符则更加简化,熟记之后使用更方便。无论使用哪一种量词元字符,它们都是用来限制前一个字符(或元字符代表的字符)匹配次数的。
下面通过几个简单的例子了解一下上表中量词元字符的用法。
(1)使用正则表达式匹配文本中的年份(使用的量词元字符为{4}
)。
re.findall('\d{4}', '2023-01-18 2022/12/15')
# 得到:['2023', '2022']
re.findall('\d{4}', '2010-2022数字经济产业发展报告')
# 得到:['2022']
(2)匹配文本中的QQ邮箱,已知QQ号码长度为 5 到 11 位(使用的量词元字符为{5,11}
)。
re.findall('\d{5,11}@qq\.com', '56498913@qq.com 7538546524@qq.com')
# 得到:['56498913@qq.com', '7538546524@qq.com']
(3)匹配括号中的说明性文字,注意括号内不能为空(使用的量词元字符为+
)。
re.findall('(.+)', '利润总额差额(合计平衡项目)')
# 得到:['(合计平衡项目)']
re.findall('(.+)', '利润总额差额()')
# 得到:[] 括号中没有字符,所以无法满足量词元字符 +
在上面的代码中,将正则表达式书写为'(.{1,})'也能达到同样的作用。注意这里的括号是全角括号,即中文状态下的括号,如果是半角括号(英文括号),则需要使用转义符进行转义:'(.{1,})',这是因为半角括号也是一中元字符。
2. 贪婪模式和懒惰模式
我们在介绍量词元字符时,曾出现几个特殊的情况,如下表所示。
量词元字符 | 描述 |
---|---|
{n,}? | {n,}的懒惰(非贪婪)模式 |
+? | +的懒惰(非贪婪)模式 |
*? | *的懒惰(非贪婪)模式 |
在正则表达式中,仅使用{m,n}
、?
、*
、+
等量词元字符无法满足我们的基本需求。回看元字符*
的功能:匹配前一个字符 0 到多次。比如说正则表达式\d*
表示匹配任意位数字,既然如此,那这个正则表达式匹配 0 个数字是正确的,匹配 1 个数字也是正确的,匹配 100 个数字还是正确的。同样地,元字符+
,{m,n}
和{n,}
也存在这个逻辑问题,那它们到底如何工作的呢?下面我们通过一个例子来了解。
在处理某统计数据时,需要使用正则表达式提取所有字段名中括号内的部分。例如现在有一个字段名如下。
利润总额差额(合计平衡项目)(元)
我们期望匹配得到两个部分,一个是“(合计平衡项目)”,另一个是“(元)”。下面是我们的正则表达式代码。
re.findall('(.*)', '利润总额差额(合计平衡项目)(元)')
# 得到:['(合计平衡项目)(元)']
在上面的代码中,我们使用的正则表达式是(.*)
,其中第一个字符(
是一个普通字符,后面的.*
是一组,表示匹配若干个任意字符,最后一个字符)
也是一个普通字符,那么这个正则表达式就表示匹配一组括号()
及其中间的若干字符。按道理来说,应该能够匹配到两项符合要求的字符串,即“(合计平衡项目)”和“(元)”,但最终匹配到的内容却只有一项“(合计平衡项目)(元)”,这其实是因为{m,n}
、?
、*
、+
等量词元字符都是“贪婪”的,直接使用他们就表示使用贪婪模式
进行匹配。什么是贪婪模式,顾名思义,就是尽可能多地匹配字符。在这个例子中,正则表达式从第一个左半括号处开始匹配,当匹配到第一个右半括号时,正则表达式引擎发现已经匹配到一个符合要求的结果,如下。
利润总额差额(合计平衡项目)(元)
不过由于我们使用的量词元字符是*
,这是一个默认使用贪婪模式进行匹配的元字符,于是正则表达式引擎就会一直继续向后搜索,查看是否存在长度更长但符合要求的匹配结果,直到找到一个长度最长的匹配结果。那么上述的匹配结果就无法满足要求了,因为正则引擎向后搜索发现,还有一个长度更长的匹配结果,如下。
利润总额差额(合计平衡项目)(元)
所以最后的匹配结果就是“(合计平衡项目)(元)”,显然这也是符合要求的(第一个字符是左半括号,最后一个字符是右半括号,中间包含若干字符)。
💡此时可能会有人提问,为什么匹配结果只有一个,而不是三个((合计平衡项目)
,(元)
,(合计平衡项目)(元)
)呢?
这在数学中或许是一个问题,但在正则表达式中,已经被匹配过的字符是会被“消耗”掉的。例如使用正则表达式\d{4}
去匹配文本12345678
,正则引擎会从文本的第一个位置开始匹配,在得到第一个结果1234
之后,正则表达式会从数字 5 所在的位置继续向后搜索,前面得到过的1234
会被直接忽略,因此不会匹配到2345
,3456
等结果。
由于{m,n}
、*
、+
等量词元字符默认的“贪婪”性质,导致我们无法得到想要的结果。不过除了默认的贪婪模式,正则表达式还有另外一种与之相反的模式,叫做“懒惰模式”(也称非贪婪模式),使用懒惰模式后,正则表达式引擎一旦匹配到符合要求的字符,就会立刻停手,不再继续向后搜索更长的匹配结果。将贪婪模式修改为懒惰模式的方式也很简单。就是在{m,n}
、*
、+
等量词元字符的后面加上字符?
(注意是半角问号),变成一个表示懒惰模式的量词元字符组合。下面我们改用懒惰模式再次匹配试试看。
re.findall('(.*?)', '利润总额差额(合计平衡项目)(元)') # .* 改成了 .*?
# 得到:['(合计平衡项目)', '(元)']
改用懒惰模式后,就能得到期望的结果了,以上就是正则表达式贪婪模式和懒惰模式的区别以及使用方式。
💡{n,}?
、+?
、*?
等量词元字符组合能够表示以懒惰模式进行匹配。在正则表达式中,?
本身就是一个量词元字符,表示匹配前一个字符(或元字符代表的字符)0 次或 1 次,等价于{0,1}
。不过要注意,这里的?
和懒惰模式中的?
并不具有相同含义,懒惰模式中的?
只是一个用于切换匹配匹配模式的特殊字符,即*?
、+?
是固定组合,因此我们不能将*?
中的?
修改成等价的表示,即使用*{0,1}
去表示懒惰模式,这是完全错误的,正则引擎将无法识别这个表达式。
此外,如果只是针对这个场景,我们还有另外一种解决方案。此例中,我们最初使用.*
去匹配括号中的内容,由于元字符.
表示任意字符,所以括号字符)
和(
也被匹配在内,最终匹配得到(合计平衡项目)(元)
。那我们不使用.
去匹配括号内的内容是不是可以呢?这个时候我们就可以使用上期文章中提到的非字符集合
进行匹配,思路是匹配括号内的字符,但要求其中的字符不能是)
和(
(从逻辑上来说,只要不包含字符)
就可以了),这样的话,是否也可以解决这个问题呢?下面我们来一探究竟。
# [^()] 表示非“(”和“)”的任意字符
re.findall('([^()]*)', '利润总额差额(合计平衡项目)(元)')
# 得到:['(合计平衡项目)', '(元)']
实测发现使用这个方法也能得到期望的结果。正则表达式是一个非常灵活的工具,很多时候解决问题的方案不止一种,只有多练习,多应用,才可以做到熟能生巧。
总结
继正则表达式基本元字符之后,本期文章我们介绍了 Python 正则表达式中非常实用的量词元字符,并重点讲解了贪婪模式和懒惰模式的差异以及使用方式。正则表达式的学习是一个探索的过程,正则表达式的熟练则是一个实践的过程。短时间内记不住这些元字符的含义是非常正常的现象,谁最开始学习正则表达式还不是照着元字符表格去写呢?想要熟练使用正则表达式,还是要多多实践,希望大家学习顺利。
前两期的文章,我们向大家介绍了 Python 正则表达式中的基本元字符和量词元字符,熟练使用这些元字符,已经能够应对基本的匹配场景,不过这两期内容我们一直在使用re.findall()
函数,下一期文章我们将向大家介绍 Python 中的其他正则函数,下期再见。
Python教学
星标⭐我们不迷路!想要文章及时到,文末“在看”少不了!
点击搜索你感兴趣的内容吧
往期推荐
Python 教学 | “小白”友好型正则表达式教学(一)
Python教学 | 盘点 Python 数据处理常用标准库
Python教学 | 最常用的标准库之一 —— os
案例分享:使用 Python 批量处理统计年鉴数据(下)
数据Seminar
这里是大数据、分析技术与学术研究的三叉路口
文 |《社科领域大数据治理实务手册》
欢迎扫描👇二维码添加关注