写字符串验证时,几个正常示例很快通过,并不能说明表达式在其他输入上也很快。某些回溯式匹配中,只在末尾多出一个不符合规则的字符,就可能让一次失败比成功慢许多。

本文回答一个具体问题:为什么一个最终返回“不匹配”的表达式,仍然要做大量工作,又怎样在保持规则的前提下减少这些工作? 我在本机运行了固定模式与合成字符串的对照,包括耗时增长、匹配结果、捕获组差异和超时中止;没有测试网站接口、服务吞吐或其他正则引擎。

例子只要求输入“非空,而且全部由小写字母 a 构成”。它很简单,正好能把业务规则和表达式写法的成本分开看。

先写清楚规则和匹配接口

下面两种写法,在 Python 的 fullmatch 下都能检查上述规则:

import re

re.fullmatch(r"(a+)+", "aaaa") is not None  # True
re.fullmatch(r"a+", "aaaa") is not None    # True

a+ 表示一个或多个 a;(a+)+ 则把“一段或多段,每段有一个或多个 a”再套了一层重复。对于只判断整串是否符合规则的用途,这层重复没有增加需要接受的字符串种类。

本例使用 fullmatch 检查整串,默认标志,不使用 search 找子串。接口是实验条件的一部分:若只搜索某处有没有 a,那么带有其他字符的字符串也可能找出一段匹配,问题已经变了。Python:fullmatch 与 search 的区别

因此,要看失败情况,就把输入从 "a" * n 改为 "a" * n + "!"。前面的 a 完全相同,最后的感叹号令整串无法满足规则。最终答案应该是不匹配,但得到这个答案所需的搜索过程未必短。

成功很快,末尾失败却迅速变慢

本次环境为 CPython 3.12.14、macOS 27.0.1、arm64,只使用标准库 re。每种条件在独立子进程中运行三次已编译模式的 Pattern.fullmatch,用 perf_counter_ns 计时,记录三次读数与中位数。编译模式、构造输入和启动进程都不计入这个匹配计时。

嵌套写法 (a+)+ 的结果如下,失败列的输入总长度是 n+1:

a 的数量 n纯 a 输入:成功耗时末尾加 !:失败耗时
16< 0.01 ms1.241 ms
18< 0.01 ms4.749 ms
20< 0.01 ms19.320 ms
22< 0.01 ms78.825 ms
24< 0.01 ms311.528 ms

这里只有 25 个字符的失败输入,却已经耗费约 0.31 秒。相邻两行只增加两个 a,本次失败耗时大致增加到四倍;成功输入没有出现相同量级的增长。这比“它在我的几个正常样本上运行很快”提供了更有用的性能信息。

短调用接近微秒甚至更短,容易受到计时开销与机器状态影响,所以表中统一写成小于 0.01 毫秒,不给几个优化写法做微小差距的排名。完整读数保留在附件中。这是当前模式、输入和环境的局部实验,不是不同 Python 版本或处理器的统一速度指标。

失败后,还有许多分法可以重新尝试

贪婪重复通常先尽量多地吃掉字符。如果后续要求无法成立,回溯会返回先前的选择点,尝试另一种分配。Python 的官方教程给出了这种“先多匹配、失败后退回重试”的过程。Python 正则教程:重复与回溯

对于 (a+)+,同一串 aaaa 可以被当成一个四字母的组,也可以分成三加一、二加二、一加三,或者更多小组。内层 + 决定每组长度,外层 + 决定有多少组。成功时,首先尝试的一个大组就足以通过;末尾出现 ! 时,不同分法都无法让这个字符变成 a,却仍留下可重试的选择。

可以直接数出分组的组合数量:n 个 a 之间有 n−1 个间隙,每个间隙都可以选择“切开”或“不切开”,所以连续非空分组有 2^(n−1) 种。n=24 时是 8,388,608 种。这是对例子中潜在分法的组合分析,不是实际测出的解释器指令数或精确回溯次数。

关键是多个重复层能够用不同方式消耗相同前缀,而失败发生得很晚。匹配器需要排除候选路径,才能确认没有成功路径;返回的答案虽短,证明这个答案的过程却可能很长。回溯与状态集合模拟的算法区别,可参考 Russ Cox 的原始说明;本文的计时独立于其中的历史基准。Russ Cox:正则匹配算法

诊断时不能仅凭表达式里有括号和多个量词,就断言一定会出现这种增长。需要寻找的是重复部分对同一输入存在多种可重试的分配,而且后续失败迫使这些选择被继续排除。固定前缀在开头就失败、重复部分消费的字符范围互不重叠,或引擎采用不同处理方式,都可能改变搜索过程。本次只验证了给出的几种模式,不能由这个小例子判定所有带嵌套重复的表达式都有相同成本。

先去掉规则不需要的重复层

对这个仅需布尔判断的规则,a+ 已经足够。它不用在多个 a 组之间重新分配同一串字符,是最直接的改写。先明确需要接受什么,再选择表达式,往往比在冗余写法上继续加修饰符更容易核对。

我同时测试了 a+、原子组写法 (?>a+)+ 和占有量词写法 a++。对于 24 个 a 加一个 !,结果如下:

模式是否匹配整串三次计时中位数
(a+)+否311.528 ms
a+否< 0.01 ms
(?>a+)+否< 0.01 ms
a++否< 0.01 ms

除了成功与失败的计时输入,脚本还核对了空串、单个 a、多个 a、带感叹号和末尾换行的例子,并枚举由 a、b、!、换行组成、长度 0–5 的 1,365 个字符串。四种模式的接受结果一致,只有 a 到 aaaaa 五个字符串通过。

这个有限枚举补充了边界验证;对于本例,接受集合相同也能从“一段或多段非空 a,合起来仍是非空 a”直接解释。它不能成为任意复杂正则改写都等价的证明。修改表达式时,应保留自己的规则说明和能覆盖失败位置的样本,而不只保留几个正常输入。

还有一个接口差异:(a+)+ 匹配 aaa 后,.groups() 实测为 ('aaa',),其他三种模式是空元组。接受同样的字符串,不代表捕获结果也相同。 如果调用者使用组号、组名或替换内容,改写还必须核对这些输出;本文没有把仅有布尔等价的写法称为整个调用接口等价。

原子组和占有量词会主动放弃某些尝试

Python 从 3.11 开始支持原子组与占有量词。原子组退出后丢弃组内回溯点;占有量词尽量匹配后,不再为了后续部分退让。它们能减少一些尝试,也会影响哪些后续条件还能成功。Python 3.11:新增正则语法、语法含义

本次实际执行的反例很短:

re.fullmatch(r"a+a", "aa") is not None   # True
re.fullmatch(r"a++a", "aa") is not None  # False

普通 a+ 最初吃下两个 a,后面的字面量 a 没有字符可用时,可以退回一个,于是整串匹配成功。a++ 吃完后不退让,最后的 a 得不到字符,匹配失败。输入是合法的 aa,只增加一个加号却改变了结果。

因此,使用这类语法之前,需要证明被放弃的回溯不是业务成功所依赖的步骤。它们适合明确知道边界的场合,不应作为所有慢表达式的自动补丁。在当前仅检查全 a 字符串的例子里,直接使用清楚的 a+,已经完成了规则需要的工作。

中止等待与确认不匹配,是两个不同结果

脚本另外尝试了 32 个 a 加一个 ! 的嵌套写法,对 subprocess.run 的通信等待设置 2 秒超时。这次没有得到完成的子进程输出,程序捕获 TimeoutExpired,记录为中止,匹配结果保留为空,而不是伪造一次返回 False 的测量。

这个超时与表格中的匹配计时口径不同。等待包含进程创建后剩余的初始化和匹配调用,进程创建与清理还可能增加总墙钟时间。官方文档说明,run 超时后会杀掉子进程并等待它退出;进程创建本身不能保证被中断。Python:subprocess.run 的超时行为

因此不能把这一项写成“正则恰好耗时两秒”,也不能据此算它最终需要多久。它展示的是本次实验如何限制继续等待,并明确保留未完成状态。这个外部等待限制没有改变模式的复杂度;脚本也没有验证把同样的进程管理方式放进某个服务后的成本。

对应用而言,还应分别记录成功、正常失败和等待超时。把后两者都记为“不合法”,会掩盖程序是否已经完成判断。若文本来自不受控制的输入,长度上限可以限制一次需要处理的材料;但它不能证明规则写法合理,也不能代替失败样本的增长测试。实际阈值要依据允许的文本规模与响应时间决定,本文没有给出可直接用于生产服务的统一阈值。

下载实验,复核自己的失败样本

下载并解压完整实验包,在目录内执行:

python3.12 regex-backtracking-lab.py regex-backtracking-output

它只依赖标准库,需要 Python 3.11 或更新版本,本次用的是 3.12.14。另可下载脚本、运行说明、本次完整结果和原始计时 CSV。CSV 只含完成的条件,超时案例单独记录在 JSON 中;复跑的毫秒值会随环境变化。

应用到自己的模式时,先写出接受规则和捕获输出,再准备正常、空值、换行以及接近成功但在后部失败的样本。逐步增加相关前缀长度,观察失败耗时如何增长。这里只实验了短小合成输入,没有衡量真实文本的分布、并发负载或完整程序延迟。

引擎选择也会改变边界。RE2 官方给出的保证是匹配时间随输入长度线性增长,同时不支持反向引用和环视等构造。这是其设计说明,本文没有安装或对比测试 RE2;要换引擎,还需要核对语法和匹配输出。RE2:时间保证与支持范围

最终,排查这类问题需要同时回答“规则是否正确”和“确认失败要付出多少工作”。成功输入可能很早找到一条路径,失败输入却需要排除很多路径;删除多余的分组重复,可以让本例的规则表达更直接。 原子组、占有量词和外部超时各有作用,使用时仍要核对匹配语义、捕获接口与未完成状态。只检查返回值,不检查失败过程,就会漏掉这次实验中最明显的成本。