正则表达式:“于是你就有两个问题了”
前言
借鉴了一些资料,当然也有不足,欢迎在评论区指出。
本文章使用 Python 语言。
正式开始!
首先,关于正则表达式,有一个经典的美式笑话。有些人在面临一个问题时会说:“我知道,可以用正则表达式来解决。”于是他就有两个问题了。大家可能不懂,意思是正则表达式本身就是个难题。这也是这篇文章题目的意思。
正则表达式虽然难学,但真的很有用、很强大。就拿下面的例子来说吧:
如果要从这段文字里面提取所有薪资,该怎么做?
Python3 高级开发工程师 2万/月已满员
测试开发工程师(C/C++/python)2.5万/每月未满员
Python3 开发工程师1.3万/每月剩余11人
测试开发工程师(Python)1.1万/每月剩余5人
Python高级开发工程师2.8万/月剩余255人
python开发工程师2.5万/每月满员
即输出:
2
2.5
1.3
1.1
2.8
2.5
很明显是字符串处理。根据 Python 字符串处理的相关知识点,我们可以写出如下代码:
content = '''
Python3 高级开发工程师 2万/月已满员
测试开发工程师(C/C++/python)2.5万/每月未满员
Python3 开发工程师1.3万/每月剩余11人
测试开发工程师(Python)1.1万/每月剩余5人
Python高级开发工程师2.8万/月剩余255人
python开发工程师2.5万/每月满员
'''
lines = content.splitlines() # 将文本内容按行放入列表
for eveline in lines:
posend = eveline.find('万/月') # 查找'万/月' 在 字符串中什么地方
if posend == -1:
posend = eveline.find('万/每月') # 查找'万/每月' 在 字符串中什么地方
if posend == -1: # 都找不到返回
continue
# 执行到这里,说明可以找到薪资关键字
# 接下来分析薪资数字的起始位置
idx = posend - 1
# 只要是数字或者小数点,就继续往前面找
while eveline[idx].isdigit() or eveline[idx] == '.':
idx -= 1
# 现在 idx 指向薪资数字前面的那个字,
# 所以薪资开始的索引就是 idx + 1
possta = idx + 1
# 打印找到的数字
print(eveline[possta:posend])
根据注释理解一下。
运行,发现完全可以。
在兴奋之余,我们回头看看我们的代码,是不是太复杂了?
那怎么优化呢?
这就要让今天的主角——正则表达式来帮忙。
使用正则表达式后,代码瞬间短了许多。
content = '''
Python3 高级开发工程师 2万/月已满员
测试开发工程师(C/C++/python)2.5万/每月未满员
Python3 开发工程师1.3万/每月剩余11人
测试开发工程师(Python)1.1万/每月剩余5人
Python高级开发工程师2.8万/月剩余255人
python开发工程师2.5万/每月满员
'''
import re
for money in re.findall(r'([\d.]+)万/每?月', content):
print(money)
运行一下,完全可以。
大家可能惊讶了:这是怎么做到的??!!
这就是正则表达式的强大之处了,代码中:
re.findall(r'([\d.]+)万/每?月', content)
这一句就能匹配所有的像这样的表达式。而:
([\d.]+)万/每?月
就是要匹配的正则表达式。
大家可能说:这是什么鬼东西?!
先不要急,我们一点一点学正则表达式的语法,到后面就知道是如何实现的了。
验证
打开 Python3 IDLE,或使用这个正则表达式在线验证网站。注意选择 Python 风格。
如果看不懂英文或访问不了,也可以用这个国内网站。
在上面窗口中输入正则表达式,中间的大窗口中输入要匹配的字符串,下面就会显示匹配结果,用彩色标出。
以下用 Python3 IDLE 示范。
最基础的语法
在提示行内输入以下内容:
>>> import re
>>> re.search(r'luogu', 'I love luogu.com.cn!')
敲下回车,会看到给出来了这样的结果:
<re.Match object; span=(7, 12), match='luogu'>
>>>
细致地讲解一下:
第一行是导入库 re,这就是正则表达式所使用的库。(往下的代码默认已经带了这个库)。
第二行就是使用正则表达式的 search 方法匹配,它会返回一个形如 <re.Match object; span=(, ), match=''> 的字符串,表示匹配到的第一处,
再返回去讲 search,它的第一个参数就是正则表达式。注意,正则表达式要用原始字符串写,能避免一些不必要的错误。 第二个参数是要被匹配的字符串(有时要用多行字符串)。如果找不到匹配,就返回 None(即看着是没有返回)。
>>> re.search(r'Luogu', 'I love luogu.com.cn!')
>>>
上面的正则表达式就是最基础的写法:直接匹配字符串 luogu。而我们知道 Python 会区分大小写,所以要匹配大写的 Luogu 就匹配不到了。
神奇的东西:元字符
大家可能说了:就这个?我用 find 方法一样可以实现!还省去一个 import 呢,难道不比正则表达式好?!
>>> 'I love luogu.com.cn!'.find('luogu')
7
>>>
确实可以,但如果正则表达式真的那么简单,就不会来讲它了,而且前面的薪资匹配又是怎么实现的呢?
正则表达式的精髓在于它有一群比 find 的匹配要灵活的符号:元字符。
我们一个一个学。
匹配任意字符
好,现在来一个 find 做不到的,怎么匹配任意字符串的第一个字符呢?
可以使用元字符 .。它可以匹配任意除换行符外的单个字符。
>>> re.search(r'.', 'I love luogu.com.cn!')
<re.Match object; span=(0, 1), match='I'>
>>>
顺便把转义字符讲了,如果我就要匹配 . 这个字符怎么办?
很简单,用 \ 转义!
>>> re.search(r'\.', 'I love luogu.com.cn!')
<re.Match object; span=(12, 13), match='.'>
>>>
重复匹配多次
这一类有几个元字符:
* 匹配前面的子表达式任意次(包括
+ 匹配前面的子表达式任意次(不包括
? 匹配前面的子表达式
比如:
>>> re.search(r'.*', 'I love luogu.com.cn!')
<re.Match object; span=(0, 20), match='I love luogu.com.cn!'>
>>>
也可以转义。
>>> re.search(r'\*', 'I love luogu.com.cn*!')
<re.Match object; span=(19, 20), match='*'>
>>>
注意,这三个字符不能单独出现,否则就会报错。
>>> re.search(r'*', 'I love luogu.com.cn!')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "C:\Python39\lib\re.py", line 201, in search
return _compile(pattern, flags).search(string)
File "C:\Python39\lib\re.py", line 304, in _compile
p = sre_compile.compile(pattern, flags)
File "C:\Python39\lib\sre_compile.py", line 764, in compile
p = sre_parse.parse(p, flags)
File "C:\Python39\lib\sre_parse.py", line 948, in parse
p = _parse_sub(source, state, flags & SRE_FLAG_VERBOSE, 0)
File "C:\Python39\lib\sre_parse.py", line 443, in _parse_sub
itemsappend(_parse(source, state, verbose, nested + 1,
File "C:\Python39\lib\sre_parse.py", line 668, in _parse
raise source.error("nothing to repeat",
re.error: nothing to repeat at position 0
>>>
贪婪模式和非贪婪模式
接下来要给大家一个问题了:请匹配字符串 for(int i=1;i<=n;i++){cin >> a[i];}for(int i=1;i<=n;i++){a[i]+=1;} 中第一个 for 循环的循环体。(提示一下,{} 是元字符,记得转义)
我知道大家想都不想就会这么写:
>>> re.search(r'\{.*\}', 'for(int i=1;i<=n;i++){cin >> a[i];}for(int i=1;i<=n;i++){a[i]+=1;}')
但是……返回好像和我们想的不大一样啊。
<re.Match object; span=(21, 66), match='{cin >> a[i];}for(int i=1;i<=n;i++){a[i]+=1;}'>
>>>
为什么连着第二个 for 循环也匹配上了呢?这是因为正则表达式用来重复匹配的元字符(现在学的只有 * + ?)默认是贪婪的,也就是会尽可能多的匹配内容。所以匹配时,* 从 cin 前面的大括号,一直匹配到了整个字符串最后的大括号才停住。
解决方法也很简单,就是在 * 后面加一个 ? 关闭贪婪模式,对另外两个重复匹配的元字符一样适用。
>>> re.search(r'\{.*?\}', 'for(int i=1;i<=n;i++){cin >> a[i];}for(int i=1;i<=n;i++){a[i]+=1;}')
<re.Match object; span=(21, 35), match='{cin >> a[i];}'>
>>>
进入非贪婪模式后,就会尽可能少地进行匹配,于是就成功了。
匹配几个字符之一
可以用 [] 匹配其中的任意一个字符。如:
正则表达式 [abc] 会匹配 a 或者 b 或者 c,也可以简写成 [a-c],意思是匹配 a 到 c 间的所有字符,等价于前面的。
注意:
- 元字符在中括号内会自动失去意义,变成普通字符。
- 如果
-放在最前或最后,也表示普通的减号字符。 - 在中括号里使用
^,会变成匹配除中括号内字符外的所有字符。
匹配指定次数
前面说过大括号 {} 是元字符,它用来匹配指定次数,如 {3} 就是匹配
同时它也是默认贪婪的,也就是也可以切换非贪婪模式,即 {m,n}?(
匹配其中之一
| 表示匹配其中之一,有点像 C++ 中的或。
注意,它的优先级很低,即 66|8 匹配的是
一个实践
学了这么多了,看到大家跃跃欲试的样子,我就来考考大家:怎么匹配
知道肯定有人想都不想就这么写:
>>> re.search(r'[0-255]', 'hhh188hh')
<re.Match object; span=(3, 4), match='1'>
>>>
或者这样写的也会有:
>>> re.search(r'[0-2][0-5][0-5]', 'hhh188hh')
>>>
怎么样?很意外吧?
第一种方法应该是我没有讲清楚,其实中括号除了用减号连接的字符串外,只认单个字符,意思就是 [0-255] 是要匹配
第二个就更明显了,没有规定个位和十位。
其实,我们可以把这些数分成以下几类:
- 百位
0 或1 ,十位个位任意。 - 百位
2 ,十位0 ~4 ,个位任意。 - 百位还是
2 ,十位5 ,个位只能0 ~5 。
这样就好做了,我们不难写出下面的代码:
>>> re.search(r'[0-1]\d\d|2[0-4]\d|25[0-5]', 'hhh188hh')
<re.Match object; span=(3, 6), match='188'>
>>>
这就对了。
这里有一个没讲过的知识点,就是 \d,它表示匹配所有数字字符。具体的我会放在反斜杠一部分讲。
再加一点难度,试试匹配 ip 地址!
>>> re.search(r'(([0-1]\d\d|2[0-4]\d|25[0-5])\.){3}([0-1]\d\d|2[0-4]\d|25[0-5])', 'haha198.13.1.1haha')
>>>
() 表示分组,这是用 ip 地址的特性匹配的,即四个
那为什么不对呢?细心的人发现了:没有规定位数,也就是 001 这种方式。所以再改一下:
>>> re.search(r'(([0-1]?\d?\d|2[0-4]\d|25[0-5])\.){3}([0-1]?\d?\d|2[0-4]\d|25[0-5])', 'haha198.13.1.1haha')
<re.Match object; span=(4, 14), match='198.13.1.1'>
>>>
终于搞定了!
现在大家应该可以理解“当你发现一个问题可以用正则表达式来解决的时候,于是你就有两个问题了”这句话了。