数据结构-字符串-AC自动机

数据结构-字符串-AC自动机

作用:单文本串多模式串匹配。
前缀知识: trie树 \color{orange}\texttt{trie树}

AC自动机可以看作是在字典树上做 KMP,但并不是把 KMP 算法放到树上来,而是用了一种和 KMP 类似的思想,即在字典树上匹配文本串的时候如果失配,就跳到 f a i l fail 指针所指的节点,所以学AC自动机没必要精通 KMP。

拿例题来讲:给定 n n 个模式串和 1 1 个文本串,求有多少个模式串在文本串里出现过。

那么要先构造一个字典树将上述字符串存储,代码如下:

class Trie{
public:
	int ch[N][26],mk[N],cnt;
	Trie(){cnt=1;}
	void insert(char*s){
		int n=strlen(s+1),x=1;
		for(int i=1;i<=n;i++){
			int c=s[i]-'a'+1;
			if(!ch[x][c]) ch[x][c]=++cnt;
			x=ch[x][c];
		}
		mk[x]++;//以该节点为结尾的模式串数
	}
};

比如有这些模式串:
kony \texttt{kony}
wen \texttt{wen}
emm \texttt{emm}
kib \texttt{kib}

那么构造成的字典树会长这样:
演示文稿1.jpg
AC自动机就是在字典树的基础上,对于每个节点 x x ,增加一个指针 f a i l [ x ] fail[x] ,如上文所述,用来在失配时跳转指针,这样如果匹配失败就不需要回溯了。

如上图,根节点的子节点,即第一层的的节点 x x ,应该有 f a i l [ x ] = 1 fail[x]=1 ,即如果在第一个字符失配,就从头开始再找(如下图,黄色箭头表示 f a i l [ ] fail[] )。

acam2.jpg
如果节点 x x 没有子节点 c h [ x ] [ c ] ch[x][c] ,那么 c h [ x ] [ c ] = c h [ f a i l [ x ] ] [ c ] ch[x][c]=ch[fail[x]][c] ,相当于由于没有该字符子节点而失配(末尾符失配),自动跳转 f a i l fail (如下图,紫色箭头表示 c h [ ] [ ] ch[][] )。

acam3.jpg

为了简化问题,后文图中不加末尾符失配紫色指针。

b f s bfs 序遍历字典树。如果节点 x x 有子节点 c h [ x ] [ c ] ch[x][c] ,那么 f a i l [ c h [ x ] [ c ] ] = c h [ f a i l [ x ] ] [ c ] fail[ch[x][c]]=ch[fail[x]][c] ,即如果失配就跳到最相邻已经遍历过的字符为 c c 的节点中。如果目前还没有发现字符为 c c 的节点,就令 f a i l [ c h [ x ] [ c ] ] = 1 fail[ch[x][c]]=1 。如下图:

acam4.jpg
这是构造AC自动机 f a i l [ ] fail[] 数组的代码:

void build(){
	for(int i=1;i<=26;i++) ch[0][i]=1;//把1节点的子节点同样操作
	queue<int> q({1});
	while(q.size()){//按bfs序求fail 指针
		int x=q.front();q.pop();
		for(int i=1;i<=26;i++)
			if(ch[x][i]) fail[ch[x][i]]=ch[fail[x]][i],q.push(ch[x][i]);//☆
			else ch[x][i]=ch[fail[x]][i];//☆
	}
}

最后构造好 f a i l fail 指针以后的字典树就是AC自动机,长这样:

acam5.jpg
然后就是重点了——应用 f a i l fail 查找有几个模式串在文本串中出现。代码如下:

int fapp(char*s){
	int n=strlen(s+1),x=1,res=0;
	for(int i=1;i<=n;i++){
		x=ch[x][s[i]-'a'+1];
		for(int j=x;j&&mk[j]!=-1;j=fail[j])//mk置为-1防止重复计算
			res+=mk[j],mk[j]=-1;
	}
	return res;
}

这时候你会很惊骇:这哪是失配跳转啊,这分明就是指针乱飞!其实仔细想的话,其实是指针在整个AC自动机间穿梭(说了等于没说),由于之前的紫色箭头 c h [ ] [ ] ch[][] 指针,指针表面上顺着字典树走的同时,也在自动末尾符失配跳转,即单前字典树节点如果没有某个字符子节点,就会自动跳到有该字符的节点上或者根节点。而后面那句 f a i l fail 指针跳转的 for \texttt{for} 循环,就求出了单前节点到根节点所连成的字符串的后缀的出现次数。

然后如上一波猛如犇的操作以后,答案——模式串在文本串中出现的次数就出现了。如果你懂了,蒟蒻就放代码了:

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
class Trie{
public:
	int ch[N][26],mk[N],cnt;
	Trie(){cnt=1;}
	void insert(char*s){
		int n=strlen(s+1),x=1;
		for(int i=1;i<=n;i++){
			int c=s[i]-'a'+1;
			if(!ch[x][c]) ch[x][c]=++cnt;
			x=ch[x][c];
		}
		mk[x]++;
	}
};
class Acam:public Trie{//Class 继承
public:
	int fail[N];
	void build(){
		for(int i=1;i<=26;i++) ch[0][i]=1;
		queue<int> q({1});
		while(q.size()){
			int x=q.front();q.pop();
			for(int i=1;i<=26;i++)
				if(ch[x][i]) fail[ch[x][i]]=ch[fail[x]][i],q.push(ch[x][i]);
				else ch[x][i]=ch[fail[x]][i];
		}
	}
	int fapp(char*s){
		int n=strlen(s+1),x=1,res=0;
		for(int i=1;i<=n;i++){
			x=ch[x][s[i]-'a'+1];
			for(int j=x;j&&mk[j]!=-1;j=fail[j])
				res+=mk[j],mk[j]=-1;
		}
		return res;
	}
}m;
int num;
char s[N];
int main(){
	scanf("%d",&num);
	for(int i=1;i<=num;i++)
		scanf("%s",s+1),m.insert(s);
	m.build();
	scanf("%s",s+1);
	printf("%d\n",m.fapp(s));
	return 0;
}

可是如果字符串一多,字典树一大,那么那个重要的语句:

for(int j=x;j&&mk[j]!=-1;j=fail[j])
	res+=mk[j],mk[j]=-1;

反而会造成时间超限,如这道例题:

洛谷P5357 【模板】AC自动机(二次加强版)

如果你直接按上面的代码改改,会 TLE75分 \color{#333377}\texttt{TLE75分}

是时候优化如上穿梭指针语句了,那么怎么优化呢?我们发现如果把 f a i l [ ] fail[] 看成一些边,就会构成一个 DAG \texttt{DAG} ,而答案更新又是按照 f a i l [ ] fail[] 数组跳指针的,这时我们必须有想到一个算法的直觉:拓扑排序。

因为不跳 f a i l fail 了,所以就不需要 m k [ x ] mk[x] 数组标记以 x x 节点为结尾的字符串数了。因为要拓扑排序,所以记录每个字符串编号 i i 的终止节点 e n [ i ] en[i] 。所以 insert() \texttt{insert()} 函数长这样:

void insert(char*s,int x){
	int n=strlen(s+1),p=1;
	for(int i=1;i<=n;i++){
		int c=s[i]-'a'+1;
		if(!ch[p][c]) ch[p][c]=++cnt;
		p=ch[p][c];
	}
	en[x]=p;
}

然后AC自动机的 f a i l fail 构造函数不变,因为要拓扑求答案,所以对于每个字符,只需要在该字符结尾的最长串加上标记即可。所以 fapp() \texttt{fapp()} 求答案函数要变成这样:

void fapp(char*s){
	int n=strlen(s+1),p=1;
	for(int i=1;i<=n;i++)
		//mk[p=ch[p][s[i]-'a'+1]]++; 这么写对萌新不友好
		p=ch[p][s[i]-'a'+1],mk[p]++;
}

然后按 f a i l fail 指针加反边:

for(int i=2;i<=m.cnt;i++) g[m.fa[i]].push_back(i);

然后拓扑求答案即可:

void dfs(int x){
	for(auto to:g[x]) dfs(to),m.mk[x]+=m.mk[to];
}

最后对于每个字符串编号 i i m k [ e n [ i ] ] mk[en[i]] 就是该模式字符串在文本串中出现的次数。如果你都懂了,那么蒟蒻就放代码了:

#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10,T=2e6+10;
class Trie{
public:
	int cnt,ch[N][30],en[N],mk[N];
	Trie(){cnt=1;}
	void insert(char*s,int x){
		int n=strlen(s+1),p=1;
		for(int i=1;i<=n;i++){
			int c=s[i]-'a'+1;
			if(!ch[p][c]) ch[p][c]=++cnt;
			p=ch[p][c];
		}
		en[x]=p;
	}
};
class Acam:public Trie{
public:
	int fa[N];
	void build(){
		for(int i=1;i<=26;i++) ch[0][i]=1;
		queue<int> q({1});
		while(q.size()){
			int x=q.front();q.pop();
			for(int i=1;i<=26;i++)
				if(ch[x][i]) fa[ch[x][i]]=ch[fa[x]][i],q.push(ch[x][i]);
				else ch[x][i]=ch[fa[x]][i];
		}	
	}
	void fapp(char*s){
		int n=strlen(s+1),p=1;
		for(int i=1;i<=n;i++)
			mk[p=ch[p][s[i]-'a'+1]]++;
	}
}m;
int n;
char s[T];
vector<int> g[N];
void dfs(int x){
	for(auto to:g[x]) dfs(to),m.mk[x]+=m.mk[to];
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%s",s+1),m.insert(s,i);
	m.build(),scanf("%s",s+1),m.fapp(s);
	for(int i=2;i<=m.cnt;i++) g[m.fa[i]].push_back(i);
	dfs(1);
	for(int i=1;i<=n;i++) printf("%d\n",m.mk[m.en[i]]);
	return 0;
}

学字符串数据结构之路( \texttt{★} 表示单前位置):

hash - kmp - manacher - exkmp - trie - acam - sa - sam - pam \color{#cccccc}\texttt{hash}\color{#aaaaff}\texttt{-}\color{#8888ff}\texttt{kmp}\color{#88cccc}\texttt{-}\color{#88ff88}\texttt{manacher}\color{#cccc88}\texttt{-}\color{#dddd44}\texttt{exkmp}\color{#eeaa44}\texttt{-}\color{#ffaa00}\texttt{trie}\color{#ff8800}\texttt{-}\color{#ee2200}\texttt{acam}\color{#000000}\texttt{★}\color{#ee0088}\texttt{-}\color{#cc00ff}\texttt{sa}\color{#660077}\texttt{-}\color{#555555}\texttt{sam}\color{#272727}\texttt{-}\color{#000000}\texttt{pam}

蒟蒻前途不可斗量,蒟蒻必须努力,不停下奋斗的脚步。

祝大家学习愉快!

发布了11 篇原创文章 · 获赞 24 · 访问量 628

猜你喜欢

转载自blog.csdn.net/KonnyWen/article/details/104226807
今日推荐