Skip to content

Python 正则表达式:字符串的"智能筛选器"

引言:从一堆名片中找出所有手机号

假设你有一堆名片,想快速找出所有人的手机号。你会怎么做?

  • 方法 A:肉眼逐个看 — 太慢,容易漏;
  • 方法 B:写代码判断 — 先找 11 位连续数字,再判断是不是 1 开头……代码写一大堆,换个格式就失效。

其实,你只需要告诉电脑一个"规则":

"找 1 开头,第二位是 3/5/7/8/9,后面跟 9 个数字的字符串"

电脑就能瞬间筛选出所有手机号。这个"规则"就是正则表达式(Regular Expression)

正则表达式是一种描述字符串模式的迷你语言。它用一套简洁的符号系统,定义"什么样的字符串符合要求",然后让计算机自动匹配。

这一篇,我们从最基本的字符匹配讲起,逐步深入量词、分组、贪婪匹配等核心概念,最后通过大量实战案例让你真正掌握这个"字符串处理神器"。


一、为什么需要正则表达式?

1.1 没有正则时的痛苦

判断一个字符串是不是合法邮箱,不用正则的话:

python
def is_valid_email(s):
    # 找 @
    if '@' not in s:
        return False
    parts = s.split('@')
    if len(parts) != 2:
        return False
    username, domain = parts

    # 用户名不能为空
    if not username:
        return False

    # 域名要有 .
    if '.' not in domain:
        return False

    # 用户名不能含特殊字符……
    # 域名后缀要是字母……
    # 还有无数边界情况……

    return True  # 写了 50 行,还是有漏洞

代码又臭又长,还未必覆盖所有情况。

1.2 用正则的优雅

python
import re

def is_valid_email(s):
    return re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', s) is not None

一行搞定!

1.3 正则表达式的核心思想

正则表达式 = 用字符描述字符

你想找的正则表达式含义
3 个数字\d\d\d\d{3}数字×3
字母或数字\w单词字符
任意字符.除换行外的任意字符
0 个或多个*前面的元素重复 0~∞ 次
1 个或多个+前面的元素重复 1~∞ 次

二、基础语法:字符的"描述语言"

2.1 精确匹配

直接写字母/数字,就是找一模一样的:

python
import re

re.match(r'hello', 'hello world')  # 匹配成功
re.match(r'hello', 'Hello world')  # 匹配失败(区分大小写)

2.2 特殊字符:预定义字符类

符号含义等价于例子
\d数字[0-9]\d\d 匹配 "42"
\w字母/数字/下划线[a-zA-Z0-9_]\w+ 匹配 "py3"
\s空白字符(空格/Tab/换行)[ \t\n\r\f\v]\s+ 匹配 " "
.任意字符(除换行)py. 匹配 "pya", "py!"
\D非数字[^0-9]\D+ 匹配 "abc"
\W非单词字符[^a-zA-Z0-9_]\W+ 匹配 "!@#"
\S非空白字符[^ \t\n\r\f\v]\S+ 匹配 "hello"

2.3 量词:控制重复次数

符号含义例子匹配
*0 次或多次ab*"a", "ab", "abb"
+1 次或多次ab+"ab", "abb"(不匹配 "a"
?0 次或 1 次ab?"a", "ab"
{n}恰好 n 次\d{3}"123"
{n,}至少 n 次\d{3,}"123", "1234"
{n,m}n 到 m 次\d{3,5}"123", "12345"

生活化理解:量词就像点外卖时的"份数选择":

  • * = "随便几份,不要也行"
  • + = "至少来一份"
  • ? = "来一份或不要"
  • {3} = "必须 3 份"
  • {3,5} = "3 到 5 份之间"

2.4 转义字符:特殊符号的"护身符"

有些字符在正则里有特殊含义(. * + ? ^ $ [] () {} | \),如果想匹配它们本身,需要加 \ 转义:

python
# 匹配 "010-12345" 中的 -
re.match(r'\d{3}\-\d{3,8}', '010-12345')  # ✅ 正确
re.match(r'\d{3}-\d{3,8}', '010-12345')   # ❌ 错误:- 在 [] 外不是特殊字符,但建议转义

# 匹配 "3.14" 中的 .
re.match(r'3\.14', '3.14')  # ✅ 正确
re.match(r'3.14', '3.14')   # ⚠️ 能匹配,但 . 表示任意字符,不严谨

口诀:在正则中,看到 \ 就是"后面那个字符不要特殊对待"。


三、进阶语法:更精确的控制

3.1 字符集合:[] 的魔力

[...] 表示"匹配括号内的任意一个字符":

python
# 匹配数字、字母或下划线
[0-9a-zA-Z_]

# 匹配元音字母
[aeiou]

# 匹配非数字(^ 在 [] 内表示取反)
[^0-9]

常见组合

正则含义例子
[0-9a-zA-Z_]+至少一个单词字符"a100", "Py3000"
[a-zA-Z_][0-9a-zA-Z_]*Python 合法变量名"name", "_var", "x1"
[a-zA-Z_][0-9a-zA-Z_]{0,19}1-20 位的变量名限制长度

3.2 或运算:| 的选择

A|B 表示匹配 A 或 B:

python
# 匹配 Python 或 python
(P|p)ython

# 匹配多种图片格式
\.(jpg|jpeg|png|gif)$

3.3 边界锚点:^$

符号含义例子
^字符串开头^\d 以数字开头
$字符串结尾\d$ 以数字结尾
^...$整行匹配^py$ 只匹配 "py",不匹配 "python"

生活化理解^$ 就像门框——^ 是"从门口开始",$ 是"到门口结束",合起来就是"整扇门"。

3.4 一个复杂的例子

匹配带区号的电话号码(如 010-12345010 - 12345):

python
r'^\d{3}\s*-\s*\d{3,8}$'

拆解:

  • ^ — 开头
  • \d{3} — 3 位数字(区号)
  • \s* — 0 个或多个空格
  • \- — 字面量 -
  • \s* — 0 个或多个空格
  • \d{3,8} — 3 到 8 位数字
  • $ — 结尾

四、Python 的 re 模块:正则的"执行引擎"

4.1 导入与基本使用

python
import re

# 判断是否匹配
result = re.match(r'^\d{3}\-\d{3,8}$', '010-12345')
if result:
    print('匹配成功')
else:
    print('匹配失败')

4.2 重要:r 前缀

Python 字符串本身也用 \ 转义,所以要用 r'...' 表示原始字符串:

python
# 不用 r 前缀
s = 'ABC\\-001'  # Python 字符串:ABC\-001
                 # 传给正则:ABC\-001(\- 被转义为 -)

# 用 r 前缀
s = r'ABC\-001'  # Python 字符串:ABC\-001
                 # 传给正则:ABC\-001(\- 保持原样,表示字面量 -)

强烈建议:正则表达式一律用 r'...'

4.3 常用函数速查表

函数作用返回值
re.match(pattern, string)开头匹配Match 对象或 None
re.search(pattern, string)任意位置匹配第一个Match 对象或 None
re.findall(pattern, string)找到所有匹配列表
re.finditer(pattern, string)找到所有匹配(迭代器)Match 对象迭代器
re.sub(pattern, repl, string)替换匹配内容替换后的字符串
re.split(pattern, string)切分字符串列表
re.compile(pattern)预编译正则Pattern 对象
python
import re

s = 'hello123world'

# match:从开头匹配
re.match(r'\d+', s)        # None(开头是字母)
re.match(r'[a-z]+\d+', s)  # 匹配 'hello123'

# search:任意位置找第一个
re.search(r'\d+', s)       # 匹配 '123'

记忆技巧match 是"严格门卫",必须从门口开始检查;search 是"巡逻警察",到处找。


五、分组提取:从字符串中"挖宝"

5.1 分组的基本用法

() 把要提取的部分括起来:

python
import re

m = re.match(r'^(\d{3})-(\d{3,8})$', '010-12345')
m.group(0)   # '010-12345' — 整个匹配
m.group(1)   # '010' — 第一个括号
m.group(2)   # '12345' — 第二个括号
m.groups()   # ('010', '12345') — 所有分组

生活化理解:分组就像快递单上的"收件人"和"电话"两个栏目——整个单子是一个匹配,但你关心的是栏目里的具体内容。

5.2 实战:解析时间

python
t = '19:05:30'
m = re.match(r'^(0[0-9]|1[0-9]|2[0-3]|[0-9])\:(0[0-9]|[1-5][0-9]|[0-9])\:(0[0-9]|[1-5][0-9]|[0-9])$', t)
m.groups()  # ('19', '05', '30')

这个正则精确匹配 00:00:00 到 23:59:59 的合法时间。

5.3 命名分组:给分组起名字

python
m = re.match(r'^(?P<area>\d{3})-(?P<number>\d{3,8})$', '010-12345')
m.group('area')    # '010'
m.group('number')  # '12345'

好处:代码可读性大大提高,不用记 group(1)、group(2) 哪个是哪个。

5.4 非捕获分组:(?:...)

python
# 只匹配,不提取
m = re.match(r'^(?:\d{3})-(\d{3,8})$', '010-12345')
m.groups()  # ('12345',) — 只有一个分组

六、切分与替换:字符串的"变形术"

6.1 智能切分:re.split()

普通 split() 的局限:

python
'a b  c'.split(' ')  # ['a', 'b', '', 'c'] — 连续空格产生空字符串

用正则切分:

python
import re

re.split(r'\s+', 'a b  c')           # ['a', 'b', 'c'] — 任意多个空格
re.split(r'[\s\,]+', 'a,b, c d')      # ['a', 'b', 'c', 'd'] — 空格和逗号
re.split(r'[\s\,\;]+', 'a,b;; c d')   # ['a', 'b', 'c', 'd'] — 空格、逗号、分号

6.2 替换:re.sub()

python
import re

# 把所有数字替换为 #
re.sub(r'\d+', '#', 'abc123def456')  # 'abc#def#'

# 把多个空格压缩为一个
re.sub(r'\s+', ' ', 'a  b   c')       # 'a b c'

# 分组引用:交换姓和名
re.sub(r'(\w+) (\w+)', r'\2 \1', 'John Smith')  # 'Smith John'

\1\2 表示引用第一个、第二个分组。


七、贪婪匹配 vs 非贪婪匹配

7.1 贪婪匹配:能吞多少吞多少

正则默认是贪婪的——尽可能多地匹配:

python
import re

re.match(r'^(\d+)(0*)$', '102300').groups()
# ('102300', '') — \d+ 吞掉了所有数字,包括后面的 0

生活化理解:贪婪匹配就像大胃王比赛——选手看到食物就拼命往嘴里塞,直到塞不下为止。

7.2 非贪婪匹配:适可而止

在量词后加 ?,变成非贪婪(尽可能少匹配):

python
re.match(r'^(\d+?)(0*)$', '102300').groups()
# ('1023', '00') — \d+? 只匹配到第一个非零数字,把 00 留给 (0*)

对比表

贪婪非贪婪含义
**?0 次或多次,尽可能少
++?1 次或多次,尽可能少
???0 次或 1 次,尽可能少
{n,m}{n,m}?n 到 m 次,尽可能少

7.3 实战:提取 HTML 标签内容

python
html = '<div>内容1</div><div>内容2</div>'

# 贪婪:匹配整个字符串
re.search(r'<div>(.*)</div>', html).group(1)
# '内容1</div><div>内容2' — 太多了!

# 非贪婪:只匹配第一个标签
re.search(r'<div>(.*?)</div>', html).group(1)
# '内容1' — 刚刚好

八、预编译:提升性能的"预热"

8.1 为什么要预编译?

每次调用 re.match(),Python 都要做两件事:

  1. 编译正则表达式字符串 → 内部对象;
  2. 匹配用编译后的对象去匹配字符串。

如果一个正则要重复用几千次,编译就浪费了几千次。

8.2 预编译用法

python
import re

# 编译一次,重复使用
pattern = re.compile(r'^(\d{3})-(\d{3,8})$')

pattern.match('010-12345').groups()  # ('010', '12345')
pattern.match('010-8086').groups()   # ('010', '8086')

性能对比(匹配 10 万次):

方式大致耗时
直接 re.match()~0.5 秒
预编译后调用~0.3 秒

建议:循环中反复使用的正则,务必预编译。


九、实战案例集

案例 1:验证邮箱

python
import re

def is_valid_email(addr):
    # 用户名:字母数字._%+-,至少1位
    # @:必须有
    # 域名:字母数字.-,至少1个.
    # 后缀:纯字母,至少2位
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return re.match(pattern, addr) is not None

# 测试
print(is_valid_email('someone@gmail.com'))        # True
print(is_valid_email('bill.gates@microsoft.com')) # True
print(is_valid_email('bob#example.com'))          # False
print(is_valid_email('mr-bob@example.com'))       # True

案例 2:提取带名字的邮箱

python
def name_of_email(addr):
    # 匹配 <Tom Paris> tom@voyager.org
    m = re.match(r'^<?([a-zA-Z\s]+)>?\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})?$', addr)
    if m:
        name = m.group(1).strip()
        email = m.group(2)
        if name and email:
            return name
        elif email:
            # 从邮箱提取名字(@ 前的部分)
            return re.match(r'^([a-zA-Z0-9._%+-]+)@', email).group(1)
    return None

# 测试
print(name_of_email('<Tom Paris> tom@voyager.org'))  # 'Tom Paris'
print(name_of_email('tom@voyager.org'))               # 'tom'

案例 3:验证手机号

python
def is_valid_phone(phone):
    # 1 开头,第二位 3/4/5/6/7/8/9,共 11 位
    pattern = r'^1[3-9]\d{9}$'
    return re.match(pattern, phone) is not None

print(is_valid_phone('13800138000'))   # True
print(is_valid_phone('12345678901'))   # False
print(is_valid_phone('1380013800'))    # False(少一位)

案例 4:提取 URL 中的域名

python
def extract_domain(url):
    m = re.search(r'https?://([^/]+)', url)
    return m.group(1) if m else None

print(extract_domain('https://www.example.com/path/page.html'))
# 'www.example.com'

案例 5:清理用户输入的标签

python
def clean_tags(input_str):
    # 按空格、逗号、分号切分,去除空字符串
    tags = re.split(r'[\s,;]+', input_str.strip())
    return [tag for tag in tags if tag]

print(clean_tags('python,  java;; c++   go'))
# ['python', 'java', 'c++', 'go']

十、常见误区解析

误区 1:正则表达式可以解决所有字符串问题

真相:正则不是万能的,有些问题用正则反而更复杂。

适合正则不适合正则
格式验证(邮箱、手机号)嵌套结构(HTML、JSON)
简单提取(电话号码)复杂语法分析(代码解析)
批量替换(清理空格)需要上下文判断的逻辑

例子:解析嵌套括号 ((a+b)*(c+d)) 用正则几乎不可能,需要用栈或递归。

误区 2:正则表达式越复杂越好

真相:复杂的正则难以阅读、难以维护、容易出错。

python
# 过度复杂的正则(试图用正则验证日期)
r'^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$'

# 问题:2 月 30 日、4 月 31 日还是能通过!
# 正确做法:先用正则提取数字,再用 datetime 验证
import datetime

def is_valid_date(s):
    m = re.match(r'^(\d{1,2})-(\d{1,2})$', s)
    if not m:
        return False
    month, day = int(m.group(1)), int(m.group(2))
    try:
        datetime.date(2024, month, day)  # 随便一年,主要验证月日
        return True
    except ValueError:
        return False

误区 3:忽略贪婪匹配导致的 bug

真相:忘记加 ? 是新手最常见的错误。

python
# 想提取 <tag> 里的内容
html = '<div>hello</div><div>world</div>'

# 贪婪:匹配太多
re.findall(r'<div>(.*)</div>', html)
# ['hello</div><div>world'] — 错了!

# 非贪婪:正确
re.findall(r'<div>(.*?)</div>', html)
# ['hello', 'world'] — 对了

误区 4:不处理边界情况

真相:正则匹配失败返回 None,不处理会报错。

python
# ❌ 危险代码
m = re.match(r'\d+', 'abc')
print(m.group())  # AttributeError: 'NoneType' object has no attribute 'group'

# ✅ 安全代码
m = re.match(r'\d+', 'abc')
if m:
    print(m.group())
else:
    print('没有匹配')

误区 5:滥用正则导致性能问题

真相:某些正则会导致灾难性回溯(catastrophic backtracking)。

python
# 危险:指数级回溯
re.match(r'^(a+)+$', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab')

# 安全:改写为等价但高效的正则
re.match(r'^a+$', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaab')

检测方法:如果正则匹配时间随着输入长度指数增长,就是灾难性回溯。


十一、正则表达式速查表

字符类

符号含义等价
\d数字[0-9]
\D非数字[^0-9]
\w单词字符[a-zA-Z0-9_]
\W非单词字符[^a-zA-Z0-9_]
\s空白字符[ \t\n\r\f\v]
\S非空白字符[^ \t\n\r\f\v]
.任意字符(除换行)

量词

符号含义贪婪/非贪婪
*0 次或多次贪婪
*?0 次或多次非贪婪
+1 次或多次贪婪
+?1 次或多次非贪婪
?0 次或 1 次贪婪
??0 次或 1 次非贪婪
{n}恰好 n 次
{n,}至少 n 次
{n,m}n 到 m 次
{n,m}?n 到 m 次非贪婪

边界与分组

符号含义
^字符串开头
$字符串结尾
\b单词边界
(...)捕获分组
(?:...)非捕获分组
(?P<name>...)命名分组
A|B匹配 A 或 B

Python re 模块常用函数

函数用途
re.match()从开头匹配
re.search()搜索第一个匹配
re.findall()返回所有匹配的列表
re.sub()替换匹配内容
re.split()按正则切分
re.compile()预编译正则

十二、小结

核心知识点一句话总结
正则表达式用字符描述字符的迷你语言
基础符号\d 数字、\w 单词字符、. 任意字符
量词* 0+、+ 1+、? 0/1、{n,m} n~m
字符集合[...] 匹配括号内任意一个
分组(...) 提取子串,(?:...) 只匹配不提取
锚点^ 开头、$ 结尾
贪婪匹配默认尽可能多,加 ? 变非贪婪
re 模块match 开头匹配、search 搜索、findall 找全部
预编译re.compile() 提升重复匹配性能
适用场景格式验证、简单提取、批量替换
不适用嵌套结构、复杂语法分析

记住三句话

  1. 正则表达式是描述字符串规则的语言,不是万能的;
  2. 默认贪婪匹配,需要精确控制时记得加 ?
  3. 复杂问题先用正则提取,再用代码逻辑验证,正则 + 代码 > 纯正则

参考代码

本文所有示例代码基于廖雪峰 Python 教程的正则表达式章节重新整理和扩充。