Python 语法篇 · 第13章

正则表达式

文本处理的"瑞士军刀"。NLP 语料清洗、信息抽取、格式校验都离不开它。本章配合大量实战案例讲透 re 模块。

🎯 学习目标

  • 理解正则表达式的用途,熟练使用 re 模块六大函数
  • 掌握元字符、量词、字符集合、边界锚点等核心语法
  • 彻底理解贪婪与非贪婪匹配的区别
  • 掌握分组捕获、命名分组、反向引用与替换回调
  • 会编译正则、使用标志位、处理常见中文场景
  • 能写出手机号、邮箱、身份证、URL 等常用模式

1正则表达式是什么

正则表达式(Regular Expression,regex)是用一套符号描述"文本规律"的微型语言,用来在字符串中做查找、提取、替换、校验、切分。

  • 校验:手机号格式是否正确
  • 提取:从网页/日志中抠出所有邮箱、URL
  • 清洗:去掉 HTML 标签、emoji、多余符号
  • 切分:按多种分隔符同时切分文本

学习工具推荐:正则可视化 regex101.com(选 Python 方言,实时高亮+解释)。Python 中模式串一律写 原始字符串 r"...",避免反斜杠被 Python 先转义一次。

2re 模块六大核心函数

import re

# search:在文本中【搜索第一个】匹配,返回 Match 对象,找不到返回 None
m = re.search(r"\d+", "订单号 10086 已发货")
if m:
    print(m.group())        # 10086

# match:只从字符串【开头】匹配(不常用,记住和 search 的区别即可)
print(re.match(r"\d+", "10086abc"))      # <re.Match>
print(re.match(r"\d+", "abc10086"))     # None

# fullmatch:整个字符串必须【完全匹配】模式(做校验用)
print(bool(re.fullmatch(r"1[3-9]\d{9}", "13812345678")))  # True

# findall:找出【全部】匹配,返回列表(最常用)
print(re.findall(r"\d+", "a12b34c56"))  # ['12','34','56']

# finditer:返回匹配对象的迭代器(大量结果时省内存,可取分组)
for m in re.finditer(r"\d+", "a12b34"):
    print(m.group(), m.span())          # span() 返回 (起始,结束) 位置

# sub:替换
re.sub(r"\s+", " ", "a   b\tc\nd")      # 'a b c d'

# split:切分
re.split(r"[,,;;]", "a,b,c;d;e")     # ['a','b','c','d','e']
需求函数返回
是否包含 / 取第一个searchMatch 或 None
整体格式校验fullmatchMatch 或 None
全部提取findallstr 列表
大量结果且要分组finditerMatch 迭代器
替换sub新字符串
智能切分split列表

判断"是否匹配"永远用 if re.search(...):,不要用 if re.findall(...):(找全量浪费性能),也不要写成 if re.search(...) == True。

3正则语法速查表

3.1 字符与字符类

写法含义例子
.任意单个字符(不含换行)a.c 匹配 abc、a-c
\d数字 [0-9]\d+
\D非数字
\w字母数字下划线(Python3 默认含汉字!)
\W非单词字符
\s空白(空格、制表、换行等)
\S非空白
[abc]字符集合:a 或 b 或 c[aeiou] 元音
[^abc]取反:不是 a/b/c 的任意字符
[a-z]范围:小写字母[a-zA-Z0-9_]
[\u4e00-\u9fa5]常用汉字范围NLP 高频

3.2 量词(重复次数)

写法次数
*0 次或多次
+1 次或多次(至少 1 次)
?0 次或 1 次(可选)
{n}恰好 n 次
{n,}至少 n 次
{n,m}n 到 m 次

3.3 边界与逻辑

写法含义
^字符串开头(多行模式下为行首)
$字符串结尾
\b单词边界(\w 与非 \w 的交界处)
|或:cat|dog
(...)分组:整体控制 + 捕获内容
(?:...)只分组不捕获
(?P<name>...)命名分组
# 转义:元字符要匹配其字面含义时前面加 \
re.findall(r"\.", "a.b.c")          # 匹配真正的点
re.findall(r"\\", r"C:\dir")        # 匹配真正的反斜杠
re.findall(r"[.+*?]", "a+b.c?")     # 在字符类[]中,大多数字符失去特殊含义

4贪婪与非贪婪(核心难点)

* + {n,m} 默认都是贪婪的:尽可能多匹配,再通过回溯满足整体模式;在其后加 ? 变为非贪婪(懒惰):尽可能少匹配。

html = '<h1>标题</h1><p>段落</p>'

# 贪婪:.* 一口气吃到最后一个 </...>,再回溯
re.findall(r"<.+>", html)
# ['<h1>标题</h1><p>段落</p>']   ← 整个一坨,不是想要的!

# 非贪婪:.+? 匹配到第一个 > 就停
re.findall(r"<.+?>", html)
# ['<h1>', '</h1>', '<p>', '</p>']

# 经典场景:提取标签内文本
re.findall(r"<h1>(.*?)</h1>", html)   # ['标题']

规则:跨字符提取内容时,默认先想非贪婪 .*?;而 [^字符]*(否定字符类)是比非贪婪更高效的替代,如提取双引号内容 "([^"]*)"。

5分组与捕获

# 分组让量词作用于整体,并把括号内内容单独"捕获"出来
re.findall(r"(ab)+", "ababab")           # ['ab'](整体只捕获最后一次,注意)

# findall 中有【一个】捕获组时,只返回组内内容;有多个组时返回元组列表
text = "张三:90, 李四:85"
re.findall(r"(\w+):(\d+)", text)
# [('张三','90'), ('李四','85')]

# search + group:按编号取分组(1,2,3...,group(0) 是整个匹配)
m = re.search(r"(\w+):(\d+)", text)
m.group(0)     # '张三:90'  完整匹配
m.group(1)     # '张三'
m.group(2)     # '90'
m.groups()     # ('张三','90')

# 命名分组:可读性更好
m = re.search(r"(?P<name>\w+):(?P<score>\d+)", text)
m.group("name")       # '张三'
m.groupdict()         # {'name':'张三','score':'90'}

# 非捕获组 (?:...):只用来分组,不占编号
re.findall(r"(?:https?://)?([\w.]+)", "http://x.com")

反向引用与替换回调

# 反向引用:\1 表示第1组已匹配到的内容(找重复词)
re.findall(r"\b(\w+)\s+\1\b", "hello hello world", re.I)   # ['hello']

# sub 中用 \1 \g<name> 引用分组(给手机号打码)
re.sub(r"(\d{3})\d{4}(\d{4})", r"\1****\2", "13812345678")
# '138****5678'

# sub 支持用函数处理每个匹配(脱敏金额翻倍等)
def mask(m):
    return m.group(1) + "*" * len(m.group(2))
re.sub(r"(\w{1})(\w+)", mask, "张三和李四")   # '张*和李*'

6标志位 flags

re.IGNORECASE / re.I    # 忽略大小写
re.DOTALL / re.S        # 让 . 也能匹配换行符(提取多行 HTML 常用)
re.MULTILINE / re.M     # ^ $ 按行匹配
re.VERBOSE / re.X       # 模式中可写空白和注释(复杂正则用)

re.findall(r"python", "Python PYTHON", re.I)        # ['Python','PYTHON']
re.search(r"<div>(.*?)</div>", "<div>a\nb</div>", re.S)  # 跨行提取

# 多个标志用 | 组合:flags=re.I | re.S

7编译复用与性能

# 同一个模式要在循环/大量文本中反复使用,先编译成对象更快
pattern = re.compile(r"\d+")
pattern.findall("a12b34")      # 用法与 re.函数 完全一样
pattern.sub("#", "a12b")

# 预编译放模块顶层(全局只编译一次)
PHONE_RE = re.compile(r"1[3-9]\d{9}")
def has_phone(text):
    return bool(PHONE_RE.search(text))

8实战案例库

① 手机号 / 邮箱 / 身份证 / URL

patterns = {
    "手机号": r"1[3-9]\d{9}",
    "邮箱":   r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}",
    "身份证": r"\d{17}[\dXx]",
    "URL":    r"https?://[^\s一-龥]+",
    "日期":   r"\d{4}-\d{1,2}-\d{1,2}",
    "IP地址": r"(?:\d{1,3}\.){3}\d{1,3}",
}
text = "联系我 13812345678 或 a.b@qq.com,主页 https://demo.com"
for name, p in patterns.items():
    print(name, re.findall(p, text))
手机号 ['13812345678']
邮箱 ['a.b@qq.com']
URL ['https://demo.com']

② 文本清洗(NLP 预处理最常用)

import re

raw = "<p>Python 真    好用!!!</p>\n访问 https://x.com 或致电13800000000"

text = re.sub(r"<[^>]+>", "", raw)          # 1. 去 HTML 标签
text = re.sub(r"https?://\S+", "", text)      # 2. 去网址
text = re.sub(r"1[3-9]\d{9}", "[PHONE]", text)# 3. 手机号归一化
text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text)  # 4. 只保留中英文数字
text = re.sub(r"\s+", " ", text).strip()      # 5. 压缩空白
print(text)        # Python真好用访问或致电PHONE

③ 从日志中提取结构化信息

log = '2026-10-25 12:00:01 INFO 用户 admin 登录成功 ip=192.168.1.5'

row = re.search(
    r"(?P<time>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+"
    r"(?P<level>\w+)\s+用户\s+(?P<user>\w+).*ip=(?P<ip>[\d.]+)",
    log)
print(row.groupdict())
# {'time':'2026-10-25 12:00:01','level':'INFO','user':'admin','ip':'192.168.1.5'}

④ 抽取所有中文字词 / 英文单词

text = "我爱 NLP,也爱 Python3.12"
print(re.findall(r"[\u4e00-\u9fa5]+", text))   # ['我爱','也爱'] 连续中文
print(re.findall(r"[a-zA-Z]+", text))          # ['NLP','Python']
print(re.findall(r"[a-zA-Z]+|\d+", text))      # 英文词或数字

正则是强大的通用工具,但处理中文语义(分词、断句)应交给专业库——这正是 NLP 篇 jieba 等工具要解决的问题。正则适合规则明确的模式,不要用它解析完整 HTML(用 BeautifulSoup/lxml),也不要用它做中文分词。

9本章小结与练习

📌 关键结论

  • 判断存在用 search、校验整体用 fullmatch、全提取用 findall、替换用 sub。
  • 模式串一律 r"";量词默认贪婪,提内容用 .*? 或否定字符类。
  • findall 有捕获组时返回组内容;命名分组 (?P<n>...) 配合 groupdict 最清晰。
  • 反复使用先 re.compile;中文匹配 [\u4e00-\u9fa5]。
练习1:写出匹配并提取字符串中所有 QQ 邮箱(@qq.com 之前是 5~11 位数字)的代码。
re.findall(r"(\d{5,11})@qq\.com", text)。注意点号要转义 \.,{5,11} 限定长度,捕获组只取 QQ 号;若需完整邮箱去掉外层括号。
练习2:为什么 re.findall(r"<div>(.*)</div>", "<div>a</div>x<div>b</div>") 结果出乎意料?怎么改?
.* 贪婪,会从第一个 <div> 一直吃到最后一个 </div>,结果是 'a</div>x<div>b' 一整段。改为非贪婪 <div>(.*?)</div>,即可分别得到 ['a','b']。跨行文本再加 flags=re.S。
练习3:把文本中所有连续的空白(空格/制表/换行)压缩为一个空格并去除首尾空白。
re.sub(r"\s+", " ", text).strip()。\s 匹配任意空白,+ 表示一段连续空白统一替换成单个普通空格,这是 NLP 清洗的标准操作之一。