跳转至

第十三章 字符串算法

字符串是计算机科学中最基本的数据类型之一。在 ACM 竞赛中,字符串问题涉及模式匹配、回文检测、子串统计等经典场景。掌握字符串哈希、KMP、Trie、Manacher 等核心算法,是解决中高难度竞赛题的关键基础。


13.1 字符串哈希

13.1.1 基本思想

字符串哈希将一个字符串映射为一个整数值(哈希值),使得我们可以用 O(1) 的时间比较两个子串是否相等。这是许多字符串算法的基石。

多项式哈希的定义如下:

\[ h(s) = (s[0] \cdot P^{n-1} + s[1] \cdot P^{n-2} + \cdots + s[n-1] \cdot P^{0}) \bmod M \]

其中:

  • \(s[i]\) 是字符串第 \(i\) 个字符的编码值(通常用 ASCII 码)
  • \(P\) 是一个选定的进制基数(通常取质数,如 131、13331)
  • \(M\) 是一个大质数模数(如 \(10^9 + 7\)\(2^{64}\)
  • \(n\) 是字符串长度
graph LR
    subgraph 多项式哈希计算示意 - 字符串 "abc"
        A["a * P^2"] --> D["求和取模"]
        B["b * P^1"] --> D
        C["c * P^0"] --> D
        D --> E["h('abc')"]
    end

13.1.2 前缀哈希与子串哈希

直接对每个子串计算哈希的时间是 O(n) 的。为了实现 O(1) 子串哈希查询,我们预计算前缀哈希数组

前缀哈希的定义:

\[ H[i] = (s[0] \cdot P^{i} + s[1] \cdot P^{i-1} + \cdots + s[i-1] \cdot P^{0}) \bmod M \]

注意这里 \(H[i]\) 表示前 \(i\) 个字符(即 \(s[0..i-1]\))的哈希值。

子串哈希公式: 子串 \(s[l..r]\)(从下标 \(l\)\(r\),包含两端)的哈希值为:

\[ \text{hash}(l, r) = (H[r+1] - H[l] \cdot P^{r-l+1}) \bmod M \]

这样,任意子串的哈希值可以在 O(1) 时间内计算出来。

下标约定

本节公式采用 0 下标\(l\)\(r\) 从 0 计起);而 13.1.4 的代码模板将字符串存成下标从 1 开始,对应公式变为 \(\text{hash}(l, r) = (H[r] - H[l-1] \cdot P^{r-l+1}) \bmod M\)。两者只差一个偏移,套用时注意换算。

13.1.3 双哈希

单哈希存在哈希冲突的风险——两个不同的字符串可能产生相同的哈希值。在竞赛中,为了降低冲突概率,通常使用双哈希:选取两组不同的 \((P, M)\) 对,计算两个哈希值。只有当两个哈希值都相等时,才认为字符串相等。

graph TD
    subgraph 双哈希判定流程
        A["字符串 S1, S2"] --> B["计算哈希1 (P1=131, M1=1e9+7)"]
        A --> C["计算哈希2 (P2=13331, M2=1e9+9)"]
        B --> D{"哈希1 相等?"}
        C --> E{"哈希2 相等?"}
        D -->|否| F["S1 != S2"]
        E -->|否| F
        D -->|是| G{"哈希2 相等?"}
        G -->|是| H["S1 == S2(大概率)"]
        G -->|否| F
    end

13.1.4 完整代码实现

注意:本模板采用 1 下标(读入后在字符串前补一个占位字符),与 13.1.2 节的 0 下标公式相差一个偏移,子串公式相应变为 \(h[r] - h[l-1] \cdot p[r-l+1]\)

#include <iostream>
#include <string>
#include <vector>
using namespace std;

// ==================== 单哈希版本 ====================
const int P = 131;              // 进制基数,选一个质数
const long long M = 1e9 + 7;   // 模数,选一个大质数

const int MAXN = 1000010;       // 字符串最大长度

long long h[MAXN];              // 前缀哈希数组
long long p[MAXN];              // P 的幂次数组,p[i] = P^i mod M

// 预计算前缀哈希和幂次数组
// s: 输入字符串(下标从 1 开始存储)
// n: 字符串长度
void buildHash(const string& s, int n) {
    h[0] = 0;
    p[0] = 1;
    for (int i = 1; i <= n; i++) {
        // 前缀哈希递推: h[i] = h[i-1] * P + s[i]
        h[i] = (h[i - 1] * P % M + s[i]) % M;
        // 幂次递推: p[i] = p[i-1] * P
        p[i] = p[i - 1] * P % M;
    }
}

// 计算子串 s[l..r] 的哈希值(下标从 1 开始)
// 时间复杂度: O(1)
long long getHash(int l, int r) {
    // hash(l, r) = h[r] - h[l-1] * p[r-l+1]
    long long res = (h[r] - h[l - 1] * p[r - l + 1] % M + M) % M;
    return res;
}

// ==================== 双哈希版本 ====================
// 使用两组 (P, M) 对,降低冲突概率
// 注意:本结构体含 4 个长度为 MAXN 的 long long 数组,约 32MB,
// 必须定义为全局变量或 static;在函数内局部实例化会直接爆栈(栈一般只有 1~8MB)
struct DoubleHash {
    static const int P1 = 131, P2 = 13331;
    static const long long M1 = 1e9 + 7, M2 = 1e9 + 9;

    long long h1[MAXN], h2[MAXN];   // 两组前缀哈希
    long long p1[MAXN], p2[MAXN];   // 两组幂次

    // 构建双哈希数组
    void build(const string& s, int n) {
        h1[0] = h2[0] = 0;
        p1[0] = p2[0] = 1;
        for (int i = 1; i <= n; i++) {
            h1[i] = (h1[i - 1] * P1 % M1 + s[i]) % M1;
            h2[i] = (h2[i - 1] * P2 % M2 + s[i]) % M2;
            p1[i] = p1[i - 1] * P1 % M1;
            p2[i] = p2[i - 1] * P2 % M2;
        }
    }

    // 返回子串 s[l..r] 的双哈希值,用 pair 存储
    pair<long long, long long> get(int l, int r) {
        long long hash1 = (h1[r] - h1[l - 1] * p1[r - l + 1] % M1 + M1) % M1;
        long long hash2 = (h2[r] - h2[l - 1] * p2[r - l + 1] % M2 + M2) % M2;
        return {hash1, hash2};
    }

    // 判断两个子串是否相等
    // 子串1: s1[l1..r1], 子串2: s2[l2..r2]
    bool equal(int l1, int r1, int l2, int r2) {
        return get(l1, r1) == get(l2, r2);
    }
};

// ==================== 使用示例 ====================
int main() {
    string s;
    cin >> s;
    int n = s.size();

    // 在字符串前补一个字符,使下标从 1 开始
    s = " " + s;

    buildHash(s, n);

    // 查询子串 s[l..r] 是否与 s[l2..r2] 相等
    int q;
    cin >> q;
    while (q--) {
        int l1, r1, l2, r2;
        cin >> l1 >> r1 >> l2 >> r2;
        if (getHash(l1, r1) == getHash(l2, r2)) {
            cout << "Yes" << endl;
        } else {
            cout << "No" << endl;
        }
    }
    return 0;
}

时间复杂度分析:

操作 时间复杂度 说明
预处理(构建前缀哈希) O(n) 遍历一次字符串
查询子串哈希 O(1) 利用前缀哈希公式直接计算
比较两个子串是否相等 O(1) 比较哈希值
双哈希额外开销 常数倍 空间和时间均翻倍
操作 空间复杂度 说明
单哈希 O(n) 存储前缀哈希和幂次数组
双哈希 O(n) 存储两组前缀哈希和幂次数组

如何选择 P 和 M 的值

  • P 的选择:通常取大于字符集大小的质数。对于小写字母(字符集大小 26),可以选 131、13331、19260817 等。P 越大,冲突概率越低。
  • M 的选择:推荐使用大质数,如 \(10^9 + 7\)\(10^9 + 9\)。也可以使用 unsigned long long 自然溢出(等价于对 \(2^{64}\) 取模),速度更快,但需要注意被刻意构造数据卡掉的风险。
  • 双哈希的必要性:在正式比赛中,单哈希被卡掉的情况时有发生。使用双哈希可以极大地降低冲突概率,是更稳妥的做法。

大数组必须定义为全局或 static

DoubleHash 结构体内含 4 个长度为 MAXNlong long 数组,约 32MBh[]p[] 等全局大数组同理)。这类对象必须定义为全局变量或 static:函数内的局部变量分配在栈上,而栈空间通常只有 1~8MB,局部实例化会直接爆栈(表现为 RE / Segmentation Fault)。

字符串哈希的局限

字符串哈希是概率性算法,理论上存在哈希冲突的可能(虽然双哈希后概率极低)。如果题目要求绝对正确的答案(如判定两个字符串是否相等),需要结合其他方法验证。此外,哈希值的大小依赖于字符编码,不同编码方式可能导致不同的哈希结果。


13.2 KMP 算法

13.2.1 问题定义

字符串匹配问题:给定一个长度为 \(n\) 的文本串 text 和一个长度为 \(m\) 的模式串 pattern,找出 patterntext 中所有出现的位置。

朴素算法的思路是:对于 text 中的每个位置,逐字符比较是否与 pattern 匹配。最坏时间复杂度为 \(O(n \cdot m)\)

KMP(Knuth-Morris-Pratt)算法通过预处理模式串,构建一个next 数组(也叫前缀函数 \(\pi\)),使得匹配过程中可以跳过不必要的比较,将时间复杂度降至 \(O(n + m)\)

13.2.2 next 数组(前缀函数)

定义:对于模式串 \(p\)\(\pi[i]\) 表示子串 \(p[0..i]\) 中,最长的既是真前缀又是真后缀的长度

具体来说,\(\pi[i]\) 是满足以下条件的最大 \(k\): - \(p[0..k-1] = p[i-k+1..i]\) - \(k < i + 1\)(即真前缀/真后缀,不能是整个子串本身)

以模式串 "ABABAC" 为例:

下标 i 0 1 2 3 4 5
字符 A B A B A C
π[i] 0 0 1 2 3 0

推导过程:

  • \(\pi[0] = 0\):单个字符没有真前缀/真后缀
  • \(\pi[1] = 0\):子串 "AB",前缀 "A" != 后缀 "B"
  • \(\pi[2] = 1\):子串 "ABA",前缀 "A" == 后缀 "A",长度 1
  • \(\pi[3] = 2\):子串 "ABAB",前缀 "AB" == 后缀 "AB",长度 2
  • \(\pi[4] = 3\):子串 "ABABA",前缀 "ABA" == 后缀 "ABA",长度 3
  • \(\pi[5] = 0\):子串 "ABABAC",没有匹配的前缀后缀
graph TD
    subgraph next 数组构建过程 - 模式串 "ABABAC"
        A["i=0, π[0]=0: 'A' 无真前后缀"]
        B["i=1, π[1]=0: 'AB', 'A' ≠ 'B'"]
        C["i=2, π[2]=1: 'ABA', 'A'='A' (长1)"]
        D["i=3, π[3]=2: 'ABAB', 'AB'='AB' (长2)"]
        E["i=4, π[4]=3: 'ABABA', 'ABA'='ABA' (长3)"]
        F["i=5, π[5]=0: 'ABABAC', 无匹配"]
        A --> B --> C --> D --> E --> F
    end

13.2.3 next 数组的构建

构建 next 数组的核心思想是:利用已经计算好的 \(\pi[0..i-1]\) 来推导 \(\pi[i]\),避免重复计算。

graph TD
    subgraph next数组构建的递推过程
        A["设 j = π[i-1](上一个位置的最长前后缀长度)"] --> B{"p[i] == p[j]?"}
        B -->|是| C["π[i] = j + 1"]
        B -->|否| D["j = π[j-1](回退到更短的前缀)"]
        D --> B
        C --> E["处理下一个位置 i+1"]
    end

13.2.4 KMP 匹配过程

当文本串中某位字符与模式串不匹配时,利用 next 数组跳转到模式串中合适的位置,避免从头开始比较。

graph LR
    subgraph KMP匹配 - 跳过已匹配部分
        A["text: ...ABABABAC..."] --> B["匹配 'ABAB' 后, text[4]='B'≠pattern[4]='A'"]
        B --> C["查 next 数组: next[3]=2"]
        C --> D["将 pattern 右移, 从 pattern[2]='A' 继续比较"]
    end

13.2.5 完整代码实现

#include <iostream>
#include <string>
#include <vector>
using namespace std;

const int MAXN = 1000010;

int nxt[MAXN];  // next 数组(前缀函数)
int n, m;        // n: 文本串长度, m: 模式串长度

// ==================== 构建 next 数组 ====================
// p: 模式串(下标从 0 开始)
// m: 模式串长度
// 时间复杂度: O(m)
void buildNext(const string& p, int m) {
    nxt[0] = 0;  // 第一个字符的 next 值为 0
    // j 记录当前最长匹配的前缀末尾位置
    for (int i = 1, j = 0; i < m; i++) {
        // 如果 p[i] 不匹配 p[j],不断回退 j
        while (j > 0 && p[i] != p[j]) {
            j = nxt[j - 1];
        }
        // 如果 p[i] == p[j],匹配长度加 1
        if (p[i] == p[j]) {
            j++;
        }
        nxt[i] = j;  // 记录 π[i]
    }
}

// ==================== KMP 搜索 ====================
// s: 文本串(下标从 0 开始)
// p: 模式串(下标从 0 开始)
// 返回: 模式串在文本串中所有出现的起始位置
// 时间复杂度: O(n + m)
vector<int> kmpSearch(const string& s, const string& p) {
    n = s.size();
    m = p.size();
    buildNext(p, m);

    vector<int> matches;  // 存储匹配位置

    // i: 文本串指针, j: 模式串指针
    for (int i = 0, j = 0; i < n; i++) {
        // 当 s[i] != p[j] 时,利用 next 数组跳转
        while (j > 0 && s[i] != p[j]) {
            j = nxt[j - 1];
        }
        // 如果 s[i] == p[j],模式串指针前移
        if (s[i] == p[j]) {
            j++;
        }
        // 模式串完全匹配
        if (j == m) {
            // 匹配位置 = 当前文本串位置 - 模式串长度 + 1
            matches.push_back(i - m + 1);
            // 继续搜索下一个匹配:利用 next 数组跳转
            j = nxt[j - 1];
        }
    }
    return matches;
}

int main() {
    string text, pattern;
    cin >> text >> pattern;

    vector<int> matches = kmpSearch(text, pattern);

    // 输出所有匹配位置(下标从 0 开始)
    for (int pos : matches) {
        cout << pos << endl;
    }

    // 输出 next 数组
    cout << "next数组: ";
    for (int i = 0; i < (int)pattern.size(); i++) {
        cout << nxt[i] << " ";
    }
    cout << endl;

    return 0;
}

复杂度分析:

操作 时间复杂度 空间复杂度 说明
构建 next 数组 O(m) O(m) 模式串长度为 m
KMP 匹配 O(n) O(1) 文本串长度为 n
总计 O(n + m) O(m) 线性时间复杂度

KMP 的核心思想

KMP 的精髓在于:当发生失配时,我们已经知道文本串中的一段字符(它们与模式串的某个前缀匹配)。利用 next 数组,我们可以直接跳过这段已知信息,将模式串滑动到正确的位置继续比较。这就是"不回退文本串指针"的含义——文本串的每个字符最多被访问两次(一次匹配,一次失配后的跳转),因此总时间为 \(O(n)\)

next 数组的常见应用

  • 字符串匹配:找所有匹配位置
  • 求最小循环节:字符串 \(s\) 的最小循环节长度为 \(n - \pi[n-1]\)(当 \(n \bmod (n - \pi[n-1]) == 0\) 时)
  • 求最长公共前后缀:直接查 \(\pi[n-1]\)
  • 统计前缀出现次数:利用 next 数组链式跳转

13.3 Trie(字典树)

13.3.1 基本概念

Trie(发音同 "try"),又称字典树前缀树,是一种树形数据结构,用于高效存储和检索字符串集合。Trie 的每个节点代表一个字符,从根到某个节点的路径构成一个字符串的前缀。

核心性质:

  • 根节点不存储字符
  • 每条边代表一个字符
  • 从根到任意节点的路径构成一个前缀
  • 使用公共前缀来节省存储空间
graph TD
    subgraph Trie 示例 - 存储 "abc", "abd", "acd", "b"
        R["根节点"] --> A["a"]
        R --> B["b (标记: 单词结束)"]
        A --> AB["b"]
        A --> AC["c"]
        AB --> ABA["c (标记: 单词 'abc' 结束)"]
        AB --> ABB["d (标记: 单词 'abd' 结束)"]
        AC --> ACA["d (标记: 单词 'acd' 结束)"]
    end

13.3.2 数组实现 Trie

在 ACM 竞赛中,通常使用数组模拟实现 Trie,以获得更高的效率。每个节点用一个数组 son[node][c] 存储指向字符 c 对应子节点的指针。

#include <iostream>
#include <cstring>
using namespace std;

const int MAXN = 100010;   // 最大节点数(所有字符串的总字符数)
const int CHARSET = 26;    // 字符集大小(小写字母 26 个)

int son[MAXN][CHARSET];    // son[i][c]: 节点 i 的字符 c 子节点
int cnt[MAXN];             // cnt[i]: 以节点 i 结尾的单词数量
int idx;                   // 当前已分配的节点数(0 号节点为根)

// 初始化 Trie
void init() {
    memset(son, 0, sizeof son);
    memset(cnt, 0, sizeof cnt);
    idx = 0;
}

// 插入字符串 s
// 时间复杂度: O(L),L 为字符串长度
void insert(const string& s) {
    int p = 0;  // 从根节点出发
    for (char ch : s) {
        int c = ch - 'a';  // 将字符映射到 0~25
        // 如果对应子节点不存在,创建新节点
        if (!son[p][c]) {
            son[p][c] = ++idx;
        }
        p = son[p][c];  // 移动到子节点
    }
    cnt[p]++;  // 标记该节点为一个单词的结尾
}

// 查找字符串 s 是否存在
// 返回: s 在 Trie 中出现的次数
// 时间复杂度: O(L)
int search(const string& s) {
    int p = 0;  // 从根节点出发
    for (char ch : s) {
        int c = ch - 'a';
        if (!son[p][c]) {
            return 0;  // 路径不存在,说明 s 不在 Trie 中
        }
        p = son[p][c];
    }
    return cnt[p];  // 返回该单词的出现次数
}

// 查询是否有以 prefix 为前缀的字符串
// 返回: Trie 中以 prefix 为前缀的字符串数量
// 时间复杂度: O(L)
int startsWith(const string& prefix) {
    int p = 0;
    for (char ch : prefix) {
        int c = ch - 'a';
        if (!son[p][c]) {
            return 0;  // 前缀路径不存在
        }
        p = son[p][c];
    }
    // 需要遍历该子树来统计数量(此处简化为返回 1 表示前缀存在)
    return 1;
}

int main() {
    init();

    // 插入字符串
    insert("apple");
    insert("app");
    insert("application");

    // 查找
    cout << "search('app'): " << search("app") << endl;       // 输出 1
    cout << "search('apple'): " << search("apple") << endl;   // 输出 1
    cout << "search('ap'): " << search("ap") << endl;         // 输出 0(不是完整单词)
    cout << "startsWith('app'): " << startsWith("app") << endl;  // 输出 1

    return 0;
}

复杂度分析:

操作 时间复杂度 空间复杂度 说明
插入 O(L) O(L) L 为单个字符串长度
查找 O(L) O(1) 沿路径走一遍
前缀查询 O(L) O(1) 沿前缀路径走一遍
总空间 - O(N * L * C) N 个字符串,字符集大小 C

Trie 的常见应用

  • 自动补全/词频统计:在搜索引擎中用于前缀匹配和排序
  • XOR 最大值:将整数的二进制位存入 Trie,可以快速查询与给定整数 XOR 结果最大的值
  • 拼写检查:检查单词是否存在或给出最相近的建议
  • 01-Trie:字符集为 {0, 1} 的特殊 Trie,常用于处理二进制异或问题
  • AC 自动机:将多个模式串建成 Trie,结合 KMP 的 fail 指针实现多模式匹配(见 13.4 节)

Trie 的空间开销

数组实现的 Trie 空间开销较大(每个节点需要一个大小为字符集的数组)。如果字符集很大(如 Unicode),需要考虑使用 unordered_map 替代数组来存储子节点,或者使用链表实现。在竞赛中,通常只处理小写字母或数字,数组实现是最高效的。


13.4 AC 自动机

13.4.1 问题引入

多模式串匹配问题:给定 \(n\) 个模式串 \(s_1, s_2, \ldots, s_n\) 和一个文本串 \(t\),统计有多少个模式串在文本串中出现过。

如果对每个模式串单独跑一遍 KMP,总时间为 \(O(n \cdot |t| + \sum |s_i|)\)——文本串会被反复扫描 \(n\) 遍,当模式串数量很大(如 \(10^5\) 个)时完全无法承受。

AC 自动机(Aho-Corasick Automaton)解决了这个问题:先把所有模式串插入一棵 Trie(13.3 节),再借鉴 KMP 的失配思想在 Trie 上构建 fail 指针,文本串只需在自动机上走一遍,就能同时完成对所有模式串的匹配。可以概括为一个公式:

\[ \text{AC 自动机} = \text{Trie} + \text{KMP 的 fail 思想} \]

13.4.2 fail 指针与 Trie 图

定义:设节点 \(u\) 代表"从根走到 \(u\)"形成的字符串,则 \(fail[u]\) 指向 \(u\) 所代表字符串的最长真后缀在 Trie 中对应的节点(若不存在这样的后缀,则指向根)。

这与 KMP 的 next 数组"最长的既是真前缀又是真后缀"的思想一致:当匹配走不下去时,跳到 \(fail[u]\) 相当于保留当前已匹配串的最长可用后缀,接着尝试。

以模式串集合 {he, she, his, hers} 为例(实线为 Trie 树边,虚线为 fail 指针,指向根的省略):

graph TD
    subgraph Trie 与 fail 指针 - 模式串 he / she / his / hers
        R["根"] --> H["h"]
        R --> S["s"]
        H --> HE["he (结尾)"]
        H --> HI["hi"]
        HI --> HIS["his (结尾)"]
        HE --> HER["her"]
        HER --> HERS["hers (结尾)"]
        S --> SH["sh"]
        SH --> SHE["she (结尾)"]
        SH -.->|fail| H
        SHE -.->|fail| HE
        HIS -.->|fail| S
        HERS -.->|fail| S
    end

例如节点 "she" 的最长真后缀中能在 Trie 中找到的是 "he",故 \(fail[\text{she}] = \text{he}\)——文本走到 "she" 时,模式串 "he" 也恰好在此结束出现。

fail 指针用 BFS 逐层构建(保证处理节点 \(v\) 时,深度比它小的节点的 fail 都已求出):

  1. 深度为 1 的节点:fail 一律指向根。
  2. 若子节点 \(v = tr[u][c]\) 存在:\(fail[v] = tr[fail[u]][c]\)
  3. \(tr[u][c]\) 不存在:直接令 \(tr[u][c] = tr[fail[u]][c]\)

第 3 步把 Trie 补成了 Trie 图:任何节点对任何字符都有转移,匹配时永不"失配",不需要像 KMP 那样在循环里反复回跳,每读一个字符只走一步。

13.4.3 文本匹配

文本串在 Trie 图上逐字符转移(每步 \(O(1)\))。走到节点 \(p\) 时,\(p\) 的 fail 链上的每个"结尾节点"都对应一个恰好在当前位置结束出现的模式串,因此沿 fail 链上溯统计即可。为避免重复统计(同时保证复杂度),统计过的节点将计数置为 \(-1\),之后再遇到就直接停止上溯。

13.4.4 完整模板与例题(洛谷 P3808)

题目洛谷 P3808【模板】AC 自动机(简单版)

题意:给定 \(n\) 个模式串 \(s_i\) 和一个文本串 \(t\),求有多少个模式串在文本串中出现过(相同的模式串分别计数)。\(1 \le n \le 10^6\)\(\sum |s_i| \le 10^6\)\(|t| \le 10^6\)

#include <iostream>
#include <string>
#include <queue>
using namespace std;

const int MAXN = 1000010;  // Trie 节点数上限(所有模式串的总长度)
const int CHARSET = 26;    // 字符集大小(小写字母)

// 注意:tr 数组约 100MB 级别(1e6 x 26 x 4 字节),必须定义为全局变量,
// 局部定义会爆栈;若题目空间较紧,可按 ∑|s_i| 的实际上限缩小 MAXN
int tr[MAXN][CHARSET];     // tr[u][c]: 节点 u 的字符 c 出边(建好 fail 后为 Trie 图)
int fail[MAXN];            // fail[u]: 节点 u 的失配指针
int cnt[MAXN];             // cnt[u]: 以节点 u 结尾的模式串个数
int idx;                   // 已分配的节点数(0 号节点为根)

// 第一步:把模式串插入 Trie(与 13.3 节完全相同)
void insert(const string& s) {
    int p = 0;
    for (char ch : s) {
        int c = ch - 'a';
        if (!tr[p][c]) tr[p][c] = ++idx;
        p = tr[p][c];
    }
    cnt[p]++;  // 该节点是一个模式串的结尾
}

// 第二步:BFS 逐层构建 fail 指针,同时把 Trie 补成 Trie 图
void build() {
    queue<int> q;
    // 深度为 1 的节点:fail 一律指向根(初值 0,无需显式赋值)
    for (int c = 0; c < CHARSET; c++) {
        if (tr[0][c]) q.push(tr[0][c]);
    }
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int c = 0; c < CHARSET; c++) {
            int v = tr[u][c];
            if (v) {
                // 子节点存在:其 fail = 父节点 fail 沿字符 c 的转移
                fail[v] = tr[fail[u]][c];
                q.push(v);
            } else {
                // 子节点不存在:出边直接指向 fail 的对应转移(补成 Trie 图)
                tr[u][c] = tr[fail[u]][c];
            }
        }
    }
}

// 第三步:文本串在 Trie 图上跑一遍,沿 fail 链统计出现的模式串
int query(const string& t) {
    int p = 0, res = 0;
    for (char ch : t) {
        p = tr[p][ch - 'a'];  // Trie 图上转移,永不失配
        // 沿 fail 链上溯:链上每个结尾节点对应一个在此处出现的模式串
        // 统计过的节点置为 -1,保证每个节点只被统计一次
        for (int u = p; u && cnt[u] != -1; u = fail[u]) {
            res += cnt[u];
            cnt[u] = -1;
        }
    }
    return res;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    string s;
    for (int i = 0; i < n; i++) {
        cin >> s;
        insert(s);
    }
    build();

    string t;
    cin >> t;
    cout << query(t) << endl;
    return 0;
}

验证:洛谷 P3808 样例(模式串 a、aa、aa,文本 ab)输出 1;经典用例——模式串 {he, she, his, hers} 匹配文本 "ahishers",he、she、his、hers 各出现一次,输出 4

13.4.5 复杂度分析

\(L = \sum |s_i|\) 为模式串总长,\(m = |t|\) 为文本串长,\(C\) 为字符集大小(此处为 26,视作常数):

阶段 时间复杂度 说明
建 Trie O(L) 逐字符插入
建 fail(含 Trie 图) O(L * C) BFS 中每个节点枚举整个字符集
文本匹配 O(m + L) 均摊 Trie 图转移每字符 O(1);置 -1 保证每个节点的计数只被统计一次
总计 O(L + m) 即 O(∑模式串长 + 文本串长),字符集视为常数
指标 空间复杂度 说明
tr 数组 O(L * C) 空间瓶颈,注意按题目范围开数组
fail / cnt 数组 O(L) 一维数组

为什么匹配是均摊线性的

匹配中每读一个字符,Trie 图上的转移只走一步,这部分严格 \(O(m)\)。沿 fail 链上溯看似可能很长,但每个节点被完整统计一次后就置为 \(-1\),之后任何上溯一碰到 \(-1\) 节点立即停止——所以所有上溯的总步数不超过节点数 \(O(L)\)。两部分合计 \(O(m + L)\)。如果不做标记而每次都暴力跳完整条 fail 链,最坏会退化到 \(O(m \cdot L)\)(例如全 'a' 的数据)。

AC 自动机的常见坑

  • 数组大小MAXN 必须按所有模式串的总长度开,不是单个串的长度;tr 数组是空间瓶颈,本模板约 100MB,需按题目实际范围调整。
  • 必须置 -1(或改用 fail 树):沿 fail 链统计时不做标记,复杂度会退化(见上方 note)。需要统计每个模式串出现次数(如洛谷 P5357 二次加强版)时,不能置 -1,应改为在 fail 树上求子树和。
  • build 之后 Trie 已被改写:第 3 步把不存在的出边补成了转移边,tr 不再是原始 Trie,如需原树结构要提前备份。
  • 多组数据要完整清空trfailcntidx 都要重置,只清 idx 会残留脏边。
  • 大数组定义为全局:与 13.1 节的哈希数组同理,放在函数内会爆栈。

13.5 Manacher 算法

13.5.1 问题定义

最长回文子串问题:给定一个字符串,找出其中最长的回文子串的长度。

例如,字符串 "abacaba" 的最长回文子串是 "abacaba"(长度 7),而 "abcba" 的最长回文子串是 "abcba"(长度 5)。

朴素算法对每个中心向两边扩展,时间复杂度为 \(O(n^2)\)。Manacher 算法可以在 \(O(n)\) 时间内解决这个问题。

13.5.2 字符串预处理

为了统一处理奇数长度和偶数长度的回文串,我们对原字符串进行预处理:在每两个相邻字符之间插入一个特殊字符(如 #),并在首尾分别添加特殊字符(如 ^$)。

例如:

  • 原串 "abc" -> 预处理后 "^#a#b#c#$"
  • 原串 "abba" -> 预处理后 "^#a#b#b#a#$"

这样,所有回文串的中心一定落在某个字符上(不再是两个字符之间的间隙),统一了处理逻辑。

graph LR
    subgraph 预处理示意
        A["原串: abc"] --> B["预处理: ^#a#b#c#$"]
        C["原串: abba"] --> D["预处理: ^#a#b#b#a#$"]
    end

13.5.3 核心变量

  • p[i]:以位置 \(i\) 为中心的回文半径(包含中心字符本身)。即预处理后字符串中,以 \(i\) 为中心的最长回文子串长度为 \(2 \cdot p[i] - 1\)
  • center:当前已知回文串能够到达最右端的那个回文串的中心
  • right:当前已知回文串的最右端位置(不包含该位置)

13.5.4 核心思想

当计算 p[i] 时,如果 i 在当前 right 范围内,我们可以利用已有的回文信息,将 p[i] 初始化为 p[i 关于 center 的对称点] 的值,从而跳过一部分比较。

具体来说,设 \(i\) 关于 \(center\) 的对称点为 \(i' = 2 \cdot center - i\)

  • 如果 \(i < right\),则 \(p[i] \geq \min(p[i'], right - i)\)
  • 然后在此基础上继续向外扩展
  • 每次扩展成功都会更新 right,而 right 只会增加不会减少,因此总时间复杂度为 \(O(n)\)

13.5.5 完整代码实现

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

const int MAXN = 2000010;  // 预处理后字符串最大长度

int p[MAXN];  // p[i]: 以位置 i 为中心的回文半径

// Manacher 算法
// s: 原始字符串
// 返回: 最长回文子串的长度
// 时间复杂度: O(n)
int manacher(const string& s) {
    // 步骤 1: 预处理,在每两个字符间插入 '#'
    string t = "^#";
    for (char ch : s) {
        t += ch;
        t += '#';
    }
    t += '$';  // 首尾添加哨兵字符,防止越界

    int n = t.size();
    int center = 0, right = 0;  // 当前回文串的中心和右边界
    int maxLen = 0;              // 最长回文半径

    // 步骤 2: 遍历预处理后的字符串
    for (int i = 1; i < n - 1; i++) {
        // 利用对称性初始化 p[i]
        if (i < right) {
            // i' = 2 * center - i 是 i 关于 center 的对称点
            p[i] = min(p[2 * center - i], right - i);
        } else {
            p[i] = 1;  // 初始半径为 1(只包含中心字符本身)
        }

        // 步骤 3: 以 i 为中心,尝试向两边扩展
        while (t[i - p[i]] == t[i + p[i]]) {
            p[i]++;
        }

        // 步骤 4: 更新 center 和 right
        if (i + p[i] > right) {
            center = i;
            right = i + p[i];
        }

        // 更新最大回文半径
        maxLen = max(maxLen, p[i]);
    }

    // 最长回文子串的原串长度 = 回文半径 - 1
    // 因为预处理后半径为 R 的回文串,对应原串长度为 R - 1
    return maxLen - 1;
}

int main() {
    string s;
    cin >> s;
    cout << manacher(s) << endl;
    return 0;
}

13.5.6 详解示例

以字符串 "ababa" 为例:

预处理后:^#a#b#a#b#a#$

下标 i 0 1 2 3 4 5 6 7 8 9 10 11
t[i] ^ # a # b # a # b # a $
p[i] 0 1 2 1 4 1 6 1 4 1 2 0

\(i = 6\)(字符 'a',正中心)时,\(p[6] = 6\),对应原串回文长度为 \(6 - 1 = 5\),即 "ababa"。

复杂度分析:

指标 复杂度 说明
时间复杂度 O(n) right 只增不减,每个位置最多被访问常数次
空间复杂度 O(n) 存储预处理后的字符串和 p 数组
预处理 O(n) 插入分隔符

Manacher 算法的边界条件

  • 预处理时必须添加哨兵字符(如 ^$),否则扩展时可能越界
  • 原始字符串长度为 \(n\) 时,预处理后长度为 \(2n + 3\),数组需要开足够大
  • 最终结果需要将预处理字符串中的回文半径减 1,才能得到原串的回文长度
  • 如果需要输出回文子串本身(而不仅仅是长度),需要记录 maxLen 对应的中心位置,然后根据公式还原

13.6 Z 函数简介

13.6.1 定义

给定长度为 \(n\) 的字符串 \(s\)Z 函数定义为:\(z[i]\) 表示 \(s\)\(s[i..n-1]\)最长公共前缀(LCP)的长度。

按照惯例,\(z[0] = 0\)(或有时定义为 \(n\),取决于实现)。

示例:对于字符串 "aabxaab":

下标 i 0 1 2 3 4 5 6
字符 a a b x a a b
z[i] 0 1 0 0 3 1 0
  • \(z[1] = 1\):$s[1..] = $ "abxaab",与 $s = $ "aabxaab" 的最长公共前缀是 "a",长度 1
  • \(z[4] = 3\):$s[4..] = $ "aab",与 $s = $ "aabxaab" 的最长公共前缀是 "aab",长度 3

13.6.2 Z 函数的构建

Z 函数的构建过程与 KMP 的 next 数组类似,也利用了"已计算的信息"来加速。

#include <iostream>
#include <string>
#include <vector>
using namespace std;

// 构建 Z 函数
// s: 输入字符串
// 返回: z 数组
// 时间复杂度: O(n)
vector<int> zFunction(const string& s) {
    int n = s.size();
    vector<int> z(n, 0);

    // l, r 维护当前已知的最右端的 Z-box
    // Z-box: 某个 z[i] 对应的 s[0..z[i]-1] == s[i..i+z[i]-1] 的区间
    for (int i = 1, l = 0, r = 0; i < n; i++) {
        // 利用已知的 Z-box 信息初始化 z[i]
        if (i <= r) {
            z[i] = min(r - i + 1, z[i - l]);
        }
        // 暴力扩展
        while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
            z[i]++;
        }
        // 更新 Z-box
        if (i + z[i] - 1 > r) {
            l = i;
            r = i + z[i] - 1;
        }
    }
    return z;
}

int main() {
    string s;
    cin >> s;
    vector<int> z = zFunction(s);
    for (int i = 0; i < (int)z.size(); i++) {
        cout << "z[" << i << "] = " << z[i] << endl;
    }
    return 0;
}

13.6.3 Z 函数用于模式匹配

Z 函数的一个经典应用是模式匹配:将模式串 \(p\) 和文本串 \(t\) 拼接为 \(s = p + "\$" + t\)\(\$\) 是一个不在两个串中出现的分隔符),然后计算 \(s\) 的 Z 函数。对于 \(t\) 中的每个位置,如果对应的 \(z\) 值等于 \(|p|\),则说明找到了一个匹配。

// 利用 Z 函数进行模式匹配
// pattern: 模式串
// text: 文本串
// 返回: 所有匹配的起始位置
vector<int> zMatch(const string& pattern, const string& text) {
    string s = pattern + "$" + text;
    vector<int> z = zFunction(s);
    int m = pattern.size();
    vector<int> matches;

    // 遍历文本串对应的部分
    for (int i = m + 1; i < (int)s.size(); i++) {
        if (z[i] == m) {
            matches.push_back(i - m - 1);  // 转换为 text 中的下标
        }
    }
    return matches;
}

复杂度分析:

操作 时间复杂度 空间复杂度
构建 Z 函数 O(n) O(n)
Z 函数模式匹配 O(n + m) O(n + m)

Z 函数与 next 数组的关系

Z 函数和 KMP 的 next 数组(前缀函数)都是线性时间构建的字符串自匹配工具。两者的适用场景略有不同: - next 数组:更自然地用于 KMP 匹配算法 - Z 函数:更直观地用于"子串是否等于某个前缀"这类查询,以及某些数学性质的推导 - 两者可以在线性时间内互相转换


13.7 后缀数组简介

13.7.1 定义

后缀数组(Suffix Array, SA)是字符串处理中非常强大的工具。给定一个长度为 \(n\) 的字符串 \(s\),其后缀数组 \(SA\) 定义如下:

  • \(SA[i]\) 表示将 \(s\) 的所有后缀按字典序排序后,第 \(i\) 小的后缀的起始下标

例如,字符串 "banana" 的所有后缀:

排名 i SA[i] 后缀
0 5 a
1 3 ana
2 1 anana
3 0 banana
4 4 na
5 2 nana

13.7.2 倍增算法(O(n log^2 n))

最常用的后缀数组构建算法是倍增法。核心思想:

  1. 首先按每个后缀的第一个字符排序
  2. 然后按前 2 个字符排序(利用前 1 个字符的排序结果)
  3. 再按前 4 个字符排序...
  4. 每次将排序的长度翻倍,直到长度 >= n

每次排序可以用基数排序(O(n)),共需 \(O(\log n)\) 轮,总时间复杂度为 \(O(n \log n)\)\(O(n \log^2 n)\)(取决于排序方法)。

#include <iostream>
#include <string>
#include <algorithm>
#include <cstring>
using namespace std;

const int MAXN = 1000010;

int sa[MAXN];      // 后缀数组: sa[i] = 第 i 小后缀的起始下标
int rk[MAXN];      // 排名数组: rk[i] = 后缀 i 的排名
int tmp[MAXN];     // 辅助数组
int cnt[MAXN];     // 基数排序的计数数组

// 使用基数排序的后缀数组构建(简化版本,O(n log^2 n))
// s: 输入字符串(下标从 0 开始)
// n: 字符串长度
void buildSA(string& s, int n) {
    // 初始按单个字符排序
    for (int i = 0; i < n; i++) {
        sa[i] = i;
        rk[i] = s[i];
    }

    // 倍增过程:k 为当前排序的子串长度
    for (int k = 1; k < n; k <<= 1) {
        // 按第二关键字排序:即按 sa[i] - k 位置的排名排序
        // 第二关键字为 0 的排在前面(即起始位置 < k 的后缀)
        auto cmp = [&](int a, int b) {
            if (rk[a] != rk[b]) return rk[a] < rk[b];
            int ra = (a + k < n) ? rk[a + k] : -1;
            int rb = (b + k < n) ? rk[b + k] : -1;
            return ra < rb;
        };

        sort(sa, sa + n, 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]) ? 1 : 0);
        }
        for (int i = 0; i < n; i++) {
            rk[i] = tmp[i];
        }

        // 如果所有排名已经唯一,提前退出
        if (rk[sa[n - 1]] == n - 1) break;
    }
}

int main() {
    string s;
    cin >> s;
    int n = s.size();

    buildSA(s, n);

    // 输出后缀数组
    cout << "后缀数组: ";
    for (int i = 0; i < n; i++) {
        cout << sa[i] << " ";
    }
    cout << endl;

    return 0;
}

13.7.3 height 数组与 LCP

height 数组定义:\(height[i] = \text{LCP}(s[SA[i-1]..], s[SA[i]..])\),即排名第 \(i\) 的后缀与排名第 \(i-1\) 的后缀的最长公共前缀长度。

height 数组有一个非常重要的性质(性质 LCP):对于任意 \(i < j\)\(\text{LCP}(SA[i], SA[j]) = \min(height[i+1], height[i+2], \ldots, height[j])\)

这意味着,height 数组可以将子串间的 LCP 问题转化为区间最小值问题。

// 构建 height 数组
// s: 输入字符串, sa: 后缀数组, rk: 排名数组
// n: 字符串长度
// 时间复杂度: O(n)
int height[MAXN];

void buildHeight(const string& s, int n) {
    for (int i = 0, k = 0; i < n; i++) {
        if (rk[i] == 0) {
            height[0] = 0;
            continue;
        }
        // 利用性质: height[rk[i]] >= height[rk[i-1]] - 1
        if (k > 0) k--;
        int j = sa[rk[i] - 1];  // 排名在 i 前一位的后缀起始位置
        while (i + k < n && j + k < n && s[i + k] == s[j + k]) {
            k++;
        }
        height[rk[i]] = k;
    }
}

13.7.4 常见应用

应用一:最长重复子串

最长重复子串的长度就是 height 数组中的最大值。

应用二:不同子串的个数

一个长度为 \(n\) 的字符串的不同子串总数为:

\[ \text{总数} = \frac{n(n+1)}{2} - \sum_{i=1}^{n-1} height[i] \]

因为每对相邻后缀共享 height[i] 个前缀,这些前缀对应的子串是重复的。

应用 时间复杂度 说明
构建后缀数组 O(n log^2 n) 倍增法(使用 sort)
构建后缀数组 O(n log n) 倍增法(使用基数排序)
构建 height 数组 O(n) 利用性质 LCP
最长重复子串 O(n) height 数组最大值
不同子串个数 O(n) 利用 height 数组公式
两子串 LCP O(1) 配合 ST 表求区间最小值

关于更高效的构建算法

后缀数组的倍增法实现简单、常数小,足以应对绝大多数竞赛题目。如果需要 \(O(n)\) 的构建时间,可以学习 SA-IS 算法(Suffix Array by Induced Sorting),该算法通过分类后缀和诱导排序,在线性时间内构建后缀数组。不过 SA-IS 的实现较为复杂,在竞赛中一般不需要。另外,后缀自动机(SAM)和后缀树是更强大的字符串处理工具,适合处理更复杂的字符串问题,属于进阶内容。


13.8 算法对比与总结

在结束本章之前,我们将各算法的适用场景进行对比总结:

graph TD
    subgraph 字符串算法选择指南
        A{"需要解决什么问题?"}
        A -->|"子串快速比较/哈希"| B["字符串哈希"]
        A -->|"单模式串匹配"| C["KMP 算法"]
        A -->|"前缀查询/词典"| D["Trie 字典树"]
        A -->|"最长回文子串"| E["Manacher 算法"]
        A -->|"后缀相关问题"| F["后缀数组 / Z 函数"]
        A -->|"多模式串匹配"| G["AC 自动机"]
    end
算法 适用场景 时间复杂度 空间复杂度
字符串哈希 子串比较、子串出现次数、回文判断 预处理 O(n),查询 O(1) O(n)
KMP 单模式串匹配、最小循环节 O(n + m) O(m)
Trie 前缀查询、词典存储、XOR 最大值 O(L) 每次操作 O(N * L * C)
AC 自动机 多模式串匹配、模式串出现统计 O(L + m),L 为模式串总长,m 为文本长 O(L * C)
Manacher 最长回文子串、回文计数 O(n) O(n)
Z 函数 模式匹配、子串与前缀关系 O(n) O(n)
后缀数组 子串排序、LCP、不同子串计数 O(n log n) O(n)

ACM Day10 字符串 练习题

题号 平台 题目 难度 链接 完成
1948D CF Tandem Repeats? 1700 https://codeforces.com/contest/1948/problem/D - [ ]
2010C2 CF Message Transmission Error (hard version) 1700 https://codeforces.com/contest/2010/problem/C2 - [ ]
1979D CF Fixing a Binary String 1800 https://codeforces.com/contest/1979/problem/D - [ ]
2069D CF Palindrome Shuffle 1800 https://codeforces.com/contest/2069/problem/D - [ ]
1968G1 CF Division + LCP (easy version) 1900 https://codeforces.com/contest/1968/problem/G1 - [ ]
1902E CF Collapsing Strings 1900 https://codeforces.com/contest/1902/problem/E - [ ]
1943B CF Non-Palindromic Substring 2000 https://codeforces.com/contest/1943/problem/B - [ ]
1984D CF "a" String Problem 2000 https://codeforces.com/contest/1984/problem/D - [ ]
abc346_f AT String Shifting 1900 https://atcoder.jp/contests/abc346/tasks/abc346_f - [ ]
abc284_f AT Yet Another String Game 1800 https://atcoder.jp/contests/abc284/tasks/abc284_f - [ ]
abc287_f AT Predilection 1800 https://atcoder.jp/contests/abc287/tasks/abc287_f - [ ]
P3375 洛谷 【模板】KMP字符串匹配 1700 https://www.luogu.com.cn/problem/P3375 - [ ]
P3501 洛谷 [POI2010] ANT-Antisymmetry 1900 https://www.luogu.com.cn/problem/P3501 - [ ]
P2852 洛谷 [USACO06DEC] Milk Patterns G 1800 https://www.luogu.com.cn/problem/P2852 - [ ]