前一段时间,在将多正则表达式匹配工具用于数十万任意的正则表达式时,以前一直担心的问题终于出现了:NFA 转化 DFA 时的指数爆炸,那样的 DFA 根本创建不出来,因为那些正则表达式之间有不可预料的各种交集!
这个问题对我打击很大,我甚至顿时觉得多正则表达式匹配工具完全是个废柴,最多,是个玩具!但是,只有挑战,才能激励人的斗志,挖掘人的潜能。我想起了曾经对之不屑一顾的动态 DFA 匹配算法,之前我在研究 RE2 时知道,RE2 在动态 DFA 的内存用量达到限定时,会抛弃已经创建整个动态 DFA,因为 DFA 的状态图比较复杂,节点之间互相引用,无法象普通的 Cache 一样部分的进行 Swap ,直觉上对它就没有好印象。
但是碰到组合爆炸这个魔咒,只有尝试一下,就先用 RE2 中的 set 试一下,先从那数十万正则表达式中任选一万个,re2 执行倒是很快,但立马就提示 DFA Out Of Memory 了,然后我尝试将 re2 的内存设到 8G,还是不行,浪费若干脑细胞,最后才惊奇地发现,Re2 的内存限制用的是 int 来表示的,而 int 的事实标准是32 bit,最大只能是 2G-1!设成 2G-1之后,re2 运行正常,性能还不错。
于是我开始加码,将所有正则表达式都扔进去,果然,re2 崩了!
虽然如此,但是至少表明,动态 DFA 解决该问题是可行的,我开始实现我的计划。一切都很顺利:
之前我实现其他算法时(r1303),扩展了自动机的字符集,而没有在自动机的状态上打专用 Tag,这个决定真是明智之举,在这里又用上了:
ch=256 | 表示括号捕获 | 用于我最早实现 One Pass 正则表达式的 DFA 算法 |
ch=257 | 表示全匹配 | 之前在一遍扫描中捕获所有括号时,发现因括号位置太靠前而导致严重的性能问题, 就实现了两遍扫描捕获括号,第一遍只看全匹配,获得正则表达式ID, 第二遍专门针对具体正则ID捕获括号,性能要好的多 |
ch=258 | 表示伪Epsilon转移 | 这是实现动态 DFA 匹配的一个根基,不过这个伪 Epsilon 转移只起到一个链表的作用,将所有子 DFA 的根(初始状态)链起来 |
如果一切都照抄 re2 的实现,那不过是无聊的体力劳动。我的动态 DFA 实现,完全建立在我之前的各种自动机算法之上,有了这个根基,我的实现相比 re2,有很多优势:
- 系统:如同一套数学理论,有定义、公理、定理,任何新的推论,都建立在之前已经确立的坚实的基础之上
- 简单:因为已经有了坚实的基础,并不需要引入多少全新的东西,就能解决新的问题
- 可靠:不需要验证已有的代码,只要证明新加入的代码是正确的,整个系统就是正确可靠的
- 高效:已有的代码已足够高效,只要新的代码没有冗余计算,最终的算法必然是高效的
- 一个基于已有 DenseDFA 的 DynDFA 基础类
- 用于 subset 查找的专用 hash table 实现,包括 find,insert,hash resize,hash function,hash equal,hash cache,...
- 动态 DFA 的 warm up ,将靠近完整 DFA 的根(初始状态) 附近的状态构造出来作为热点,re2 中则完全没有考虑 warm up
- 动态 DFA 内存超限时,只抛弃不在 warm up 集合的那些状态,而不像 re2 一样抛弃整个动态 DFA
- 动态 DFA 的基础 NFA 只是简单地用 ch=258 将所有子 DFA 并到一起,而且每个子 DFA 都是最小化的,同时所有的子 DFA 共享了相同的尾
- 动态 DFA 直接引用作为捕获括号(submatch) 的子 DFA