零、前言
对于模式串匹配问题,在很多基础的数据结构课程中都有涉及到,如 KMP 算法,BM算法,Trie。
但是给定文本串,我们有多个模式串要去查询。难道要多次调用KMP / BM,或者在Trie上多次查询吗?
Aho 和 Corasick 基于 Trie,对 KMP 进行了推广,使得 Trie 可以在一个文本串中识别一个关键字集合中的任何关键字。
这就是本博客的主题——AC自动机。
一、AC 自动机
1.1 概述
AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想 建立的自动机,用于解决多模式匹配等任务。
AC 自动机本质上是 Trie 上的自动机。
关于KMP:[KMP算法详解 c++]_kmpc+±CSDN博客
关于Trie:[Trie树/字典树的原理及实现C/C++]_trie字典树原理-CSDN博客
1.2 基本原理
建立 AC 自动机 一般分为两步:
- 用 所有的模式串 构建一个 Trie
- 为 Trie 上的所有结点构建 失配指针(fail)
1.3 Trie 构建
按照 普通 Trie 插入模式串的方式将 模式串集合中的串依次插入到Trie中
我们在Trie 上匹配模式串的过程,实质上就是在自动机上进行状态转换的过程。
因此,我们可以把Trie 的每个节点看作一个状态,每个状态事实上都对应了某个模式串的前缀。
所以下文有时会称Trie 上的节点为 前缀、状态,这里约定下。
1.4 失配指针
AC 自动机利用一个 fail 指针来辅助多模式串的匹配。
fail 指针:节点(状态) u 的 fail 指针 指向另一个 节点 v,v 是 u 的最长后缀
换句话说,v 所代表的前缀 是 u所代表的前缀的最长后缀
fail vs KMP的next
-
共同点:两者都是在失配时用于跳转的指针
-
不同点:next 指针 求的是最长公共前后缀,fail 指针则指向的是 所有模式串前缀中 和当前前缀 公共后缀最长的那个
KMP 只对一个模式串做匹配,而AC自动机要对多个模式串做匹配。因而fail 指针可能指向的是另一个模式串,二者前缀不同。
简而言之:AC 自动机的失配指针指向当前状态的最长后缀状态。
下面先解释如何构建fail指针,再解释为何能够通过fail指针来实现多模式串匹配。
1.5 fail 指针的构建
fail 指针的构建其实就是利用 bfs 自顶向下递推的过程,从而避免了后效性。
为了方便描述,先给出节点定义:
struct AhoCorasick {
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node {
int len; // 当前前缀长度
int fail; // 失配指针
int cnt; // 此状态结尾单词数目
std::array<int, ALPHABET> son; // 儿子节点
Node(): len(0), fail(0), cnt(0), son{} {}
};
std::vector<Node> tr; // 节点池
};
构建流程:
- 队列 q 保存 bfs 过程中的节点
- 节点 u 从队头出队,此时 u 以及 u 以上的节点的fail 都已经构建完毕
- 遍历 u 的 儿子节点 son[i]
- 如果 son[i] 为空,那么 son[i] 指向 fail 的 son[i]
- 如果 son[i] 存在,那么 son[i] 的 fail 指向 u 的 fail 的 son[i],并将 son[i] 入队
- 队空,算法结束
由于自顶向下构建,保证了每一个节点出队时,它前面的节点都已构建完毕,保证了对子节点 fail指针构建的正确性。
事实上这是对于暴力构建fail指针的递推优化。
代码如下:
void build_fail() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i])
tr[u].son[i] = tr[tr[u].fail].son[i];
else {
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
q.push(tr[u].son[i]);
}
}
}
}
放个图可以手玩一下。
1.6 多模式串匹配
先给出多模式串匹配的代码:
/*
文本串: s
模式串构建的 AC 自动机: ac
*/
int cur = 1; // 根结点编号为1
int res = 0;
for (char ch : s) {
cur = ac.son(cur, ch - 'a'); // 往下跳一步
// 开始跳fail,遍历所有后缀
for (int newcur = cur; newcur && ac.cnt(newcur) >= 0; newcur = ac.fail(newcur)) {
res += ac.cnt(newcur), ac.cnt(newcur) = -1;
}
}
- 默认从根节点开始匹配s
- 当前节点号为cur
- 遍历到字符ch
- cur = son[ch]
- son[ch] 要么是成功匹配节点,要么是 构建fail 时 指向的最长后缀的 son[ch]
- 然后从 cur 开始跳fail,将所有后缀节点都跑一遍,记录匹配
事实上,大部分 编译原理 课程中都会介绍最长匹配原则,保证了我们的匹配不漏,这点可以自行了解。
1.7 后缀链接优化
尝试自己模拟下这组样例:
target 为文本串,words 为 模式串集合
发现复杂度会爆炸的
巧妙的是,KMP算法中也对nxt 数组进行了优化,我们这里也是利用类似思想来进行优化。
这里引入后缀链接(link):
后缀链接(suffix link),用来快速跳到一定是某个 模式串 的最后一个字母的节点(等于 root 则表示没有)
这样一来节点定义如下:
struct Node {
std::vector<int> id;
int len;
int fail;
int last;
std::array<int, ALPHABET> son;
Node() : last(0), id{}, len(0), fail(0), son{} {}
};
构建过程:
last 是和 fail 在一起构建的:
根节点的 last 默认指向 根节点
- 继承前面bfs 构建fail 的逻辑
- u出队,此时u 以及前面的 fail 和 last 都已经构建完毕
- 遍历 u 的 儿子节点 son[i]
- 如果 son[i] 为空,那么 son[i] 指向 fail 的 son[i]
- 如果 son[i] 存在,那么 son[i] 的 fail 指向 u 的 fail 的 son[i]
- 如果 son[i].fail 是单词结尾,那么 son[i].last = son[i].fail,否则指向 son[i].fail.last
- 将 son[i] 入队
- 队空,算法结束
代码如下:
void build() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt ?
tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
1.8 拓扑优化
见OJ练习2.3
1.9 dfs 优化
见OJ练习2.3
二、OJ 练习
2.1 P3808 AC 自动机(简单版I)
原题链接
P3808 AC 自动机(简单版) - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
思路分析
如果暴力跳 fail 或者 last 都会TLE,我们对于走过的点打个标记,即 cnt = -1
AC代码
#include <bits/stdc++.h>
// #include <ranges>
using u32 = unsigned;
using i64 = long long;
using u64 = unsigned long long;
constexpr int P = 1E9 + 7;;
constexpr int inf32 = 1E9 + 7;
constexpr i64 inf64 = 1E18 + 7;
struct AhoCorasick {
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node {
int len; // 当前前缀长度
int fail; // 失配指针
int cnt; // 此状态结尾单词数目
int last; // 后缀链接
std::array<int, ALPHABET> son; // 儿子节点
Node(): last{0}, len(0), fail(0), cnt(0), son{} {}
};
std::vector<Node> tr;
AhoCorasick() {
init();
}
void init() {
tr.assign(2, Node());
tr[0].son.fill(1);
tr[0].len = -1;
tr[0].last = 1;
}
int newNode() {
tr.emplace_back();
return (int)tr.size() - 1;
}
int add(const std::string &s) {
int cur = 1;
for (char ch : s) {
int x = ch - base;
if (!tr[cur].son[x]) {
tr[cur].son[x] = newNode();
tr[tr[cur].son[x]].len = tr[cur].len + 1;
}
cur = tr[cur].son[x];
}
++ tr[cur].cnt;
return cur;
}
void work() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt > 0 ? tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
int son(int cur, int x) const{
return tr[cur].son[x];
}
int fail(int cur) const{
return tr[cur].fail;
}
int len(int cur) const{
return tr[cur].len;
}
int size() const{
return tr.size();
}
int last(int cur) const {
return tr[cur].last;
}
int& cnt(int cur) {
return tr[cur].cnt;
}
};
void solve() {
int n;
std::cin >> n;
AhoCorasick ac;
for (int i = 0; i < n; ++ i) {
std::string s;
std::cin >> s;
ac.add(s);
}
ac.work();
std::string t;
std::cin >> t;
int res = 0;
for (int i = 0, cur = 1; i < t.size(); ++ i) {
cur = ac.son(cur, t[i] - 'a');
for (int ncur = cur; ncur && ac.cnt(ncur) >= 0; ncur = ac.last(ncur)) {
res += ac.cnt(ncur);
ac.cnt(ncur) = -1;
}
}
std::cout << res << '\n';
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int t = 1;
// std::cin >> t;
while (t--) {
solve();
}
return 0;
}
2.2 P3796 AC 自动机(简单版 II)
原题链接
P3796 AC 自动机(简单版 II) - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
思路分析
也是板题,额外存个id就行
AC代码
#include <bits/stdc++.h>
// #include <ranges>
using u32 = unsigned;
using i64 = long long;
using u64 = unsigned long long;
constexpr int P = 1E9 + 7;;
constexpr int inf32 = 1E9 + 7;
constexpr i64 inf64 = 1E18 + 7;
struct AhoCorasick{
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node{
int len;
int cnt;
int fail;
int last;
int id;
std::array<int, ALPHABET> son;
Node(): len{0}, cnt{0}, fail{0}, last{0}, id{-1}, son{} {}
};
std::vector<Node> tr;
AhoCorasick() {
init();
}
void init() {
tr.assign(2, Node());
tr[0].son.fill(1);
tr[0].len = -1;
tr[0].last = 1;
}
int newNode() {
tr.emplace_back();
return (int)tr.size() - 1;
}
int add(const std::string &s, int id) {
int cur = 1;
for (char ch : s) {
int x = ch - base;
if (!tr[cur].son[x]) {
tr[cur].son[x] = newNode();
tr[tr[cur].son[x]].len = tr[cur].len + 1;
}
cur = tr[cur].son[x];
}
++ tr[cur].cnt;
tr[cur].id = id;
return cur;
}
void work() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt > 0 ? tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
int cnt(int cur) const{
return tr[cur].cnt;
}
int len(int cur) const{
return tr[cur].len;
}
int fail(int cur) const{
return tr[cur].fail;
}
int son(int cur, int x) const{
return tr[cur].son[x];
}
int last(int cur) const{
return tr[cur].last;
}
int id(int cur) const{
return tr[cur].id;
}
};
void solve() {
int n;
while (std::cin >> n, n) {
AhoCorasick ac;
std::vector<std::string> words(n);
for (int i = 0; i < n; ++ i) {
std::cin >> words[i];
ac.add(words[i], i);
}
ac.work();
std::string t;
std::cin >> t;
std::vector<int> cnt(n);
int cur = 1;
for (char ch : t) {
int x = ch - 'a';
cur = ac.son(cur, x);
for (int newcur = cur; newcur > 1; newcur = ac.last(newcur))
if (~ac.id(newcur))
++ cnt[ac.id(newcur)];
}
int ma = *std::max_element(cnt.begin(), cnt.end());
std::cout << ma << '\n';
for (int i = 0; i < n; ++ i)
if (cnt[i] == ma)
std::cout << words[i] << '\n';
}
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int t = 1;
// std::cin >> t;
while (t--) {
solve();
}
return 0;
}
2.3 P5357 【模板】AC 自动机(拓扑&dfs优化)
原题链接
P5357 【模板】AC 自动机 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
思路分析
用后缀链接优化可以跑到84分,我们这个时候有两个选择:
- 拓扑优化
- dfs 优化
二者其实思想相同,只不过一个实现方式是bfs拓扑排序,一个是dfs(类似树形dp)
我们看这个样例图:
如果我们把fail 边抽离出来会得到什么?
一棵树
因为一个节点只有一条fail边
那么我们按照fail边建树,有两种方式,一个是不改变原图的fail方向,一个是翻转原图fail方向
两种方式分别对应了拓扑和dfs的做法
那么我们在文本串上匹配,每匹配一个字符,自动机都会跳转到一个状态,我们将该状态贡献+1
那么每个模式串都对应着AC自动机上的一个状态节点
每个模式串在文本串中出现次数 就是 从该状态节点,沿着fail边反方向所能到达节点的贡献和
因而我们可以:
- 不改变fail边方向建树,进行拓扑排序,在拓扑排序的时候将贡献往后累加
- 改变fail边方向建树,进行dfs,树形dp计数
AC代码
拓扑优化:
#include <bits/stdc++.h>
// #include <ranges>
using u32 = unsigned;
using i64 = long long;
using u64 = unsigned long long;
constexpr int P = 1E9 + 7;;
constexpr int inf32 = 1E9 + 7;
constexpr i64 inf64 = 1E18 + 7;
struct AhoCorasick{
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node{
int len;
int cnt;
int fail;
int last;
int in;
std::array<int, ALPHABET> son;
Node(): len{0}, cnt{0}, fail{0}, last{0}, in{0}, son{} {}
};
std::vector<Node> tr;
AhoCorasick() {
init();
}
void init() {
tr.assign(2, Node());
tr[0].son.fill(1);
tr[0].len = -1;
tr[0].last = 1;
}
int newNode() {
tr.emplace_back();
return (int)tr.size() - 1;
}
int add(const std::string &s) {
int cur = 1;
for (char ch : s) {
int x = ch - base;
if (!tr[cur].son[x]) {
tr[cur].son[x] = newNode();
tr[tr[cur].son[x]].len = tr[cur].len + 1;
}
cur = tr[cur].son[x];
}
++ tr[cur].cnt;
return cur;
}
void work() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
++ tr[tr[tr[u].son[i]].fail].in;
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt > 0 ? tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
int cnt(int cur) const{
return tr[cur].cnt;
}
int len(int cur) const{
return tr[cur].len;
}
int fail(int cur) const{
return tr[cur].fail;
}
int son(int cur, int x) const{
return tr[cur].son[x];
}
int last(int cur) const{
return tr[cur].last;
}
int &in(int cur) {
return tr[cur].in;
}
int size() const{
return tr.size();
}
};
void solve() {
int n;
std::cin >> n;
AhoCorasick ac;
std::vector<int> end(n);
for (int i = 0; i < n; ++ i) {
std::string s;
std::cin >> s;
end[i] = ac.add(s);
}
ac.work();
std::string t;
std::cin >> t;
std::vector<int> f(ac.size());
int cur = 1;
for (char ch : t) {
cur = ac.son(cur, ch - 'a');
++ f[cur];
}
std::queue<int> q;
for (int i = 2; i < ac.size(); ++ i)
if (!ac.in(i))
q.push(i);
while (q.size()) {
int u = q.front();
q.pop();
f[ac.fail(u)] += f[u];
if (! -- ac.in(ac.fail(u)))
q.push(ac.fail(u));
}
for (int i = 0; i < n; ++ i)
std::cout << f[end[i]] << '\n';
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int t = 1;
// std::cin >> t;
while (t--) {
solve();
}
return 0;
}
dfs优化:
#include <bits/stdc++.h>
// #include <ranges>
using u32 = unsigned;
using i64 = long long;
using u64 = unsigned long long;
constexpr int P = 1E9 + 7;;
constexpr int inf32 = 1E9 + 7;
constexpr i64 inf64 = 1E18 + 7;
struct AhoCorasick{
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node{
int len;
int cnt;
int fail;
int last;
std::array<int, ALPHABET> son;
Node(): len{0}, cnt{0}, fail{0}, last{0}, son{} {}
};
std::vector<Node> tr;
AhoCorasick() {
init();
}
void init() {
tr.assign(2, Node());
tr[0].son.fill(1);
tr[0].len = -1;
tr[0].last = 1;
}
int newNode() {
tr.emplace_back();
return (int)tr.size() - 1;
}
int add(const std::string &s) {
int cur = 1;
for (char ch : s) {
int x = ch - base;
if (!tr[cur].son[x]) {
tr[cur].son[x] = newNode();
tr[tr[cur].son[x]].len = tr[cur].len + 1;
}
cur = tr[cur].son[x];
}
++ tr[cur].cnt;
return cur;
}
void work() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt > 0 ? tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
int cnt(int cur) const{
return tr[cur].cnt;
}
int len(int cur) const{
return tr[cur].len;
}
int fail(int cur) const{
return tr[cur].fail;
}
int son(int cur, int x) const{
return tr[cur].son[x];
}
int last(int cur) const{
return tr[cur].last;
}
int size() const{
return tr.size();
}
};
void solve() {
int n;
std::cin >> n;
AhoCorasick ac;
std::vector<int> end(n);
for (int i = 0; i < n; ++ i) {
std::string s;
std::cin >> s;
end[i] = ac.add(s);
}
ac.work();
std::string t;
std::cin >> t;
std::vector<int> f(ac.size());
int cur = 1;
for (char ch : t) {
cur = ac.son(cur, ch - 'a');
++ f[cur];
}
std::vector<std::vector<int>> adj(ac.size());
for (int i = 2; i < ac.size(); ++ i)
adj[ac.fail(i)].push_back(i);
auto dfs = [&](auto &&self, int x) -> void {
for (int y : adj[x]) {
self(self, y);
f[x] += f[y];
}
};
dfs(dfs, 1);
for (int i = 0; i < n; ++ i)
std::cout << f[end[i]] << '\n';
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int t = 1;
// std::cin >> t;
while (t--) {
solve();
}
return 0;
}
2.4 L语言
原题链接
https://www.luogu.com.cn/problem/P2292
思路分析
对于每个 字符串t,我们要判断其是否可以由给定的字符串集合构造出来
那么考虑dp
定义 f(i) 为 t 的前 i 个字符是否可以被构造
那么 f(i) = f(i - len(s1)) || f(i - len(s2))…
其中 si 为 t[1, i] 的 后缀,且 si 在字符串集合中
对于 si 的获取我们可以跳 last 来快速获取
这个题比较 sb 的是洛谷加数据了,题解很多用的状态压缩,实际上不用
我们加两个剪枝:
记 ma 为最长匹配前缀长度
- 如果 i - ma > 20,我们就break(因为 s 最长也就20)
- 如果 f[i] 已经为 true,我们就不跳last
AC代码
#include <bits/stdc++.h>
// #include <ranges>
using u32 = unsigned;
using i64 = long long;
using u64 = unsigned long long;
constexpr int P = 1E9 + 7;;
constexpr int inf32 = 1E9 + 7;
constexpr i64 inf64 = 1E18 + 7;
struct AhoCorasick{
static constexpr int ALPHABET = 26;
static constexpr char base = 'a';
struct Node{
int len;
int cnt;
int fail;
int last;
std::array<int, ALPHABET> son;
Node(): len{0}, cnt{0}, fail{0}, last{0}, son{} {}
};
std::vector<Node> tr;
AhoCorasick() {
tr.reserve(10000);
init();
}
void init() {
tr.assign(2, Node());
tr[0].son.fill(1);
tr[0].len = -1;
tr[0].last = 1;
}
int newNode() {
tr.emplace_back();
return (int)tr.size() - 1;
}
int add(const std::string &s) {
int cur = 1;
for (char ch : s) {
int x = ch - base;
if (!tr[cur].son[x]) {
tr[cur].son[x] = newNode();
tr[tr[cur].son[x]].len = tr[cur].len + 1;
}
cur = tr[cur].son[x];
}
++ tr[cur].cnt;
return cur;
}
void work() {
std::queue<int> q;
q.push(1);
while (q.size()) {
int u = q.front();
q.pop();
for (int i = 0; i < ALPHABET; ++ i) {
if (!tr[u].son[i]) {
tr[u].son[i] = tr[tr[u].fail].son[i];
continue;
}
tr[tr[u].son[i]].fail = tr[tr[u].fail].son[i];
tr[tr[u].son[i]].last = tr[tr[tr[u].son[i]].fail].cnt > 0 ?
tr[tr[u].son[i]].fail : tr[tr[tr[u].son[i]].fail].last;
q.push(tr[u].son[i]);
}
}
}
int cnt(int cur) const{
return tr[cur].cnt;
}
int len(int cur) const{
return tr[cur].len;
}
int fail(int cur) const{
return tr[cur].fail;
}
int son(int cur, int x) const{
return tr[cur].son[x];
}
int last(int cur) const{
return tr[cur].last;
}
int size() const{
return tr.size();
}
};
void solve() {
int n, m;
std::cin >> n >> m;
AhoCorasick ac;
for (int i = 0; i < n; ++ i) {
std::string s;
std::cin >> s;
ac.add(s);
}
ac.work();
for (int i = 0; i < m; ++ i) {
std::string t;
std::cin >> t;
n = t.size();
std::vector<bool> f(n + 1);
f[0] = true;
int cur = 1;
int ma = 0;
for (int i = 1; i <= n; ++ i) {
cur = ac.son(cur, t[i - 1] - 'a');
for (int newcur = cur; newcur > 1; newcur = ac.last(newcur)) {
if (f[i]) break;
if (ac.cnt(newcur)) {
f[i] = f[i] || f[i - ac.len(newcur)];
}
}
if (f[i]) ma = i;
if (i - ma > 20) break;
}
std::cout << ma << '\n';
}
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int t = 1;
// std::cin >> t;
while (t--) {
solve();
}
return 0;
}
标签:std,AC,cur,int,tr,son,详解,fail,自动机
From: https://blog.csdn.net/EQUINOX1/article/details/142420788