什么是AC自动机?如何实现?
- 什么是AC自动机?
是基于 Trie树 和 KMP失配指针 的一种高效多模式匹配算法。AC自动机能够一次构建,随后在遍历文本时同时匹配多个敏感词。
AC自动机算法的典型应用是敏感词匹配,在各大社交媒体平台如:微博、知乎、抖音等各用户发表评论时,该算法便派上了用场,最典型的是王者荣耀,玩家在聊天框发表一些暴力,色情语言时,会被标记为'*'显示。 - 如果不用该算法,怎么实现敏感词的匹配?
想到用哈希表查找的方式去实现敏感此匹配。 - 分析一下AC自动机和哈希表查找两种方式的优劣
-
哈希表
哈希表匹配 是通过将敏感词存储在哈希表中,然后逐个检查文本中的每个子串是否存在于哈希表中。每次从文本中取出一段子串,检查该子串是否在哈希表中存在。哈希表的查找时间复杂度为 𝑂(1)。
-
AC自动机
使用Trie树存储多个敏感词,确保每个敏感词的共享前缀不会被重复存储。
失配指针用于在匹配失败时跳转到下一个可能的匹配位置,避免重新扫描文本。
AC自动机在搜索阶段的时间复杂度为 O(m),其中 m 是文本长度。
- 时间复杂度
哈希表
预处理时间:将敏感词插入到哈希表的时间复杂度为 O(k),其中 k 是所有敏感词的总长度。
匹配时间:对于每个可能的子串,哈希表需要逐个验证。假设文本长度为 m,最长敏感词长度为 L,则需要 O(m⋅L) 的时间复杂度来进行逐个子串的检查。
AC自动机
预处理时间:构建Trie树和失配指针的时间复杂度为 O(k),与哈希表相同。
匹配时间:AC自动机只需要遍历文本一次,每次沿着Trie树进行状态转移。匹配时间复杂度为 O(m),其中 m 是文本长度。 - 空间复杂度
哈希表
空间消耗:哈希表需要为每个敏感词存储独立的键值对,哈希表的大小与敏感词的数量成正比,因此空间复杂度为 O(n),其中 n 是敏感词的数量。
敏感词独立存储:哈希表无法利用敏感词之间的前缀共享,因此每个敏感词都需要独立存储,容易导致重复存储较长的前缀。
AC自动机
空间消耗:AC自动机的Trie树利用了敏感词之间的前缀共享,减少了空间消耗。构建Trie树的空间复杂度为O(k),其中 k 是所有敏感词的总字符数。
失配指针:失配指针需要额外的空间,但由于失配指针只会指向Trie树的其他节点,因此额外消耗不高。 - 匹配性能
哈希表
逐字符匹配:哈希表匹配的效率较低,因为它需要逐个子串进行查找。对于每一个起始位置,必须从1个字符开始,逐渐扩大到最长敏感词长度,直到发现敏感词或整个文本扫描完毕。因此,它的时间复杂度会随着文本长度的增加成倍增长。
局限性:哈希表无法有效处理前缀相同的敏感词。例如,对于敏感词 "ab" 和 "abc",如果从文本中读到 "abc",哈希表会需要分别检查 "a"、"ab"、"abc" 三个子串。
AC自动机
一次性匹配:AC自动机的设计可以一次性完成多模式匹配。它在遍历文本的过程中,能够同时检测到所有敏感词的出现,且每个字符只会被遍历一次,避免了重复的子串检查。
效率高:AC自动机的时间复杂度为 O(m),在处理大文本和大量敏感词时,匹配性能显著优于哈希表。
代码实现:
1.trie树的构建(前缀树)
点击查看代码
// 构建Goto表(Trie树)
void AcString::BuildGotoTable() {
for (size_t i = 0; i < patterns.size(); ++i) {
const std::string& word = patterns[i].pattern;
int current = 0; // 从根节点开始
for (char ch : word) {
if (nodes[current].sons.find(ch) == nodes[current].sons.end()) {
AddState(current, ch);
nodes[current].sons[ch] = nodes.size() - 1;
}
current = nodes[current].sons[ch];
}
// 在最后一个节点添加输出
nodes[current].output.push_back(i);
}
}
2.Fial指针的构建
点击查看代码
void AcString::BuildFailTable() {
std::queue<int> q;
// 初始化根节点的子节点的fail指针为根节点
for (auto& pair : nodes[0].sons) {
int child = pair.second;
nodes[child].fail = 0;
q.push(child);
}
// BFS遍历Trie树,构建fail指针
while (!q.empty()) {
int current = q.front();
q.pop();
for (auto& pair : nodes[current].sons) {
char ch = pair.first;
int child = pair.second;
// 寻找current节点的fail指针指向的节点的子节点是否有字符ch
int fail = nodes[current].fail;
while (fail != -1 && nodes[fail].sons.find(ch) == nodes[fail].sons.end()) {
fail = nodes[fail].fail;
}
if (fail == -1) {
nodes[child].fail = 0; // 回到根节点
} else {
nodes[child].fail = nodes[fail].sons[ch];
// 将fail节点的输出添加到当前节点的输出
for (int index : nodes[nodes[fail].sons[ch]].output) {
nodes[child].output.push_back(index);
//如果 fail == -1,表示当前节点没有找到合适的失败跳转路径,因此将子节点 child 的 fail 指针指向根节点 nodes[0]。
否则,将子节点 child 的 fail 指针指向 fail 节点的子节点(即 nodes[fail].sons[ch],表示可以通过 fail 节点继续匹配字符 ch)。
接着,将 fail 节点的输出模式(output)追加到子节点 child 的 output 列表中。这一步是因为 fail 节点已经匹配到了一部分模式,所以如果 child 节点也到达这个状态,它应该继承这些匹配的模式。
}
}
q.push(child);
}
}
}

浙公网安备 33010602011771号