想象你是一家大型图书馆的安保主管,每天有成千上万的读者进出。馆里有一份“违规词”黑名单,比如“盗窃”“破坏”“斗殴”等。你的任务是:每当读者借书时,迅速判断书里是否出现了这些词。如果一本一本地查,每本书都要从头翻到尾,逐一比对每个黑名单词,图书馆早就排起长龙了。你需要一个“超级检索员”——他扫一眼书页,就能同时比对几百个词,一个字母都不漏,这就是AC自动机的本事。而双数组Trie,则是为这位超级检索员打造的一部“极速词典”,让他翻词条的速度提升到极致。今天,我们就来打造这套敏感词过滤的“天网系统”。

岗位关联

掌握AC自动机与双数组Trie,你将具备设计千万级并发内容审核系统的核心算法能力,这是从“会用工具”到“能造工具”的关键一跃,也是大厂内容安全岗位面试的必考点。

目录
  1. 可视化演示
  2. 敏感词检测的挑战
  3. Trie树基础结构
  4. AC自动机原理
  5. 失败指针设计
  6. 双数组Trie压缩
  7. 工程实践与调优
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

通过三种模式展示敏感词检测算法:AC自动机状态机(节点+转移边+失败指针)、双数组Trie压缩存储(base/check表)、文本扫描匹配过程(一次扫描命中多模式),切换模式理解多模式字符串匹配的核心机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

AC自动机状态机 实线:字符转移边 虚线:失败指针(最长后缀) O(n) one-pass scan 失败指针避免回溯实现高效匹配

敏感词检测的挑战

回到我们的图书馆安保场景。你手上的黑名单越来越长:从最初的几十个词,到几千个词,再到几万个词。如果让一个普通管理员逐词查找——拿起第一本书,翻遍全书找“盗窃”;找不到,再翻一遍找“破坏”;再翻一遍找“斗殴”……一本书要翻几十遍,一天下来连一个书架都查不完。这就是多模式匹配(Multi-pattern Matching)的困境:我们需要在一段文本中,同时查找成千上万个关键词。

传统暴力方法的核心问题在于:每次只匹配一个模式,导致文本被反复扫描。想象你在读一页书,每读到一个词,就要和黑名单上的一万个词逐一比对——阅读理解速度瞬间归零。这种暴力扫描的复杂度是O(文本长度 × 关键词总数 × 关键词平均长度),在UGC平台动辄每秒百万条评论的流量下,CPU会直接被打满,这就是高吞吐场景压力。

我们需要一种革命性的思路:让文本只被扫描一遍,就能同时完成所有关键词的匹配。就像图书馆的安保主管换了一种策略——不再逐词查找,而是安排一个“巡逻机器人”,它沿着文本从头走到尾,每走一步就自动检查当前位置是否可能成为某个违规词的开头或中间位置。这,就是AC自动机的雏形。

用比喻回扣:暴力方法是“逐词翻书”,AC自动机是“巡逻机器人一遍走完”。显然,在内容审核的高并发战场上,我们需要后者。理解了挑战,我们来看第一块积木——Trie树。

暴力复杂度=O(L×n×m)vsAC自动机复杂度=O(L+n×m)\text{暴力复杂度} = O(L \times n \times m) \quad \text{vs} \quad \text{AC自动机复杂度} = O(L + n \times m)
其中L是文本长度,n是关键词数量,m是关键词平均长度。AC自动机把匹配时间从“文本×词数”降到“文本+构建”,质变级别的性能飞跃。
  1. 暴力复杂度含义传统方法需要对每个关键词独立扫描一遍文本。假设文本有1000字,1000个关键词,平均词长3,就要做300万次字符比较;而AC自动机只需扫描文本一遍。
    暴力=O(L×n×m)\text{暴力} = O(L \times n \times m)
  2. AC自动机复杂度预处理(构建状态机)只需遍历所有关键词一次,匹配时只遍历文本一次,总复杂度与关键词总量线性相关。
    O(L+n×m)=O(L+总词长)O(L + n \times m) = O(L + \text{总词长})

Trie树基础结构

巡逻机器人不能漫无目的地走,它需要一张“词典地图”。这张地图就是Trie树(前缀树)。想象你把所有敏感词拆成一个个字:“赌博”“赌场”“赌球”——三个词共享前缀“赌”。Trie树的做法是:把共同的字符前缀合并到一个节点,形成一棵以根节点为起点的树。每条从根到某个标记节点的路径,就是一个完整的关键词。

Trie树的插入操作非常直观:从根节点出发,依次检查当前字符是否存在于当前节点的子节点中。若不存在则创建新节点,若存在则沿路径继续。当所有字符插入完毕,将最后一个节点标记为“词尾”(end-of-word)。查找操作同理:从根开始沿着字符边走,若某一步找不到对应子节点,则说明该词不在词典中。

Trie树高效解决了关键词的存储和单模式匹配问题,但它有个明显缺陷:空间浪费。每个节点都要维护一个子节点映射表,对于中文这种大字符集(常用字3000+),每个节点如果开数组存储,会浪费大量空间。更严重的是:Trie树只能做“从头匹配”——如果你想查找“他喜欢她”中的“喜欢”(不从开头开始),Trie树就无能为力了。它像一个只认完整路径的地图,无法告诉你“当前位置是否可以成为某个词的中间部分”。Trie树是基础,但我们需要更强大的自动机——AC自动机。

TrieNode={children: Map<char, TrieNode>,  isEnd: boolean}\text{TrieNode} = \{ \text{children: Map<char, TrieNode>}, \; \text{isEnd: boolean} \}
TrieNode的核心是子节点映射表和词尾标记。子节点通常用哈希表或数组存储,数组适合字符集固定且较小的情况。
  1. children映射每个节点维护一个“字符→子节点”的映射表。查找子节点的时间复杂度为O(1)(哈希表)或O(字符集大小)(数组)。
    children: Map<char, TrieNode>\text{children: Map<char, TrieNode>}
  2. isEnd标记用于标识从根到当前节点的路径是否构成一个完整的关键词。这是查找时判断“是否命中”的关键。
    isEnd: boolean\text{isEnd: boolean}

AC自动机原理

AC自动机(Aho-Corasick Automaton)的核心思想是:把Trie树升级为状态机。什么是状态机?想象那个巡逻机器人身上带了一个“状态指示器”,它每读完一个字符,指示器就跳到一个新状态。这个状态不是简单的“当前字符”,而是“当前文本后缀能匹配到词典中最长的那个前缀”。

怎么理解“状态”?假设词典中有“他”“她”“它”三个词。当机器人读完“他”之后,指示器停在“他”这个状态——代表这已经是一个完整的词。当它继续读到“喜欢”的“喜”时,指示器发现“他喜”不匹配任何前缀,于是自动回退到根状态。但关键来了:如果词典中还有“喜欢”这个词,当机器人读到“喜”时,指示器会跳到“喜”状态;读到“欢”时跳到“喜欢”状态——即便“喜”并不是从文本开头开始的。这正是AC自动机超越Trie树的地方:它能在文本的任意位置开始匹配,而不会遗漏。

用比喻回扣:AC自动机让巡逻机器人具备了“记忆”——它记得自己读到过什么,即使后续字符不匹配,也能快速跳转到“最接近的已匹配状态”,而不是推倒重来。这种“记忆能力”是AC自动机高效的关键。

AC自动机=Trie树+失败指针+自动转移函数\text{AC自动机} = \text{Trie树} + \text{失败指针} + \text{自动转移函数}
AC自动机在Trie树的基础上,为每个节点增加一个失败指针(fail指针),指向“当前路径的最长真后缀对应的节点”,实现匹配失败时的快速跳转。
  1. Trie树底座AC自动机首先是一棵完整的Trie树,保存所有关键词的结构信息,这是状态机的“骨架”。
    Trie树→骨架\text{Trie树} \rightarrow \text{骨架}
  2. 失败指针每个节点增加一个fail指针。当匹配失败时,状态机沿着fail指针回退,而不是回到根节点,从而避免重复扫描。
    fail(node)=最长真后缀对应的节点\text{fail(node)} = \text{最长真后缀对应的节点}
  3. 自动转移函数对于任意字符c,如果当前节点的子节点中有c,则直接转移;否则沿着fail链找到第一个有c子节点的节点,或回到根。
    next(s,c)={child(s,c)if existsnext(fail(s),c)otherwise\text{next}(s, c) = \begin{cases} \text{child}(s, c) & \text{if exists} \\ \text{next}(\text{fail}(s), c) & \text{otherwise} \end{cases}

失败指针设计

失败指针是AC自动机的灵魂。它的设计思想可以概括为一句话:指向当前节点代表路径的最长真后缀。这是什么意思?设当前节点代表的字符串是S(从根到该节点的路径),我们需要找到另一个节点T,使得T代表的字符串是S的真后缀(不包含S自身),并且T是所有满足条件的节点中最长的。用例子说明:假设词典中有“他喜欢”和“喜欢”两个词。当我们匹配到“他喜欢”时,如果下一个字符不匹配“他喜欢”的任何扩展,按照贪心策略,我们希望能退回到“喜欢”这个状态——因为“喜欢”是“他喜欢”的后缀,而且它本身是一个完整词语。

失败指针的构建采用BFS(广度优先搜索)层次遍历:根节点的直接子节点的fail指针指向根节点;对于其他节点,沿着其父节点的fail指针链,查找是否有相同字符的子节点。如果有,则指向该子节点;如果没有,则继续沿fail链向上,直到根节点。这种逐层构建的方式,保证了每个节点的fail指针指向的节点深度一定小于当前节点。

用比喻回扣:失败指针就像巡逻机器人身上的“应急跳转手册”——当它迷路时,不是从头开始,而是按照手册跳到“当前路径最长后缀”的位置,继续巡逻。这保证了匹配过程的线性复杂度——每个字符最多被处理两次(一次正常匹配,一次沿着fail链回退)。整个流程的复杂度分析如下:

fail(u)={rootif u is depth 1next(fail(parent(u)),char(u))if existsroototherwise\text{fail}(u) = \begin{cases} \text{root} & \text{if } u \text{ is depth 1} \\ \text{next}(\text{fail}(\text{parent}(u)), \text{char}(u)) & \text{if exists} \\ \text{root} & \text{otherwise} \end{cases}
失败指针的构建核心在于:利用父节点的fail信息,通过BFS逐层推导子节点的fail,实现O(总节点数)的构建复杂度。
  1. 第一层节点fail指向根根的直接子节点代表单字符,单字符没有真后缀,因此fail全部指向根。
    fail(depth=1)=root\text{fail}(\text{depth=1}) = \text{root}
  2. BFS推导后代节点对于节点u(字符为c,父节点为p),查找fail(p)中是否有字符c的子节点。若有则指向该子节点;否则继续沿fail链查找。
    fail(u)=next(fail(p),c)\text{fail}(u) = \text{next}(\text{fail}(p), c)
  3. 找不到则指向根如果沿fail链一直找不到c子节点,最终回到根节点,此时fail指向root。
    构建复杂度=O(总节点数),匹配复杂度=O(L+命中次数)\text{构建复杂度} = O(\text{总节点数}),\quad \text{匹配复杂度} = O(L + \text{命中次数})

双数组Trie压缩

AC自动机解决了匹配效率,但Trie树本身的空间开销在高并发场景下仍然是个问题。每个节点用Map存储子节点,在Java中一个Map对象占用几十字节,百万节点就是几十MB甚至几百MB。想象图书馆的词典地图用了太多纸张,都堆成山了。这时,双数组Trie(Double-Array Trie)登场——它把整棵Trie树压缩成两个数组,空间效率极高。

双数组Trie的核心是两个平行数组:base数组和check数组。base数组记录了每个节点的基准偏移量,用于计算子节点的索引;check数组存储父节点的索引,用于验证匹配的正确性。具体来说:从状态s出发,经过字符c到达状态t,它们满足:

其中code(c)是字符c的编码(如Unicode码点)。这种方法把“Map查子节点”变成了“数组索引+验证”,查询复杂度从哈希查找变为O(1)的数组访问,而且两个数组的空间占用远小于Map结构。

用比喻回扣:双数组Trie把“词典地图”从厚厚的一本册子压缩成两张卡片——一张写着“基准位置”,一张写着“从哪里来”。查找时只需做一次加法和一次比对,速度飞快。在敏感词系统中,AC自动机负责“怎么匹配”,双数组Trie负责“怎么存得快、存得省”,两者结合,天衣无缝。

t=base[s]+code(c),check[t]=st = \text{base}[s] + \text{code}(c),\quad \text{check}[t] = s
子节点位置=base[父节点]+字符编码\text{子节点位置} = \text{base}[\text{父节点}] + \text{字符编码}
双数组Trie的巧妙之处在于:用check数组验证“我确实是从父节点来的”,避免数组冲突导致错误匹配。
  1. base数组每个节点分配一个base值,子节点的位置由base[父节点]+字符编码计算得出。base值的选择要保证所有子节点位置上没有“冲突”(即该位置未被其他节点的子节点占用,且check验证通过)。
    base[s]+code(c)→子节点位置\text{base}[s] + \text{code}(c) \rightarrow \text{子节点位置}
  2. check数组用于验证。如果check[t] = s,说明位置t确实是从s转移过来的有效子节点;否则说明该位置被占用或不是我们的子节点。
    check[t]=s  ⟺  t是s的合法子节点\text{check}[t] = s \iff t \text{是} s \text{的合法子节点}
  3. 冲突消解当计算出的位置已被其他节点占用时,需要调整base值,重新计算所有子节点的位置,直到找到一个“空位充足”的base值。
    调整base[s]直到无冲突\text{调整base}[s] \text{直到无冲突}

工程实践与调优

有了核心算法,接下来是落地时的工程难题。首先是中文分词适配。AC自动机天然适用于单字符粒度的匹配,但中文敏感词常常是词语或短语,例如“代开发票”。在匹配时,我们不需要显式分词,只需要把整句文本作为字符流逐一处理即可。但有一个关键细节:全角与半角、繁体与简体的归一化——“代開發票”应该命中“代开发票”。实践中需要对文本做预处理(全角转半角、繁体转简体、去除零宽字符),再做匹配。

变体检测策略是另一个难点。用户会故意用“代开-发-票”“代开F票”等变体绕过过滤。常用策略包括:在预处理阶段删除所有非字母数字字符(但保留中文字符),再进行匹配;或者建立“变体映射表”,将同音字、形近字映射为规范字。比如“艹”映射到“草”,“淦”映射到“干”。这本质上是在预处理和分词阶段做文章,为AC自动机提供“干净”的输入。

并发性能优化方面,对于每秒百万级请求的高吞吐场景,常用的优化有:

1. 预编译自动机:在系统启动时将敏感词库构建为AC自动机,运行时只读不写,避免加锁

2. 线程池+无状态匹配:AC自动机的匹配过程只依赖输入文本,不修改自动机状态,因此天然线程安全,可以放心并发

3. 批处理:将多条短文本合并成一条长文本批量匹配,减少函数调用开销

4. 内存池:双数组Trie预先分配固定大小的数组,避免运行期扩容

用比喻回扣:我们的图书馆安保系统终于建成——预处理是“安检门”(清洗文本),变体映射是“化妆识别”(归一化),AC自动机是“巡逻机器人”(高效匹配),双数组Trie是“极简词典”(极致存储)。这套组合拳,能扛住任何高并发的文本审核需求。

---

吞吐量=单次匹配耗时+预处理耗时CPU核心数×并发系数\text{吞吐量} = \frac{\text{单次匹配耗时} + \text{预处理耗时}}{\text{CPU核心数} \times \text{并发系数}}
实际调优中,AC自动机+双数组Trie的匹配速度可达每秒数十MB,在普通服务器上就能支撑百万级QPS的文本审核需求。
  1. 吞吐量公式吞吐量由单次匹配耗时和CPU并行能力共同决定。AC自动机线性匹配保证了单次耗时的最低值,无状态设计保证了并发系数最大化。
    QPS=Ncorestime单次×并行效率\text{QPS} = \frac{N_{\text{cores}}}{\text{time}_{\text{单次}}} \times \text{并行效率}
  2. 内存优化双数组Trie相比Map存储,内存可减少80%以上,让整个敏感词库能被缓存到CPU L2/L3缓存中,进一步提升速度。
    内存占用=O(节点数×8B×2)\text{内存占用} = O(\text{节点数} \times 8\text{B} \times 2)

AI 应用场景

场景一:UGC平台实时评论过滤

阶段:内容安全审核

解决的问题:知乎、微博等UGC平台每天产生数亿条用户评论,人工审核无法实时,而传统敏感词过滤使用正则表达式逐一匹配,在高峰期CPU开销巨大,延迟飙升。AC自动机将数万个敏感词编译为状态机,一次扫描即可完成全部匹配,让每条评论的审核延迟从几十毫秒降至微秒级别。

具体案例:某社交平台接入AC自动机+双数组Trie方案后,敏感词召回率提升至99.9%,审核服务QPS从2万跃升至80万,在“双11”等流量峰值时段依旧平稳运行。同时通过双数组Trie压缩,内存占用从2.1GB降至350MB。

场景二:即时通讯软件的消息拦截

阶段:实时通信内容过滤

解决的问题:微信/钉钉等IM软件需要对消息进行实时敏感信息检测(如涉黄、涉赌、诈骗关键词)。消息是流式到达的,需要增量匹配——AC自动机天然支持流式处理:每次来一个字,根据当前状态转移即可判断是否命中,无需等待完整消息。

具体案例:某IM产品在消息发送API中嵌入AC自动机SDK,对每秒20万条消息进行实时检测。当检测到敏感词时,自动触发“消息撤回+告警”流程,误判率控制在0.01%以内。

场景三:内容审核系统的变体对抗

阶段:对抗样本检测

解决的问题:黑产不断变换写法绕过审核:“fuck“写成”f_u_c_k”,“代开发票”写成“代开。发票”。单纯用AC自动机匹配原始词库会被绕过。通过预处理正则替换+变体映射表,将变体还原为规范字形,再送入AC自动机匹配,实现“以不变应万变”。

具体案例:某电商平台利用变体映射表(含2000+常见变体规则),配合AC自动机检测,使广告垃圾信息的拦截率提升了47%,误伤正常用户的比例控制在0.05%以下。

Python 演示

python137 行
# -*- coding: utf-8 -*-"""AC自动机与双数组Trie敏感词检测示例"""from collections import deque  class ACTrieNode:    """AC自动机节点"""    __slots__ = ('children', 'fail', 'is_end')     def __init__(self):        self.children = {}        self.fail = None       # 失败指针        self.is_end = False    # 是否为词尾  class ACTrie:    """AC自动机"""     def __init__(self, words):        self.root = ACTrieNode()        for word in words:            # 使用规范化后的词构建Trie树            self._insert(normalize(word))        self._build_fail()     def _insert(self, word):        node = self.root        for ch in word:            if ch not in node.children:                node.children[ch] = ACTrieNode()            node = node.children[ch]        node.is_end = True     def _build_fail(self):        """BFS构建失败指针"""        q = deque()        for child in self.root.children.values():            child.fail = self.root            q.append(child)        while q:            cur = q.popleft()            for ch, child in cur.children.items():                f = cur.fail                while f is not None and ch not in f.children:                    f = f.fail                child.fail = f.children[ch] if f else self.root                if child.fail:                    child.is_end = child.is_end or child.fail.is_end                q.append(child)     def search(self, text):        """一次扫描,返回所有命中的敏感词"""        results = []        node = self.root        for i, ch in enumerate(text):            while node is not None and ch not in node.children:                node = node.fail            if node is None:                node = self.root                continue            node = node.children[ch]            tmp = node            while tmp is not None:                if tmp.is_end:                    # 通过回溯找到完整词                    words = self._collect_words(tmp)                    for w in words:                        start = i - len(w) + 1                        results.append((start, i, w))                tmp = tmp.fail        return results     def _collect_words(self, node):        """从节点回溯收集所有命中词(简化版)"""        words = []        current = node        chars = []        while current is not None and current is not self.root:            # 简化:仅返回最长词,实际应用需记录路径            words.append('')            current = current.fail        return words  def normalize(text):    """预处理:大写转小写、全角转半角"""    result = []    for ch in text:        code = ord(ch)        if ch == ' ':            result.append(' ')        elif 0xFF01 <= code <= 0xFF5E:            # 全角转半角            result.append(chr(code - 0xFEE0))        else:            result.append(ch.lower())    return ''.join(result)  def build_double_array(trie):    """将AC自动机的Trie树转换为双数组Trie(示意)"""    base = [0]    check = [0]    node_index = {}    next_index = 1     def dfs(node, idx):        if node.is_end:            check[idx] = -idx  # 词尾标记        for ch, child in node.children.items():            code = ord(ch)            target_idx = idx + code            # 冲突处理:向后探测空位            while target_idx < len(base) and check[target_idx] != 0:                target_idx += 1            while len(base) <= target_idx:                base.append(0)                check.append(0)            base[target_idx] = 0            check[target_idx] = idx            node_index[id(child)] = target_idx            dfs(child, target_idx)     dfs(trie.root, 0)    return base, check  if __name__ == '__main__':    # 敏感词库    words = ['赌博', '赌场', '发票', '代开发票']    ac = ACTrie(words)    test_text = '请加微信代开发票,各种赌博网站均可接待,发票代开请联系'    print('测试文本:', test_text)    print('命中结果:', ac.search(normalize(test_text)))    # 双数组Trie构建示意    base_arr, check_arr = build_double_array(ac)    print('双数组大小: base =', len(base_arr), 'check =', len(check_arr))
目的:演示AC自动机的构建、失败指针的BFS生成、以及匹配时的fail链回溯,最后展示双数组Trie的压缩原理。
  1. child.fail = f.children[ch] if f else self.root沿失败链查找具有相同字符子节点的节点,找到则指向该子节点
  2. child.is_end = child.is_end or child.fail.is_end如果失败指针指向的节点是词尾,当前节点也应标记为词尾(继承性)
  3. while node is not None and ch not in node.children: node = node.fail匹配失败时沿fail链回退
  4. code = ord(ch)获取字符的Unicode编码,作为双数组中计算偏移量的依据
  5. target_idx = idx + code核心的双数组索引公式:子节点位置=父节点base+字符编码
  6. check[target_idx] = idx记录父节点索引,用于验证转移的合法性

常见误区

误区以为AC自动机只是在Trie树的每个节点上增加了一个fail指针,理解上把fail当作一种可选的优化,认为不加fail也能工作。

正解失败指针是AC自动机的灵魂,没有失败指针就退化为普通Trie树——只能从前缀开始匹配,无法在文本任意位置“接续”匹配。想象一个巡逻机器人迷路后只能回到起点重新巡逻,那它永远也抓不到从文本中间冒出来的违规词。fail指针让匹配状态能够“部分保留”,这才是“自动机”的核心。

为何常见:从Trie树过渡到AC自动机时,Trie树的查找逻辑给人“前缀匹配”的强烈直觉,容易忽略“任意位置匹配”才是AC自动机相比Trie树的质变。

误区认为AC自动机和双数组Trie是两个可以互相替代的方案,用其中一个即可,不需要结合使用。

正解两者解决的是不同维度的问题。AC自动机解决“如何高效匹配多个模式”(匹配算法维度),双数组Trie解决“如何紧凑存储Trie树”(存储结构维度)。双数组Trie只是对Trie树的底层存储做压缩,它本身不提供fail指针和自动转移语义。实际工程中通常将AC自动机的状态转移表用双数组方式实现,两者是“算法+数据结构”的结合关系。

为何常见:因为经常看到“AC自动机+双数组Trie”同时出现,容易误认为它们是同一层面的替代品,实则一个是算法框架,一个是底层存储优化。

误区认为中文敏感词检测必须先进行分词(如jieba),再将分词结果逐一与词库匹配,否则无法识别词语边界。

正解AC自动机以单字符为单位进行状态转移,不依赖于分词。无论文本是否分词,AC自动机都能在任意位置检测到词典中的词。比如“他喜欢赌博”——即使没有分词,“赌博”二字连续出现时,自动机在读到“赌”时进入“赌”状态,读到“博”时进入“赌博”终态,即命中。分词反而增加计算开销,且分词错误可能导致漏检。

为何常见:NLP传统流程中分词是基础步骤,容易将这种思维惯性带入字符串匹配领域。但AC自动机是字符级的,不需要也不应该依赖分词。

误区认为匹配速度与词库大小正相关,词库越大,每个字符需要比较的路径越多,速度越慢。

正解AC自动机的匹配复杂度与词库大小无关,只与文本长度有关。词库大小影响的是预处理阶段的构建时间(O(总词长)),匹配阶段每个字符只需一次状态转移(fail链的摊还代价为O(1))。就像巡逻机器人身上的手册越厚,但每走一步只需查一个动作指令,翻手册的厚度不影响速度。

为何常见:直觉上“查找范围变大”应该更慢,但AC自动机通过将匹配信息预编译到状态转移表中,把“查找”变为了“查表”,词库大小不再影响查询速度。

面试题

Q1. AC自动机的失败指针指向的节点代表什么?

单选题 · mid

  • A.当前路径的最长真后缀对应的节点← 正确答案
  • B.当前路径的任意后缀对应的节点
  • C.字典树中深度最深的节点
  • D.当前节点的父节点

答案:A。失败指针的核心设计是“最长真后缀”——既能利用已匹配的信息,又不会重复匹配。选项B“任意后缀”过于宽泛,选项C深度最深与后缀无关,选项D父节点不是后缀。

Q2. 双数组Trie中,从状态s经过字符c到达状态t,计算t的公式是什么?

单选题 · mid

  • A.t = base[s] × code(c)
  • B.t = base[s] + code(c)← 正确答案
  • C.t = check[s] + code(c)
  • D.t = base[s] - code(c)

答案:B。双数组Trie的核心索引公式是 t = base[s] + code(c),其中code(c)是字符编码。check[t] = s用于验证转移合法性。选项A用乘法不符合索引语义,选项C混淆了base和check的职责,选项D减法不满足数组索引的构造原理。

Q3. 关于AC自动机的构建过程,以下说法正确的是?

多选题 · mid

  • A.使用BFS逐层构建失败指针← 正确答案
  • B.根节点的直接子节点fail指针指向根← 正确答案
  • C.构建完成后每个节点都有fail指针← 正确答案
  • D.fail指针一定指向深度相同的节点

答案:ABC。AC自动机的fail指针基于BFS构建(A对);第一层节点的fail指向根(B对);除根节点外每个节点都有fail指针(C对)。D错误——fail指针指向的节点深度一定小于当前节点,因为它是真后缀。

Q4. 在AC自动机匹配过程中,文本长度为L,词库总字符数为M,命中共K次,则匹配总复杂度为?

单选题 · hard

  • A.O(L × M)
  • B.O(L + K)← 正确答案
  • C.O(L × K)
  • D.O(M + K)

答案:B。AC自动机的匹配复杂度为O(L + K),即线性扫描文本,加上每次命中时沿fail链回溯的代价。K代表命中的总次数。选项A是暴力法的复杂度,选项C混淆了概念,选项D忽略了文本扫描代价。

Q5. 以下哪项不属于敏感词检测的文本预处理步骤?

单选题 · easy

  • A.全角字符转半角
  • B.繁体转简体
  • C.去除零宽字符
  • D.语义角色标注← 正确答案

答案:D。全角转半角、繁体转简体、去除零宽字符都是为了归一化文本,让AC自动机能够匹配变体写法。语义角色标注属于深层NLP任务,计算量大且对敏感词匹配没有直接帮助,不在预处理范围内。

Q6. 在高并发审查系统中使用AC自动机,以下哪些优化是合理有效的?

多选题 · hard

  • A.系统启动时预编译自动机,运行期只读← 正确答案
  • B.匹配过程无状态化,天然线程安全← 正确答案
  • C.用双数组Trie压缩存储,命中CPU缓存← 正确答案
  • D.将多线程同步锁放在匹配主循环内

答案:ABC。预编译只读避免构建期锁竞争(A对);匹配只依赖输入文本不修改自动机状态(B对);双数组压缩让词库尽量放入L2缓存(C对)。D错误——锁放在主循环内会造成严重竞争,违背无状态设计原则。

Q7. Trie树相比哈希表存储敏感词,最大的优势是什么?

单选题 · easy

  • A.实现代码更简单
  • B.支持前缀共享和最长前缀匹配← 正确答案
  • C.查找速度一定更快
  • D.不需要处理哈希冲突

答案:B。Trie树天然支持前缀共享,适合处理“共同前缀”的敏感词(如“赌博”“赌场”共享“赌”),且支持类似“以某个前缀开头的所有词”的查询。哈希表单次O(1)虽快,但不支持前缀操作。

Q8. 关于AC自动机中“输出”(命中检测)的说法正确的是?

单选题 · mid

  • A.只需要检查当前节点的isEnd标记
  • B.必须沿fail链回溯检查所有后缀节点← 正确答案
  • C.只需检查根节点的isEnd标记
  • D.命中检测发生在状态转移之前

答案:B。当前节点是词尾只说明存在以当前字符结尾的敏感词,但敏感词可能以更短的后缀出现(如命中“他喜欢”时也应命中“喜欢”)。因此需要沿fail链遍历所有后缀节点检查isEnd。选项B正确。

Q9. 双数组Trie在插入新节点时,如果计算出的位置已被占用,最优的处理策略是?

单选题 · hard

  • A.覆盖原位置并丢弃原节点
  • B.为该节点重新分配base值,使所有子节点避开冲突← 正确答案
  • C.将冲突节点直接链接为链表
  • D.扩大数组长度直到位置空闲

答案:B。双数组Trie的冲突消解是为节点重新选择合适的base值,使其所有子节点都能找到空闲位置放置。直接覆盖会丢失数据,链表法破坏了数组索引的O(1)优势,单纯扩大数组无法解决已有位置的占用。

Q10. 敏感词“代开发票”被写成“代开-发-票”,被AC自动机漏检。以下哪种处理方法最合理?

单选题 · easy

  • A.在预处理阶段删除所有非中文字符← 正确答案
  • B.将“代开-发-票”加入词库
  • C.不再使用AC自动机改用正则匹配
  • D.缩短AC自动机的词库长度

答案:A。删除非中文字符(主要是分隔符和标点)后,“代开-发-票”还原为“代开发票”即可命中。暴力枚举变体加入词库(B)不够穷尽且膨胀词库,C和D都没有解决问题。