什么是AC自动机?如何实现?

  1. 什么是AC自动机?
    是基于 Trie树 和 KMP失配指针 的一种高效多模式匹配算法。AC自动机能够一次构建,随后在遍历文本时同时匹配多个敏感词。
    AC自动机算法的典型应用是敏感词匹配,在各大社交媒体平台如:微博、知乎、抖音等各用户发表评论时,该算法便派上了用场,最典型的是王者荣耀,玩家在聊天框发表一些暴力,色情语言时,会被标记为'*'显示。
  2. 如果不用该算法,怎么实现敏感词的匹配?
    想到用哈希表查找的方式去实现敏感此匹配。
  3. 分析一下AC自动机和哈希表查找两种方式的优劣
  • 哈希表

    哈希表匹配 是通过将敏感词存储在哈希表中,然后逐个检查文本中的每个子串是否存在于哈希表中。每次从文本中取出一段子串,检查该子串是否在哈希表中存在。哈希表的查找时间复杂度为 𝑂(1)。

  • AC自动机

    使用Trie树存储多个敏感词,确保每个敏感词的共享前缀不会被重复存储。
    失配指针用于在匹配失败时跳转到下一个可能的匹配位置,避免重新扫描文本。
    AC自动机在搜索阶段的时间复杂度为 O(m),其中 m 是文本长度。

  1. 时间复杂度
    哈希表
    预处理时间:将敏感词插入到哈希表的时间复杂度为 O(k),其中 k 是所有敏感词的总长度。
    匹配时间:对于每个可能的子串,哈希表需要逐个验证。假设文本长度为 m,最长敏感词长度为 L,则需要 O(m⋅L) 的时间复杂度来进行逐个子串的检查。
    AC自动机
    预处理时间:构建Trie树和失配指针的时间复杂度为 O(k),与哈希表相同。
    匹配时间:AC自动机只需要遍历文本一次,每次沿着Trie树进行状态转移。匹配时间复杂度为 O(m),其中 m 是文本长度。
  2. 空间复杂度
    哈希表
    空间消耗:哈希表需要为每个敏感词存储独立的键值对,哈希表的大小与敏感词的数量成正比,因此空间复杂度为 O(n),其中 n 是敏感词的数量。
    敏感词独立存储:哈希表无法利用敏感词之间的前缀共享,因此每个敏感词都需要独立存储,容易导致重复存储较长的前缀。
    AC自动机
    空间消耗:AC自动机的Trie树利用了敏感词之间的前缀共享,减少了空间消耗。构建Trie树的空间复杂度为O(k),其中 k 是所有敏感词的总字符数。
    失配指针:失配指针需要额外的空间,但由于失配指针只会指向Trie树的其他节点,因此额外消耗不高。
  3. 匹配性能
    哈希表
    逐字符匹配:哈希表匹配的效率较低,因为它需要逐个子串进行查找。对于每一个起始位置,必须从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);
    }
}
一、 构建fail指针的遍历为层次遍历 二、 root节点的fail指针指向自己本身 三、 如果当前节点父节点的fail指针指向的节点下存在与当前节点一样的子节点,则当前节点的fail指针指向该子节点,否则指向root节点。

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);
        }
    }
}
![](https://img2024.cnblogs.com/blog/3367816/202410/3367816-20241015143830833-326653636.png)
posted @ 2025-04-26 19:31  czx122691411  阅读(177)  评论(0)    收藏  举报