第2章:正则表达式(Regular Expressions)
一、导读
1.1 本章学习目标
正则表达式是自然语言处理中最基础也最实用的工具之一。尽管它看起来简单,但正则表达式在文本处理、信息抽取、数据清洗等任务中扮演着不可替代的角色。
本章的核心学习目标包括:
- 掌握正则表达式的基本语法:字符类、量词、分组、锚点等基本元素的用法
- 理解正则表达式的计算模型:有限状态自动机(DFA/NFA)与正则表达式的对应关系
- 能够编写实用的正则表达式:处理常见的文本匹配、提取和替换任务
- 理解正则表达式在 NLP 中的应用:分词、词干提取、模式匹配等场景
- 掌握正则表达式的局限性:为什么正则表达式不能处理所有语言现象?
- 了解正则表达式的高级特性:前瞻断言、后顾断言、非贪婪匹配等
1.2 为什么本章如此重要
正则表达式是 NLP 工具箱中的"瑞士军刀"。尽管深度学习时代很多任务已经转向神经网络方法,但正则表达式在以下场景中仍然不可或缺:
数据预处理:清洗噪声数据、提取结构化信息、格式化文本。在将文本输入任何模型之前,通常需要正则表达式进行预处理。
规则系统:很多 NLP 系统需要硬编码的规则来处理特殊情况。例如,检测日期格式、邮箱地址、电话号码等模式。
特征工程:在使用传统机器学习方法时,正则表达式常用于构造文本特征。
快速原型:在开发 NLP 系统的早期阶段,正则表达式可以快速验证想法,无需训练模型。
更重要的是,正则表达式背后有限状态自动机的理论,是理解更复杂的语言模型的基础。从正则表达式到上下文无关文法,再到 Transformer,这是一个逐步提升表达能力的过程。
1.3 核心问题引导
正则表达式能匹配什么样的语言?它的表达能力边界在哪里?
正则表达式是如何被计算机执行的?DFA 和 NFA 有什么区别?
如何用正则表达式处理中文文本?中文分词能用正则表达式完成吗?
正则表达式的贪婪匹配和非贪婪匹配有什么区别?
为什么正则表达式不能解析 HTML?这与乔姆斯基层级有什么关系?
二、核心概念详解
2.1 正则表达式基础
正则表达式(Regular Expression, 简称 regex 或 regexp)是一种描述文本模式的字符串。它由普通字符和特殊字符(元字符)组成。
2.1.1 基本字符匹配
最简单的正则表达式就是普通字符,它们匹配自身:
import re
# 基本匹配
pattern = r"hello"
text = "hello world"
match = re.search(pattern, text)
print(match.group()) # hello2.1.2 字符类(Character Classes)
方括号 [] 定义一个字符类,匹配其中任意一个字符:
# 匹配任意一个元音字母
pattern = r"[aeiou]"
text = "hello"
matches = re.findall(pattern, text)
print(matches) # ['e', 'o']
# 预定义字符类
# \d 匹配数字 [0-9]
# \w 匹配字母数字下划线 [a-zA-Z0-9_]
# \s 匹配空白字符 [ \t\n\r\f]
# \D, \W, \S 分别是上述的取反
# 示例:匹配电话号码格式
pattern = r"\d{3}-\d{4}"
text = "Call me at 555-1234"
match = re.search(pattern, text)
print(match.group()) # 555-12342.1.3 量词(Quantifiers)
量词指定前面的元素应该匹配多少次:
# * 匹配 0 次或多次
# + 匹配 1 次或多次
# ? 匹配 0 次或 1 次
# {n} 匹配恰好 n 次
# {n,m} 匹配 n 到 m 次
# {n,} 匹配至少 n 次
# 示例:匹配不同格式的日期
patterns = {
"YYYY-MM-DD": r"\d{4}-\d{2}-\d{2}",
"MM/DD/YYYY": r"\d{2}/\d{2}/\d{4}",
"灵活日期": r"\d{1,2}[-/]\d{1,2}[-/]\d{2,4}",
}
dates = ["2024-01-15", "01/15/2024", "1-5-24", "12/25/2023"]
for date in dates:
for name, pattern in patterns.items():
if re.match(f"^{pattern}$", date):
print(f"'{date}' 匹配 {name}")2.1.4 分组与捕获
圆括号 () 用于分组和捕获:
# 分组:将多个字符视为一个整体
# 捕获:提取匹配的部分
# 提取姓名和邮箱
pattern = r"(\w+)\s+(\w+).*?(\w+@\w+\.\w+)"
text = "Contact: John Doe, email: john@example.com"
match = re.search(pattern, text)
if match:
print(f"姓: {match.group(1)}") # John
print(f"名: {match.group(2)}") # Doe
print(f"邮箱: {match.group(3)}") # john@example.com
# 命名分组
pattern = r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})"
text = "Date: 2024-01-15"
match = re.search(pattern, text)
if match:
print(match.groupdict())
# {'year': '2024', 'month': '01', 'day': '15'}2.1.5 锚点(Anchors)
锚点匹配位置而非字符:
# ^ 匹配字符串开头
# $ 匹配字符串结尾
# \b 匹配单词边界
# 示例:验证邮箱格式
email_pattern = r"^[\w.+-]+@[\w-]+\.[\w.]+$"
emails = ["user@example.com", "invalid@", "@example.com", "a@b.co"]
for email in emails:
if re.match(email_pattern, email):
print(f"'{email}' 是有效邮箱")
else:
print(f"'{email}' 不是有效邮箱")
# 单词边界
pattern = r"\bcat\b"
text = "The cat sat on the catalog"
matches = re.findall(pattern, text)
print(matches) # ['cat'] (不匹配 catalog 中的 cat)2.2 正则表达式的高级特性
2.2.1 贪婪与非贪婪匹配
默认情况下,量词是贪婪的(greedy),会尽可能多地匹配字符。添加 ? 使其变为非贪婪(lazy):
text = "<b>bold</b> and <i>italic</i>"
# 贪婪匹配:匹配尽可能多的内容
greedy = re.findall(r"<.*>", text)
print(greedy) # ['<b>bold</b> and <i>italic</i>']
# 非贪婪匹配:匹配尽可能少的内容
lazy = re.findall(r"<.*?>", text)
print(lazy) # ['<b>', '</b>', '<i>', '</i>']
# 更精确的匹配:排除特定字符
precise = re.findall(r"<[^>]+>", text)
print(precise) # ['<b>', '</b>', '<i>', '</i>']2.2.2 前瞻断言与后顾断言
断言(lookaround)匹配位置而不消耗字符:
# 前瞻断言 (lookahead)
# (?=...) 正向前瞻:后面必须是 ...
# (?!...) 负向前瞻:后面不能是 ...
# 示例:匹配价格数字(后面跟着 $ 或 元)
pattern = r"\d+(?=元|\$)"
text = "苹果5元,香蕉3$,橘子10个"
matches = re.findall(pattern, text)
print(matches) # ['5', '3']
# 后顾断言 (lookbehind)
# (?<=...) 正向后顾:前面必须是 ...
# (?<!...) 负向后顾:前面不能是 ...
# 示例:匹配 @ 后面的用户名
pattern = r"(?<=@)\w+"
text = "Contact @admin or @support for help"
matches = re.findall(pattern, text)
print(matches) # ['admin', 'support']2.2.3 替换与回调
re.sub() 支持强大的替换功能:
# 基本替换
text = "Hello World"
result = re.sub(r"World", "Python", text)
print(result) # Hello Python
# 使用回调函数进行复杂替换
def censor_name(match):
name = match.group()
return name[0] + "*" * (len(name) - 1)
text = "Contact John or Jane for details"
result = re.sub(r"\b(John|Jane)\b", censor_name, text)
print(result) # Contact J*** or J*** for details
# 使用替换进行文本规范化
def normalize_whitespace(text):
return re.sub(r"\s+", " ", text).strip()
messy = " Hello world! This is messy. "
print(normalize_whitespace(messy)) # Hello world! This is messy.2.3 正则表达式的计算模型
正则表达式背后有严格的数学模型——有限状态自动机(Finite State Automaton, FSA)。
2.3.1 确定性有限自动机(DFA)
DFA 是一个五元组 (Q, Σ, δ, q₀, F):
- Q:有限状态集合
- Σ:输入字母表
- δ:转移函数 Q × Σ → Q
- q₀:起始状态
- F:接受状态集合
# 模拟一个简单的 DFA:匹配 "ab" 字符串
class SimpleDFA:
def __init__(self):
self.state = 0
def process(self, text):
self.state = 0
for char in text:
if self.state == 0 and char == 'a':
self.state = 1
elif self.state == 1 and char == 'b':
self.state = 2
else:
self.state = 0
return self.state == 2 # 2 是接受状态
dfa = SimpleDFA()
print(dfa.process("ab")) # True
print(dfa.process("aab")) # True (最后一个 ab 匹配)
print(dfa.process("abc")) # False2.3.2 非确定性有限自动机(NFA)
NFA 允许一个状态在同一个输入字符上转移到多个状态,也允许 ε-转移(不消耗输入字符的转移)。NFA 和 DFA 在表达能力上是等价的,但 NFA 可能更简洁。
Python 的 re 模块使用基于回溯的 NFA 实现,这赋予了它支持高级特性(如后顾断言)的能力,但也可能导致指数级的时间复杂度。
# 灾难性回溯示例
# 这个正则表达式在某些输入上会导致指数级时间
import time
pattern = r"(a+)+b"
text_bad = "a" * 25 + "c" # 不匹配的输入
start = time.time()
result = re.search(pattern, text_bad)
elapsed = time.time() - start
print(f"Time: {elapsed:.4f}s") # 可能很慢!
# 解决方法:使用原子组或避免嵌套量词
# 安全的等价写法
safe_pattern = r"a+b"2.4 正则表达式在 NLP 中的应用
2.4.1 文本清洗
def clean_text(text):
# 去除 HTML 标签
text = re.sub(r"<[^>]+>", "", text)
# 去除 URL
text = re.sub(r"https?://\S+", "", text)
# 去除邮箱
text = re.sub(r"\S+@\S+", "", text)
# 去除特殊字符(保留中文、英文、数字和基本标点)
text = re.sub(r"[^\w\s\u4e00-\u9fff,。!?、]", "", text)
# 规范化空白
text = re.sub(r"\s+", " ", text).strip()
return text
raw_text = """
<p>欢迎访问 https://example.com!</p>
联系邮箱:test@example.com
特殊字符:@#$%^&*()
"""
print(clean_text(raw_text))2.4.2 信息抽取
def extract_contact_info(text):
"""从文本中提取联系信息"""
info = {}
# 提取手机号
phones = re.findall(r"1[3-9]\d{9}", text)
if phones:
info["phones"] = phones
# 提取邮箱
emails = re.findall(r"[\w.+-]+@[\w-]+\.[\w.]+", text)
if emails:
info["emails"] = emails
# 提取身份证号(简化版)
id_cards = re.findall(r"\d{17}[\dXx]", text)
if id_cards:
info["id_cards"] = id_cards
return info
text = """
张三,手机:13812345678,邮箱:zhangsan@example.com
身份证:110101199001011234
"""
print(extract_contact_info(text))
# {'phones': ['13812345678'], 'emails': ['zhangsan@example.com'], 'id_cards': ['110101199001011234']}2.4.3 简单的分词器
def simple_tokenizer(text, language="en"):
if language == "en":
# 英文分词:按空白分割,保留标点
tokens = re.findall(r"\b\w+(?:'\w+)?\b|[^\w\s]", text)
return tokens
elif language == "zh":
# 中文分词:按字分割(简单版本)
chars = re.findall(r"[\u4e00-\u9fff]|[^\w\s]", text)
return chars
print(simple_tokenizer("I don't like NLP!", "en"))
# ['I', "don't", 'like', 'NLP', '!']
print(simple_tokenizer("自然语言处理很有趣!", "zh"))
# ['自', '然', '语', '言', '处', '理', '很', '有', '趣', '!']三、实战案例
3.1 日志文件解析
import re
from datetime import datetime
# Apache 访问日志格式
log_pattern = r'(\S+) (\S+) (\S+) \[([^\]]+)\] "(\S+) (\S+) (\S+)" (\d+) (\S+)'
log_lines = [
'192.168.1.1 - - [06/Oct/2026:10:15:30 +0800] "GET /index.html HTTP/1.1" 200 1234',
'10.0.0.1 - admin [06/Oct/2026:10:16:00 +0800] "POST /api/login HTTP/1.1" 302 567',
'172.16.0.1 - - [06/Oct/2026:10:17:45 +0800] "GET /static/style.css HTTP/1.1" 304 0',
]
parsed = []
for line in log_lines:
match = re.match(log_pattern, line)
if match:
parsed.append({
"ip": match.group(1),
"user": match.group(3),
"timestamp": match.group(4),
"method": match.group(5),
"path": match.group(6),
"protocol": match.group(7),
"status": int(match.group(8)),
"size": match.group(9),
})
for entry in parsed:
print(f"{entry['ip']} {entry['method']} {entry['path']} → {entry['status']}")
# 192.168.1.1 GET /index.html → 200
# 10.0.0.1 POST /api/login → 302
# 172.16.0.1 GET /static/style.css → 3043.2 文本规范化管道
class TextNormalizer:
def __init__(self):
self.replacements = []
def add_rule(self, pattern, replacement, flags=0):
self.replacements.append((re.compile(pattern, flags), replacement))
def normalize(self, text):
for pattern, replacement in self.replacements:
text = pattern.sub(replacement, text)
return text
# 构建规范化管道
normalizer = TextNormalizer()
# 添加规则
normalizer.add_rule(r"[ \t]+", " ") # 合并空格
normalizer.add_rule(r"\n{3,}", "\n\n") # 合并多个换行
normalizer.add_rule(r"(\d)\s+(\d)", r"\1\2") # 合并数字间的空格
normalizer.add_rule(r"\s+([,。!?;:])", r"\1") # 去除标点前的空格
normalizer.add_rule(r"([(【《])\s+", r"\1") # 去除左括号后的空格
normalizer.add_rule(r"\s+([)】》])", r"\1") # 去除右括号前的空格
normalizer.add_rule(r"\s+$", "", re.MULTILINE) # 去除行尾空格
text = """
今天天气 真好!
我们 去 公园 玩 吧 。
好的 , 我 同意 !
"""
print(normalizer.normalize(text))3.3 简单的模板引擎
class SimpleTemplateEngine:
"""基于正则表达式的简单模板引擎"""
def __init__(self):
self.var_pattern = re.compile(r"\{\{(\w+)\}\}")
self.if_pattern = re.compile(
r"\{%\s*if\s+(\w+)\s*%\}(.*?)\{%\s*endif\s*%\}",
re.DOTALL
)
self.for_pattern = re.compile(
r"\{%\s*for\s+(\w+)\s+in\s+(\w+)\s*%\}(.*?)\{%\s*endfor\s*%\}",
re.DOTALL
)
def render(self, template, context):
result = template
# 处理 for 循环
while self.for_pattern.search(result):
match = self.for_pattern.search(result)
var_name = match.group(1)
list_name = match.group(2)
body = match.group(3)
items = context.get(list_name, [])
expanded = ""
for item in items:
loop_context = {**context, var_name: item}
expanded += self.var_pattern.sub(
lambda m: str(loop_context.get(m.group(1), "")),
body
)
result = result[:match.start()] + expanded + result[match.end():]
# 处理 if 条件
while self.if_pattern.search(result):
match = self.if_pattern.search(result)
var_name = match.group(1)
body = match.group(2)
if context.get(var_name):
result = result[:match.start()] + body + result[match.end():]
else:
result = result[:match.start()] + result[match.end():]
# 处理变量替换
result = self.var_pattern.sub(
lambda m: str(context.get(m.group(1), "")),
result
)
return result
# 使用示例
engine = SimpleTemplateEngine()
template = """
Hello, {{name}}!
{% if is_vip %}Welcome, VIP member!{% endif %}
Your items:
{% for item in items %}- {{item}}
{% endfor %}
"""
context = {
"name": "Alice",
"is_vip": True,
"items": ["Apple", "Banana", "Cherry"],
}
print(engine.render(template, context))四、本章小结
本章系统讲解了正则表达式:
基本语法:字符类、量词、分组、锚点构成了正则表达式的核心语法,能够描述丰富的文本模式。
高级特性:前瞻/后顾断言、贪婪/非贪婪匹配、命名分组等特性使正则表达式更加强大。
计算模型:正则表达式与有限状态自动机(DFA/NFA)等价。Python 的 re 模块使用回溯 NFA,支持高级特性但需注意性能问题。
NLP 应用:正则表达式在文本清洗、信息抽取、简单分词等场景中发挥重要作用。
局限性:正则表达式只能描述正则语言,无法处理嵌套结构(如 HTML 解析、上下文无关语法)。这是乔姆斯基层级的基本结论。
正则表达式是 NLP 的基础工具。尽管现代 NLP 越来越依赖深度学习方法,但正则表达式在数据预处理和规则系统中仍然是不可替代的。掌握正则表达式,是每一个 NLP 从业者的基本功。
五、思考与练习
思考题
正则表达式能匹配所有偶数长度的 0-1 串吗?请写出对应的正则表达式或证明不可能。
为什么正则表达式不能解析嵌套的 HTML 标签?这与乔姆斯基层级有什么关系?
Python 的 re 模块使用回溯 NFA,而 grep 使用 DFA。这两种实现各有什么优缺点?
在处理中文文本时,正则表达式面临哪些特殊的挑战?
编程练习
编写正则表达式匹配以下模式:
- 中国大陆手机号(1开头,第二位 3-9,共 11 位)
- IPv4 地址(如 192.168.1.1)
- 中文日期格式(如 2024年1月15日)
实现一个简单的 Markdown 到 HTML 转换器,使用正则表达式处理以下语法:
- # 标题 → <h1>标题</h1>
- **粗体** → <strong>粗体</strong>
- *斜体* → <em>斜体</em>
- ` 代码 → <code>代码</code>`
实现一个文本脱敏工具,使用正则表达式自动检测并替换文本中的敏感信息(手机号、身份证号、邮箱、银行卡号)。