1. AC自动机在信奥赛中的核心价值
第一次接触AC自动机是在准备CSP-S复赛时,当时遇到一道需要同时匹配多个模式串的题目。暴力解法直接超时,而AC自动机让我在O(n)时间复杂度内就解决了问题。这种高效的多模式匹配算法,正是信奥赛选手必须掌握的利器。
AC自动机全称Aho-Corasick自动机,是由贝尔实验室的Alfred Aho和Margaret Corasick在1975年发明的。它结合了Trie树和KMP算法的思想,特别适合处理"给定一个文本串和多个模式串,快速找出所有模式串在文本中出现位置"这类问题。在信奥赛提高组中,这类题目几乎每年都会出现,比如2021年CSP-S的"字符串匹配"、2020年的"敏感词过滤"等。
提示:AC自动机的预处理时间复杂度是O(∑|pattern|),其中∑|pattern|是所有模式串长度之和;查询时间复杂度是O(|text|),与模式串数量无关。
2. AC自动机核心原理拆解
2.1 Trie树的基础构建
AC自动机的骨架是一棵Trie树。我们先看一个具体例子,假设有模式串集合{"he","she","his","hers"}。构建Trie树时,每个字符对应一个节点,从根节点出发沿着字符路径往下延伸。
cpp复制struct TrieNode {
TrieNode* children[26]; // 假设只处理小写字母
TrieNode* fail; // 失败指针
bool isEnd; // 是否是某个模式串的结尾
int length; // 如果是结尾,记录模式串长度
TrieNode() {
memset(children, 0, sizeof(children));
fail = nullptr;
isEnd = false;
length = 0;
}
};
构建过程就是依次插入每个模式串。以"she"为例:
- 从根节点开始,检查's'子节点是否存在,不存在则创建
- 移动到's'节点,检查'h'子节点
- 最后检查'e'子节点并标记为结束节点
2.2 失败指针的精妙设计
AC自动机的核心在于fail指针的构建。fail指针的含义是:当当前字符不匹配时,应该跳转到哪个节点继续匹配。这与KMP算法中的next数组思想类似,但扩展到树形结构上。
构建fail指针采用BFS层次遍历的方式:
- 根节点的fail指向自己
- 根节点的直接子节点的fail指向根节点
- 对于其他节点u,设其父节点为v,字符为c:
- 如果v->fail有字符c的子节点,则u->fail指向该子节点
- 否则继续查看v->fail->fail,直到根节点
cpp复制void buildFailPointer() {
queue<TrieNode*> q;
root->fail = root;
for (int i = 0; i < 26; ++i) {
if (root->children[i]) {
root->children[i]->fail = root;
q.push(root->children[i]);
}
}
while (!q.empty()) {
TrieNode* curr = q.front();
q.pop();
for (int i = 0; i < 26; ++i) {
if (curr->children[i]) {
TrieNode* fail = curr->fail;
while (fail != root && !fail->children[i]) {
fail = fail->fail;
}
if (fail->children[i]) {
curr->children[i]->fail = fail->children[i];
} else {
curr->children[i]->fail = root;
}
q.push(curr->children[i]);
}
}
}
}
2.3 多模式匹配的查询过程
构建好AC自动机后,查询过程非常高效:
- 从根节点和文本串起始位置开始
- 如果当前节点有对应字符的子节点,则移动到子节点
- 否则通过fail指针跳转
- 每到一个节点,检查是否是某个模式串的结尾
cpp复制void query(const string& text) {
TrieNode* curr = root;
for (int i = 0; i < text.size(); ++i) {
int c = text[i] - 'a';
while (curr != root && !curr->children[c]) {
curr = curr->fail;
}
if (curr->children[c]) {
curr = curr->children[c];
}
TrieNode* temp = curr;
while (temp != root) {
if (temp->isEnd) {
cout << "在位置 " << i - temp->length + 1
<< " 发现模式串,长度为 " << temp->length << endl;
}
temp = temp->fail;
}
}
}
3. 信奥赛中的优化技巧
3.1 空间优化:双数组Trie实现
传统Trie节点使用指针会带来较大内存开销。在比赛中,可以使用双数组Trie(Double-Array Trie)优化:
cpp复制int base[MAX_NODE], check[MAX_NODE];
int node_count = 1; // 根节点为1
void insert(const string& s) {
int p = 1;
for (char c : s) {
int idx = c - 'a' + 1; // +1避免0冲突
if (check[base[p] + idx] == 0) {
base[p] = node_count;
check[base[p] + idx] = p;
node_count++;
}
p = base[p] + idx;
}
// 标记结束
}
3.2 时间优化:路径压缩技巧
在构建fail指针时,可以采用路径压缩来加速跳转:
cpp复制TrieNode* getFail(TrieNode* p, int c) {
while (p != root && !p->children[c]) {
p = p->fail; // 原始跳转
}
if (p->children[c]) {
return p->children[c];
}
return root;
}
// 优化后可以记忆化结果,避免重复计算
3.3 输出优化:记录所有匹配位置
比赛中通常需要输出所有匹配位置,而不仅仅是检测是否存在。可以在构建时记录每个模式串的id:
cpp复制struct TrieNode {
// ...其他成员
vector<int> ids; // 存储匹配的模式串id
};
void query(const string& text, vector<vector<int>>& results) {
// results[i]保存模式串i在text中的所有起始位置
TrieNode* curr = root;
for (int i = 0; i < text.size(); ++i) {
// ...跳转逻辑
TrieNode* temp = curr;
while (temp != root) {
for (int id : temp->ids) {
results[id].push_back(i - length[id] + 1);
}
temp = temp->fail;
}
}
}
4. 典型题目分析与实战代码
4.1 CSP-S 2021字符串匹配题解析
题目大意:给定文本串T和n个模式串P_i,统计每个P_i在T中出现的次数。
完整AC代码:
cpp复制#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;
const int MAXN = 1e6 + 5;
const int CHAR_SET = 26;
struct TrieNode {
TrieNode* children[CHAR_SET];
TrieNode* fail;
int count;
int ends; // 记录以此节点结尾的模式串数量
TrieNode() {
memset(children, 0, sizeof(children));
fail = nullptr;
count = 0;
ends = 0;
}
};
TrieNode* root;
vector<TrieNode*> nodes; // 用于最后内存释放
void insert(const string& s) {
TrieNode* curr = root;
for (char c : s) {
int idx = c - 'a';
if (!curr->children[idx]) {
curr->children[idx] = new TrieNode();
nodes.push_back(curr->children[idx]);
}
curr = curr->children[idx];
}
curr->ends++;
}
void buildAC() {
queue<TrieNode*> q;
root->fail = root;
for (int i = 0; i < CHAR_SET; ++i) {
if (root->children[i]) {
root->children[i]->fail = root;
q.push(root->children[i]);
} else {
root->children[i] = root;
}
}
while (!q.empty()) {
TrieNode* curr = q.front();
q.pop();
for (int i = 0; i < CHAR_SET; ++i) {
if (curr->children[i]) {
curr->children[i]->fail = curr->fail->children[i];
q.push(curr->children[i]);
} else {
curr->children[i] = curr->fail->children[i];
}
}
}
}
void query(const string& text, vector<int>& ans) {
TrieNode* curr = root;
for (char c : text) {
int idx = c - 'a';
curr = curr->children[idx];
curr->count++;
}
// 按照fail指针的拓扑逆序统计
for (int i = nodes.size() - 1; i >= 0; --i) {
if (nodes[i]->fail != nodes[i]) {
nodes[i]->fail->count += nodes[i]->count;
}
if (nodes[i]->ends) {
ans.push_back(nodes[i]->count);
}
}
}
int main() {
string text;
int n;
cin >> text >> n;
root = new TrieNode();
nodes.push_back(root);
vector<string> patterns(n);
for (int i = 0; i < n; ++i) {
cin >> patterns[i];
insert(patterns[i]);
}
buildAC();
vector<int> ans;
query(text, ans);
for (int cnt : ans) {
cout << cnt << endl;
}
// 释放内存
for (auto node : nodes) {
delete node;
}
return 0;
}
4.2 内存管理的注意事项
在比赛中,动态内存管理容易出问题。可以采用以下策略:
- 预分配节点池(适用于已知最大节点数)
- 使用智能指针(但可能影响性能)
- 静态数组实现(牺牲灵活性换取稳定性)
cpp复制TrieNode pool[MAX_NODES];
int pool_pos = 0;
TrieNode* newNode() {
if (pool_pos >= MAX_NODES) {
cerr << "节点池耗尽" << endl;
exit(1);
}
TrieNode* node = &pool[pool_pos++];
// 初始化代码
return node;
}
5. 调试技巧与常见错误
5.1 典型错误排查表
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
| 段错误 | fail指针未正确初始化 | 确保根节点的fail指向自己 |
| 结果遗漏 | 未处理fail链上的所有结束节点 | 检查查询时的while循环条件 |
| 内存超限 | 节点数估算不足 | 根据题目约束计算最大节点数 |
| 时间超限 | 未优化fail指针跳转 | 实现路径压缩或记忆化 |
5.2 调试输出技巧
在开发阶段,可以添加调试输出:
cpp复制void printTrie(TrieNode* node, int depth = 0) {
for (int i = 0; i < CHAR_SET; ++i) {
if (node->children[i]) {
cout << string(depth * 2, ' ') << (char)('a' + i);
if (node->children[i]->isEnd) {
cout << " (E)";
}
cout << " -> fail: " << node->children[i]->fail << endl;
printTrie(node->children[i], depth + 1);
}
}
}
// 在buildAC后调用
printTrie(root);
5.3 边界情况测试集
在练习时务必测试这些边界情况:
- 空文本串
- 空模式串集合
- 所有模式串相同
- 模式串之间存在包含关系(如"abc"和"abcd")
- 超大字符集(如包含数字和特殊符号)
6. 性能对比与算法选择
6.1 与其他算法的对比
| 算法 | 预处理时间 | 查询时间 | 适用场景 |
|---|---|---|---|
| 暴力匹配 | O(1) | O(n*m) | 模式串很少 |
| KMP | O(m) | O(n) | 单模式串 |
| Trie树 | O(∑ | P | ) |
| AC自动机 | O(∑ | P | ) |
6.2 信奥赛中的选择策略
根据题目特点选择合适算法:
- 当模式串有公共前缀 → AC自动机
- 需要同时处理多个查询 → 预处理AC自动机
- 模式串会动态变化 → 可能需要结合其他数据结构
在时间紧迫时,可以先用暴力解法拿到部分分,再实现AC自动机争取满分。