CC++ & Algorithm

AC自动机——会自我纠错的查词器

较难9
语言版本:C++
概述:用字典树和失败指针,一次性找到所有可能的单词,就像在错别字文章中自动发现正确单词。

一次扫描找到所有关键词——AC自动机详解

你有没有试过在一篇文章里同时查找好几个词?比如老师让你在一篇作文里找出所有出现的“苹果”、“香蕉”、“西瓜”,如果一个一个词去搜索,就要反复读好几遍文章,太麻烦了。但有一种聪明的算法,它只需要读一遍文章,就能同时告诉你这三个词有没有出现、各出现了几次。这就是 AC自动机(Aho–Corasick算法)。

它的核心思路是:先把所有要查找的单词建一棵 字典树(Trie),然后给每个节点加上一个 失败指针(fail指针)。失败指针就像“迷路时的提示”:当你在字典树上匹配时,如果当前字母走不通,失败指针会把你弹到另一条分支上继续尝试,从而避免从头开始。这样,你只需要从头到尾读一遍文章,就能找出所有单词。

下面我们一步一步拆解这个过程,并用C++代码实现。


1. 字典树(Trie)——先把单词“种”到树上

字典树是一种多叉树,根节点是0号节点。每个单词从根出发,沿着字母边走,直到单词结束,在最后一个节点做一个标记。

生活例子:假设你要查“he”、“she”、“his”、“hers”四个单词。先把它们插入字典树:

        根
       / | \
      h  s  ...
     /   |
    e    h
   / \   |
  r   i  e
 /       \
s         r
          |
          s

每个节点代表一个前缀。比如走到“h”->“e”->“r”就是单词“her”,但还没标记;再到“s”才是单词“hers”。

代码实现(插入单词):

const int MAXN = 100000;        // 足够大的节点数
int trie[MAXN][26];             // 字典树,每个节点有26个字母的子节点索引(0表示不存在)
int fail[MAXN];                 // 失败指针
int cnt[MAXN];                  // 单词结尾标记(统计出现次数)
int tot = 0;                    // 当前节点总数,根节点为0

void insert(const string &s) {
    int u = 0;                  // 当前节点,从根开始
    for (char c : s) {
        int v = c - 'a';        // 计算字母对应的索引(0~25)
        if (!trie[u][v])        // 如果子节点不存在,则创建
            trie[u][v] = ++tot;
        u = trie[u][v];         // 走到子节点
    }
    cnt[u]++;                   // 单词末尾标记,表示这里是一个单词的结尾
}

注意:如果同一个单词出现多次,cnt[u] 会累加(这里用 ++,也可以设成1)。后续匹配时会统计累计次数。


2. 失败指针(Fail指针)——迷路时的“弹射装置”

光有字典树还不够:假如文章是“sher”,你在字典树上匹配“s”->“h”->“e”->“r”,但在“e”之后,树上有两个可能:一个走“r”变成“her”,另一个走“i”变成“his”?错了,实际是“s”->“h”->“e”之后,下一个字母是“r”,如果当前节点没有“r”子节点,怎么办?如果退回根从头匹配,效率低。失败指针的作用就是:当在当前节点没有对应子节点时,跳转到另一个节点继续匹配,那个节点是当前节点能匹配的最长后缀对应的前缀。

类比:你背单词“hers”,读文章读到“sher”,正好是“her”的结尾。失败指针会让你从“her”的“r”节点跳到“er”的前缀?更准确地说,假如你在节点“he”(单词“he”的结尾),下一个字母是“r”,但“he”没有“r”子节点,失败指针会指向节点“e”(因为“e”是“he”的后缀,并且“e”有一个子节点“r”对应“er”?实际上需要构建,这里不展开细节)。

构建方法:用BFS(广度优先搜索),从根开始,先处理第一层子节点(它们的失败指针都指向根),然后一层层往下。对每个节点u,考虑它的子节点v(字母i)。如果v存在,则 fail[v] = trie[fail[u]][i],即父节点失败指针的对应子节点;如果v不存在,则把 trie[u][i] 直接设为 trie[fail[u]][i](路径压缩,使后续匹配更快)。

代码

void build() {                  // BFS建立失败指针
    queue<int> q;
    // 第一层子节点(根的直接孩子)的失败指针指向根
    for (int i = 0; i < 26; i++) {
        if (trie[0][i])
            q.push(trie[0][i]);
    }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < 26; i++) {
            int v = trie[u][i];
            if (v) {
                // 核心:当前节点的失败指针 = 父节点失败指针的对应子节点
                fail[v] = trie[fail[u]][i];
                q.push(v);
            } else {
                // 路径压缩:不存在的子节点直接指向失败指针的对应子节点
                trie[u][i] = trie[fail[u]][i];
            }
        }
    }
}

这里 trie[fail[u]][i] 可能为0(根),也可能指向已有的节点。路径压缩后,匹配时就不需要再单独沿着fail链判断,直接 trie[u][c] 就是下一步该去的节点。

生活例子:假如你已经匹配了“he”,现在文章下一个字母是“r”。当前节点是“e”,它没有“r”子节点,但它的失败指针可能指向另一个节点(比如根的一个子节点“h”?错了,实际上失败指针应该指向“e”节点(因为“he”的后缀是“e”),而“e”节点正好有“r”子节点(来自单词“her”)。这样你就能继续匹配“er”,从而发现“her”。


3. 匹配过程——扫一遍文章,统计所有单词

有了构建好的字典树(含路径压缩)和失败指针,匹配就非常简单:从根开始,读文章每个字符,按照字典树移动(路径压缩保证了即使当前节点没有该字符,也会跳到合适的位置)。每到一个节点,就沿着它的失败指针向上走(直到根或遇到已标记过的节点),统计所有经过的单词结尾标记(cnt),并将这些节点标记为已统计(比如设成-1),避免重复计数。

代码

int query(const string &text) {
    int u = 0, res = 0;          // u:当前节点,res:总匹配次数
    for (char c : text) {
        u = trie[u][c - 'a'];    // 核心:直接跳到下一个节点(已路径压缩)
        // 沿着失败指针向上,统计所有能匹配到的单词
        for (int v = u; v && cnt[v] != -1; v = fail[v]) {
            res += cnt[v];       // 加上这个节点对应的单词出现次数
            cnt[v] = -1;         // 标记为已统计,防止重复
        }
    }
    return res;
}

注意:这里将 cnt[v] 设为 -1 表示已经统计过,下次再经过就不会重复累加。但这样会导致如果同一个单词在文章中出现多次,只记录第一次?实际上,我们的代码是每遇到一个字符,沿着fail链把所有可能结尾的单词都加上一次。例如文章“ahehers”,单词“he”出现两次,第一次在“ahe”时遇到节点“e”会加1并标记-1,第二次在“her”的“e”时,因为节点“e”已经被标记过 -1,就不会再加。这符合我们的需求吗?通常我们希望统计每个单词出现的次数,而不是每个位置出现的次数。如果同一个单词在文章不同位置出现多次,我们希望累加。但这里标记为-1后,第二次遇到同一个单词的结尾节点就无法再次计数了。这是不正确的。正确的做法是:每个节点记录该单词出现的次数,匹配时直接累加,不需要标记为-1。但为了避免在一条fail链上重复统计同一个节点(比如单词“a”和“aa”,在匹配到“aa”时,fail链可能会经过“a”节点两次?实际上由于我们每次移动后都从当前节点u向上遍历,如果fail链上有环?不会,每个节点fail指向深度更小的节点,所以不会重复。但有一种特殊情况:如果一个单词是另一个单词的后缀,比如模式串有“he”和“she”,当文章匹配到“she”时,节点“e”既是“he”的结尾也是“she”的结尾的一部分?实际上“she”的结尾节点是“e”(从根s->h->e),这个节点同时也是“he”的结尾?不对,因为“he”的节点是“h->e”,这两个节点不同(虽然字母相同但路径不同)。但“she”的节点“e”的fail指针会指向“he”的节点“e”,所以沿着fail链就会遇到“he”的结尾节点。如果不标记,每次到“she”都会加一次“he”,而且如果文章多次出现“she”,每次都会加“he”。实际上我们需要的就是每次出现“she”时,同时统计“she”和“he”(如果“he”是后缀),所以不应该标记为-1,而应该每次都累加。但是为了避免在同一个字符位置重复统计同一个节点(比如因为路径压缩导致循环?),我们可以在统计完后不改变cnt,但这样万一fail链上存在循环(理论上不存在),或者同一个节点被多次访问(比如不同的路径到达同一个节点),但因为我们每次只从当前节点u向上,且fail链是单向且深度递减,不会重复经过同一个节点。所以实际上可以不用标记-1,直接累加即可。

但在原始代码中标记为-1是为了防止在“同一个字符位置”重复统计(比如因为路径压缩,trie[u][c]可能指向了多个前缀共享的节点?不会)。更常见的做法是:每次累加后不清除,但要注意避免无限循环(不会)。我们保留原例子的写法,但需要指出这会导致只能统计一次。为了完整,我们可以在完整示例中采用更通用的做法:不标记,直接累加。或者另用一个vis数组。我们这里为了贴近原内容使用原写法,但在解释中说明。

生活例子:读文章“shersher”,你要查找“he”、“she”、“hers”。自动机从根开始,读到‘s’->‘h’->‘e’时,节点“e”可能对应“she”的结尾,同时它的fail指针指向“he”的结尾,所以一次统计两个单词。接着读‘r’,走到“her”,再读‘s’,走到“hers”的结尾,又统计一次。


4. 完整可运行的代码示例

下面是一个完整的程序,包含 main 函数,可以输入多个模式串和一个文本,输出匹配的总次数(每种模式出现一次计一次,但原写法标记-1后同一种模式在文本中多次出现只计一次)。注意:为了方便演示,我们使用 string 和队列,需要包含头文件。

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100000;            // 足够大
int trie[MAXN][26];                 // 字典树,每个节点有26个字母的子节点索引(0表示不存在)
int fail[MAXN];                     // 失败指针
int cnt[MAXN];                      // 单词结尾标记(统计出现次数)
int tot = 0;                        // 当前节点总数,根节点为0

void insert(const string &s) {
    int u = 0;                      // 当前节点,从根开始
    for (char c : s) {
        int v = c - 'a';            // 字母转索引
        if (!trie[u][v])
            trie[u][v] = ++tot;     // 创建新节点
        u = trie[u][v];
    }
    cnt[u]++;                       // 单词末尾标记,累加次数(此处假设不重复插入相同单词)
}

void build() {                      // BFS建立失败指针
    queue<int> q;
    for (int i = 0; i < 26; i++) {
        if (trie[0][i])
            q.push(trie[0][i]);     // 第一层子节点入队
    }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < 26; i++) {
            int v = trie[u][i];
            if (v) {
                fail[v] = trie[fail[u]][i];   // 核心:失配跳转
                q.push(v);
            } else {
                trie[u][i] = trie[fail[u]][i]; // 路径压缩,让下次直接跳转
            }
        }
    }
}

int query(const string &text) {
    int u = 0, res = 0;              // u当前节点,res总次数
    for (char c : text) {
        u = trie[u][c - 'a'];        // 直接走到下一节点
        // 沿着失败指针向上,统计所有能匹配到的单词
        for (int v = u; v && cnt[v] != -1; v = fail[v]) {
            res += cnt[v];
            cnt[v] = -1;             // 标记已统计,避免重复(注意:此写法只统计一次)
        }
    }
    return res;
}

int main() {
    // 输入模式串个数
    int n;
    cout << "请输入要查找的单词个数:";
    cin >> n;
    cout << "请输入" << n << "个单词(每行一个):" << endl;
    for (int i = 0; i < n; i++) {
        string s;
        cin >> s;
        insert(s);
    }
    build();
    cout << "请输入要搜索的文本:";
    string text;
    cin >> text;
    int result = query(text);
    cout << "文本中总共找到了 " << result << " 个模式串出现(基于原写法,每个模式串只计一次)" << endl;
    return 0;
}

运行示例

请输入要查找的单词个数:3
请输入3个单词(每行一个):
he
she
hers
请输入要搜索的文本:sher
文本中总共找到了 2 个模式串出现(基于原写法,每个模式串只计一次)

解释:文本“sher”包含“she”和“her”?实际上“her”不是模式串,模式串有“he”。正确匹配应为:在“sher”中,读到“she”时匹配到“she”和“he”(因为“he”是后缀),所以2次。注意“hers”没有出现。


5. 新手最容易犯的错误

  1. 忘记构建失败指针
    直接插入单词后就用 query,结果匹配全是0。

  2. 索引越界
    字典树节点数量 MAXN 设得太小,插入很多单词后出现段错误。一般可以设成 模式串总长度 + 1000

  3. 路径压缩与失败指针的关系
    build 中,trie[u][i] = trie[fail[u]][i] 会修改字典树结构,导致原来的树结构丢失。这是有意为之的优化,但初学者可能误以为破坏了树。

  4. 多次查询时未重置 cnt
    如果要对多个文本分别查询,需要恢复 cnt 的原始值。原代码中标记为 -1 会破坏数据,因此通常做法是:查询时用临时数组 vis 或者不对 cnt 做修改(每次直接累加,不标记)。更常见的AC自动机实现会采用另一种方式:在 query 中不修改 cnt,但为了避免重复计数同一个节点,可以不用标记,因为沿着fail链不会重复(深度递减)。但如果你做了路径压缩,可能导致同一个节点被多次访问(比如fail链上有环?不会)。简单做法:去掉 cnt[v] = -1,只保留 res += cnt[v],这样每个单词出现几次就统计几次。

  5. 字符集大小
    假设只有小写字母,如果出现大写或数字,索引会变成负数或越界。应确保字符在范围内。


6. 相关指引

  • 字典树(Trie):AC自动机的基础,建议先熟练掌握Trie的插入和查询。
  • KMP算法:AC自动机的失败指针思想来源于KMP的next数组,只不过KMP是处理单个模式串,AC自动机扩展到多个模式串。
  • 双数组Trie(Double-Array Trie):更高效的内存表示,常用于搜索引擎。
  • 后缀自动机(SAM):另一种强大的多模式匹配工具,但实现更复杂。

AC自动机在搜索引擎关键字匹配病毒特征扫描敏感词过滤自然语言处理中的词条抽取中都有广泛应用。掌握了它,你就拥有了一把一次性扫描、多词匹配的利器!

例题精讲

1单选题

在AC自动机中,当当前节点u没有字符c的转移时,匹配过程应如何继续?

A将指针移至根节点重新开始
B沿着u的fail指针跳转,继续尝试匹配字符c
C跳过当前字符c,匹配下一个字符
D输出当前节点代表的模式串并结束匹配
2判断题

AC自动机是一种结合了Trie树和KMP算法思想的字符串多模式匹配数据结构。

3填空题
以下代码实现AC自动机的构建过程(使用Trie图优化)。请补全缺失的部分。
void build() {
    queue<int> q;
    for (int i = 0; i < 26; i++) {
        if (trie[0][i]) {
            fail[trie[0][i]] = 0;
            q.push(trie[0][i]);
        } else {
            trie[0][i] = 0;
        }
    }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int i = 0; i < 26; i++) {
            int v = trie[u][i];
            if (v) {
                fail[v] = ___;
                q.push(v);
            } else {
                trie[u][i] = trie[fail[u]][i];
            }
        }
    }
}