3889 字
19 分钟
字符串算法详解:从CSP-S到IOI

字符串算法详解:从CSP-S到IOI#

字符串算法是信息学竞赛中的重要组成部分,从基础的模式匹配到高级的后缀结构,涵盖了广泛的应用场景。本文将系统地介绍从CSP-S到IOI级别的核心字符串算法。

1. 字符串基础操作#

1.1 字符串表示与存储#

在C++中,字符串有多种表示方式:

#include <iostream>
#include <string>
#include <cstring>
using namespace std;
// 方式1:C风格字符串
char s1[1005] = "hello";
// 方式2:C++ string类
string s2 = "world";
// 方式3:字符数组(常用于OI)
char s3[1005];
scanf("%s", s3);

1.2 常用操作#

// 字符串长度
int len1 = strlen(s1); // C风格
int len2 = s2.length(); // string类
// 字符串比较
if (strcmp(s1, s3) == 0) { /* 相等 */ }
if (s2 == "world") { /* 相等 */ }
// 字符串拼接
strcat(s1, s3); // C风格
string s4 = s2 + "!"; // string类
// 子串提取
string sub = s2.substr(0, 3); // "wor"

2. 字符串哈希#

字符串哈希是一种将字符串映射到数值的技术,可以快速比较字符串是否相同。

2.1 单哈希#

原理:将字符串看作P进制数,对M取模得到哈希值。

const int P = 131; // 或 13331
const int M = 1e9 + 7;
// 计算字符串哈希值
unsigned long long getHash(string s) {
unsigned long long hash = 0;
for (char c : s) {
hash = hash * P + c;
}
return hash;
}
// 预处理前缀哈希(支持O(1)查询子串哈希)
const int N = 1e5 + 5;
unsigned long long h[N], p[N]; // h[i]为前i个字符的哈希,p[i]=P^i
void initHash(string s) {
int n = s.length();
p[0] = 1;
for (int i = 1; i <= n; i++) {
h[i] = h[i-1] * P + s[i-1];
p[i] = p[i-1] * P;
}
}
// 获取子串[l, r]的哈希值(下标从1开始)
unsigned long long getSubHash(int l, int r) {
return h[r] - h[l-1] * p[r-l+1];
}

2.2 双哈希#

为了降低哈希冲突概率,可以使用两个不同的模数:

const int P = 131;
const int M1 = 1e9 + 7;
const int M2 = 1e9 + 9;
struct HashPair {
long long h1, h2;
bool operator == (const HashPair& other) const {
return h1 == other.h1 && h2 == other.h2;
}
};
HashPair h[N], p[N];
void initDoubleHash(string s) {
int n = s.length();
p[0] = {1, 1};
for (int i = 1; i <= n; i++) {
h[i].h1 = (h[i-1].h1 * P + s[i-1]) % M1;
h[i].h2 = (h[i-1].h2 * P + s[i-1]) % M2;
p[i].h1 = (p[i-1].h1 * P) % M1;
p[i].h2 = (p[i-1].h2 * P) % M2;
}
}

复杂度分析

  • 预处理:O(n)
  • 查询子串哈希:O(1)

应用场景

  • 快速判断两个子串是否相同
  • 最长公共子串问题
  • 回文子串检测

3. KMP算法#

KMP(Knuth-Morris-Pratt)算法是经典的字符串模式匹配算法,可以在O(n+m)时间内完成匹配。

3.1 原理#

KMP的核心是next数组(也叫失配数组或前缀函数),next[i]表示模式串前i个字符的最长相等前后缀长度。

3.2 实现#

const int N = 1e6 + 5;
int nxt[N]; // next是关键字,用nxt代替
// 计算next数组
void getNext(string p) {
int m = p.length();
nxt[0] = 0;
for (int i = 1, j = 0; i < m; i++) {
while (j > 0 && p[i] != p[j]) {
j = nxt[j-1];
}
if (p[i] == p[j]) {
j++;
}
nxt[i] = j;
}
}
// KMP匹配,返回所有匹配位置
vector<int> KMP(string s, string p) {
int n = s.length(), m = p.length();
vector<int> result;
getNext(p);
for (int i = 0, j = 0; i < n; i++) {
while (j > 0 && s[i] != p[j]) {
j = nxt[j-1];
}
if (s[i] == p[j]) {
j++;
}
if (j == m) {
result.push_back(i - m + 1); // 匹配位置
j = nxt[j-1]; // 继续寻找下一个匹配
}
}
return result;
}

3.3 优化版本#

// 优化的next数组,避免重复比较
void getNextOpt(string p) {
int m = p.length();
nxt[0] = -1;
for (int i = 0, j = -1; i < m; ) {
if (j == -1 || p[i] == p[j]) {
i++; j++;
// 优化:如果下一个字符相同,直接跳过
nxt[i] = (p[i] != p[j]) ? j : nxt[j];
} else {
j = nxt[j];
}
}
}

复杂度分析

  • 时间复杂度:O(n + m)
  • 空间复杂度:O(m)

应用场景

  • 文本编辑器的查找功能
  • DNA序列匹配
  • 数据压缩

4. 字典树 Trie#

Trie树是一种用于快速检索字符串的树形数据结构。

4.1 基本结构#

const int MAXN = 1e5 + 5;
const int CHAR_SIZE = 26;
struct Trie {
int ch[MAXN][CHAR_SIZE]; // 子节点
int cnt[MAXN]; // 以当前节点结尾的字符串个数
int idx; // 节点编号
Trie() {
idx = 0;
memset(ch, 0, sizeof(ch));
memset(cnt, 0, sizeof(cnt));
}
// 插入字符串
void insert(string s) {
int p = 0;
for (char c : s) {
int u = c - 'a';
if (!ch[p][u]) ch[p][u] = ++idx;
p = ch[p][u];
}
cnt[p]++;
}
// 查询字符串出现次数
int query(string s) {
int p = 0;
for (char c : s) {
int u = c - 'a';
if (!ch[p][u]) return 0;
p = ch[p][u];
}
return cnt[p];
}
// 查询前缀出现次数
int queryPrefix(string s) {
int p = 0, count = 0;
for (char c : s) {
int u = c - 'a';
if (!ch[p][u]) return count;
p = ch[p][u];
count += cnt[p];
}
return count;
}
};

4.2 应用:最大异或对#

利用Trie可以高效求解最大异或对问题:

struct BinaryTrie {
int ch[MAXN * 31][2];
int idx;
void insert(int x) {
int p = 0;
for (int i = 30; i >= 0; i--) {
int u = (x >> i) & 1;
if (!ch[p][u]) ch[p][u] = ++idx;
p = ch[p][u];
}
}
int queryMax(int x) {
int p = 0, res = 0;
for (int i = 30; i >= 0; i--) {
int u = (x >> i) & 1;
// 尽量走相反的路径
if (ch[p][!u]) {
p = ch[p][!u];
res |= (1 << i);
} else {
p = ch[p][u];
}
}
return res;
}
};

复杂度分析

  • 插入:O(|S|)
  • 查询:O(|S|)
  • 空间:O(总字符数 × 字符集大小)

5. AC自动机#

AC自动机(Aho-Corasick Automaton)是多模式串匹配算法,可以同时匹配多个模式串。

5.1 原理#

AC自动机 = Trie + KMP的fail指针

5.2 实现#

const int MAXN = 1e6 + 5;
struct ACAutomaton {
int ch[MAXN][26];
int fail[MAXN]; // fail指针
int cnt[MAXN]; // 标记节点
int idx;
void init() {
idx = 0;
memset(ch, 0, sizeof(ch));
memset(cnt, 0, sizeof(cnt));
memset(fail, 0, sizeof(fail));
}
// 插入模式串
void insert(string s) {
int p = 0;
for (char c : s) {
int u = c - 'a';
if (!ch[p][u]) ch[p][u] = ++idx;
p = ch[p][u];
}
cnt[p]++;
}
// 构建fail指针(BFS)
void build() {
queue<int> q;
for (int i = 0; i < 26; i++) {
if (ch[0][i]) q.push(ch[0][i]);
}
while (!q.empty()) {
int p = q.front(); q.pop();
for (int i = 0; i < 26; i++) {
if (ch[p][i]) {
// 找到fail指针
fail[ch[p][i]] = ch[fail[p]][i];
q.push(ch[p][i]);
} else {
// 路径压缩优化
ch[p][i] = ch[fail[p]][i];
}
}
}
}
// 在文本串中匹配
int query(string s) {
int p = 0, res = 0;
for (char c : s) {
int u = c - 'a';
p = ch[p][u]; // 路径压缩后直接转移
// 统计匹配
for (int t = p; t && cnt[t] != -1; t = fail[t]) {
res += cnt[t];
cnt[t] = -1; // 避免重复计数
}
}
return res;
}
};

复杂度分析

  • 构建:O(总字符数 × 字符集大小)
  • 匹配:O(|文本| + 匹配次数)

应用场景

  • 敏感词过滤
  • 病毒特征码匹配
  • 多模式串检索

6. 后缀数组 SA#

后缀数组是一种强大的字符串数据结构,可以解决很多复杂的字符串问题。

6.1 基本概念#

  • sa[i]:排名第i的后缀的起始位置
  • rk[i]:起始位置为i的后缀的排名
  • height[i]:排名相邻的两个后缀的最长公共前缀(LCP)

6.2 倍增算法#

const int MAXN = 1e6 + 5;
struct SuffixArray {
int sa[MAXN], rk[MAXN], tmp[MAXN];
int height[MAXN];
int n;
string s;
// O(n log n) 构建后缀数组
void buildSA() {
n = s.length();
// 初始化
for (int i = 0; i < n; i++) {
sa[i] = i;
rk[i] = s[i];
}
// 倍增
for (int k = 1; k < n; k *= 2) {
// 基数排序
auto cmp = [&](int i, int j) {
if (rk[i] != rk[j]) return rk[i] < rk[j];
int ri = (i + k < n) ? rk[i + k] : -1;
int rj = (j + k < n) ? rk[j + k] : -1;
return ri < rj;
};
sort(sa, sa + n, cmp);
// 更新rk
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);
}
memcpy(rk, tmp, sizeof(int) * n);
}
}
// 构建height数组
void buildHeight() {
int k = 0;
for (int i = 0; i < n; i++) {
if (rk[i] == 0) continue;
if (k) k--;
int j = sa[rk[i] - 1];
while (i + k < n && j + k < n && s[i + k] == s[j + k]) k++;
height[rk[i]] = k;
}
}
// 初始化
void init(string str) {
s = str;
buildSA();
buildHeight();
}
};

6.3 应用:最长重复子串#

// 找最长重复子串(出现至少两次)
string longestRepeatedSubstring(string s) {
SuffixArray sa;
sa.init(s);
int maxLen = 0, pos = 0;
for (int i = 1; i < sa.n; i++) {
if (sa.height[i] > maxLen) {
maxLen = sa.height[i];
pos = sa.sa[i];
}
}
return s.substr(pos, maxLen);
}

复杂度分析

  • 倍增算法:O(n log n)
  • DC3算法:O(n)
  • height数组:O(n)

7. Manacher算法#

Manacher算法用于在O(n)时间内求解最长回文子串问题。

7.1 原理#

通过添加分隔符将问题统一为奇数长度回文,利用回文的对称性减少比较次数。

7.2 实现#

string preprocess(string s) {
string t = "^#";
for (char c : s) {
t += c;
t += '#';
}
t += '$';
return t;
}
// 返回最长回文子串
string manacher(string s) {
string t = preprocess(s);
int n = t.length();
vector<int> p(n, 0); // p[i]为以i为中心的回文半径
int center = 0, right = 0; // 当前最右回文的中心和右边界
int maxLen = 0, maxCenter = 0;
for (int i = 1; i < n - 1; i++) {
// 利用对称性
int mirror = 2 * center - i;
if (i < right) {
p[i] = min(right - i, p[mirror]);
}
// 尝试扩展
while (t[i + p[i] + 1] == t[i - p[i] - 1]) {
p[i]++;
}
// 更新右边界
if (i + p[i] > right) {
center = i;
right = i + p[i];
}
// 记录最长回文
if (p[i] > maxLen) {
maxLen = p[i];
maxCenter = i;
}
}
// 提取原字符串中的回文
int start = (maxCenter - maxLen) / 2;
return s.substr(start, maxLen);
}

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

8. 字符串DP问题#

8.1 最长公共子序列(LCS)#

int LCS(string s1, string s2) {
int n = s1.length(), m = s2.length();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1[i-1] == s2[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
} else {
dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
}
}
return dp[n][m];
}

8.2 编辑距离#

int editDistance(string s1, string s2) {
int n = s1.length(), m = s2.length();
vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
// 初始化
for (int i = 0; i <= n; i++) dp[i][0] = i;
for (int j = 0; j <= m; j++) dp[0][j] = j;
// DP转移
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s1[i-1] == s2[j-1]) {
dp[i][j] = dp[i-1][j-1];
} else {
dp[i][j] = min({
dp[i-1][j] + 1, // 删除
dp[i][j-1] + 1, // 插入
dp[i-1][j-1] + 1 // 替换
});
}
}
}
return dp[n][m];
}

8.3 回文子串个数#

int countPalindromes(string s) {
int n = s.length();
vector<vector<bool>> dp(n, vector<bool>(n, false));
int count = 0;
// 长度为1的回文
for (int i = 0; i < n; i++) {
dp[i][i] = true;
count++;
}
// 长度为2的回文
for (int i = 0; i < n - 1; i++) {
if (s[i] == s[i+1]) {
dp[i][i+1] = true;
count++;
}
}
// 长度>=3的回文
for (int len = 3; len <= n; len++) {
for (int i = 0; i <= n - len; i++) {
int j = i + len - 1;
if (s[i] == s[j] && dp[i+1][j-1]) {
dp[i][j] = true;
count++;
}
}
}
return count;
}

9. 后缀自动机 SAM(IOI难度)#

后缀自动机是最强大的字符串数据结构之一,可以在线性时间内处理几乎所有字符串问题。

9.1 基本概念#

SAM是一个能识别一个字符串所有后缀的最小DFA(确定有限状态自动机)。

9.2 核心实现#

const int MAXN = 2e6 + 5;
struct SAM {
int ch[MAXN][26]; // 转移
int link[MAXN]; // 后缀链接
int len[MAXN]; // 最长字符串长度
int cnt[MAXN]; // endpos集合大小
int last, idx;
void init() {
idx = last = 0;
memset(ch, 0, sizeof(ch));
memset(link, -1, sizeof(link));
memset(len, 0, sizeof(len));
memset(cnt, 0, sizeof(cnt));
}
// 添加字符
void extend(int c) {
int cur = ++idx;
len[cur] = len[last] + 1;
cnt[cur] = 1;
int p = last;
while (p != -1 && !ch[p][c]) {
ch[p][c] = cur;
p = link[p];
}
if (p == -1) {
link[cur] = 0;
} else {
int q = ch[p][c];
if (len[p] + 1 == len[q]) {
link[cur] = q;
} else {
int clone = ++idx;
len[clone] = len[p] + 1;
memcpy(ch[clone], ch[q], sizeof(ch[q]));
link[clone] = link[q];
while (p != -1 && ch[p][c] == q) {
ch[p][c] = clone;
p = link[p];
}
link[q] = link[cur] = clone;
}
}
last = cur;
}
// 构建SAM
void build(string s) {
init();
for (char c : s) {
extend(c - 'a');
}
}
// 统计endpos大小(需要拓扑排序)
void calcEndpos() {
vector<int> bucket[MAXN];
for (int i = 1; i <= idx; i++) {
bucket[len[i]].push_back(i);
}
for (int i = idx; i >= 1; i--) {
for (int u : bucket[i]) {
if (link[u] != -1) {
cnt[link[u]] += cnt[u];
}
}
}
}
// 查询子串出现次数
int queryCount(string t) {
int p = 0;
for (char c : t) {
int u = c - 'a';
if (!ch[p][u]) return 0;
p = ch[p][u];
}
return cnt[p];
}
// 计算不同子串个数
long long countDistinct() {
long long ans = 0;
for (int i = 1; i <= idx; i++) {
ans += len[i] - len[link[i]];
}
return ans;
}
};

9.3 应用示例#

// 最长公共子串(多个字符串)
int longestCommonSubstring(vector<string>& strs) {
if (strs.empty()) return 0;
SAM sam;
sam.build(strs[0]);
int ans = 0;
for (int i = 1; i < strs.size(); i++) {
int p = 0, len = 0, maxLen = 0;
for (char c : strs[i]) {
int u = c - 'a';
while (p && !sam.ch[p][u]) {
p = sam.link[p];
len = sam.len[p];
}
if (sam.ch[p][u]) {
p = sam.ch[p][u];
len++;
}
maxLen = max(maxLen, len);
}
ans = (i == 1) ? maxLen : min(ans, maxLen);
}
return ans;
}

复杂度分析

  • 时间复杂度:O(n)
  • 空间复杂度:O(n × 字符集大小)
  • 节点数:≤ 2n - 1
  • 边数:≤ 3n - 4

10. 经典竞赛题目#

10.1 基础题目(CSP-S / 省选)#

  1. P3375 【模板】KMP字符串匹配

    • 难度:普及+/提高
    • 考点:KMP算法
  2. P3808 【模板】AC自动机(简单版)

    • 难度:提高+/省选-
    • 考点:AC自动机
  3. P3796 【模板】AC自动机(加强版)

    • 难度:省选/NOI-
    • 考点:AC自动机 + 统计
  4. P4551 最长异或路径

    • 难度:省选/NOI-
    • 考点:字典树 + 异或

10.2 进阶题目(NOI / CTSC)#

  1. P3804 【模板】后缀自动机

    • 难度:NOI/NOI+
    • 考点:SAM基础应用
  2. P2408 不同子串个数

    • 难度:NOI-
    • 考点:SAM / 后缀数组
  3. P3181 [HAOI2016]找相同字符

    • 难度:NOI/NOI+
    • 考点:SAM / 后缀数组 + LCP

10.3 高级题目(IOI / WC)#

  1. P6139 【模板】广义后缀自动机

    • 难度:NOI+/WC
    • 考点:广义SAM
  2. P4094 [HEOI2016/TJOI2016]字符串

    • 难度:NOI+
    • 考点:后缀数组 + 二分 + 线段树
  3. P5546 [POI2000]公共串

    • 难度:NOI+/WC
    • 考点:SAM + 动态规划

10.4 解题技巧#

  1. 选择合适的算法

    • 单模式匹配 → KMP
    • 多模式匹配 → AC自动机
    • 子串问题 → 哈希 / SAM
    • 后缀问题 → 后缀数组 / SAM
  2. 常见优化技巧

    • 字符串哈希:降低常数,快速比较
    • 路径压缩:AC自动机优化转移
    • 拓扑排序:SAM统计endpos
    • 倍增/ST表:RMQ优化LCP查询
  3. 调试技巧

    • 小数据手模
    • 输出中间状态
    • 对拍验证正确性
    • 特殊数据测试(全相同、全不同)

总结#

字符串算法是算法竞赛的重要组成部分,掌握以下要点:

  1. 基础算法(CSP-S必备):

    • 字符串哈希
    • KMP算法
    • 字典树Trie
  2. 进阶算法(省选/NOI):

    • AC自动机
    • Manacher算法
    • 后缀数组
  3. 高级算法(NOI+/IOI):

    • 后缀自动机SAM
    • 广义SAM
    • 回文自动机

建议学习路径:

  1. 先掌握基础的字符串哈希和KMP
  2. 熟练运用Trie和AC自动机
  3. 理解后缀数组的构造和应用
  4. 最后挑战SAM及其高级应用

每个算法都要:

  • 理解原理和证明
  • 手写代码实现
  • 刷相关题目巩固
  • 总结常见套路

字符串算法看似复杂,但只要循序渐进、多加练习,一定能够掌握!加油!

参考资料#

  1. 《算法竞赛进阶指南》- 李煜东
  2. 《算法导论》- CLRS
  3. OI Wiki - 字符串专题
  4. Codeforces 字符串专题
  5. 洛谷题单 - 字符串算法

本文涵盖了从CSP-S到IOI级别的核心字符串算法,希望对你的学习有所帮助!

字符串算法详解:从CSP-S到IOI
https://myblog-590.pages.dev/posts/algorithm-string/
作者
谢星宇
发布于
2026-01-21
许可协议
CC BY-NC-SA 4.0