量词与贪婪:* + ? {n,m},贪婪 vs 懒惰 vs 占有

六个量词撑起半个正则

第一章见过 * + ? 三个"老量词",它们只表达了"没上限"和"至多一次"。补上区间写法 {n,m},量词家族就齐了:

写法 次数含义 典型场景
* 0 次或更多 可选前缀、任意空白
+ 1 次或更多 至少一位数字
? 0 或 1 次 可选的协议头 https?
{n} 恰好 n 次 固定位数校验码
{n,} 至少 n 次 {4,} 至少四位
{n,m} n 到 m 次 {8,16} 长度限界

区间量词是"精确控制"的开始:手机号 \d{11}、快递单号 [A-Z]{2}\d{11}、密码长度 .{8,20}——凡是有格式规范的字段,都值得用区间把长度钉死。

import re
# 提取订单号:字母前缀 2 位 + 数字 10~12 位
text = "DD2024102600 与 DD771(缺位)与 DD20241026118833(超长 14 位)"
print(re.findall(r"\b[A-Z]{2}\d{10,12}\b", text))
# 输出:['DD2024102600']

第二个缺位、第三个超长,都被区间与边界联手排除——12 位上限之后没有单词边界,引擎宁可整体不匹配也不会截一段充数。

贪婪:默认的"越多越好"

量词默认是贪婪(greedy)的:在能匹配成功的前提下,尽可能多吃字符。看一个删除 HTML 标签的经典场景:

import re
html = "<p>第一段</p><p>第二段</p>"
print(re.findall(r"<.+>", html))
# 输出:['<p>第一段</p><p>第二段</p>']

<.+> 一口气从第一个 < 吃到行尾最后一个 >,把两对标签并成一团——这不是 bug,是贪婪的本来面目。引擎的规则是:先吃到底,匹配失败才回吐(回吐机制即"回溯",第二章详解)。

懒惰:加一个问号反转性格

在量词后面追加 ?,得到懒惰(lazy)版本:*?、+?、??、{n,m}?。懒惰量词采取"能少就少,不够再添"策略:

import re
html = "<p>第一段</p><p>第二段</p>"
print(re.findall(r"<.+?>", html))
# 输出:['<p>', '</p>', '<p>', '</p>']

同一个正则只差一个 ?,结果从一整团变成四个独立标签。更专业的写法是 [^>]+(匹配到第一个 > 就停,根本不需要回溯),既准确又更快——这也是后文性能章节的主旋律:懒惰优于贪婪,排除法优于懒惰。

输入串 <.+> 的结果 <.+?> 的结果 <[^>]+> 的结果
<b>加粗</b> <b>加粗</b> 整段 <b>、</b> <b>、</b>
<img src="a.png"> 整个标签 整个标签 整个标签

经验之谈:把三种风格当成三级火箭——先用贪婪发现问题,再用懒惰止血,最后用排除字符组根治。<.+?> 和 <[^>]+> 在这个例子里结果一致,但后者没有回溯成本,在长文本上性能差距会放大几百倍。

占有量词:吃下就不吐

第三种性格叫占有(possessive),写法是在量词后再加 +:*+、++、?+、{n,m}+。它像贪婪一样先吃满,但绝不回吐——匹配失败就直接判死,不做任何让步。这个特性只有部分引擎支持:Java、PCRE 支持,Python 3.11 起支持(如 a++),而 ECMAScript 标准目前不支持占有量词,具体支持情况以各语言官方文档为准。

import re
# 贪婪版会让步:\d+ 吐出一位数字,让后面的 \d 也有得吃
print(re.findall(r"\d+\d", "123"))
# 输出:['123']
# 占有版绝不回吐:\d++ 吃光数字后,第二个 \d 无字可拿,整体失败
print(re.findall(r"\d++\d", "123"))
# 输出:[]

贪婪版 \d+\d 为了凑齐第二个 \d,让前者吐出一个 3;占有版 \d++\d 吃下 123 后一口不吐,发现后面的 \d 落空便直接判死。

这个"看似变笨"的特性恰恰是性能武器:当你确定"某个字符类后面绝不可能需要让位"时,占有量词能直接掐断灾难性回溯(详见 re-perf 一章)。

量词的边界感:可空量词的陷阱

* 和 ? 允许零次出现,这意味着它们可以"匹配空"。把可空量词放进宽松模式要格外当心:

import re
# 想匹配「姓名:张三」或单独的「张三」
print(re.findall(r"姓名:?([一-龥]{2,4})", "姓名:张三 / 张三 / 姓名李四"))
# 输出:['张三', '张三', '李四']

:? 表示冒号可有可无,于是两种格式都收进了网里。但反过来,.* 这种"任意内容可空"的组合几乎从不该出现在生产正则里——它边界感全无,碰上多行文本或包含 xxx 的恶意输入还会引爆回溯灾难。写每一条量词前先问自己:它的下限是几?遇到不匹配的文本它会退到哪? 想清楚这两个问题,量词才真正属于你。

量词的三版演化:同一个需求的打磨过程

需求:从一段 HTML 里提取加粗文本。看看量词选择如何让同一条正则从"能用"进化到"好用":

import re
html = "<b>第一</b><b>第二</b>"
print(re.findall(r"<b>(.*)</b>", html))    # v1 贪婪:吞成一团
print(re.findall(r"<b>(.*?)</b>", html))   # v2 懒惰:逐个命中
print(re.findall(r"<b>([^<]*)</b>", html)) # v3 排除法:见 < 就停
# 输出:['第一</b><b>第二']
# 输出:['第一', '第二']
# 输出:['第一', '第二']

v1 错、v2 对、v3 对且更快。注意 v3 的分组内是 [^<]*——"加粗文本里不可能有左尖括号"这个领域知识,被直接编译进了正则。把你对数据的了解写进字符组,引擎就不必靠回溯试错。这条演化路径(贪婪 → 懒惰 → 排除法)值得在任何匹配需求上走一遍,它同时也是一次小型的性能优化。

实战对比:贪婪/懒惰在 HTML 提取上的实际差异

量词性格的选择不是理论题,下面这个真实场景里,两种写法的产出完全不同。需求:从一页 HTML 里提取所有链接的 href 值。

import re
html = '<a href="/a">A</a> 和 <a href="/b">B</a>'
print(re.findall(r'<a href="(.*)">', html))
# 输出:['/a">A</a> 和 <a href="/b']
print(re.findall(r'<a href="(.*?)">', html))
# 输出:['/a', '/b']

逐位推演贪婪版的行进路线:.* 从第一个引号后出发,一路吃到全文最后一个引号(能成功的前提下尽量多吃),然后 "> 回头找落点——落在第二个标签的 "> 上,于是第一个链接、中间的正文、第二个链接的前半全被装进了一个"捕获值"。懒惰版在每个引号处见好就收,两个值干干净净。

再换一个更危险的场景——删除 <script> 块:

import re
s = "<script>x</script>正文<script>y</script>"
print(re.sub(r"<script>.*</script>", "[脚本]", s, flags=re.S))
# 输出:[脚本]           ← "正文"被连坐删掉了
print(re.sub(r"<script>.*?</script>", "[脚本]", s, flags=re.S))
# 输出:[脚本]正文[脚本]

贪婪版的删除范围从第一个 <script> 横跨到最后一个 </script>,夹在中间的正文无辜陪葬。这类 bug 在"页面里恰好只有一个 script 块"的测试样本上永远测不出来,上线遇到多个块才爆发——贪婪/懒惰的差异属于"样本量一到就现形"的潜伏型 bug,开发时就要用多目标的样本去测。结论照旧:逐项提取用懒惰,能写排除字符组([^"]*、(?:(?!</script>).)* 的更优替代是 <script>[^<]* 场景化收窄)就不要偷懒用点号。

三个高频疑问

问:{n,m} 超出上限会怎样? 引擎匹配满 m 个就停,多余的部分留给后续模式或直接导致失配——上一章订单号示例里"14 位超长单号整体不匹配"就是这个机制。区间上限是硬约束,不是截断建议。

问:懒惰量词会不会更慢? 通常略慢(多次"试探-扩张"),但差距通常在常数级;真正的性能杀手是嵌套量词与 .*,而不是 +?。别为了性能牺牲正确性,该懒惰就懒惰。

问:x? 和 x{0,1} 一样吗? 完全一样,? 就是 {0,1} 的缩写。同理 * 等价 {0,}、+ 等价 {1,}。读到别人的正则里出现 x{0,1},不必怀疑有深意——多半是作者从工具生成的表达式里抄来的。

理解了这三问,你会发现量词的设计空间其实很收敛:先确定"至少几个",再确定"至多几个",最后决定"不够时让不让步"。三个决定三个选项,就是你与量词的全部关系。

参考资源

本章由 WorkBuddy 原创撰写,示例均经实际验证。