【笔记/模板】AC 自动机

AC 自动机

前缀函数以及 KMP 算法很好的处理了单个模式串和文本串之间的字符匹配问题,但是如果存在多个模式串对于单个文本串进行匹配时,每个模式串都要和文本串进行一次预处理,时间复杂度为 \(O(n (|T| + |S|))\)\(n\) 为模式串个数,\(|T|,|S|\) 为模式串和文本串长度。

而 AC 自动机很好的解决了这个问题,AC 自动机(Aho-Corasick Automaton)是一种同样利用了 KMP 思想,以 Tire 树为结构的多模式匹配算法。

算法过程

AC 自动机的过程分为两步:建立 Trie 树以及预处理失配指针。

建立 Trie 树

不多赘述,Trie 树上的每一个节点表示某一个模式串的前缀。

inline void insert(char *str)
{
    int cur = 0, len = strlen(str);
    for (int i = 0; i < len; i ++)
    {
        int k = str[i] - 'a';
        if (!tr[cur][k]) tr[cur][k] = ++ idx;
        cur = tr[cur][k];
    }
}

处理失配指针

失配指针,又称为 \(fail\) 指针,本质即为 KMP 算法中的 \(next\) 辅助数组。

我们知道,KMP 算法中的 \(next[]\) 中记录着每一个前缀字符串的最长 \(border\) 值,AC 自动机的 \(fail\) 数组同样如此。\(fail_u\) 表示状态 \(u\) 的指针,指向着状态 \(u\) 的最大 border 值所处的状态(因为存在多个模式串,他们的 \(border\) 相互交叉),然而 Trie 树上每一个节点的状态刚好就是每一个模式串可能的所有前缀集合。

因此,AC 自动机的失配指针指向当前状态的最长后缀状态。

OI-Wiki 的图举个例子:

节点 \(6\) 的状态为 \(\texttt{his}\),它的失配指针 \(fail_6 = 7\) 的状态为 \(\texttt{s}\),满足定义。

节点 \(9\) 的状态为 \(\texttt{she}\),它的失配指针 \(fail_9 = 2\) 的状态为 \(\texttt{he}\),满足定义。

那么如何构建呢?

首先一个显然的性质,一个 Trie 树上某一个节点的 \(fail\) 指针必然指向深度小于它的节点,因此可以使用 bfs 的框架。

其次,就像我们在 KMP 预处理 \(next_1 \gets 0\) 一样,Trie 树的第二层(根节点为第一层)不存在匹配,因此直接指向 \(0\)

剩下的每一层,不难发现会有继承关系,就像上图所示的 \(fail_8 = 1, fail_9 = 2\),通过前缀函数定义显然易证。因此让每一个当前节点的 fail 指向它的父亲节点的 fail 指针的这一个下标,代码语言可以写为:

cur -> son[k] -> fail = cur -> fail -> son[k];

以上是在有这个节点的情况下,如果不存在而不去处理,后面的 bfs 的 \(fail\) 数组很可能指向这里,因此我们还要把不存在的这个节点直接当作之前所说的哪一个节点,即:

cur -> son[k] = cur -> fail -> son[k];

像这样,后面的 \(fail\) 如果跳到了 \(cur\) 上的 \(son_k\),就会直接跳向 \(fail_{cur}\) 上的 \(son_k\) 了。

之所以不用考虑边界问题,是因为不存在的默认置 \(0\) 了。

inline void build()
{
    hh = 0, tt = -1;
    for (int k = 0; k < 26; k ++)
        if (tr[0][k]) fail[tr[0][k]] = 0, que[++ tt] = tr[0][k];
    while (hh <= tt)
    {
        int cur = que[hh ++];
        for (int k = 0; k < 26; k ++)
        {
            if (tr[cur][k]) fail[tr[cur][k]] = tr[fail[cur]][k], que[++ tt] = tr[cur][k];
            else tr[cur][k] = tr[fail[cur]][k];
        }
    }
}

多模式匹配

对于查询的文本串 \(T\),我们只需要在处理好的 Trie 树上走一遍,对于每一个走到的状态,这个状态都是 \(T\) 的前缀,因此这个状态和包括所有的 \(fail\) 指针都匹配上了 \(T\),按照要求处理即可。

比如 P3808 AC 自动机(简单版) - 洛谷 | 计算机科学教育新生态 中的查询:

int query(char *str)
{
    int cur = 0, ans = 0, len = strlen(str);
    for (int i = 0; i < len; i ++)
    {
        int k = str[i] - 'a';
        cur = tr[cur][k];
        for (int temp = cur; temp && ~cnt[temp]; temp = fail[temp])
            ans += cnt[temp], cnt[temp] = -1;
    }
    return ans;
}

P3796 AC 自动机(简单版 II) - 洛谷 | 计算机科学教育新生态 中:

void query(char *str)
{
    int cur = 0, len = strlen(str);
    for (int i = 0; i < len; i ++)
    {
        cur = tr[cur][str[i] - 'a'];
        for (int temp = cur; temp; temp = fail[temp])
            ans[rec[temp]].cnt ++;
    }
}

实际上这样的复杂度是错误的,和 KMP 算法中的一样,不断地跳 \(next\) 时间复杂度为 \(O(|S|)\),因此此时的 AC 自动机时间复杂度达到了 \(O(\textstyle{\sum}|S| \times |T|)\),因此我们需要优化。

效率优化

之前说到,一个状态的 \(fail\) 深度小于这个状态的深度,又由于每一个点只有一个 \(fail\) 指针,每个点互相之间应当联通,因此可以证明:

Trie 树上只保留 \(fail\) 的情况下是一棵树,根节点为 \(0\)

因此暴力跳 \(fail\) 的过程变为了在一棵树上求和的过程,用 拓扑排序 或者 bfs 都可以。

P5357 【模板】AC 自动机 - 洛谷 | 计算机科学教育新生态 Code:

struct ACAutomaton
{
    int tr[N][26], fail[N], idx;
    int que[N], hh = 0, tt = -1;

    inline void insert(char *str, int id)
    {
        int cur = 0, len = strlen(str);
        for (int i = 0; i < len; i ++)
        {
            int k = str[i] - 'a';
            if (!tr[cur][k]) tr[cur][k] = ++ idx;
            cur = tr[cur][k];
        }
        rec[id] = cur;
    }

    inline void build()
    {
        hh = 0, tt = -1;
        for (int k = 0; k < 26; k ++)
            if (tr[0][k]) fail[tr[0][k]] = 0, que[++ tt] = tr[0][k];
        while (hh <= tt)
        {
            int cur = que[hh ++];
            for (int k = 0; k < 26; k ++)
            {
                if (tr[cur][k]) fail[tr[cur][k]] = tr[fail[cur]][k], que[++ tt] = tr[cur][k];
                else tr[cur][k] = tr[fail[cur]][k];
            }
        }
    
        for (int ver = 1; ver <= idx; ver ++) add(fail[ver], ver);
    }

    void query(char *str)
    {
        int cur = 0, len = strlen(str);
        for (int i = 0; i < len; i ++)
        {
            cur = tr[cur][str[i] - 'a'];
            sum[cur] ++;
        }
    }
} AC;

void dfs(int ver)
{
    for (int i = h[ver], to = e[i]; ~i; i = ne[i], to = e[i])
    {
        dfs(to);
        sum[ver] += sum[to];
    }
}

Reference

P3808 AC 自动机(简单版) - 洛谷 | 计算机科学教育新生态

P3796 AC 自动机(简单版 II) - 洛谷 | 计算机科学教育新生态

P5357 【模板】AC 自动机 - 洛谷 | 计算机科学教育新生态

AC 自动机 - OI Wiki

posted @   ThySecret  阅读(16)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
点击右上角即可分享
微信分享提示