您当前的位置:首页 > 算法研究

C++字符串专题:KMP、AC自动机、后缀数组怎么选?

时间:2026-05-03 17:12:35  来源:  作者:

C++字符串处理是算法竞赛中的半壁江山。从判断子串到模式匹配,从敏感词过滤到最长公共子串,你总会遇到这样的困惑:KMP 我会写,AC 自动机我学过,后缀数组好像也能做,但面对一道具体的字符串题,我到底该用哪一个?

选错算法,轻则多写几百行代码,重则 TLE 或 MLE。本文将从核心思想、时间复杂度、代码复杂度、适用场景四个维度,帮你彻底厘清 KMP、AC 自动机、后缀数组这三个经典字符串算法之间的关系与选择策略。无论你是竞赛选手还是面试准备者,这篇文章都能让你少走弯路。

一、KMP:单模式串匹配的王者

核心思想

KMP(Knuth‑Morris‑Pratt)算法解决的是 单模式串匹配 问题:给定一个文本串 S 和一个模式串 P,找出 P 在 S 中所有出现的位置。朴素匹配的复杂度为 O(|S|·|P|),而 KMP 通过 前缀函数(π 数组)next 数组 在匹配失败时避免回退文本串指针,将复杂度降至 O(|S|+|P|)。

前缀函数 π[i] 表示子串 P[0..i] 中真前缀与真后缀相等的最长长度。当字符失配时,我们可以根据 π 值跳跃到下一个可能匹配的位置。

C++ 实现(简洁版)

cpp
vector<int> build_prefix(const string &p) {
    int m = p.size();
    vector<int> pi(m, 0);
    for (int i = 1; i < m; ++i) {
        int j = pi[i-1];
        while (j > 0 && p[i] != p[j]) j = pi[j-1];
        if (p[i] == p[j]) ++j;
        pi[i] = j;
    }
    return pi;
}

vector<int> kmp_search(const string &s, const string &p) {
    vector<int> pi = build_prefix(p);
    vector<int> res;
    int j = 0;
    for (int i = 0; i < s.size(); ++i) {
        while (j > 0 && s[i] != p[j]) j = pi[j-1];
        if (s[i] == p[j]) ++j;
        if (j == p.size()) {
            res.push_back(i - j + 1);
            j = pi[j-1];
        }
    }
    return res;
}

适用场景

  • 单一模式串,需要在文本中查找。

  • 多次查询同一模式串(可预处理 π 数组复用)。

  • 需要 最坏情况稳定 的 O(n+m) 时间,不受数据影响。

局限性

  • 只支持单模式串。如果有多个模式串(例如 10^5 个关键词),对每个都跑一次 KMP 会超时。

  • 无法处理模式串集合的“同时匹配”。

二、AC 自动机:多模式串的瑞士军刀

核心思想

AC 自动机(Aho‑Corasick Automaton)是 KMP 在多模式串上的推广。它先将所有模式串构建成一棵 Trie 树,然后通过 失配指针(fail 指针) 在匹配失败时快速跳转到另一个状态。预处理复杂度 O(总模式串长度),匹配复杂度 O(文本串长度 + 每个字符的跳转次数),整体接近 O(文本长 + 所有模式串总长)。

失配指针 的设计非常像 KMP 的 π 数组,只不过从一维的链扩展到了树上的跨分支跳跃。构建时使用 BFS 逐层计算 fail 指针。

C++ 实现(结构体封装)

cpp
struct AhoCorasick {
    struct Node {
        int next[26];
        int fail;
        int cnt;  // 以该节点结尾的模式串数量
        Node() {
            memset(next, 0, sizeof(next));
            fail = cnt = 0;
        }
    };
    vector<Node> trie;
    
    AhoCorasick() { trie.emplace_back(); }
    
    void insert(const string &s) {
        int u = 0;
        for (char ch : s) {
            int c = ch - 'a';
            if (!trie[u].next[c]) {
                trie[u].next[c] = trie.size();
                trie.emplace_back();
            }
            u = trie[u].next[c];
        }
        trie[u].cnt++;
    }
    
    void build() {
        queue<int> q;
        for (int c = 0; c < 26; ++c) {
            if (trie[0].next[c]) {
                trie[trie[0].next[c]].fail = 0;
                q.push(trie[0].next[c]);
            }
        }
        while (!q.empty()) {
            int u = q.front(); q.pop();
            for (int c = 0; c < 26; ++c) {
                int v = trie[u].next[c];
                if (v) {
                    trie[v].fail = trie[trie[u].fail].next[c];
                    trie[v].cnt += trie[trie[v].fail].cnt;  // 累加后缀模式串数量
                    q.push(v);
                } else {
                    trie[u].next[c] = trie[trie[u].fail].next[c];
                }
            }
        }
    }
    
    int match(const string &text) {
        int u = 0, res = 0;
        for (char ch : text) {
            int c = ch - 'a';
            u = trie[u].next[c];
            res += trie[u].cnt;  // 统计所有匹配的模式串
        }
        return res;
    }
};

适用场景

  • 多模式串匹配:例如敏感词过滤、DNA 序列中查找多个基因片段。

  • 需要同时统计所有模式串的出现位置或次数。

  • 模式串集合固定,文本串动态变化(例如在线流式匹配)。

局限性

  • 构建和存储开销较大(每个字符对应一个节点,节点数组大小 ≈ 总模式串字符数 × 字母表大小)。

  • 如果模式串数量很少(例如 1 个),用 AC 自动机属于“杀鸡用牛刀”,不如直接 KMP。

  • 不能直接处理模式串之间的“重叠”计数细节(可通过拓扑排序或单独记录解决)。

三、后缀数组:全局视角的强大工具

核心思想

后缀数组(Suffix Array)是对字符串的所有后缀进行排序后得到的数组 sa[i] 表示排名第 i 的后缀的起始位置。通常配合 rank 数组(后缀的排名)和 height 数组(相邻排名后缀的最长公共前缀长度)使用。

后缀数组可以在 O(n log n) 或 O(n) 时间内构建(倍增法、DC3、SA‑IS),配合 LCP 可以解决一类“子串”相关问题:最长重复子串、不同子串个数、两个字符串的最长公共子串、模式串在文本中的出现次数(成功匹配次数可用二分 + LCP 在 O(|P| log n) 内得到)等。

C++ 实现(倍增法模板,简短版)

cpp
vector<int> build_sa(const string &s) {
    int n = s.size();
    vector<int> sa(n), rank(n), tmp(n);
    for (int i = 0; i < n; ++i) sa[i] = i, rank[i] = s[i];
    for (int k = 1; k < n; k <<= 1) {
        auto cmp = [&](int i, int j) {
            if (rank[i] != rank[j]) return rank[i] < rank[j];
            int ri = (i + k < n) ? rank[i + k] : -1;
            int rj = (j + k < n) ? rank[j + k] : -1;
            return ri < rj;
        };
        sort(sa.begin(), sa.end(), cmp);
        tmp[sa[0]] = 0;
        for (int i = 1; i < n; ++i) tmp[sa[i]] = tmp[sa[i-1]] + cmp(sa[i-1], sa[i]);
        rank = tmp;
    }
    return sa;
}

更常用的是同时构建 height 数组:

cpp
vector<int> build_height(const string &s, const vector<int> &sa) {
    int n = s.size();
    vector<int> rank(n), height(n);
    for (int i = 0; i < n; ++i) rank[sa[i]] = i;
    int h = 0;
    for (int i = 0; i < n; ++i) {
        if (rank[i] == 0) continue;
        int j = sa[rank[i] - 1];
        while (i + h < n && j + h < n && s[i+h] == s[j+h]) ++h;
        height[rank[i]] = h;
        if (h) --h;
    }
    return height;
}

适用场景

  • 子串统计类问题:不同子串个数(n*(n+1)/2 - sum(height))。

  • 最长公共子串(两个或多个字符串)。

  • 出现至少 k 次的最长子串(通过 height 数组的滑动窗口)。

  • 模式串匹配:如果仅需判断模式串是否出现,后缀数组 + 二分也能做,但通常不如 KMP 直接。唯一优势是预先建好后缀数组后,可以应对大量不同模式串的查询。

  • 字符串周期性分析

局限性

  • 代码复杂,调试困难,常数较大。

  • 无法在线处理(需要预先知道整个文本)。

  • 对于单纯的多模式串匹配,AC 自动机会更轻量。

四、三者对比表(一目了然)

 
 
维度 KMP AC 自动机 后缀数组
解决的问题 单模式串匹配 多模式串匹配 子串全局属性(LCP、不同子串、最长重复等)
时间复杂度 O(n+m) 构建+匹配 O(总模式长 + 文本长) O(n log n) 构建,查询 O(m log n)(二分)
空间复杂度 O(m) O(总模式长 × 字母表) O(n)
代码长度 ~30 行 ~70 行 ~100+ 行
在线 / 离线 在线(文本流) 在线(文本流) 离线(需全文)
是否支持动态模式串 否(需重新预处理) 否(重建自动机) 不适用
典型题目 字符串匹配、循环节 敏感词过滤、多关键词查找 最长公共子串、不同子串数

五、选择决策树(照着做)

当你拿到一道字符串题时,按以下顺序提问:

  1. 是不是只需要在一个文本中查找一个模式串?
    → 是:用 KMP。简单高效,不易错。
    → 否:进入下一问。

  2. 是不是需要同时查找多个模式串(比如字典中的单词)?
    → 是:用 AC 自动机。这是它的主场。
    → 否:进入下一问。

  3. 问题是否涉及子串的排序、最长公共前缀、重复次数、不同子串个数等“全局统计”属性?
    → 是:用 后缀数组(或后缀自动机 SAM,如果是竞赛也可考虑)。
    → 否:再检查是否能用哈希 + 二分或者更简单的做法(比如双指针)。

另外,还有一些特殊情况:

  • 如果模式串集合不固定,而是动态变化(例如允许插入删除),则 AC 自动机不合适,可能需要使用 Trie + fail 树 + BIT 离线处理,但这就更复杂了。

  • 如果只是判断一个模式串是否在文本中出现,且文本长度巨大(1e7),模式串单次查询,哈希(滚动哈希 + 二分)可能比 KMP 更快(但哈希有碰撞风险)。

  • 对于字符串周期性问题,KMP 的 π 数组可以直接给出循环节长度(len - π[len-1] if len % (len-π[...]) == 0)。

六、实战决策案例

例子 1:洛谷 P3375 【模板】KMP 字符串匹配
→ 明显的单模式串匹配,直接 KMP。

例子 2:洛谷 P3808 【模板】AC 自动机(简单版)
→ 多模式串匹配,统计出现次数,AC 自动机。

例子 3:洛谷 P3809 【模板】后缀排序
→ 后缀数组模板,用于后续各类子串问题。

例子 4:给定一个字符串,求其不同子串的数量。
→ 后缀数组 + height 数组,公式 n*(n+1)/2 - sum(height[i])。也可以用后缀自动机,但后缀数组足够。

例子 5:给定一个文本串 T 和 Q 个模式串,Q 很大(1e5),T 固定(1e5),要求判断每个模式串是否在 T 中出现。
→ 用后缀数组 + 二分:对每个模式串,在 sa 上二分检查是否匹配。预处理 O(n log n),每次查询 O(|P| log n)。如果模式串总长很大,也可以考虑构建 T 的哈希表做滚动哈希,但后缀数组更安全。

例子 6:给定一组单词,找出其中任何一个单词是否为另一个单词的前缀。
→ 将单词插入 Trie 树(可不用 AC 自动机的 fail),直接遍历即可。

七、C++ 实现注意事项

  • KMP:注意边界,pi 数组的含义以及失配时 j = pi[j-1]

  • AC 自动机:节点数组用 vector<Node> 动态分配,避免静态数组大小难估算。build() 中需要将不存在的 next 指针指向 fail 的对应转移,可以节省匹配时的循环。

  • 后缀数组:倍增法实现中,对 pair 的排序可以用 std::sort,但常数较大。竞赛中常使用基数排序优化到 O(n)。如果追求简洁,可先写倍增 sort 版本(n ≤ 200000 能过)。

  • 内存:后缀数组需要多个 vector<int>(sa, rank, height, tmp),每个 n+5 大小,1e6 级别下约 16MB,可以接受。

八、总结

  • 单模式串匹配 → KMP

  • 多模式串匹配 → AC 自动机

  • 子串全局统计、LCP 相关 → 后缀数组

这三个算法不是互相替代的,而是各司其职。一个成熟的竞赛选手应该熟练掌握 KMP 和 AC 自动机,并至少能手写后缀数组的倍增版(或会用 std::sort 的版本)。遇到题目时,先分析问题类型,再按上述决策树选择工具。

字符串的世界远不止这些:后缀自动机(SAM)、Z 函数(扩展 KMP)、Manacher 等同样重要。但掌握了今天讨论的三种“顶梁柱”,你已经能应对 80% 的字符串题了。剩下的 20%,留给下一次的专题吧。

淮安信息学奥赛CSP J/S 培训网, 一个专注于信息学奥赛学习的网站,专注于信息学奥赛培优,助力于孩子在NOIP/NOI/CSP/IOI中取得好成绩。