JavaScript 正则的贪婪、惰性和回溯怎么理解?
简化版
正则默认是贪婪匹配,会尽可能多地匹配;在量词后加 ? 会变成惰性匹配,尽可能少地匹配。回溯是正则引擎为了让整体模式匹配成功,尝试退回前面的匹配结果重新分配字符。
复杂正则如果写得不好,可能触发大量回溯,造成性能问题。要尽量明确边界、减少模糊重复和嵌套量词。
详细版
贪婪匹配:
"<p>a</p><p>b</p>".match(/<p>.*<\/p>/)[0];
// "<p>a</p><p>b</p>"
惰性匹配:
"<p>a</p><p>b</p>".match(/<p>.*?<\/p>/)[0];
// "<p>a</p>"
回溯例子:
/a.*b/.test("axxxb");
.* 会先吃掉尽可能多字符,发现末尾还要匹配 b,就向后退,让 b 有机会匹配成功。
性能风险常见于嵌套量词:
/^(a+)+$/.test("aaaaaaaaaaaaaaaa!");
这种模式可能产生大量组合尝试,应避免用于不可信输入。
完整版教学
一、正则匹配不是简单从左到右扫一遍
JavaScript 常见正则引擎会按模式从左到右尝试匹配,但遇到量词、分支、捕获组时,会保存一些可回退的选择点。
匹配失败时,引擎会回到选择点尝试另一种分配方式,这就是回溯。
正则回溯题的核心不是背术语,而是知道“引擎会尝试多种字符分配方案”。
二、贪婪量词默认尽可能多
*、+、?、{m,n} 默认都是贪婪的。
const html = "<strong>A</strong><strong>B</strong>";
console.log(html.match(/<strong>.*<\/strong>/)[0]);
结果会跨过第一个 </strong>,一直吃到最后一个 </strong>。
| 量词 | 含义 |
|---|---|
* | 0 次或多次 |
+ | 1 次或多次 |
? | 0 次或 1 次 |
{2,5} | 2 到 5 次 |
三、惰性量词尽可能少
在量词后面加 ?,会变成惰性。
const html = "<strong>A</strong><strong>B</strong>";
console.log(html.match(/<strong>.*?<\/strong>/)[0]);
它会在第一个能让整体模式成功的位置停下来。
惰性不是“不回溯”,它只是优先选择更短匹配;如果整体不成功,仍然会继续扩展尝试。
四、回溯如何发生
看这个模式:
/ab*bc/.test("abbbc");
过程可以理解为:
a matches a
b* eats bbb
next b fails at c
b* gives back one b
b matches b
c matches c
b* 先贪婪,再为了后续 bc 成功让出字符。
五、灾难性回溯的来源
灾难性回溯常来自“嵌套重复 + 模糊边界”。
const re = /^(a+)+$/;
console.log(re.test("aaaaaaaaaaaaaaaa!"));
多个 a+ 可以用很多种方式分配同一串 a。当最后的 ! 让整体失败时,引擎会尝试大量组合。
16 个 a 可能不是只尝试 16 次,而是接近指数级增长。
六、如何写更稳的正则
优化方向:
- 用明确字符集替代
.* - 给重复段加清晰边界
- 避免嵌套量词
- 对用户输入限制长度
- 能用解析器时不要用复杂正则解析复杂语言
const safe = /^a+$/;
如果要匹配标签内容,[^<]* 通常比 .*? 更明确。
/<p>[^<]*<\/p>/
七、常见误区与追问
- 误区:惰性匹配一定更快。 惰性只是优先短匹配,仍可能回溯。
- 误区:
.*总能安全匹配任意内容。 它边界模糊,容易跨越过多内容。 - 误区:回溯一定是坏事。 回溯是正则能力的一部分,问题在于组合爆炸。
- 误区:正则适合解析所有 HTML。 HTML 有嵌套和容错规则,复杂场景应使用解析器。
- 追问:灾难性回溯为什么危险? 它可能让一次匹配占满主线程,形成 ReDoS 风险。
- 追问:如何排查正则性能问题? 缩短输入、拆分模式、检查嵌套量词和模糊分支。
八、加强记忆
记三句话:
greedy: eat more first
lazy: eat less first
backtracking: try another split
面试回答时先用 <p>.*</p> 和 <p>.*?</p> 对比贪婪惰性,再用嵌套量词解释回溯风险,最后给出明确边界、限制输入、避免复杂正则解析复杂结构这些工程建议。