前置知识: 零基础起步

正则进阶机制:回溯、环视与灾难性回溯防御

7 min入门

承接正则实战篇,从「一条 pattern 卡死整个服务」的事故现场拆开引擎:回溯如何工作、嵌套量词为何指数爆炸、3.11 原子组与占有量词的防御写法;再学环视断言「只看不 吃」、反向引用配对匹配,附 re 与第三方 regex 库、各语言方言的对比与调试测试方法。

前置知识

  • 已读完 正则表达式实战:会字符类、量词、分组、findall/sub,写过至少一个能跑的模式;
  • 不需要自动机理论,本文用一个「猜数字」式的直觉模型讲清引擎行为。

你现在要解决什么问题

你写了个日志清洗脚本,其中一条模式用来匹配「引号包起来的任意内容」:

python
import re
policy = re.compile(r'^(\w+\s+)*BAD$')
text = "good good good good good good good good ... "  # 50 个 good,结尾没有 BAD

(把 50 个 good 拼进 text 后跑 policy.search(text),自己试试——它不会报错,也不会结束。几十个词就能让匹配跑上万年。)

模式看着无害,可服务上这条正则一跑,CPU 单核 100%,请求堆积。这篇拆开引擎看它到底在干嘛,给你三件武器:读懂回溯、识别危险模式、用 3.11 的新语法一招封死。顺带把实战篇留的悬念补上:(?:...) 为什么不编号、(?=...) 这类「问号开头」的神秘写法都是什么。

机制:回溯是「后悔药」

把引擎想成一个逐字符前进的猜测者:走到岔路(量词、多分支)时先选一条路走,用小本本记下「刚才其实还有别的选法」;一旦发现走不通,就翻小本本退回上一个岔口换一条路再试。这个「退回重试」就是回溯(backtracking)。

正常时回溯是救命的——没有它,量词的贪婪与惰性根本无法工作。实战篇那个「懒惰 .+? 停在第一个 >」的行为,本质就是:贪婪版先吞到尾、发现 > 不匹配、一路退回来;懒惰版每次只吃一个就试探、不成就多吃一个。两种写法引擎都在反复回溯,只是方向相反。

灾难来自岔路的组合爆炸。(a+)* 这类「量词套量词」的模式,在「接近匹配又最终失败」的文本上,每一种「把字符分给内层还是外层」的组合都要试一遍——n 个字符就是 2 的 n 次方条路径。上面的 \w+\s+ 套 * 同理:每个 good 都能以多种方式被切开重分,50 个词的组合数超过宇宙原子数。特征要记住:卡死的都是「能接近成功但最终失败」的输入,正常输入反而跑得飞快——所以测试发现不了,线上才炸。

防御一:改写模式,消除嵌套量词

多数情况不需要新语法,改写即可。危险的共同形状是「(X+)+」「(X*)」「(X+)」——量词的作用对象本身带量词且两者能吃同样的字符。改写思路:

python
r"^(\w+\s+)*BAD$"     # 危险:内外两层都能吃同一批字符
r"^\w+(\s+\w+)*\s+BAD$"   # 安全:每层各吃各的,边界唯一

写完用「最坏输入」测一发:构造一条「前缀全对、结尾故意错」的长字符串,断言它毫秒级返回 None。这个测试模式比模式本身更值得沉淀。

防御二:3.11 的原子组与占有量词

Python 3.11 给 re 加了两个「禁止后悔」语法,直击回溯命门:

占有量词:量词后面再跟一个 +,表示「吃下的绝不吐出来」:

python
r"\w++\s+"     # \w++:把单词吃完,不参与回溯

原子组:(?>...) 把一组封成「要么整体成、要么整体败」的原子,组内允许试错、组外不接受它的回溯:

python
r"(?>\w+\s+)*BAD$"

两者对正常输入的行为完全不变(匹配结果一样),只砍掉「退回来重分」的路径——指数爆炸直接归零。把第 1 节的事故模式加上占有量词再跑那个 50 个 good 的文本,瞬间返回。限制条件也要知道:它们是较新的方言,Perl、PCRE、Java 早就有,Python 直到 3.11 才跟上——旧解释器上会直接 SyntaxError,跨环境分发前确认最低版本。

环视断言:只看不吃

实战篇挑战题里的 (?:...) 先兑现解释:?: 开头的组是非捕获组,只分组不编号、不进 groups()——纯粹为了「把一段模式当整体」,比如给量词 (?:,\d{3})*。需要分组但不需要取值时用它,省得编号污染。

环视则是更进一步的「只检查、不消费」的组:

python
r"\d+(?= 元)"      # 后面必须有「 元」,但「 元」不算进匹配
r"(?<!-)\d{4}"     # 左边不能是负号(排除「-1234」里的数字)
写法名字含义
(?=...)正向先行右边必须能匹配 …,但 … 不计入结果
(?!...)负向先行右边必须不能匹配 …
(?<=...)正向后行左边必须能匹配 …
(?<!...)负向后行左边必须不能匹配 …

直感场景:给一段文本里所有「不是邮箱域名部分的」数字打码,或者「匹配密码字段值但排除确认字段」:

python
re.findall(r"\d+(?=%)", "命中率 85%,涨幅 12%")   # ['85', '12'],百分号不带走
re.sub(r"(?<!\d)(\d{3})(?!\d)", r"\1 ", "1234567")  # 每三位分组前的辅助

读环视模式的心法:把它读成「要求的条件」而不是「匹配的内容」。四个语法都是零宽的——不消耗字符,纯粹立条件。

反向引用:匹配「刚才吃过的那个」

圆括号除了捕获,还记号:\1 在模式内引用第 1 组实际捕获的内容(不是重复那个模式,是重复那段文本):

python
re.search(r"(\w+) \1", "hello hello world")     # 匹配 "hello hello":两处必须是同一个词
re.search(r"(?P<name>\w+)=(?P=name)", "key=key")  # 命名版:(?P=名字)

典型战场:找重复单词、找成对引号 (['"]).*?\1(前后引号必须同款)。这再次体现「模式只管形状,不管语义」的边界——它能保证前后一致,不能保证那个词真的该重复。

re 与它的邻居们:方言与替代品

写跨语言正则前先认清「方言」差异,同一个符号在不同引擎里含义可能不同:

环境与 Python re 的差异要点
JavaScript同属回溯型;后行断言 ES2018 起才有,原子组与占有量词至今未支持
PCRE / Perl语法超集,Python 3.11 后差异已不大;(?R) 递归等仍是 PCRE 独有
Go (RE2)完全不同的引擎:不回溯、线性时间、天然免疫灾难回溯,但也没有反向引用
POSIX (grep/awk)BRE/ERE 方言,\d 不存在,要用 [0-9]

第三方库 regex(pip install regex)是 re 的增强替代:更全的 Unicode 属性(\p{Han} 直接匹配汉字)、递归模式、更细的匹配选项;API 与 re 几乎一致,项目里 import regex as re 即平滑升级。但默认答案仍是标准库 re——除非你明确需要那些特性。

顺带补一句判断链(实战篇的清单在这里闭环):字符串方法能干的不用正则;正则能干但「形状太复杂」的(HTML、JSON、代码)换专用解析器;Go 那类线性引擎说明「回溯不是正则的必需品」,只是 re 的实现选择。

调试与测试

调试三件套:

  1. regex101.com(选 Python 方言):右上角实时显示每一步的匹配与回溯次数,指数爆炸直接以数字现形;
  2. re.compile(pattern, re.DEBUG):把模式解析成引擎内部的节点树打印出来,看结构用;
  3. 最坏输入手测:任何新模式上线前,跑一遍「长前缀 + 错误结尾」,看耗时是不是毫秒级。

测试套路(pytest 写法,正式的测试篇在后面等你):

python
import re

ID = re.compile(r"[A-Z]{2}-\d{4}")

def test_accepts():
    assert ID.fullmatch("AB-1234")

def test_rejects():
    assert ID.fullmatch("AB-123") is None
    assert ID.fullmatch("ab-1234") is None

def test_fails_fast_on_hostile_input():
    text = "AB-1234 " * 10_000          # 前缀接近成功,结尾无匹配
    assert ID.search(text + "!") is None  # 该断言必须毫秒级通过

第三条是本文的独门:普通测试验证「对的时候对」,它验证「坏的时候不炸」。

自我检查

  • 能用自己的话给同事讲清回溯机制与灾难回溯的触发条件(嵌套量词 + 接近成功的失败输入);
  • 能识别 (X+)+ 形状并给出两种防御(改写 / 原子组或占有量词);
  • 能说出四个环视语法的名字与「零宽」含义,并解释 ?: 与 ?P=name;
  • 能讲出 RE2 与 re 的根本差异(回溯 vs 线性);
  • 新正则上线前会跑「最坏输入」测试。

练习

预测题:re.findall(r"\d+(?=px)", "10px 20em 30px") 输出什么?把 (?=px) 换成 (?!px) 再猜一次。

修改题:把 r"^(\d+,)*\d+$"(校验逗号分隔数字)改写成无嵌套量词的安全版,并用 5000 位的「只差结尾一个字符」输入对比两者耗时。

排错题:同事的正则 r"(a|a)*b" 在 30 个 a 的串上卡死。指出危险结构,给出原子组版 r"(?>a|a)+b" 验证修复,并解释为什么合并分支为 a+ 也是修法。

挑战题:用环视写一条模式,从 price=100, discount_price=80, cost=50 里只捕获键不含 discount 与 cost 的值。要求一条 findall 完成,并写三个断言自测。

下一步