leetcode1967 村长云服务器 IV:当暴力美学遇上AC自动机

💻村长云服务器 IV:当暴力美学遇上AC自动机!

村长抱着那台熟悉的二手服务器冲进院子,机箱上的标签从’村长云服务器·公测版’ 又升级成了’‘村长云服务器·旗舰版’’——手写字歪歪扭扭,一看就是老头自己拿马克笔描的。风扇还是那个风扇,嗡嗡声堪比直升机,升了版本号没升硬件,典型的“软件升级,硬件抗压”。

“小白!老板娘说抽奖系统现在流量暴涨,用户发的弹幕里老有人刷敏感词!什么’代抽’、‘外挂’、‘假一赔十’——把评论区搞得乌烟瘴气!”村长把显示器往桌上一搁,屏幕上滚动着一条超长弹幕文本,旁边密密麻麻列着一百多个敏感词,从"代抽"到"假货",从"退款"到"差评",琳琅满目,活像一本黑话词典。

“老板娘要我上线一个‘敏感词拦截系统’,统计文本里到底命中了多少个敏感词!” 村长咽了口唾沫:“她还说了,如果今天搞不定,就把我的服务器拿去垫桌角!”

我瞥了眼数据范围:word 长度 100,patterns 数量 100,每个模式串长度不超过 100——这任务倒是不难。


暴力一行流

“这不就是找子串吗?”我嘴角一勾,“Python 里有 in 运算符,一行流搞定!看我的 Pythonic 大法——”

def numOfStrings(patterns: List[str], word: str) -> int:
	return sum(p in word for p in patterns)

一气呵成。我得意地晃了晃鼠标,“这就叫站在巨人的肩膀上——哦不,是站在 CPython 的肩膀上!in 运算符底层调用的是 C 实现的字符串查找算法,常数小、效率高,对于这种小规模数据绰绰有余。人生苦短,我用 Python,能一行解决的事,绝不多写第二行!”

话音未落,后脑勺"啪"地挨了轻轻一下——“一行流能 AC,是因为评测机今天心情好,数据没上强度。”

老勇者不知何时已站在我身后,手眼神锐利如刀:“如果 patterns 有十万个,word 长度百万,你这 in 每次都要从头扫描 word。数据量一上去,CPython 的肩膀也扛不住。”

我愣了一下:“呃……十万个模式串?百万长度的文本?老板娘的抽奖系统怎么可能会有这么大流量?”

老勇者微微一笑:“年轻人,格局打开。你现在做的小题,是未来大战的预演。听说过多模式匹配吗?那可是搜索引擎、病毒扫描、内容审核系统的核心武器。今天好好学一招,将来遇到千万级数据就不至于被按在地上摩擦。”


多模式匹配

“小子,听好了。”老勇者从我的本子上撕了张纸,拿起笔就开始画,“在一个文本串中同时查找多个模式串——这叫多模式匹配问题(Multi-Pattern Matching)。对于这种问题,有几把经典武器。”

“第一把,暴力遍历。”他指了指屏幕上的 in 操作符,“就是你刚才那行代码。每个模式串独立扫描 word,相当于派一百个侦探,每个人都要把整栋楼从头搜到尾。数据小的时候确实能用,数据一大,就是百人团灭现场。O(m × n),不解释。”

“第二把,KMP 单模式优化。”他在纸上画了一排箭头,“预处理模式串,构建一个部分匹配表,在失配时跳过已经匹配过的前缀,避免重复比较。相当于每个侦探都带着一张‘搜过的地方不用再搜’的线索图。但问题是——你还是要派一百个侦探分别搜一遍楼——KMP 只优化了单个侦探的效率,没减少侦探的数量。有 m 个模式串,就要跑 m 次 KMP。”

“第三把:Trie 树(字典树)。”老勇者在纸上又画了一棵树,节点上标满了字母,“把所有模式串插进一棵树里,共享前缀。比如 ‘abc’ 和 ‘abd’,它们的前缀 ‘ab’ 可以共用,走到分叉路口再各走各的。空间换时间。”

他停顿了一下,用笔重重一点:“但 Trie 有个致命缺陷:当你在某个节点失配时,必须回到根节点重新开始。 比如你匹配了 ‘abcde’,下一个字符不是 ‘f’,你就得从根节点的 ‘a’ 重新来。文本串的指针要回溯,最坏情况依然是 O(n × m)。相当于侦探走到死胡同,必须跑回大楼入口重新找路。纯 Trie 没有记忆,不会跳跃,只能硬着头皮回溯。”

他扫了我一眼,确认我跟上了节奏,然后在纸的正中央写下四个大字,字迹遒劲有力:

“第四把:AC 自动机(Aho-Corasick Automaton)。”

“多模式匹配的终极武器。”老勇者的声音低沉而充满力量,“ Trie 树 + KMP 失败指针思想的融合体——Trie 负责存储所有模式串,KMP 的失配思想负责处理失配时的跳转。所有模式串构建成一棵 Trie,然后加上两个指针——fail 和 last——让文本串在自动机上只跑一趟,就能同时匹配所有模式串。”

他在 Trie 树上画了一堆虚线箭头:“核心在于两个指针:fail 指针(失配指针)last 指针(后缀链接)!”

Fail 指针:失配时的传送门

“核心一:fail 指针。fail 指针指向的是’当前字符串的最长真后缀’,且这个后缀也是某个模式串的前缀。” 老勇者指了指图上的节点,“当你在当前节点失配(下一个字符不匹配)时,就跳到 fail 指向的节点继续尝试匹配,不需要回到根节点重新开始!”

他顿了顿:“比如你匹配了 ‘abcd’,失配了,fail 指针可能指向 ‘bcd’、‘cd’ 或 ’d’ 对应的节点——具体是哪个,取决于哪个后缀同时也是某个模式串的前缀。”

“这和 KMP 的 next 数组异曲同工,但它是跨模式串的——KMP 只在单个模式串内部跳转,AC 自动机的 fail 可以在不同模式串之间跳跃。相当于侦探在迷宫里走到死胡同,不是传送回城,而是通过密道直接跳到另一条分支上。文本串的指针永不回溯——这是 AC 自动机的灵魂。”

Last 指针:直达模式串末尾的捷径

“核心二:last 指针。”他继续画箭头,“ fail 指针有个小问题:它跳到的节点,不一定是某个模式串的末尾。 比如你匹配到 ‘abcde’,fail 指针可能跳到 ‘cde’,但 ‘cde’ 可能只是个中间节点,不是完整的模式串。如果只靠 fail,你还得继续沿着 fail 链一层一层往上跳,直到找到某个模式串的末尾,或者跳回根节点。”

“这时候就需要 last 指针了——后缀链接,直接指向 fail 链上第一个是模式串末尾的节点。如果整条 fail 链上都没有模式串末尾,last 就指向根节点。它相当于 fail 链上的‘直达电梯’——不用一层一层爬楼梯,直接按顶层按钮就到了。”

他笔锋一转:“回到刚才的例子。你站在 ‘abcde’ 节点,想收集所有以当前位置结尾的模式串。先检查自己:如果是模式串末尾,记下来。然后沿着 last 指针一跳,直接飞到 fail 链上下一个模式串末尾(比如 ‘cde’ 如果恰好是某个模式串,last 就指向它)。再检查,再跳,直到跳到根节点为止。整个过程,你只访问了模式串末尾节点,中间那些不是末尾的节点全被跳过了。这就是 O(1) 均摊时间的秘密——每收集一个命中才走一步,零命中零步,十个命中十步。比沿着 fail 链一个一个节点傻找,效率高了一个数量级。”

老勇者放下笔,总结道:“AC 自动机的生命周期分三步。第一步,构建 Trie,把所有模式串插进去。第二步,构建 fail 和 last 指针,BFS 一遍搞定。第三步,查询文本串,逐个读入字符,在自动机上转移状态——失配时靠 fail 跳转,命中时靠 last 收集。时间复杂度 O(n + m + z),n 是文本长,m 是所有模式串总长,z 是匹配次数。”

他盯着我,一字一顿地说:“看到没?跟模式串数量无关。 相当于只派一个侦探,但他脑子里装着所有敏感词的线索地图,还有传送门(fail)和快捷通道(last),搜一遍楼就把所有敏感词全揪出来。”

我听得头皮发麻:“把暴力匹配的重复劳动,通过预处理转化成智能跳转?”

“没错!”老勇者眼中闪过一丝赞许,“AC 自动机的思想非常优雅——它把多模式匹配问题转化成了确定有限状态机(DFA)的问题。你每读入一个字符,就在状态机里转移一次,瞬间就能知道当前匹配到了哪些模式串。这就像玩游戏时开了全图挂+自动寻路,一眼就能看到所有敌人的位置,还能自动规划最优路径。”

我感觉灵光一闪:“AC 自动机就是把 Trie 树上的匹配路径编码成了状态,fail 指针就是失配时的状态转移!”

“悟性不错。”老勇者难得地点点头,“那现在,想不想亲手实现一把这个’全图挂’?”

我摩拳擦掌:“当然!给我代码模板,我要开始抄——哦不,是开始学习了!”


AC 自动机

第一步:定义 Trie 树节点

首先,Trie 树的每个节点代表一个字符,从根节点到某个节点的路径就是一个字符串。需要四个字段:son 数组存储 26 个字母的子节点,fail 是失配跳转指针,last 是直达下一个模式串末尾的后缀链接,cnt 用来记录这个节点是多少个模式串的结尾。"

class Node:
    __slots__ = 'son', 'fail', 'last', 'cnt'
    
    def __init__(self):
        self.son = [None] * 26  # 26个小写字母的子节点(a-z对应索引0-25)
        self.fail = None         # 失配指针:当前字符不匹配时跳到哪里
        self.last = None         # 后缀链接:快速跳到下一个一定是模式串末尾的节点
        self.cnt = 0             # 该节点是多少个模式串的末尾(计数器)

第二步:构建 AC 自动机

接下来,把所有模式串插入 Trie 树。这部分和普通字典树一模一样——遍历每个字符,沿着 son 往下走,没有子节点就新建,走到末尾时 cnt++,表示这里是一个模式串的终点。

class AhoCorasick:
    def __init__(self):
        self.root = Node()
    
    def put(self, pattern: str) -> None:
        cur = self.root
        for ch in pattern:
            i = ord(ch) - ord('a')  # 字符转索引
            if cur.son[i] is None:
                cur.son[i] = Node()  # 没有这个子节点就创建
            cur = cur.son[i]
        cur.cnt += 1  # 标记这是一个模式串的末尾

“注意看”,老勇者提醒道,“cnt 是累加的。如果 patterns 里有三个 “a”,那么表示 “a” 的那个节点的 cnt 就是 3。这样在查询时,我们一次性就能知道有多少个相同的模式串在这里匹配。”

第三步:构建 fail 和 last 指针(BFS)

“重头戏来了——构建 fail 和 last 指针。这是 AC 自动机的灵魂所在。”老勇者搓了搓手,仿佛在展示什么绝世武功,“用 BFS(广度优先搜索)逐层处理整棵 Trie 树。”

初始化部分:根节点的 faillast 都指向自己,因为根节点没有父节点,失配了也只能回到自己。然后遍历根节点的每个子节点:如果 son[i] 为空,就让它直接指向根节点,形成自循环——这样匹配时遇到不存在的字符能自动回到起点;如果 son[i] 存在,它的 faillast 都指向根节点(第一层节点的 fail 只能是根),然后入队等待后续处理。

def build_fail(self) -> None:
    self.root.fail = self.root.last = self.root  # 根节点的 fail 和 last 都指向自己
    
    q = deque()  # BFS 队列    
    # 初始化第一层节点(根的直接子节点)
    for i, son in enumerate(self.root.son):
        if son is None:            
            self.root.son[i] = self.root	# 虚拟子节点:直接指向根节点,方便后续失配时跳转
            continue
        son.fail = son.last = self.root		# 第一层的 fail 和 last 都指向根
        q.append(son)  						# 入队,后续处理它的子节点
    
    # BFS 遍历整棵树,逐层构建 fail 和 last 指针
    while q:
        cur = q.popleft()  # 取出队首节点
        for i, son in enumerate(cur.son):  # 遍历当前节点的26个子节点
            if son is None:
                # 路径压缩:空子节点直接指向 cur.fail 的对应子节点
                # 这样匹配时遇到不存在的字符,一步就能跳到下一个可能匹配的位置
                cur.son[i] = cur.fail.son[i]
                continue
            
            # 计算 son 节点的 fail 指针:沿着父节点的 fail 链找
            # cur.fail.son[i] 就是"以 cur 的最长可匹配后缀为前缀,且下一个字符也是 ch 的节点"
            son.fail = cur.fail.son[i]
            
            # 计算 son 节点的 last 指针:直达下一个一定是模式串末尾的节点
            # 如果 fail 节点本身就是某个模式串的末尾(cnt > 0),last 就指向 fail
            # 否则指向 fail.last,继续沿着后缀链接往上找
            son.last = son.fail if son.fail.cnt else son.fail.last
            
            q.append(son)  # 处理完当前节点,把它加入队列,后续处理它的子节点

我挠挠头,指着那行 cur.son[i] = cur.fail.son[i] 问:“等等,这里为什么要修改 son 数组?这不是把原来的 Trie 树结构改了吗?”

“这就是路径压缩的精髓!”老勇者眼睛一亮,“原本如果 son[i] 为空,匹配时你得手动沿着 fail 链一层层往上找,直到找到某个节点有 son[i]。现在直接把空子节点指向 cur.fail.son[i],匹配时不管 son[i] 存不存在,直接 cur = cur.son[i] 跳过去就完事——要么是真正的子节点,匹配成功;要么是 fail 链上的后备节点,失配后跳转到下一个可能位置。省掉了中间跑路的时间,一步到位。”

第四步:查询文本串

最后是查询阶段。从根节点出发,遍历 word 的每个字符。因为路径压缩了,son[i] 总有值——要么是真正的子节点,要么是 fail 链上的后备节点——所以直接 cur = cur.son[i] 就行,全程不需要手动判断失配。每到一个新节点,沿着 last 链收集所有命中的模式串。

class Solution:
    def numOfStrings(self, patterns: List[str], word: str) -> int:
        ac = AhoCorasick()
        for pattern in patterns:
            ac.put(pattern)		# 把所有模式串插入 AC 自动机
        ac.build_fail()			# 构建 fail 和 last 指针
        
        ord_a = ord('a')		
        cur = ac.root  			# 当前状态,从根节点开始
        ans = 0  				# 统计匹配的模式串数量
        
        # 遍历文本串的每个字符
        for ch in word:              
            cur = cur.son[ord(ch) - ord_a]		# 自动机转移,无需判空            
            match_node = cur
            while match_node.cnt >= 0:  		# cnt >= 0 表示这个节点还没被统计过
                ans += match_node.cnt  			# 累加这个节点是多少个模式串的末尾
                match_node.cnt = -1  			# 标记为已统计,避免重复计数
                match_node = match_node.last  	# 跳到下一个可能是模式串末尾的节点
        
        return ans

我盯着那个 while match_node.cnt >= 0 的循环问:“为什么要标记为 -1?这个循环是在干嘛?”

“避免重复统计同一个模式串。”老勇者解释道,“假设 patterns = ["a", "ab", "abc"],word = "abc"。当匹配到 'c' 时,当前节点表示 "abc",它本身是模式串末尾,cnt=1;它的 last 链可能指向 "ab" 对应的节点,也是模式串末尾;继续沿着 last 链走,可能还指向 "a" 对应的节点。需要沿着这条链把所有匹配的模式串全统计一遍。”

“但需要统计的是‘patterns 中有多少个字符串是 word 的子串’,而不是‘总共匹配了多少次’。如果 word 后面又出现了一个 "abc",你不能把 "abc" 再算一遍。所以第一次匹配到时,把 cnt 累加到答案,然后立刻标记为 -1——之后不管 word 里再出现多少次 "abc",while 循环的条件 cnt >= 0 都不满足,直接跳过。这就叫‘见一次就标记,绝不重复计数’。”

“哦!”我恍然大悟,“last 链负责一次匹配中收集所有命中的模式串,cnt = -1 负责跨匹配去重——两条机制,一个管纵向深度,一个管横向广度,完美配合!”

“没错!”老勇者赞许地点点头,“这下你真懂了。”


尾声

提交,AC!这次不是靠 CPython 的肩膀,而是靠自动机的铁拳——文本串只扫一遍,跟模式串数量无关。

p in word 的暴力一行流,每次 in 都是独立的扫描,相当于给每个敏感词派一个侦探,让他们各自为战,每个人都要把整栋大楼从头到尾搜一遍;而 AC 自动机,则是把所有的扫描合并成了一次遍历——只派一个侦探,但他脑子里装着所有敏感词的线索地图,脚下踩着传送门(fail 指针),手里拿着快捷通道(last 指针),搜一遍楼就能把所有敏感词连锅端!从 O(m × n) 到 O(n + m),差距不是常数倍,是数量级的碾压。

“好!好!好!”村长看着运行结果,乐得合不拢嘴,一把抱住那台还在嗡嗡作响的二手机箱,“这下服务器保住了!我的‘村长云旗舰版’不用去垫桌角了!”

他激动地抹了把眼泪,但随即又凑到我身边,搓着手问道:“小白啊,真遇到大规模流量——比如老板娘的抽奖系统突然爆火,同时有十万个用户在线疯狂刷屏,老板娘要拦截十万个敏感词、处理百万字的文本……就用这个 AC 自动机,顶得住吗?”

我看着他那张写满担忧的脸,忍不住笑了:“村长,把心放回肚子里!AC 自动机的时间复杂度是 O(n + m + z)——跟模式串的数量完全无关!就算有一万个敏感词,也只是 Trie 树稍微大一点,查询的时候,文本串依然只需要从头到尾扫一遍!”

我瞅着屏幕上的AC自动机,掷地有声地说:“有了 AC 自动机,以后就算流量翻十倍、一百倍,你的服务器也照样扛得住!”

知识共享许可协议
本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可。