CASE-008
Grep & Co. 数据侦探事务所
委托书
委托人
事务所门口(寄件人:回溯者)
证物
一个黑色信封。卡片上写着一行正则和一个问题。
破案条件
查明卡片上的模式为什么慢,再用一个能找到同样字符串、却能立刻结束的模式回信。
早上,事务所的门缝里夹着一个黑色信封,里面是一张卡片。所长把卡片对着灯光看了看,放回桌上。“总算来打招呼了。不露脸,只留签名。”
^(a|aa)+$
이 패턴은 정답을 맞힌다. 그런데 왜 이렇게 느린가?
답은 패턴으로 보내라.
(a+)+$卡片上的韩文写着:“这个模式能给出正确答案。可它为什么这么慢?用模式来回答。”右下角是那个熟悉的签名。
“得知道正则引擎是怎么思考的,才解得开这道题。”所长说,“今天我们去看看引擎的脑子里在想什么。”
正则引擎从左边开始,把模式一块一块地往字符串上套。套不上时它不会马上放弃,而是撤销刚才做的一个选择,换一条路再试。这就叫回溯(backtracking)。
第 3 课说过,量词默认会“尽可能多吃”(贪婪)。我们用从 HTML 里取标签的例子来看看结果。
html = "<b>bold</b> and <i>it</i>"
re.findall(r"<.+>", html) # ['<b>bold</b> and <i>it</i>']抓到的不是四个标签,而是整整一行。跟着引擎走一遍,过程是这样的。
< 对上了开头的 <。.+ 是贪婪的,一口气吃到行尾。>,可是已经没有字符了。失败。.+ 把吃掉的字符一个一个吐出来,再重新尝试。吐回最后那个 > 的瞬间,成功。结果就是“从最外面的 < 到最后一个 >”。错的不是引擎,而是模式。
证据分析室
re.search(r"", text)
返回值
在量词后面加上 ?,就成了尽可能少吃的懒惰(lazy)量词。它先只吃最少的量,只有后面对不上时才多吃一个字符。
| 贪婪 | 懒惰 | 先尝试的次数 |
|---|---|---|
* | *? | 0 次 |
+ | +? | 1 次 |
? | ?? | 0 次 |
{m,n} | {m,n}? | m 次 |
re.findall(r"<.+?>", html) # ['<b>', '</b>', '<i>', '</i>']
re.findall(r"a+?", "aaa") # ['a', 'a', 'a']
re.findall(r"a??", "a") # ['', 'a', '']
re.search(r"\d{2,4}?", "12345").group() # '12'懒惰量词改变的是“在哪里停下”。所以像标签这种结束标记很明确的东西,用它来抓短的一段很方便。
把取标签的几种写法摆在一起看看。
| 模式 | 结果 | 一句话点评 |
|---|---|---|
<.+> | 整行一块 | 太贪婪,吃得太多 |
<.+?> | 四个标签 | 懒洋洋地及时停下 |
<[^>]+> | 四个标签 | 根本吃不了 > |
<[^>]+> 用的是第 2 课的否定字符类,只吃“不是 > 的字符”。几乎没有需要回退的地方,所以最快,意思也最清楚。所长是这么说的:“与其偷懒,不如精确。”
多特的提醒
懒惰量词可不是魔法哦。它只改变匹配在哪里结束,匹配失败时引擎回退的次数一点也没少。把一个慢模式改成懒惰的,它照样慢。
现在来看卡片上的模式 ^(a|aa)+$。它寻找“由一个以上连续的 a 组成的整个字符串”,意思和 ^a+$ 一样。
bool(re.fullmatch(r"^(a|aa)+$", "aaaa")) # True
bool(re.fullmatch(r"^a+$", "aaaa")) # True问题出在遇到对不上的字符串时。像 aaaa…ab 这样末尾多了个 b,引擎要把这些 a 拆成 a 和 aa 的所有拆法挨个试一遍,才肯承认失败。而拆法的数量会随着 a 的个数增加而爆炸式增长。
下面是在事务所的电脑(Python 3.14)上实测的时间。
| a 的个数 | ^(a|aa)+$ | ^a+$ |
|---|---|---|
| 16 | 0.25ms | 不到 0.04ms |
| 20 | 1.1ms | 不到 0.01ms |
| 24 | 7.2ms | 不到 0.01ms |
| 28 | 51ms | 不到 0.01ms |
a 每多 4 个,就慢上大约 7 倍。照这个比例,到 40 个时就远远超过 10 秒。像这样带有重叠选项的重复,就是让回溯爆炸的典型形状。(a+)+ 这种重复里套重复的写法也属于同一类。
证据分析室
re.search(r"", text)
返回值
从 Python 3.11 开始,在量词后面加 + 就成了占有(possessive)量词。吃进去的字符,哪怕后面失败了也不会吐出来。等于把回退的路直接拆掉。
re.fullmatch(r"a*a", "aaa") # <re.Match object; span=(0, 3), match='aaa'>
re.fullmatch(r"a*+a", "aaa") # Nonea*+a 不会匹配任何字符串。a*+ 把 a 全部拿走不还,后面的 a 就没有字符可对了。反过来,正是靠这个性质,卡片上的模式也能修好。
re.search(r"^(?:a|aa)++$", "a" * 28 + "b") # None(立刻结束)++ 让分组的重复变成占有的,失败时不再尝试别的拆法,直接放弃。
原子分组(atomic group)(?>...) 也是从 3.11 开始可用的。分组里面一旦匹配成功,结果就被整体锁定,不会再退回组内。占有量词其实就是原子分组的简写,a++ 和 (?>a+) 是一回事。
re.fullmatch(r"(?>a+)b", "aab") # <re.Match object; span=(0, 3), match='aab'>
re.fullmatch(r"(?>a+)a", "aaa") # None和所长一起整理回信。修好慢模式的办法有三种。
^a+$ 代替 ^(a|aa)+$。^(?:a|aa)++$ 或原子分组 ^(?>(?:a|aa)+)$。regex 有 timeout 参数。)import time
text = "a" * 40 + "b"
start = time.perf_counter()
print(re.search(r"^a+$", text)) # None
print(time.perf_counter() - start < 0.01) # True新人侦探在卡片背面写下 ^a+$,装进信封。所长点了点头。“短、好读、快。好回信。”
结案报告 — 确认挑战书上的模式 ^(a|aa)+$ 因为选项重叠,失败时耗时呈指数级增长。以意思相同的 ^a+$ 回信。决定保留分析室的时间限制装置。
re.findall(r'".+"', 'say "hi" and "yo"') 的结果,再用两种方法(懒惰量词、否定字符类)改成一次只抓一对引号。(\d+)+$ 为什么是危险的形状,并写出一个意思相同的安全模式。findall 在 "aaaaa" 上分别跑 a{2,4}? 和 a{2,4}+,各会得到什么?去分析室确认一下。回溯者的踪迹
(a+)+$
回信收到了。下一题是你们事务所的保险箱。密码规则有三条,顺序不限——前提是你们能在同一个位置把它们全部确认。
所长的笔记本
引擎从左往右尝试,走不通就撤销刚才的选择(回溯)。
* + ? {m,n} 是贪婪的。后面加 ? 就变懒惰:*? +? ?? {m,n}?
像标签这样结尾明确的,用 <[^>]+> 这种否定字符类最清楚也最快。
(a|aa)+ 或 (a+)+ 这种重叠的重复,失败时耗时会爆炸。改成懒惰的也没用。
占有量词 *+ ++ ?+ {m,n}+ 和原子分组 (?>...) 吃了不吐(3.11+)。a++ 就是 (?>a+)。
别人发来的模式:限制输入长度,并在有时间限制的地方运行。
订阅新课时和新课程通知
本课程新增课时或推出后续课程时,我们会发邮件通知你。
问答
关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。