🦁 The quadratic problem nobody fixed
正则表达式
性能优化
算法
编译器
核心问题
搜索一个文档需要一个小时。搜索一个大100倍的文档不是需要100小时——而是接近3个小时。每个正则表达式引擎,从1970年代起就存在这个问题,至今无人解决。
关键发现:所有标榜线性时间匹配的引擎(RE2、Go regexp、rust regex、.NET NonBacktracking),在调用 find_iter/FindAll 时保证立即失效。最坏情况复杂度是 O(m × n²)
问题根源
以模式 .*a|b 和 n 个 'b' 的输入为例:
在每个位置,引擎先尝试 .*a - 扫描整个剩余输入寻找 'a',找不到,失败
然后 b 分支匹配单个字符
前进一个位置,重复
总共 n + (n-1) + (n-2) + ... = O(n²) 工作
教科书式的三角和。
学术界的盲点
几乎所有正则论文都只关注单匹配问题,然后用"只需迭代"一笔带过。理论把正则简化成"匹配或不匹配"这个二元问题——这适合证明定理,但丢弃了实践中几乎所有重要信息:
- 匹配在哪里?
- 匹配多长?
- 有多少匹配?
一旦简化,正则的"全部匹配"问题就消失了。
历史解决方案
Aho-Corasick (1975)
为固定字符串解决了这个问题。在一次 O(n) 扫描中找到多个固定字符串的所有匹配。但它只适用于字面字符串列表,不支持正则。
Hyperscan/Vectorscan
真正的线性时间全匹配引擎。使用"最早匹配"语义——一进入匹配状态就报告,而非继续找最长匹配。这改变了结果。
RE# 的两遍解决方案
RE# 是第一个能在两遍扫描中找到所有匹配的引擎,无论模式和输入如何,且不改变语义:
- 反向 DFA 标记所有可能匹配开始的位置
- 正向 DFA 在每个标记位置解析最长匹配
一个匹配或一万个匹配,都是同样的两遍扫描。
Hardened 模式
可选模式,保证线性时间,即使在对抗性输入上:
| 输入大小 | 正常模式 | Hardened | 加速比 |
|---|---|---|---|
| 1,000 | 0.7ms | 28μs | 25x |
| 10,000 | 73ms | 303μs | 241x |
| 50,000 | 1.8s | 1.6ms | 1,125x |
性能对比
2663个单词的字典作为模式,匹配 ~900KB 英文散文:
| 引擎 | 吞吐量 |
|---|---|
| RE# | 627 MiB/s |
| RE# (hardened) | 163 MiB/s (3.85x) |
| rust/regex | 535 MiB/s (1.17x) |
| Aho-Corasick | 155 MiB/s (4.05x) |
结论
"一旦把正则简化为'匹配或不匹配',全部匹配问题就消失了——被塞进一个与实际使用方式几乎无关的框架中。"
RE# 证明了这个问题可以被解决——但需要全新的算法思路。