AC自动机
结构
AC自动机:
主要作用: 用来解决字符串的多模匹配问题.
主要思想: 用字典树
组织多个模式串 +kmp
避免回溯
Kmp
主要作用
首先,给定这样一个模式匹配问题:在一个长度为n的文本S中,找某个长度为m的关键词P, P可能多次出现,都需要找到。
我们首先想到的肯定是暴力做法,两层循环:
for(int i = 0;i + m < n;i ++){
int j = 0;
while(j < m && s1[i + j] == s2[j]){
j ++;
}
if(j == m){
std::cout << i << "\n";
}
}
显然,我们设文本串长度为n,模式串长度为m, 在最坏情况下时间复杂度高达O(nm)。这在n和m都很大并且字符串匹配最坏(每次前m - 1个都匹配但最后一个却不匹配)的情况下这显然是不可接受的。
Kmp的出现就是出来解决这种单模匹配问题的,在kmp的算法下可以时间复杂度优化到O(n + m) 级别。
操作方法
那Kmp到底是如何实现优化的呢?
Kmp 的要点在于
避免回溯
和next数组
。
避免回溯
为什么要避免回溯呢?
首先,让我们回到字符串匹配最坏的请况即前面m - 1的字符全部匹配,第m个字符不匹配,你说我前面做了那么多努力,难道你就白白看着我全部白费,相信大家都不愿意,那么这时我们进行观察。
如图所示,正常的情况下,一段匹配结束之后j应该直接回溯到0,但是如果模式串s2存在前缀①和后缀②相等的话,那么我们就可以仅仅让j回溯到A位置,从而不白费我们的努力。
next数组
怎么实现避免回溯呢?
那就是预处理出模式串s2的最长公共前后缀,而next数组则是用来储存这的信息的。
next[i]
表示字符串的前i个字符的最长公共前后缀(我这里的字符串下标是从0开始的)
next数组实现
当s[i] == s[j]
时
当s[i] != s[j]
时
当我们发现 s[i] != s[j] 时,我们只能往回缩小我们的最大公共前后缀,同时我们发现因为 A等于B,所以 2等于3,而往回缩的话即 i/ = next[i],所以1等于3, 我们就一直i/ = next[i], 直到s[i/] == s[j], next[j] = next[i/] + 1。
直击本质
其实如果我们将模式串s2和文本串s1合并,可以一眼看透Kmp的本质,s = s2 + ‘#’ + s1, 之后做next数组。
如图所示,我们看可以看到其实匹配的末位置就是合并之后next数组的最大位置。
代码实现
NOTE:直击本质部分只是为了便于大家理解和记忆但是我们接下来的代码实现并不依赖于这一部分,原因是虽然直击本质的代码实现便于理解和实现,但是却并不便于进行扩展,代码实现我们依旧依赖于最初的避免回溯部分。
#include<bits/stdc++.h>
using i64 = int64_t;
const int N = 1e6 + 10;
int next[N];
void Kmp(std::string& s1, std::string& s2){
auto getNext = [&](std::string& s) -> void{
next[0] = 0, next[1] = 0;//前0个和前1个的最长公共前后缀都是0
for(int i = 1;i < (int)s.size();i ++){
int j = next[i];
//这里的话j在前面, i在后面, 与前面图片画的相反注意一下
while(j && s[i] != s[j]){
j = next[j];
}
//直到s[i] == s[j]
if(s[i] == s[j]) next[i + 1] = j + 1;
else next[i + 1] = 0;//实在没有最长公共前后缀就为0
}
};
getNext(s2);
int j = 0;//j在模式串s2上
for(int i = 0;i < (int)s1.size();i ++){
while(j && s1[i] != s2[j]){
j = next[j];//回溯
}
if(s1[i] == s2[j]) j ++;
if(j == (int)s2.size()){//匹配成功
std::cout << i + 2 - (int)s2.size() << "\n";//初始匹配点
}
}
}
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
std::string s1, s2;
std::cin >> s1 >> s2;
Kmp(s1, s2);
for(int i = 0;i < (int)s2.size();i ++){
std::cout << next[i + 1] << " ";
}
std::cout << "\n";
return 0;
}
扩展问题
最短循环节问题
这道题如何用Kmp做呢?
设可能的s2的最短长度为了L, 本题所组成的字符串一共有两种情况:
case1:
s1由完整的k个s2组成,则next[n]则为(k - 1) * L,所以 n - next[n]为L。
case2:
s1由k个s2和一个z(不完整的s2)组成,则nexr[n] = (k - 1) * L + z, 所以n - next[n] = k * L + z - (k - 1) * L - z = L。
所以答案为 n - next[n]。
在S中删除所有P
本题的最大问题是在于P之间可能是嵌套的,一直循环用kmp, 时间复杂度为O(n2),会超时,所以我们还是要解决回溯问题。
#include<bits/stdc++.h>
using i64 = int64_t;
const int N = 1e6 + 10;
int next[N];
int p[N];
int stk[N];
int top = 0;
void getNext(std::string b){
next[0] = 0, next[1] = 0;
for(int i = 1;i < (int)b.size();i ++){
int j = next[i];
while(j && b[i] != b[j]){
j = next[j];
}
if(b[i] == b[j]) next[i + 1] = j + 1;
else{
next[i + 1] = 0;
}
}
}
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
std::string a, b;
std::cin >> a >> b;
getNext(b);
int j = 0;
for(int i = 0;i < (int)a.size();i ++){
while(j && a[i] != b[j]){
j = next[j];
}
p[i] = j;
stk[++ top] = i;
if(a[i] == b[j]){
j ++;
}
if(j == (int)b.size()){
top -= (int)b.size();
j = p[stk[top]] + 1;
}
}
for(int i = 1;i <= top;i ++){
std::cout << a[stk[i]];
}
std::cout << "\n";
return 0;
}
Trie树
Trie树简介
Trie, 又叫字典树或前缀树,用来储存
和查询
字符串。
那Trie树是怎么储存字符串的呢?
给定五个字符串:acd
、ach
、sfa
、ead
、sbf
,Trie树将以下的形式来储存字符串:
比如我们走1 -> 2 -> 4,存储的就是字符串acd
。
数组模拟
储存
那我们怎么用数组来实现这个数据结构呢?
如图可以表示为 son[u][c] = v
。
字符串的话,我们可以把字母映射成0~25。
之前内个图我们可以表示为:
son[0][0] = 1;//son[0][a] = 1
son[1][2] = 2;//son[1][c] = 2
son[2][3] = 3;//son[1][d] = 3
son[2][7] = 4;//son[2][h] = 4
son[0][10] = 5;//son[0][s] = 5
son[5][1] = 11//son[5][b] = 11
......
我们用一个变量idx
来进行每次编号的递增,我们以编号0
为根节点,当son[u][c] = 0
说明与u接下来的分支没有字母c,否则son[u][c] = idx
.
存储字符串的模板:
void insert(std::string s){
int p = 0;
for(int i = 0;i < (int)s.size();i ++){
int u = match(s[i]);
if(!con[p][u]) con[p][u] = ++idx;
p = con[p][u];
cnt[p] ++;
}
}
查询
查询的话我们给每个字符串的结尾打上标记。
比如我们考虑两个字符串:ab
、abcd
:
我们可以开一个cnt数组
,存储一个字符串,我们可以在结尾cnt[p] ++
。
查询字符串的模板:
void insert(std::string s){
int p = 0;
for(int i = 0;i < (int)s.size();i ++){
int u = match(s[i]);
if(!con[p][u]) con[p][u] = ++idx;
p = con[p][u];
cnt[p] ++;
}
}
完整模板
int con[N][80], cnt[N], idx = 0;
bool st[N];
int match(char s){
if(s >= 'A' && s <= 'Z'){
return s - 'A';
}
if(s >= 'a' && s <= 'z'){
return s - 'a' + 26;
}
if(s >= '0' && s <= '9'){
return s - '0' + 52;
}
}
void insert(std::string s){
int p = 0;
for(int i = 0;i < (int)s.size();i ++){
int u = match(s[i]);
if(!con[p][u]) con[p][u] = ++idx;
p = con[p][u];
cnt[p] ++;
}
}
int query(std::string s){
int res = 0;
int p = 0;
for(int i = 0;i < (int)s.size();i ++){
int u = match(s[i]);
if(!con[p][u]){
return 0;
}
p = con[p][u];
}
return cnt[p];
}
void recovery(){
for(int i = 0;i <= idx;i ++){
for(int j = 0;j < 80;j ++){
con[i][j] = 0;
}
}
for(int i = 0;i <= idx;i ++){
cnt[i] = 0;
}
idx = 0;
}
AC自动机
简介
我们学会Kmp的单模匹配问题后,现在我给出这样一个问题:给定一个长度为n的文本S,以及k个平均长度为m的模式串P1, P2, P3...Pk,要求搜索这些模式串出现的位置。
没错,这就是著名的多模匹配问题,如果我们直接用kmp来进行每次查询的话,则时间复杂度高达O(k * (n + m)), 当k足够大时绝对会超时,那么怎么办呢?
这时我们就引入了著名数据结构——AC自动机。
思想
AC自动机的主要思想 = 用Trie树
同时组织多个模式串 + Kmp
避免回溯。
(1)我们可以先用Trie树把模式串全部存进去,然后在放入文本串进行匹配。
(2)避免回溯,我们知道kmp中有next数组可以时i指针避免回溯,而AC自动机也有一个叫做fail指针的来帮忙避免回溯。
构造
通过上述这个图,我们可以发现其实匹配P1, P2, P3这三个字符串,只需要遍历P1这一个字符串即可,所以我们可以通过一个叫做Fail指针的东西来进行标记(其实也是一种在树上的类似最长公共前后缀的东西)
现在我们给三个字符串:abcd
, b
, cd
,构建出的字典树:
根据上述图片可以看出来,让每个字符的fail指针指向上一层满足后缀包含关系且
同字符的idx(即编号),没有的则指向根节点0, 注意不满足后缀包含关系的同字符是不能指向的,如下图:
Fail指针的代码实现
那么Fail指针在代码中怎么实现,其实我们只需要让一个节点x的fail指针指向的是x的父节点的fail指针指向的节点的与x同字符的子节点
。而且其实每一层的每个节点都会创造26个虚拟节点来使得下一层合适的可以进行跨越.
如图所示,在第二层更新信息的时候5就会创造一个虚拟节点8来指向6,然后下来再更新节点指向8,即指向了6。
模板
void work(){
int hh = 0, tt = -1;
for(int i = 0;i < Need;i ++){
if(t[0].son[i]){
q[++ tt] = t[0].son[i];
}
}
while(hh <= tt){
//这里是用数组模拟栈,为了以拓扑序存储住所有的节点
int now = q[hh ++];
for(int i = 0;i < Need;i ++){
if(t[now].son[i]){//如果儿子存在
t[t[now].son[i]].fail = t[t[now].fail].son[i];
//指向父节点的fail指针指向的节点的子节点
q[++ tt] = t[now].son[i];
}else{
t[now].son[i] = t[t[now].fail].son[i];
//虚拟节点指向
}
}
}
}
时间复杂度分析
我们设有k个模式串,平均长度为m,文本串的长度为n,建Trie树的时间复杂度为O(km),模式匹配的复杂度一般来说为O(n),但是特殊数据会将O(n)卡到O(nm),
比如现在我们给四个字符串:abcd
、bcd
,cd
, d
:
当我们走到‘d’时fail要操作4次,这样浪费时间。
我们传统AC自动机的查询模板是这样的:
void query(const std::string& s, int n){//查询有多少个匹配
std::unordered_map<int, int> res;
int now = 0;
for(auto& c : s){
int u = c - 'a';
now = t[now].son[u];
int tmp = now;
while(tmp){//把所有的fail都匹配一遍
if(t[tmp].end){
f[tmp] ++;
}
tmp = t[tmp].fail;
}
}
}
如果大家这样写,在洛谷的模板题上就会喜提一堆TLE。
那么我们应该怎么办呢?
拓扑排序优化
如果我们把之前内个树上的所有fail指针全部拿出来:
就会发现这组成了一个DAG(有向无环图),利用拓扑序我们就可以大大优化(这也明白了我们为什么要用数组模拟的栈)。
完整模板
#include<bits/stdc++.h>
using i64 = int64_t;
constexpr int N = 1e6 * 26 + 110;
int f[N], q[N];
struct AhoCorasick{
static constexpr int Need = 26;
struct node{
int depth;
int fail;
int end;
std::array<int, Need> son;
node(): depth{0}, fail{0}, end{0}, son{}{}
};
std::vector<node> t;
AhoCorasick(){
init();
}
void init(){
t.assign(1, node());
t[0].son.fill(0);
t[0].depth = 0;
t[0].end = 0;
}
int newNode(){
t.emplace_back();
return t.size() - 1;
}
int insert(const std::string& s){
int p = 0;
for(auto& c : s){
int u = c - 'a';
if(!t[p].son[u]){
t[p].son[u] = newNode();
t[t[p].son[u]].depth = t[p].depth + 1;
}
p = t[p].son[u];
}
t[p].end ++;
return p;
}
void work(){
int hh = 0, tt = -1;
for(int i = 0;i < Need;i ++){
if(t[0].son[i]){
q[++ tt] = t[0].son[i];
}
}
while(hh <= tt){
int now = q[hh ++];
for(int i = 0;i < Need;i ++){
if(t[now].son[i]){
t[t[now].son[i]].fail = t[t[now].fail].son[i];
q[++ tt] = t[now].son[i];
}else{
t[now].son[i] = t[t[now].fail].son[i];
}
}
}
}
int son(int p, int x){
return t[p].son[x];
}
int fail(int p){
return t[p].fail;
}
int depth(int p){
return t[p].depth;
}
int size(){
return t.size();
}
};
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n;
std::cin >> n;
AhoCorasick a;
std::vector<std::string> b(n);
std::vector<int> end(n);//尾节点
for(int i = 0;i < n;i ++){
std::cin >> b[i];
end[i] = a.insert(b[i]);
}
a.work();
std::string t;
std::cin >> t;
int p = 0;
for(auto& c : t){
p = a.son(p, c - 'a');
f[p] ++;
}
for(int i = a.size() - 1;i >= 0;i --){
f[a.fail(q[i])] += f[q[i]];//q数组满足拓扑序,从后往前加
}
for(int i = 0;i < n;i ++){
std::cout << f[end[i]] << "\n";
}
return 0;
}
补充模板
如果一个题特别卡TLE,那么你选择下面这个代码模板,常数复杂度较小点:
#include<bits/stdc++.h>
using i64 = int64_t;
constexpr int N = 1e6 + 110, M = 1000000;
int q[N];
struct AhoCorasick{
static constexpr int Need = 26;
struct node{
int depth;
int fail;
int end;
std::array<int, Need> son;
node(): depth{0}, fail{0}, end{0}, son{}{}
};
std::vector<node> t;
AhoCorasick(){
init();
}
void init(){
t.assign(1, node());
t[0].son.fill(0);
t[0].depth = 0;
t[0].end = 0;
}
int newNode(){
t.emplace_back();
return t.size() - 1;
}
int insert(const std::string& s){
int p = 0;
for(auto& c : s){
int u = c - 'a';
if(!t[p].son[u]){
t[p].son[u] = newNode();
t[t[p].son[u]].depth = t[p].depth + 1;
}
p = t[p].son[u];
}
t[p].end ++;
return p;
}
void work(){
int hh = 0, tt = -1;
for(int i = 0;i < Need;i ++){
if(t[0].son[i]){
q[++ tt] = t[0].son[i];
}
}
while(hh <= tt){
int now = q[hh ++];
for(int i = 0;i < Need;i ++){
if(t[now].son[i]){
t[t[now].son[i]].fail = t[t[now].fail].son[i];
q[++ tt] = t[now].son[i];
}else{
t[now].son[i] = t[t[now].fail].son[i];
}
}
}
}
int son(int p, int x){
return t[p].son[x];
}
int fail(int p){
return t[p].fail;
}
int depth(int p){
return t[p].depth;
}
int size(){
return t.size();
}
};
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
std::string t;
while(std::cin >> t){
int n;
std::cin >> n;
AhoCorasick a;
std::vector<std::string> b(n);
std::vector<int> end(n);
for(int i = 0;i < n;i ++){
std::cin >> b[i];
end[i] = a.insert(b[i]);
}
a.work();
int p = 0;
std::vector<int> f(a.size() + M);//M尽量加上,虽然我也觉得没有必要但是没加就是会re,不知道为什么
for(auto& c : t){
p = a.son(p, c - 'a');
f[p] ++;
}
for(int i = a.size() - 1;i >= 0;i --){
f[a.fail(q[i])] += f[q[i]];
}
int ans = 0;
for(int i = 0;i < n;i ++){
ans += (f[end[i]] == 0 ? 0 : 1);
}
std::cout << ans << "\n";
}
return 0;
}
如果一个代码特别卡RE, 第一你可选择上面内个代码模板加M,第二你可以选择下面这个代码模板(来自jiangly):
#include<bits/stdc++.h>
using i64 = int64_t;
struct AhoCorasick{
static constexpr int Need = 26;
struct node{
int depth;
int fail;
int end;
std::array<int, Need> son;
node(): depth{0}, fail{0}, end{0}, son{}{}
};
std::vector<node> t;
AhoCorasick(){
init();
}
void init(){
t.assign(1, node());
t[0].son.fill(0);
t[0].depth = 0;
t[0].end = 0;
}
int newNode(){
t.emplace_back();
return t.size() - 1;
}
int insert(const std::string& s){
int p = 0;
for(auto& c : s){
int u = c - 'a';
if(!t[p].son[u]){
t[p].son[u] = newNode();
t[t[p].son[u]].depth = t[p].depth + 1;
}
p = t[p].son[u];
}
t[p].end ++;
return p;
}
void work(){
std::queue<int> q;
for(int i = 0;i < Need;i ++){
if(t[0].son[i]){
q.push(t[0].son[i]);
}
}
while(!q.empty()){
int now = q.front();
q.pop();
for(int i = 0;i < Need;i ++){
if(t[now].son[i]){
t[t[now].son[i]].fail = t[t[now].fail].son[i];
q.push(t[now].son[i]);
}else{
t[now].son[i] = t[t[now].fail].son[i];
}
}
}
}
int son(int p, int x){
return t[p].son[x];
}
int fail(int p){
return t[p].fail;
}
int depth(int p){
return t[p].depth;
}
int size(){
return t.size();
}
};
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int n;
std::cin >> n;
AhoCorasick a;
std::vector<std::string> b(n);
std::vector<int> end(n);
for(int i = 0;i < n;i ++){
std::cin >> b[i];
end[i] = a.insert(b[i]);
}
a.work();
std::string s;
std::cin >> s;
int p = 0;
std::vector<int> f(a.size());
for(auto& c : s){
p = a.son(p, c - 'a');
f[p] ++;
}
std::vector<std::vector<int>> adj(a.size());
for(int i = 1;i < a.size();i ++){//建立有向无环图
adj[a.fail(i)].push_back(i);
}
auto dfs = [&](auto dfs, int u) -> void{//拓扑往上加
for(auto v : adj[u]){
dfs(dfs, v);
f[u] += f[v];
}
};
dfs(dfs, 0);
for(int i = 0;i < n;i ++){
std::cout << f[end[i]] << "\n";
}
return 0;
}