Python 正则表达式完全指南:掌握复杂匹配,解锁高级数据处理能力 | 实战案例解析

6次阅读
没有评论

共计 9135 个字符,预计需要花费 23 分钟才能阅读完成。

在 Python 的世界里,正则表达式(Regular Expressions,简称 RegEx)是处理字符串的瑞士军刀。无论是数据清洗、日志分析、文本挖掘还是 Web 爬虫,掌握正则表达式都能极大地提升你的编程效率和数据处理能力。尽管其语法初看起来可能令人望而却步,但一旦掌握,它将成为你解决复杂文本匹配问题的强大盟友。

本篇文章将作为一份“Python 正则表达式完全指南”,我们不仅仅会回顾基础知识,更会深入探讨各种“复杂匹配场景”,并通过丰富的“实战案例”来帮助你彻底理解并驾驭 Python 的 re 模块。准备好了吗?让我们一起解锁 Python 正则表达式的无限可能!

Python 正则表达式基础回顾

在深入复杂匹配之前,我们先快速回顾一下 Python 中处理正则表达式的核心模块 re 及其基本用法。

Python 通过内置的 re 模块来支持正则表达式操作。使用它,你通常需要导入该模块:

import re

re模块提供了几个核心函数:

  • re.match(pattern, string, flags=0): 尝试从字符串的 起始位置 匹配模式。如果匹配成功,返回一个匹配对象;否则返回None
  • re.search(pattern, string, flags=0): 扫描整个字符串,查找模式的 第一个匹配。如果匹配成功,返回一个匹配对象;否则返回None
  • re.findall(pattern, string, flags=0): 扫描整个字符串,查找模式的 所有非重叠匹配,并以列表形式返回所有匹配到的字符串(或分组)。
  • re.sub(pattern, repl, string, count=0, flags=0): 在字符串中 替换 所有匹配到的模式为repl
  • re.compile(pattern, flags=0): 预编译正则表达式模式。当你在循环中多次使用同一个正则表达式时,预编译可以显著提高性能。

元字符速览:

元字符 描述 示例 匹配示例
. 匹配除换行符外任意单个字符 a.b acb, a#b
* 匹配前一个字符零次或多次 a*b b, ab, aaab
+ 匹配前一个字符一次或多次 a+b ab, aaab
? 匹配前一个字符零次或一次 a?b b, ab
[] 匹配括号内任意一个字符 [abc] a, b, c
[^] 匹配不在括号内任意一个字符 [^abc] a,b,c 外的字符
| 或操作符 a|b ab
() 分组,可以捕获匹配内容 (ab)+ ab, abab
^ 匹配字符串开始 ^abc abc 开头的字符串
$ 匹配字符串结束 abc$ abc 结尾的字符串
转义字符,或特殊字符序列 ., d, w 匹配字面., 数字,单词字符
d 匹配任意数字(0-9) d+ 123, 45
D 匹配任意非数字字符 D+ abc, !@#
w 匹配字母、数字或下划线 w+ word123, _abc
W 匹配任意非字母、数字或下划线 W+ !@#, ` `
s 匹配任意空白字符(空格、制表符、换行符等) s+ 多个空格
S 匹配任意非空白字符 S+ abc, 123
b 匹配单词边界 bwordb 独立单词word
B 匹配非单词边界 BwordB keyword, wordup

原始字符串(Raw String):
在 Python 中,为了避免反斜杠 与字符串转义规则冲突,通常推荐使用原始字符串(r'...')。例如,r'n'会匹配字面上的n,而不是换行符。

深入理解匹配模式与修饰符

re模块的 flags 参数允许你修改正则表达式的匹配行为。这是处理复杂场景的关键。

  • re.IGNORECASEre.I: 忽略大小写匹配。

    text = "Hello World"
    match = re.search(r'hello', text, re.I)
    print(match.group() if match else "No match") # Output: Hello
  • re.MULTILINEre.M: 多行模式。使 ^$不仅匹配字符串的开头和结尾,也匹配每一行的开头和结尾。

    text = "Line 1nLine 2nLine 3"
    matches = re.findall(r'^Line', text, re.M)
    print(matches) # Output: ['Line', 'Line', 'Line']
  • re.DOTALLre.S: 点号匹配所有模式。使.(点号)匹配包括换行符在内的所有字符。

    text = "First linenSecond line"
    match = re.search(r'First.*Second', text) # No match by default
    print(match) # Output: None
    
    match_dotall = re.search(r'First.*Second', text, re.S) # Match with DOTALL
    print(match_dotall.group() if match_dotall else "No match") # Output: First linenSecond line
  • re.VERBOSEre.X: 详细模式。允许你在正则表达式中添加空格和注释,提高可读性,在处理复杂模式时尤为重要。

    # 匹配电话号码,例如 (123) 456-7890 或 123-456-7890
    phone_pattern = re.compile(r"""
        ^               # 匹配字符串开头
        (d{3}|(d{3})) # 匹配区号:三位数字 或 (三位数字)
        [s-]?          # 匹配可选的空格或短横线
        d{3}           # 匹配接下来的三位数字
        -?              # 匹配可选的短横线
        d{4}           # 匹配最后的四位数字
        $               # 匹配字符串结尾
    """, re.VERBOSE)
    
    print(phone_pattern.search("123-456-7890"))
    print(phone_pattern.search("(123) 456-7890"))
    print(phone_pattern.search("invalid-phone"))

    使用 re.VERBOSE 能让复杂的正则表达式变得像普通代码一样易于理解和维护。

复杂匹配场景实战:进阶技巧

现在,我们进入正则表达式最精彩的部分:如何处理那些初级模式难以搞定的复杂场景。

1. 贪婪与非贪婪匹配 (Greedy vs. Non-Greedy)

默认情况下,正则表达式的量词(*, +, ?)是“贪婪”的,它们会尽可能多地匹配字符。但在很多情况下,我们需要“非贪婪”匹配,即尽可能少地匹配。

通过在量词后添加?,可以使其变为非贪婪模式:*?, +?, ??

实战案例:提取 HTML 标签内容

假设我们想从 HTML 字符串中提取 <p> 标签内的内容。

html_text = "This is a <p>first paragraph</p> and this is a <p>second paragraph</p>."

# 贪婪匹配:会匹配到第一个 <p> 到最后一个 </p> 之间的所有内容
greedy_match = re.findall(r'<p>.*</p>', html_text)
print(f"贪婪匹配结果: {greedy_match}")
# Output: ['<p>first paragraph</p> and this is a <p>second paragraph</p>']

# 非贪婪匹配:会分别匹配每个 <p> 标签及其对应的内容
non_greedy_match = re.findall(r'<p>(.*?)</p>', html_text)
print(f"非贪婪匹配结果: {non_greedy_match}")
# Output: ['first paragraph', 'second paragraph']

可以看到,非贪婪匹配是处理这种嵌套或重复模式的关键。

2. 分组与捕获 (Grouping and Capturing)

括号 () 不仅可以用于组合模式(例如(ab)+),更重要的是它们可以“捕获”匹配到的子字符串,方便后续提取。

实战案例:从日志中提取日期、时间和消息

log_entry = "2023-10-27 14:35:01 INFO: User'john_doe'logged in from 192.168.1.100."
pattern = r"(d{4}-d{2}-d{2})s(d{2}:d{2}:d{2})s(w+):s(.*)"

match = re.search(pattern, log_entry)

if match:
    date = match.group(1)
    time = match.group(2)
    level = match.group(3)
    message = match.group(4)
    print(f"日期: {date}, 时间: {time}, 级别: {level}, 消息: {message}")
    # Output: 日期: 2023-10-27, 时间: 14:35:01, 级别: INFO, 消息: User 'john_doe' logged in from 192.168.1.100.

命名组 (Named Groups)

使用 (?P<name>...) 语法可以为捕获组命名,这样在访问匹配结果时更具可读性。

pattern_named = r"(?P<date>d{4}-d{2}-d{2})s(?P<time>d{2}:d{2}:d{2})s(?P<level>w+):s(?P<message>.*)"
match_named = re.search(pattern_named, log_entry)

if match_named:
    print(f"日期: {match_named.group('date')}, 时间: {match_named.group('time')}, 级别: {match_named.group('level')}, 消息: {match_named.group('message')}")

命名组的优势在于,当你的正则表达式变得很长,有多个分组时,通过名称访问比通过索引更清晰。

非捕获组 (Non-Capturing Groups)

有时你需要使用括号来组合模式以应用量词或 | 运算符,但又不希望捕获这个分组的内容。这时可以使用非捕获组 (?:...)

实战案例:匹配包含特定关键字的句子,但只捕获关键字

text = "I like apples, oranges, and bananas. She prefers grapes or berries."
# 匹配 "apples" 或 "oranges" 或 "grapes"
pattern = r"b(?:apples|oranges|grapes)b"
keywords = re.findall(pattern, text)
print(f"匹配到的关键词: {keywords}") # Output: ['apples', 'oranges', 'grapes']

# 如果使用捕获组 (apples|oranges|grapes),效果相同,但如果该组嵌套在更复杂的模式中,非捕获组能避免创建不必要的捕获。

3. 零宽断言 (Lookarounds)

零宽断言是一种高级特性,它匹配一个位置,而不是实际的字符。这意味着匹配到的内容不包含断言本身。它常用于基于上下文进行匹配,而不将上下文包含在最终结果中。

1. 正向先行断言 (Positive Lookahead): (?=...)

匹配一个位置,该位置后面紧跟着 ... 中的模式。

实战案例:提取所有后面跟着“USD”的数字

prices = "Price: 100 USD, Cost: 50 EUR, Discount: 20 USD, Total: 150 JPY"
# 提取所有价格,但只提取后面跟着“USD”的数字
usd_prices = re.findall(r'bd+(?=sUSD)', prices)
print(f"USD 价格: {usd_prices}") # Output: ['100', '20']

注意,USD本身并没有被捕获,因为先行断言只匹配位置。

2. 负向先行断言 (Negative Lookahead): (?!...)

匹配一个位置,该位置后面 紧跟着 ... 中的模式。

实战案例:提取所有不是“Python”的编程语言名称

languages = "I love Python, Java, C++, Python, JavaScript."
# 提取所有以字母开头的单词,但后面不能紧跟“Python”other_languages = re.findall(r'b(?!Pythonb)(w+)b', languages)
print(f"其他语言: {other_languages}") # Output: ['I', 'love', 'Java', 'C++', 'JavaScript']
# 这里 `I` 和 `love` 也被匹配了,因为 `w+` 后面没有 `Python`。如果只想要编程语言,需要更精确的模式。# 改进:提取所有单词,但排除“Python”other_languages_filtered = re.findall(r'b(?!bPythonb)(w+)b', languages)
print(f"过滤后的其他语言: {other_languages_filtered}") # Output: ['I', 'love', 'Java', 'C++', 'JavaScript']

3. 正向后行断言 (Positive Lookbehind): (?<=...)

匹配一个位置,该位置前面紧跟着 ... 中的模式。

实战案例:提取所有前面跟着“ID:”的数字

data = "User ID: 12345, Order ID: 67890, Product Code: ABC12"
# 提取所有数字,但只提取前面跟着“ID:”的数字
ids = re.findall(r'(?<=ID:s)d+', data)
print(f"提取的 ID: {ids}") # Output: ['12345', '67890']

4. 负向后行断言 (Negative Lookbehind): (?<!...)

匹配一个位置,该位置前面 紧跟着 ... 中的模式。

实战案例:提取所有不是以“Mr.”开头的姓名

names = "Mr. Smith, Ms. Johnson, Dr. Lee, Mr. Brown"
# 提取所有姓名,但排除前面跟着“Mr.”的姓名
non_mr_names = re.findall(r'(?<!Mr.s)(bw+b)', names)
print(f"非 Mr. 的姓名: {non_mr_names}") # Output: ['Smith', 'Ms', 'Johnson', 'Dr', 'Lee', 'Brown']
# 这里有一个小问题,它会匹配“Ms”和“Dr”以及它们后面的姓名,因为 `w+` 会单独匹配。# 改进:匹配完整名称,但不包含 Mr.
# 我们可以结合使用:names_cleaned = [name.strip() for name in re.findall(r'(?:Mr.s)?(w+),?s*', names) if not name.strip().startswith('Mr.')] # 这样会捕获 Mr. 后面的所有内容
# 让我们尝试更直接的负向后行:proper_names = re.findall(r'(?<!Mr.s)b([A-Z]w+)b', names)
print(f"更准确的非 Mr. 的姓名: {proper_names}") # Output: ['Johnson', 'Lee', 'Brown']
# 这种方法依然存在问题,比如 'Ms.' 和 'Dr.' 也被排除在外,因为它只看前面的 `Mr.s`。# 如果我们的目标是:匹配不是“Mr. Name”格式的独立姓名
names_without_mr = [name for name in re.findall(r'b(?!Mr.s)(w+.?s?w+)b', names) if 'Mr.' not in name]
# 这是一个相对复杂的场景,因为匹配的逻辑依赖于“独立姓名”的定义。# 更直接的例子是:匹配所有数字,但不是货币符号前的数字
data_amounts = "100 USD, 50 EUR, 20%"
amounts_not_percentage = re.findall(r'bd+(?<!d%)', data_amounts) # 这个断言会失败,因为后行断言必须是定长
# Python 的 re 模块要求后行断言模式是定长的。这是一个重要的限制。# 我们可以通过先行断言来模拟:匹配数字,但后面没有 `%`
amounts_not_percentage_corrected = re.findall(r'bd+b(?!s*%)', data_amounts)
print(f"非百分比金额: {amounts_not_percentage_corrected}") # Output: ['100', '50']

4. 回溯引用 (Backreferences)

回溯引用允许你引用正则表达式中先前捕获的组。它通常以 1, 2 等形式出现,表示引用第 1 个、第 2 个捕获组匹配到的内容。对于命名组,可以使用g<name>

实战案例:查找重复的单词

sentence = "This is a test test string with duplicate words words."
# 查找重复的单词,例如“test test”或“words words”duplicate_words_pattern = r'b(w+)s+1b'

matches = re.findall(duplicate_words_pattern, sentence)
print(f"重复的单词: {matches}") # Output: ['test', 'words']
# 这里 `1` 引用了第一个捕获组 `(w+)` 所匹配到的内容,确保两次匹配的是同一个单词。

5. 处理多行文本与换行

当处理包含多行内容的字符串时,re.M (MULTILINE) 和 re.S (DOTALL) 标志变得尤为重要。

  • re.M:使 ^$匹配每行的开头和结尾。
  • re.S:使 . 匹配包括换行符在内的所有字符。

实战案例:从多行日志中提取特定块

假设我们要从一个多行日志字符串中提取以 --- START BLOCK --- 开始,以 --- END BLOCK --- 结束的日志块。

log_data = """
Line 1: Some info
--- START BLOCK ---
This is block content line 1.
This is block content line 2.
It can span multiple lines.
--- END BLOCK ---
Line 2: More info
--- START BLOCK ---
Another block here.
--- END BLOCK ---
"""

# 使用 re.S 让.` 匹配换行符,使用非贪婪匹配.*? 避免匹配到多个块
block_pattern = r'--- START BLOCK ---s*(.*?)s*--- END BLOCK ---'
blocks = re.findall(block_pattern, log_data, re.S)

for i, block in enumerate(blocks):
    print(f"--- 提取到的块 {i+1} ---")
    print(block)
    print("-------------------")

# Output:
# --- 提取到的块 1 ---
# This is block content line 1.
# This is block content line 2.
# It can span multiple lines.
# -------------------
# --- 提取到的块 2 ---
# Another block here.
# -------------------

性能优化与常见陷阱

1. 预编译正则表达式 (re.compile())

如果你在一个程序中多次使用同一个正则表达式模式,预编译可以显著提高性能,因为 re 模块在每次调用时都会编译模式。

import time

# 不预编译
start_time = time.time()
for _ in range(100000):
    re.search(r'(d{4})-(d{2})-(d{2})', 'Date: 2023-10-27')
print(f"不预编译耗时: {time.time() - start_time:.4f}秒")

# 预编译
compiled_pattern = re.compile(r'(d{4})-(d{2})-(d{2})')
start_time = time.time()
for _ in range(100000):
    compiled_pattern.search('Date: 2023-10-27')
print(f"预编译耗时: {time.time() - start_time:.4f}秒")

结果会显示预编译版本更快。

2. 避免灾难性回溯 (Catastrophic Backtracking)

这是正则表达式中最常见的性能陷阱。当一个模式中包含嵌套的量词(如 (.+)*a(a*)*b)并且输入字符串无法匹配时,正则表达式引擎可能会尝试所有可能的回溯路径,导致匹配时间呈指数级增长。

陷阱示例:

# 尝试匹配以 'a' 开头,以 'b' 结尾的字符串,中间可以有任意字符
# 但这个模式会引起灾难性回溯,因为(.+)* 既是贪婪的又是嵌套的
# pattern = r"a(.+)*b"
# text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac" # 'c' 而不是 'b'
# try:
#     re.search(pattern, text) # 这行代码可能会执行非常久,甚至卡死
# except Exception as e:
#     print(f"Caught an error: {e}")

如何避免:

  • 使用非贪婪量词 (*?, +?) 减少回溯。
  • 使用更具体的字符集 代替.。例如,如果你知道中间是数字,就用d+
  • 重写模式,避免嵌套的、重复的量词。
  • 使用零宽断言 来限定匹配的上下文,而不是实际捕获大量重复的字符。

3. 清晰与可维护性

复杂的正则表达式往往难以阅读和理解。

  • 使用re.VERBOSE (re.X):如前所述,它可以让你用多行编写正则,并添加注释,大大提高可读性。
  • 分解复杂模式:如果一个模式过于复杂,考虑是否可以拆分为多个简单的模式,分步处理。
  • 测试你的正则表达式:利用在线工具(如 Regex101、RegExr)或编写单元测试来确保你的模式行为符合预期,尤其是在边缘情况下。

结语

Python 的正则表达式是一个极其强大且灵活的工具。从简单的字符串查找替换到复杂的数据提取和验证,它几乎无所不能。通过本篇“Python 正则表达式完全指南”的学习,你不仅回顾了基础,更深入掌握了贪婪 / 非贪婪匹配、分组、命名组、非捕获组、零宽断言以及回溯引用等“复杂匹配场景”的关键技巧。

记住,熟能生巧。最好的学习方法就是动手实践,将这些技巧应用到你的实际项目中。当你下次遇到棘手的文本处理任务时,不妨拿起正则表达式这把瑞士军刀,你会发现它能帮你高效地解决问题。祝你在正则表达式的世界里探索愉快!

正文完
 0
评论(没有评论)