【算法】AC 自动机

· · 算法·理论

1 前言

或许你也跟我一样,打完 CSP2025 后学习了这个知识点 —— AC 自动机。

首先如果您没有学过 KMP 和 Trie 字典树,建议还是先看一下,这里只要求掌握 KMP 的思想即可,不用太熟练。

因为我在最开始学习 AC 自动机的时候对 KMP 的理解也不是很深,所以对于这个算法也是有所畏惧的,但是稍微深入一点,你会发现他实际上没有你想象的那么难,个人感觉甚至还没有 KMP 难吧。

最后,如果想学好 AC 自动机,就请先放下 KMP,理解好 Trie,专心阅读。这样或许对你更有帮助!

KMP:前缀函数与 KMP 算法 - OI Wiki;

Trie:字典树(Trie) - OI Wiki;

更好的阅读体验:AC 自动机 | Pigeonw。

2 基础的 AC 自动机

模式串:待匹配入文本串的字符串。

文本串:被匹配的字符串。

AC 自动机应用:给出若干个模式串和一个文本串,求每个模式串在文本串中出现的次数,或者是是否出现。—— 多模式匹配。

2.1 概述

一个 AC 自动机由两个部分组成:

我们将利用它进行多模式匹配。

2.2 Trie 的构建

Trie 构建这部分的代码很简单,每次给出一个单词添加到 Trie 里面就可以了。

static const int SIGMA=; // 最多有多少个分支 
static const int MAXN=; // 所有模式串的总长度之和(最多分支节点) + 1(根节点)

struct Node {
    int nxt[SIGMA]; // 该节点的子节点,没有是 0
    int fail; // 后面会讲到

    Node() {
        memset(nxt,0,sizeof(nxt));
        fail=0;
    }
};

vector<Node> tr; 

void init() {
    tr.reserve(MAXN);
    tr.push_back(Node()); // root = 0
}

static int id(char c) { // 用于返回处理后的 id (把边权从其他类型转到编号)
    return c-'A'; // 这是个例子,将 'A'~'Z' 转换到编号 0~25,方便 nxt 存储 
}

void insert(string s) { // 添加一个模式串到 Trie
    int u=0;
    for (char c:s) {
        int v=id(c);
        if (!tr[u].nxt[v]) {
            tr[u].nxt[v]=tr.size();
            tr.push_back(Node());
        }
        u=tr[u].nxt[v];
    }
}

// 建议使用 struct 或者 class,方便开多个

假设现在我们有模式串 i, he, his, she, hers,那我们构建的 Trie 树如下:

假设现在我们有文本串 𝚜𝚑𝚎𝚛𝚜𝚑𝚎𝚒𝚜𝚑𝚒𝚜

我们能用文本串在 Trie 上进行匹配,会先经过 7,8,9 三个点,然后就无法匹配了,这时候难道我们要回到根节点重新开始吗?

不,这样效率太慢了。这时候我们想到了 KMP,它运用了 nxt 指针加速,我们也可以引用同样的方案,在找不到可匹配内容的时候跳到另一个地方。

2.3 失配指针(Fail)的构建

我们先来回顾一下失配指针的作用。如果当前文本串 T 已经匹配到了某一个点 i,则下一位 T_{i+1} 应该匹配到 Trie 树当前节点下边权为 T_{i+1} 相连的节点。我们称当找不到该相连节点时应该跳到的点就是当前点的失配指针。

我们该如何求出这个指针呢?假设当前我们找到的区间是 [l, r],则当我们在 Trie 中找到一个可以与文本串匹配的儿子并且下跳时,是增加 r 的动作。所以失配指针应该是增加 l 的动作(因为肯定是从左往右匹配啊)。这是我们很容易发现,对于相同的 r,变化前的 l_1 与变化后的 l_2l_1 < l_2,因为要增加 l)肯定保证 [T_{l_2},T_r][T_{l_1},T_r] 的后缀。如果此时 r 向右移动一位,那么我们还是满足 [T_{l_2},T_r][T_{l_1},T_r] 的后缀,所以如果一个点的父节点的失配指针有一个边权为当前点到父节点边权的点,那么这个点就应该是当前节点的失配指针。

这个可能有点难理解,我们来看一张图。

黄色边是我们已知的失配指针,3 号节点的子节点 4 号节点,失配指针便是 0 号节点下方对应边权同样为 s7 号节点。(4 号节点的失配指针为红色边)

那如果黄色边指向的节点没有对应的同样边权的节点呢?那我们就应该找父节点失配指针的失配指针,直到根节点或者有对应边权的子节点为止。因为失配指针找的是后缀,又有 ba 的后缀,且 cb 的后缀即可推断出 ca 的后缀,我们可以说:(根节点到)当前节点的失配指针 f(的路径)是(根节点到)当前节点(的路径)的后缀,且(根节点到)f 的失配指针(的路径)是(根节点到)f(的路径)的后缀

这里同样给出一张图为例:

淡红是虚点,因为找不到,所以沿着蓝色边(1 的失配指针,黄色边)跳到根节点,红色边是 11 号节点的失配指针。

最后,我们只需要初始化所有根节点的子节点的失配指针指向根节点即可,根节点自环(下图未标明)。这里给出除根节点外的所有失配指针示意。

我们应该如何用代码实现失配指针的构建呢?很好证明的一点是,失配指针指向的节点深度一定小于当前节点深度。因为(根节点到)当前节点的失配指针(的路径)是(根节点到)当前节点(的路径)的后缀,后缀长度小于原串长度,自然深度也较小。所以如果我们按照深度一层一层遍历构造失配指针,是不会发生找不到父节点失配指针的情况的,于是我们可以使用 BFS。

void build() {
    queue<int> q;

    for (int c=0;c<SIGMA;c++) {
        int v=tr[0].nxt[c];
        if (v) {
            tr[v].fail=0; // 初始化
            q.push(v);
        } else {
            tr[0].nxt[c]=0; 
        }
    }

    while (!q.empty()) {
        int u=q.front();
        q.pop();
        for (int c=0;c<SIGMA;c++) {
            int v=tr[u].nxt[c];
            if (v) {
                tr[v].fail=tr[tr[u].fail].nxt[c]; // 只更新失配指针
                q.push(v);
            } else {
                tr[u].nxt[c]=tr[tr[u].fail].nxt[c]; // 失败的时候自动跳转到失配指针
            }
        }
    }
}

到这里,一个基础的 AC 自动机模板就已经完成了,我们先把它封装一下。

struct ACor {
    static const int SIGMA=::SIGMA; 
    static const int MAXN=::MAXN; // 所有模式串的总长度之和 + 1(根节点)

    struct Node {
        int nxt[SIGMA]; 
        int fail;

        Node() {
            memset(nxt,0,sizeof(nxt));
            fail=0;
        }
    };

    vector<Node> tr; 

    ACor() {
        tr.reserve(MAXN);
        tr.push_back(Node()); // root=0
    }

    static int id(int c) { 
        return c; 
    }

    void insert(string s) {
        int u=0;
        for (char c:s) {
            int v=id(c);
            if (!tr[u].nxt[v]) {
                tr[u].nxt[v]=tr.size();
                tr.push_back(Node());
            }
            u=tr[u].nxt[v];
        }
    }

    void build() {
        queue<int> q;

        for (int c=0;c<SIGMA;c++) {
            int v=tr[0].nxt[c];
            if (v) {
                tr[v].fail=0;
                q.push(v);
            } else {
                tr[0].nxt[c]=0;
            }
        }

        while (!q.empty()) {
            int u=q.front();
            q.pop();
            for (int c=0;c<SIGMA;c++) {
                int v=tr[u].nxt[c];
                if (v) {
                    tr[v].fail=tr[tr[u].fail].nxt[c];
                    q.push(v);
                } else {
                    tr[u].nxt[c]=tr[tr[u].fail].nxt[c];
                }
            }
        }
    }
};

3 查询模式串出现次数

3.1 算法流程

刚刚我们构建完了 Fail 指针,现在我们要把它用起来。还是那个例子,𝚜𝚑𝚎𝚛𝚜𝚑𝚎𝚒𝚜𝚑𝚒𝚜。(模式串:i, he, his, she, hers

文本串的第 1 位是 s,我们到达 7 号节点;

文本串的第 2 位是 h,我们到达 8 号节点;

文本串的第 3 位是 e,我们到达 9 号节点,找到单词 she

文本串的第 4 位是 r,这时候 9 号节点下没有边的边权为 r,我们直接走 Fail 指针(缩左端点)到达 2 号节点,找到单词 he

文本串的第 5 位是 s,这时候 2 号节点下没有边的边权为 s,我们直接走 Fail 指针(缩左端点)到达根节点;

文本串的第 6 位是 h,我们到达 1 号节点;

文本串的第 7 位是 e,我们到达 2 号节点,找到单词 he。此时,你或许会发现,到了这一位还可以匹配一个单词 she,但是我们没有统计到,这是因为左端点被 Fail 指针缩的太短了,没有统计到左端点更小的结果。这时候我们开始反思,因为如果有一个单词同样满足在这一位结束且没被统计到,一定是因为这个单词的长度大于当前找到的单词的长度,所以当前单词一定是它的后缀,这也就是失配指针的定义。所以每次找到一个失配指针,失配指针指向节点 Fail[u] 要记录起始节点 u 统计的答案。回到这里,我们要把 9 统计到的答案放到 2 中,成功找到单词 she

文本串的第 8 位是 i,这时候 2 号节点下没有边的边权为 i,我们直接走 Fail 指针(缩左端点)到达根节点;

文本串的第 9 位是 s,我们到达 7 号节点;

文本串的第 10 位是 h,我们到达 8 号节点;

文本串的第 11 位是 i,这时候 8 号节点下没有边的边权为 i,我们直接走 Fail 指针(缩左端点)到达 2 节点,2 节点有边的边权为 i,跳到 5 节点;

文本串的第 12 位是 s,我们到达 6 号节点,找到单词 his

到这里,我们就成功模拟完一整个过程了,总结一下,构建的时候对于每个节点 uFail[u] 都要记录 u 的答案(包括从其他节点用失配指针转换到 u 的答案,以此类推),防止出现左端点不够长的问题。现在我们知道了流程,来做一做例题。

3.1.1 例题 —— P3808 AC 自动机(简单版)

题目描述:给定 n 个模式串 s_i 和一个文本串 t,求有多少个不同的模式串在文本串里出现过。

这时候我们需要在每个节点添加一个参数 vector<int> out,存储当前节点可以匹配到的模式串 ID。

struct Node {
    int nxt[SIGMA]; 
    int fail;
    vector<int> out;

    Node() {
        memset(nxt,0,sizeof(nxt));
        fail=0;
        out.clear();
    }
};

在每次 insert 操作时,最后到达的节点的 out 应该添加该节点的编号,因为只要到达这个点,文本串里一定会有一个当前模式串。

void insert(string s,int id) {
    int u=0;
    for (char c:s) {
        int v=id(c);
        if (!tr[u].nxt[v]) {
            tr[u].nxt[v]=tr.size();
            tr.push_back(Node());
        }
        u=tr[u].nxt[v];
    }
    tr[u].out.push_back(id);
}

当然,我们的 build 函数也需要修改。比如文本串是 she,只考虑其中的两个模式串 heshe,这时候我们的自动机会找到图中的 9 号节点,但是并不会找到 2 号节点,这时候会漏记。所以在每次得到一个 fail 指针后,fail 指针节点的 out 应该传递给当前节点。

void build() {
    queue<int> q;

    for (int c=0;c<SIGMA;c++) {
        int v=tr[0].nxt[c];
        if (v) {
            tr[v].fail=0;
            q.push(v);
        } else {
            tr[0].nxt[c]=0;
        }
    }

    while (!q.empty()) {
        int u=q.front();
        q.pop();
        for (int c=0;c<SIGMA;c++) {
            int v=tr[u].nxt[c];
            if (v) {
                tr[v].fail=tr[tr[u].fail].nxt[c];
                tr[v].out.insert(tr[v].out.end(),tr[tr[v].fail].out.begin(),tr[tr[v].fail].out.end());
                q.push(v);
            } else {
                tr[u].nxt[c]=tr[tr[u].fail].nxt[c]; 
            }
        }
    }
}

这时候我们就可以做最后一步啦,统计是否出现。直接根据文本串的字符在 AC 自动机上走一步即可。

int query(string t) {
    int u=0;
    int ans=0;
    map<int,bool> mp; // 之前是否统计过,可以换成数组
    for (char c:t) {
        int v=id(c);
        u=tr[u].nxt[v];  // 自动根据 fail 跳转
        for (auto i:tr[u].out) { // 统计答案
            if (!mp.count(i)) { mp[i]=true,ans++; }
        }
    }
    return ans;
}

3.1.2 例题—— P3796 AC 自动机(简单版 II)

这道题和上一道差距不大,只需要稍微修改一下查询函数即可。

int query(string t) {
    int u=0;
    map<int,int> mp; // 统计出现次数
    for (char c:t) {
        int v=id(c);
        u=tr[u].nxt[v];  // 自动根据 fail 跳转
        for (auto i:tr[u].out) { // 统计答案
            mp[i]++;
        }
    }
    int ans=-1; mp[ans]=LLONG_MIN;
    for (auto it:mp) {
        if (it.second>mp[ans]) ans=it.first;
    }
    return ans;
}

3.2 拓扑排序优化

咱们先来看一道题,P5357 【模板】AC 自动机。

看完题目,你会发现好像跟上一道题一模一样?可以提交了代码,却是 TLE。

考虑优化。

我们先看一个性质,把所有点的失配指针重新构成一个图,它一定是一个 DAG。证明:每个节点的失配指针一定指向深度更低的点(前面说过),就一定不会有环。

因为每个节点的贡献不仅包括它自己,还有深层节点向它的贡献,我们可以先求出当前节点的贡献(它被访问了几次),再通过刚刚用失配指针构成的 DAG 向前转移,就可以得到每个节点的真实贡献了。以这个节点结束的模式串在文本串中出现的次数也就是这个节点的真实贡献。

又因为我们需要无后效性才能更好的传递,不然一个点被一条指针更新一次,传递到下一个节点,这个点又被另一个指针更新,又要再次传递,这样效率就非常低下。于是我们想到可以让每个点都先被更新完(所有指针的已经对其赋值),再传递到下一个,这实际上就是一个拓扑排序的过程。

总结一下,我们先在 Trie 上根据文本串跑一遍,记录每个经过点的贡献为 1,然后找到在仅由 Fail 指针构成的图中入度为 0 的点入队,跑一遍拓扑排序,对于每个 u 都将其答案传递给它的失配指针即可。

3.2.1 例题 —— P5357 【模板】AC 自动机

这道题便是要用到上面的拓扑排序优化,我们总共分 4 个步骤优化我们的代码。

因为我们要记录每个节点的真实贡献,所以我们要在结构体中新加一个变量 mcnt; 因为我们要记录每个节点的入度,所以我们要在结构体中新加一个变量 rd

struct Node {
    int nxt[SIGMA]; 
    int fail;
    vector<int> out;
    int mcnt;
    int rd;

    Node() {
        memset(nxt,0,sizeof(nxt));
        out.clear();
        fail=0;
        mcnt=0;
        rd=0;
    }
};

每个经过的点都要让真实贡献 mcnt++

void assign(const string& s) {
    int u=0;
    for (auto c:s) {
        int v=id(c);
        u=tr[u].nxt[v];
        tr[u].mcnt++;
    }
}

我们还需要修改 build 函数,预处理入度。

void build() {
    queue<int> q;

    for (int c=0;c<SIGMA;c++) {
        int v=tr[0].nxt[c];
        if (v) {
            tr[v].fail=0;
            q.push(v);
        } else {
            tr[0].nxt[c]=0;
        }
    }

    while (!q.empty()) {
        int u=q.front();
        q.pop();
        for (int c=0;c<SIGMA;c++) {
            int v=tr[u].nxt[c];
            if (v) {
                tr[v].fail=tr[tr[u].fail].nxt[c];
                tr[tr[v].fail].rd++; // v -> tr[v].fail
                q.push(v);
            } else {
                tr[u].nxt[c]=tr[tr[u].fail].nxt[c]; 
            }
        }
    }
}

最后,我们需要添加一个 topu 函数,把他们两个封装起来。

vector<int> topu(int m/**out 中最大的数字**/) {
    queue<int> q;
    for (int i=0;i<tr.size();i++) {
        if (!tr[i].rd) q.push(i);
    }
    vector<int> ans(m+1,0);
    while (!q.empty()) {
        int u=q.front(); q.pop();
        for (auto i:tr[u].out) {
            ans[i]=tr[u].mcnt;
        }
        int v=tr[u].fail;
        tr[v].mcnt+=tr[u].mcnt;
        if (!--tr[v].rd) q.push(v);
    }
    return ans;
}

vector<int> match(string p,int m) {
    assign(p); return topu(m);
}

这道题就可以通过了。

3.3 完整模板

到这里,我们已经彻底掌握 AC 自动机查询模式串出现次数的题型了,我们把这个题型的最终模板放一下:

struct ACor {
    static const int SIGMA=::SIGMA; 
    static const int MAXN=::MAXN; // 所有模式串的总长度之和+1(根节点)

    struct Node {
        int nxt[SIGMA]; 
        int fail;
        vector<int> out;
        int mcnt;
        int rd;

        Node() {
            memset(nxt,0,sizeof(nxt));
            out.clear();
            fail=0;
            mcnt=0;
            rd=0;
        }
    };

    vector<Node> tr; 

    ACor() {
        tr.reserve(MAXN);
        tr.push_back(Node()); // root=0
    }

    static int id(char c) { // 用于返回nxt_id
        return -----; // TODO 转义  
    }

    void insert(string s,int _id) {
        int u=0;
        for (char c:s) {
            int v=id(c);
            // cerr << v << " " << tr[u].nxt[v] << endl;
            if (!tr[u].nxt[v]) {
                tr[u].nxt[v]=tr.size();
                tr.push_back(Node());
            }
            u=tr[u].nxt[v];
        }
        // cerr << _id << " " << u << endl;
        tr[u].out.push_back(_id);
    }
    void assign(const string& s) {
        int u=0;
        for (auto c:s) {
            int v=id(c);
            u=tr[u].nxt[v];
            tr[u].mcnt++;
        }
    }
    void build() {
        queue<int> q;

        for (int c=0;c<SIGMA;c++) {
            int v=tr[0].nxt[c];
            if (v) {
                tr[v].fail=0;
                q.push(v);
            } else {
                tr[0].nxt[c]=0;
            }
        }

        while (!q.empty()) {
            int u=q.front();
            q.pop();
            for (int c=0;c<SIGMA;c++) {
                int v=tr[u].nxt[c];
                if (v) {
                    tr[v].fail=tr[tr[u].fail].nxt[c];
                    tr[tr[v].fail].rd++; // v -> tr[v].fail
                    q.push(v);
                } else {
                    tr[u].nxt[c]=tr[tr[u].fail].nxt[c]; 
                }
            }
        }
    }
    vector<int> topu(int m/**out 中最大的数字**/) {
        queue<int> q;
        for (int i=0;i<tr.size();i++) {
            if (!tr[i].rd) q.push(i);
        }
        vector<int> ans(m+1,0);
        while (!q.empty()) {
            int u=q.front(); q.pop();
            for (auto i:tr[u].out) {
                ans[i]=tr[u].mcnt;
            }
            int v=tr[u].fail;
            tr[v].mcnt+=tr[u].mcnt;
            if (!--tr[v].rd) q.push(v);
        }
        return ans;
    }

    vector<int> match(string p,int m) {
        assign(p); return topu(m);
    }
};

4 AC 自动机与动态规划的结合

4.1 概述

个人觉得这个板块挺板的,AC 自动机里面的 DP 一般都是给出一些字符串,要求构造满足某些条件的新字符串,求最优解,通常限制长度。

4.2 引入

我们还是用一道例题来引入这个板块吧。

4.2.1 例题 —— P3041 [USACO12JAN] Video Game G

题目给出了 n 个模式串要求我们构造一个长度为 k 的文本串,求这个文本串中最多能包含多少个模式串。

最直观的想法是开一个二维的数组 dp[i][j],表示已经拼到了第 i 位,最后 15 = | s_i | 位的状态是 j(这里要二进制压缩一下),因为 s_iABC 构成,所以 j 的大小是 3^1 \leq 3^{15}dp[i][j] 的空间复杂度 O(3^{15} \times 10^3) \approx O(14,348,907,000),会 MLE。

那我们该如何做呢?我们考虑到如果我们要新拼一位,这一位一定要对答案做出贡献的,就像是在 AC 自动机的 Trie 树上,如果我们当前在某个节点,那么下一步如果想有贡献,一定不能跳到一个没有经过的节点。

诶,那我们是不是就可以用 AC 自动机里面的 Trie 树加上 Fail 指针,把 dp 的第二维改成在树上的某个节点即可。这时候我们的 j 最大是 300,也就不会超时了。

总结一下,我们可以开一个二维的 dp,第一维 i 表示当前枚举到的位数,第二维 j 表示当前所在的 Trie 树上的节点编号,转移也就很明显了:

dp[i+1][v]=\max(dp[i+1][v],dp[i][u]+tr[v]_{out}) 我们的代码也就随之而出了: ```cpp void solve() { int n,k; cin >> n >> k; vector<string> s(n+1,""); ACor ac; for (int i=1;i<=n;i++) { cin >> s[i]; ac.insert(s[i]); } ac.build(); vector<vector<int>> dp(k+1,vector<int> (MAXN,-INF)); dp[0][0]=0; for (int i=0;i<k;i++) { for (int j=0;j<ac.tr.size();j++) { if (dp[i][j]<0) continue; for (int p=0;p<ac.SIGMA;p++) { int v=ac.tr[j].nxt[p]; dp[i+1][v]=max(dp[i+1][v],dp[i][j]+ac.tr[v].out); } } } int ans=0; for (int j=0;j<ac.tr.size();j++) ans=max(ans,dp[k][j]); cout << ans << endl; } ``` #### 4.2.2 例题 —— [P4052 [JSOI2007] 文本生成器](https://www.luogu.com.cn/problem/P4052) 跟上一题相似,这道题也需要构造指定长度的字符串,只不过这次不是求最多能包含多少个模式串,而是该长度有多少个包含至少一个模式串的个数。 那么我们还是可以基本沿用上一题的思想,运用二维 DP,$dp[i][j]$ 表示走了 $i$ 步到达第 $j$ 个点(在 Trie 上)不可读文本的数量。因为我们无法记录可读文本数量,可以用所有的减去不可读的数量。 转移就是: $$dp[i][v]+=dp[i-1][u] (tr[v]_{out} == 0)$$ 初始化 $dp[0][0]$ 为 $1$。 代码: ```cpp void solve() { int n,m; cin >> n >> m; vector<string> s(n+1,""); ACor ac; for (int i=1;i<=n;i++) { cin >> s[i]; ac.insert(s[i]); } ac.build(); vector<vector<int>> dp(m+1,vector<int> (MAXN,0)); // dp[i][j] : 走了 i 步到达第 j 个点(在 Trie 上)不可读文本的数量 dp[0][0]=1; for (int i=1;i<=m;i++) { for (int j=0;j<ac.tr.size();j++) { for (int p=0;p<SIGMA;p++) { int v=ac.tr[j].nxt[p]; if (!ac.tr[v].out) { (dp[i][v]+=dp[i-1][j])%=MOD; } } } } int sum=0; for (int j=0;j<ac.tr.size();j++) (sum+=dp[m][j])%=MOD; cout << ((qpow(26,m,MOD)-sum)%MOD+MOD)%MOD << endl; } ``` ### 4.3 总结 我们发现我们使用的 $dp$ 数组都是二维,第一维是走了多少步(匹配到第几个字符),第二维是在 Trie 树上的编号,实际上绝大部分的 AC 自动机结合动态规划的题目都是这样,如果发现题目需要构造长度固定的字符串,可以往这个方向去思考。 ## 5 另类题目 ### 5.1 概述 这个部分的题目大多是没有通式通法的,是值得思考的一类题目。 ### 5.2 例题 #### 5.2.1 例题 —— [CF1202E You Are Given Some Strings...](https://www.luogu.com.cn/problem/CF1202E) 题目要求我们找到总共有多少组由两个模式串拼接起来的新串能在文本串中出现。 那么我们就可以枚举 $t$ 的每一位 $i$,找到以 $t[i]$ 结尾的模式串个数与以 $t[i+1]$ 开始的模式串个数相乘最后将每一位的答案相加即可,但是如果我们需要在一遍 AC 自动机中同时获得当前节点结尾和开始的贡献,就需要使用 `vector` 来存储 `out`,此时的时间复杂度也会大大增加,于是我们想到了跑两遍 AC 自动机,只记录当前结尾的贡献,正反跑两遍即可。 ```cpp void solve() { string t; cin >> t; int n; cin >> n; int m=t.size(); ACor ac,rac; vector<string> s(n,""); for (int i=0;i<n;i++) { cin >> s[i]; cerr << s[i] << endl; ac.insert(s[i]); string tp=s[i]; reverse(tp.begin(),tp.end()); rac.insert(tp); cerr << tp << endl; } cerr << "Debug\n"; ac.build(); rac.build(); string rt=t; reverse(rt.begin(),rt.end()); vector<int> cnt1=ac.query(t),cnt2=rac.query(rt); int ans=0; for (int i=1;i<m;i++) { ans+=cnt1[i]*cnt2[m-i]; } cout << ans << endl; } ``` #### 5.2.2 例题 —— [P2444 [POI 2000] 病毒](https://www.luogu.com.cn/problem/P2444) 题目要求我们找到一个无限长度的字符串,满足该字符串不包含任何模式串。 我们还是考虑运用到 Trie 树和 Fail 指针。我们可以转换题面,因为要求找到一个不包含任何模式串的序列,那么也就是说在 Trie 上这个序列不会找到任何一个点满足 `out` 不为 $0$,所以我们要找到一个只由树上 `out` 为 $0$ 的点构成的环,找到了就成功,找不到就是失败。 ```cpp bool flag=false; // 是否找到可行环 void dfs(int u,ACor& ac) { if (flag) return ; ac.tr[u].vis=true; for (int c=0;c<SIGMA;c++) { int v=ac.tr[u].nxt[c]; if (ac.tr[v].vis) { flag=true; return ; } if (!ac.tr[v].vis && !ac.tr[v].f && !ac.tr[v].out) { ac.tr[v].f=true; dfs(v,ac); } } ac.tr[u].vis=false; } void solve() { int n; cin >> n; vector<string> s(n+1,""); ACor ac; for (int i=1;i<=n;i++) { cin >> s[i]; ac.insert(s[i]); } ac.build(); dfs(0,ac); cout << (flag?"TAK":"NIE") << endl; } ``` #### 5.2.3 拓展 —— [P2414 [NOI2011] 阿狸的打字机](https://www.luogu.com.cn/problem/P2414) ## 6 更新日志 $2025/11/23$ 初稿。 $2025/11/25$ 修复数学公式问题。 ``` 很遗憾,您的《【算法】AC 自动机》不符合推荐标准。原因是:应使用行内代码块表示字符串或代码;数学公式(运算式、运算符、数学推导、参与运算的常数、作为变量的字母等)应使用 LaTeX。。 ``` $2025/11/25$ 补全漏掉的标点符号,行内函数使用行内代码快。 ``` 很遗憾,您的《【算法】AC 自动机》不符合推荐标准。原因是:句子末尾应添加句号,且全文使用的句号应一致;应使用行内代码块表示字符串或代码。|审核管理员:博瀚君,对审核结果有疑问请私信交流。 ``` $2025/11/28$ 模式串、文本串改用行内代码块而非 LaTeX。 ``` 很遗憾,您的《【算法】AC 自动机》不符合推荐标准。原因是:非数学公式(一般英文单词、题目名、算法名、人名等)不应使用 LaTeX。。 ``` $2025/12/16$ 修复格式问题,添加“AC 自动机与动态规划的结合”板块。 ``` 很遗憾,您的《【算法】AC 自动机》不符合推荐标准。原因是:「修改 build 函数」「添加一个 topu 函数」格式不统一啊;建议给出更多的应用场景。。 ``` $2025/12/16$ 修复格式问题。 ## 7 后记 首先感谢管理员的耐心审核! 由于篇幅较长,从最开始写稿到审核结束的时间很长,内容变更也很多,不能保证文中所有内容完全正确。如出现错误,请联系 [twyeottk](https://www.luogu.com.cn/user/1635665),感谢您对这篇文章的贡献! 最后,感谢您的阅读!希望您能通过这篇文章学懂 AC 自动机!祝您在 OI 路上走得越走越远!