量词

光会匹配一个字符不够,还要能描述"出现几次"。量词(quantifier)就是干这个的——放在元素后面,控制它重复多少次。这一章除了四个基本量词,还要重点讲清楚贪婪与懒惰这个新手最大坑,以及生产环境的灾难性回溯陷阱。

四个基本量词

量词放在一个字符或一个分组后面,影响的是它紧邻的前一个元素。比如 ab+ 影响的是 b 不是 ab(要让加号作用于整个 ab 需要圆括号分组,见分组一章)。

// 量词(quantifier):放在元素后面,控制它重复多少次

colou?r     // ? 表示 0 或 1 次:匹配 "color" 或 "colour"
go+gle      // + 表示 1 次或多次:匹配 "gogle" "google" "gooogle"...
ha*ha       // * 表示 0 次或多次:匹配 "hha" "haha" "hahaha"...
\d{3}       // 恰好 3 次数字
\d{2,4}     // 2 到 4 次
\d{3,}      // 至少 3 次(无上限)
\d{0,3}     // 0 到 3 次(等价于 \d? 但更明确)

// 实例 1:中国手机号(简化版)
1[3-9]\d{9}
//   1       第一位必须是 1
//   [3-9]   第二位 3 到 9
//   \d{9}   后面 9 位数字
// 总共 11 位

// 实例 2:6 位邮政编码
/^\d{6}$/.test("100000");    // true

// 实例 3:用户名 4~16 位字母数字下划线
/^\w{4,16}$/.test("hello_01");   // true

三个最常用:问号(可选)、加号(至少一次)、星号(任意次)。星号要慎用——它匹配零次也算成功,容易意外匹配空串。大括号适合精确控制次数,如手机号 11 位、邮编 6 位、密码至少 8 位。

贪婪 vs 懒惰

这是正则最容易出 bug 的地方,也是面试高频题。量词默认是贪婪的——会尽可能多地匹配字符,导致"跨过本应停止的位置"。解决办法是在量词后再加一个问号,改成懒惰(尽量少匹配)。

// 贪婪(greedy)vs 懒惰(lazy)

// 量词默认是"贪婪"的——会尽可能多地匹配字符
/<.+>/.exec("<a><b><c>");
// 返回 ["<a><b><c>"]  (. 太贪了,一路吃到最后的右尖括号)

// 在量词后加 ? 改成"懒惰"——尽量少匹配
/<.+?>/.exec("<a><b><c>");
// 返回 ["<a>"]  (匹配到第一个右尖括号就停)

// 贪婪 → 懒惰对照表:
//   *      →  *?
//   +      →  +?
//   ?      →  ??
//   {n,m}  →  {n,m}?

// 另一种思路:用取反字符类代替通配点号(更安全)
/<[^>]+>/.exec("<a><b><c>");   // ["<a>"]  (不含 > 自然不会跨标签)

更彻底的解决思路是上一章讲过的排斥型字符类:用"非右尖括号的任意字符"代替通配点号,这样根本不可能跨过右尖括号,既安全又快。原则是:能用具体字符类就别用通配点号配合懒惰

为什么默认是贪婪

初学者可能会问:既然贪婪容易出 bug,为什么不改成默认懒惰?这是因为大多数字符串提取场景确实需要"尽可能多"——比如匹配一整段文件路径,你希望吃到行尾而不是只到第一个斜杠。理解这一点有助于你建立对引擎行为的直觉。

// 为什么默认是贪婪?历史和实用性

// 1. 大多数字符串提取场景需要"尽可能多"
//    比如匹配一整段路径,而不是只到第一个斜杠
/^\/.+/.exec("/usr/local/bin/node");   // ["/usr/local/bin/node"]

// 2. 引擎实现上是"回溯"——先贪心吃满,匹配失败再吐回一个
//    所以贪婪模式有时反而比懒惰快(少回溯)

// 但贪婪也带来经典坑:
"\"hello\", \"world\"".match(/\".+\"/);
// 你以为匹配两个独立引号串,实际整段被一个匹配吃掉
// 解决:用懒惰 \".+?\" 或取反 \"[^\"]+\"

引擎实现上是回溯算法:先贪心吃满所有字符,如果后续匹配失败,再吐回一个字符重试,直到成功或全部吐完。所以贪婪模式有时反而比懒惰快(回溯次数少)。但如果模式设计不当,回溯会呈指数爆炸,这就是下一节要讲的灾难。

灾难性回溯

这是生产环境真正的"正则炸弹"。某些嵌套量词的组合会让引擎尝试指数级的分组方式,处理几十个字符的输入就能卡死服务几秒到几分钟。ReDoS(正则拒绝服务)攻击利用的就是这个原理。

// 灾难性回溯(catastrophic backtracking)
// 某些嵌套量词会让引擎呈指数级尝试,瞬间卡死

// 反例:匹配 "a" 重复 n 次后跟结尾
/(a+)+$/.test("a".repeat(25) + "b");
// 25 个 a 加一个 b → 引擎尝试 2^25 种分组方式,卡死几秒到几分钟

// 常见触发模式:
//   (a+)+       嵌套量词
//   (a*)*       嵌套量词
//   (.+a)+      末尾加上不可能的字符

// 防范方法:
// 1. 避免嵌套量词,改用原子组或占有量词
//    (?>a+)     原子组(PCRE,JS 暂不支持)
//    a++        占有量词(PCRE/Java,JS 暂不支持)
// 2. 用具体的取反字符类代替通配点号
//    /"[^"]+"/  比 /".+"/  安全得多
// 3. 给正则引擎设超时(部分语言支持)
//    PHP: preg_match($re, $s) 前设 pcre.backtrack_limit
// 4. 上线前用大输入测试,用 regex101 看回溯步骤数

小结

下一章讲锚点——让正则"定位"到字符串开头、结尾、单词边界,而不是在任意位置都匹配。

← 上一篇 字符类

下一篇 锚点与单词边界

✈️💬