1. 什么是量词(Quantifiers)
量词用于表示"重复匹配",即说明某个子模式可以出现多少次。
| 量词 | 含义 |
|---|---|
* |
重复 0 次或多次 |
+ |
重复 1 次或多次 |
? |
重复 0 或 1 次 |
{n} |
重复 n 次 |
{n,} |
重复至少 n 次 |
{n,m} |
重复 n 到 m 次 |
默认行为:贪婪(greedy) ------ 尽可能多地匹配,然后再回溯以满足后续模式。
2. 贪婪 vs 惰性(greedy vs lazy)
r
# 载入示例所用包(以下所有示例均基于 stringr)
library(stringr)
2.1 贪婪(greedy)
贪婪量词会尽可能多地匹配目标,然后在必要时回溯以配合后续模式(先往后找,到头了再往回找适配的结尾)。
r
str_extract("a123bxxx", "a.*b")
# [1] "a123b"
解释:.* 先尽量吃到末端,发现后面的 b 无法匹配时开始回溯,直到能匹配到最后一个 b 为止。
2.2 惰性(lazy / non-greedy)
在量词后加 ? 可将其改为惰性:尽可能少匹配,再在需要时扩展。
常见对应关系:
| 贪婪 | 惰性 |
|---|---|
* |
*? |
+ |
+? |
? |
?? |
{n,m} |
{n,m}? |
{n,} |
{n,}? |
示例:
r
str_extract("a123bxxx", "a.*?b")
# [1] "a123b"
惰性量词在遇到第一个满足条件的位置就停止,适用于"逐个捕获成对结构"的场景(例如 HTML 标签对)。
2.3 R 示例:贪婪 vs 惰性匹配的多例子演示
下面的示例均使用 stringr,并给出解释以便理解何时使用哪种量词。
r
library(stringr)
示例 1 --- 多个结束符时的区别(单行)
r
text <- "a1b2b3b"
str_extract(text, "a.*b") # 贪婪
# [1] "a1b2b3b"
str_extract(text, "a.*?b") # 惰性
# [1] "a1b"
解释:贪婪 .* 会从第一个 a 吃到最后一个 b;惰性 .*? 在第一个满足右边 b 的位置停止。
示例 2 --- HTML 标签:贪婪把多个标签吞掉,惰性逐个匹配
r
text <- "<p>a</p><p>b</p>"
str_extract(text, "<p>.*</p>") # 贪婪:会把两个标签视为一个整体
# [1] "<p>a</p><p>b</p>"
str_extract_all(text, "<p>.*?</p>") # 惰性:逐个匹配每个 <p>...</p>
# [[1]]
# [1] "<p>a</p>" "<p>b</p>"
解释:在重复结构中(如 HTML),惰性量词更适合把每个标签单独捕获。
示例 3 --- 多次出现时用 str_extract_all 对比(贪婪只返回一次大块)
r
text <- "tag1 [A] tag2 [B] tag3 [C]"
str_extract(text, "\\[.*\\]") # 贪婪 -> 从第一个 [ 到最后一个 ]
# [1] "[A] tag2 [B] tag3 [C]"
str_extract_all(text, "\\[.*?\\]") # 惰性 -> 每个 [] 单独匹配
# [[1]]
# [1] "[A]" "[B]" "[C]"
解释:当文本中有多个"成对分隔符"时,惰性能把每对独立抓出;贪婪会把中间部分也吞掉形成一整段。
3. 常见惰性量词一览
| 贪婪 | 惰性 | 行为说明 |
|---|---|---|
* |
*? |
尽可能少重复 |
+ |
+? |
至少 1 次,但尽可能少 |
? |
?? |
尽可能不重复 |
{n,m} |
{n,m}? |
匹配 n 到 m 中最小的可能数 |
{n,} |
{n,}? |
至少 n 次,但尽可能少 |
4. 为什么会卡死?(回溯炸弹)
某些正则写法会引发指数级回溯,导致匹配非常慢或看似"卡死"。典型危险写法示例:
r
library(stringr)
text1 <- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab"
text2 <- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
# 危险写法(极易触发大量回溯)
pattern_danger <- "(a+)+$"
# 可能在某些输入上非常慢或耗尽资源
str_extract(text1, pattern_danger) # 不建议在生产/大数据上运行
str_extract(text2, pattern_danger) # 不建议在生产/大数据上运行
原因:嵌套量词(如 (a+)+)会让引擎尝试大量不同的分割和回溯路径,组合数量呈指数增长。
更安全的替代写法:
r
# 如果目标只是全为 a 的串(且允许任意长度)
str_extract(text1, "^a+$")
str_extract(text2, "^a+$")
# 或者匹配多个 a 后跟 b 的情况
str_extract("aaaaab", "^a+b$")
这些写法将匹配逻辑简化为线性扫描,避免大量回溯。
5. 无回溯安全(linear-time)策略
下面以一个 text 向量为例,说明常见的安全做法与示例(所有代码均为 R):
r
library(stringr)
text <- c(
"This is a short start ... end example.",
"This one contains start and then a very long middle ...................... end",
"start\nmultiline\nend" # 第三个元素包含换行,以下示例中会根据模式返回结果或 NA
)
5.1 策略 A --- 明确字符类或限制长度
避免使用 .* 这类无限制匹配,改用排除换行或限制匹配长度的字符类:
r
# 不推荐(可能导致回溯)
str_extract(text, "start.*end")
# 推荐:限制不跨行并限定最多 200 字符
str_extract(text, "start[^\\n]{0,200}end")
说明:start[^\\n]{0,200}end 在匹配时不会跨行,且长度被上限约束,从而将复杂度控制在线性范围内。
5.2 策略 B --- 使用惰性量词减少过度扩展
在合适场景下,惰性量词通常比贪婪更节省回溯成本:
r
str_extract(text, "start.*?end")
说明:在"左边固定 / 右边标记明确"的结构中,惰性量词能显著降低回溯量。
5.3 策略 C --- 占有量词与原子组(ICU 特性)
stringr 基于 ICU,支持占有量词(++, *+, ?+)与原子组 (?>...),它们能阻断回溯,从而避免回溯炸弹。
r
# 占有量词示例:一次性吃掉所有非 > 字符,不允许回溯
str_extract("<p>a</p><p>b</p>", "<[^>]++>")
# 原子组示例:阻断回溯
str_extract("aaaaab", "^(?>a+)+b")
注意:占有量词和原子组会改变回溯行为,可能使某些在贪婪引擎下可匹配的字符串"现在匹配不到",因此在使用前务必测试样本集。
5.4 策略 D --- 避免嵌套量词 / 采用更明确的逻辑
危险写法(示例,不建议使用):
r
str_extract("aaaaaab", "(a+)+b")
安全改写建议:
r
str_extract("aaaaaab", "^a+b$")
或将复杂匹配拆分为多个步骤(先检验结构,再抽取内容),以降低回溯复杂度。
5.5 策略 E --- 使用断言把搜索范围缩窄
将范围限制交给零宽断言可以减少主表达式需要检查的位置,从而降低回溯发生机会:
r
# 先断言右边在 0--200 字符以内出现 end,再进行实际匹配
str_extract(text, "(?=.{0,200}end)start.*end")
说明:断言在不消费字符的情况下先做可行性检验,可使后续主模式的扫描更有指向性和更高效。
6. 小结(要点回顾)
- 贪婪 = 尽可能多吃再回溯;惰性 = 尽可能少吃再扩展。
- 高风险来源: 嵌套量词(如
(a+)+)、模糊匹配(.*、.+或复杂 alternation)。 - 避免回溯炸弹的核心策略:
- 限制匹配长度或使用明确字符类(如
[^\\n]{0,200})。 - 在适当场景使用惰性量词(
*?,+?等)。 - 使用占有量词(
++,*+)或原子组(?>...)(ICU 支持)阻断回溯。 - 避免嵌套量词,或将复杂表达式拆解为简单步骤。
- 使用零宽断言(lookahead / lookbehind)在不消费字符的情况下先缩小搜索范围。
- 限制匹配长度或使用明确字符类(如