正则表达式:“于是你就有两个问题了”

· · 算法·理论

前言

借鉴了一些资料,当然也有不足,欢迎在评论区指出。

本文章使用 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=''> 的字符串,表示匹配到的第一处,span 是开始和结束坐标(元组形式,坐标从 0 开始,开始坐标是第一个字符,结束坐标是最后一个字符的下一个位置),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='.'>
>>>

重复匹配多次

这一类有几个元字符:

* 匹配前面的子表达式任意次(包括 0。)

+ 匹配前面的子表达式任意次(不包括 0。)

? 匹配前面的子表达式 0 次或 1 次。

比如:

>>> 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],意思是匹配 ac 间的所有字符,等价于前面的。

注意:

  1. 元字符在中括号内会自动失去意义,变成普通字符。
  2. 如果 - 放在最前或最后,也表示普通的减号字符。
  3. 在中括号里使用 ^,会变成匹配除中括号内字符外的所有字符。

匹配指定次数

前面说过大括号 {} 是元字符,它用来匹配指定次数,如 {3} 就是匹配 3 次前面的子表达式。大括号里面也可以填入范围,用逗号分隔,表示匹配范围内的次数。

同时它也是默认贪婪的,也就是也可以切换非贪婪模式,即 {m,n}?mn 是两个整数),表示只匹配 m 次。

匹配其中之一

| 表示匹配其中之一,有点像 C++ 中的或。

注意,它的优先级很低,即 66|8 匹配的是 668,而不是 6668

一个实践

学了这么多了,看到大家跃跃欲试的样子,我就来考考大家:怎么匹配 0 ~ 255 间的数字?

知道肯定有人想都不想就这么写:

>>> 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 ~ 2 和两个 5 之一,就是匹配 0,1,2,5 之一,错是当然的。

第二个就更明显了,没有规定个位和十位。

其实,我们可以把这些数分成以下几类:

  1. 百位 01,十位个位任意。
  2. 百位 2,十位 0 ~ 4,个位任意。
  3. 百位还是 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 地址的特性匹配的,即四个 0 ~ 255 之间的数,中间用点号隔开。这也是前面给出匹配数的铺垫的原因。

那为什么不对呢?细心的人发现了:没有规定位数,也就是 1 不会刻意写成 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'>
>>>

终于搞定了!

现在大家应该可以理解“当你发现一个问题可以用正则表达式来解决的时候,于是你就有两个问题了”这句话了。