AC自动机题集

Keywords Search

题意描述
原题来自:HDU 2222
给定 n 个长度不超过 50 的由小写英文字母组成的单词准备查询,以及一篇长为 m 的文章,问:文中出现了多少个待查询的单词。多组数据。
解题思路
模板题
代码示例

#include<bits/stdc++.h>
using namespace std;

const int N = 1e4+10;	//模式串个数 
const int M = 1e6+10;	//文本串长度 

char str[N][50],s[M];

const int SZ = 50*N;	//trie节点数 
int trie[SZ][30],End[SZ],tot ;
int fail[SZ];			//失配指针,类似于nex
 
inline void init(){
	memset(trie,0,sizeof trie);
	memset(End,0,sizeof End); tot = 1;
}
void Insert(const char* s){
	/* 向trie树中插入字符串s */ 
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'a';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch];
	}
	End[p]++;
}
queue<int> q;
void getFail(){
	/*利用bfs更新fail数组*/ 
	for(int i = 0;i < 26;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
	//	printf("%d\n",q.size());
		for(int i = 0;i < 26;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
			} 
		}
	}
}
int ans = 0;
void ask(const char *s){
	/* 统计匹配成功的模式串个数并累加到ans上 */ 
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'a';
		int k = trie[p][ch];
		while(k > 1){
			ans += End[k]; End[k] = 0;
			k = fail[k];
		}
		p = trie[p][ch];
	}
}
int t,n;	//数组组数, 模式串个数 
void solve(){
	for(int i = 1;i <= n;i++) Insert(str[i]);
	getFail(); ans = 0;
	ask(s);
	printf("%d\n",ans);
}
int main(){
	scanf("%d",&t);
	while(t--){
		init();		//多组数据不要忘了初始化! 
		scanf("%d",&n);
		for(int i = 1;i <= n;i++) scanf("%s",str[i]);
		scanf("%s",s);
		solve();
	}
	return 0;
}

玄武密码

题意描述

原题来自:JSOI 2012
在美丽的玄武湖畔,鸡鸣寺边,鸡笼山前,有一块富饶而秀美的土地,人们唤作进香河。相传一日,一缕紫气从天而至,只一瞬间便消失在了进香河中。老人们说,这是玄武神灵将天书藏匿在此。很多年后,人们终于在进香河地区发现了带有玄武密码的文字。更加神奇的是,这份带有玄武密码的文字,与玄武湖南岸台城的结构有微妙的关联。于是,漫长的破译工作开始了。
经过分析,我们可以用东南西北四个方向来描述台城城砖的摆放,不妨用一个长度为 N 的序列来描述,序列中的元素分别是 E,S,W,N,代表了东南西北四向,我们称之为母串。而神秘的玄武密码是由四象的图案描述而成的 M 段文字。
现在,考古工作者遇到了一个难题。对于每一段文字,其前缀在母串上的最大匹配长度是多少呢?

解题思路
求每个子串在母串上的最大匹配长度。如果挨个用子串来匹配母串,复杂度太高,所以要用AC自动机,以母串来匹配子串。
将母串在建立好的AC自动机上进行匹配,那么其所能到达的节点,就是可以和母串匹配的长度,所以我们每到一个节点,就对其进行标记,顺便也对其fail指针所指向的节点标记,那么最后一个被标记的节点,就是该子串所能匹配的最大长度。

那么我们如何找到最后一个被标记的节点呢?通过par数组,par[x]存放x的父亲节点的编号,如此就可以从后向前遍历了(下述代码中是nex数组)。

所以具体步骤有三步:

  • 根据所有子串建立AC自动机,同时更新par数组(代码中是nex数组)
  • 利用母串对自动机上节点进行标记
  • 利用par数组自底向上查找最后一个被标记的节点,该节点到根节点的路径长度就是与母串最大匹配,为此我们还需要记录每个字串结束时的节点编号End[id],以及长度Len[id]

代码示例

#include<bits/stdc++.h>
using namespace std;
const int N = 1e7+10;
const int M = 1e5+10;
char s[N],str[M][110];
const int SZ = M*10;
int trie[SZ][5], tot = 1;
int fail[SZ],vis[SZ],End[SZ],nex[SZ];
int getIdx(char ch){
	if(ch == 'E') return 1;
	else if(ch == 'S') return 2;
	else if(ch == 'W') return 3;
	return 0;
}
int Insert(const char* s){
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = getIdx(s[i]);
		if(!trie[p][ch]) trie[p][ch] = ++tot,nex[tot] = p;
		p = trie[p][ch];
	}
	return p;
}
queue<int> q;
void getFail(){
	for(int i = 0;i < 4;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
		for(int i = 0;i < 4;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else {
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
			}
		}
	}
}
void Mark(const char *s){
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = getIdx(s[i]);
		int k = trie[p][ch];
		while(k > 1){
			if(vis[k]) break;
			vis[k] = 1; k = fail[k];
		}
		p = trie[p][ch];
	}
}
int n,m;
void search(int x){
	int len = strlen(str[x]);
	for(int i = End[x];i > 1;i = nex[i],len--) if(vis[i]) break;
	printf("%d\n",len);
}
void solve(){
	for(int i = 1;i <= m;i++) End[i] = Insert(str[i]);
	getFail();
	Mark(s);
	for(int i = 1;i <= m;i++) search(i);
}
int main(){
	scanf("%d%d",&n,&m);
	scanf("%s",s);
	for(int i = 1;i <= m;i++) scanf("%s",str[i]);
	solve();
	return 0;
} 

Censoring

题意简述
原题来自:USACO 2015 Feb. Gold
有一个长度不超过 105 的字符串 S。Farmer John 希望在 S 中删掉 n 个屏蔽词(一个屏蔽词可能出现多次),这些词记为 t1∼tn。FJ 在 S 中从头开始寻找屏蔽词,一旦找到一个屏蔽词,FJ 就删除它,然后又从头开始寻找(而不是接着往下找)。
FJ 会重复这一过程,直到 S 中没有屏蔽词为止。注意删除一个单词后可能会导致 S 中出现另一个屏蔽词。
这 n 个屏蔽词不会出现一个单词是另一个单词子串的情况,这意味着每个屏蔽词在 S 中出现的开始位置是互不相同的,请帮助 FJ 完成这些操作并输出最后的 S。
解题思路
可以用KM+栈来解决,这里用AC自动机解决。
很容易想到的一个思路就是,建立好AC自动机后,从根节点开始匹配,如果匹配到叶子节点,即有End标记的节点,就说明当前字串是屏蔽词,删掉它,假设该子串长为 len ,那就将指针 p 回退到 len 步之前,继续匹配。
如此一来我们需要记录每个时刻的p指针,以及母串中保留的字符,用栈就可以很好的维护。如果屏蔽词匹配成功,长度为 len,那么母串中保留的字符从栈顶弹出 len 个,同时保存 p 的栈也弹出 len 个元素,栈顶元素就是 len 步之前的指针。

代码示例

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+10;
const int SZ = 1e6+10;
char str[N], s[N];
int n, fail[SZ];
int trie[SZ][30],tot = 1,End[SZ];
void Insert(const char* s){
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'a';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch];
	}
	End[p] = len;
}
queue<int> q;
void getFail(){
	for(int i = 0;i < 26;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
		for(int i = 0;i < 26;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]); 
				fail[trie[p][i]] = trie[fail[p]][i];
			}
		}
	}
}
char path[N];
int top = 0,Stack[N];
void solve(){
	getFail();	//为什么非要更新Fail数组呢 
	int len = strlen(str);
	for(int i = 0,p = 1;i < len;i++){
		int ch = str[i]-'a';
		p = trie[p][ch];
		Stack[++top] = p; path[top] = str[i];
		if(End[p]) top -= End[p], p = Stack[top];
	}	
	for(int i = 1;i <= top;i++) putchar(path[i]);
}
int main(){
	scanf("%s",str);
	scanf("%d",&n);
	for(int i = 1;i <= n;i++){
		scanf("%s",s); Insert(s);
	}
	solve();
	return 0;
}

单词

题意简述
原题来自:TJOI 2013
某人读论文,一篇论文是由许多单词组成。但他发现一个单词会在论文中出现很多次,现在想知道每个单词分别在论文中出现多少次。
解题思路
这个文章是由这 n 个单词组成的,现在问每个单词出现多少次。直接用母串在fail树上匹配统计相同前缀个数是不对的,因为这样可能出现两个子串的重叠。根据AC自动机的特点啊,如果有个节点 x 的 fail 指针指向 y,那么说明 y 是 x 的前缀,即 y 是 x 的子串,那么自然有多少个 fail 指针指向 y,y就出现了多少次,而这个次数就是我们要求的单词出现的次数。

那么首先要记录AC自动机上每个节点出现的次数,因为单词可能出现重复。然后要记录结束时该子串对应的节点编号,End[id] = p;由于我们 fail 指针是从上到下更新的,而我们要统计次数需要从下到上转移,于是利用栈来存储 fail 指针转移时的节点编号,而后再更新 num 数组即可。
代码示例

#include<bits/stdc++.h>
using namespace std;
const int N = 220;
string str[N], s;
const int SZ = 1e6+10;
int trie[SZ][30],End[SZ],tot = 1;
int num[SZ];// num[x] 为经过节点x的数量 
void Insert(const string& s,const int& id){
	int len = s.length(), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'a';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch];
		num[p]++;
	}
	End[id] = p; 
}
queue<int> q;
int fail[SZ];
int Stack[SZ], top = 0;
void getFail(){
	for(int i = 0;i < 26;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop(); 
		Stack[++top] = p;	//倒序记录编号 
		for(int i = 0;i < 26;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
			}
		}
	}
}
int n;
void solve(){
	for(int i = 1;i <= n;i++) Insert(str[i],i);
	getFail();
	for(int i = top;i > 0;--i){
		num[fail[Stack[i]]] += num[Stack[i]];
	} 
	for(int i = 1;i <= n;i++) cout << num[End[i]] << endl;
}
int main(){
	//freopen("123.txt","r",stdin);
	cin >> n;
	for(int i = 1;i <= n;i++) cin >> str[i];
	solve(); 
	return 0;
}

最短母串

题意描述
原题来自:HNOI 2006
给定 n 个字符串 S1,S2,⋯,Sn,要求找到一个最短的字符串 T,使得这 n 个字符串都是 T 的子串。
解题思路
根据经验容易想到,母串要走完AC自动机上所有 有结束标记(End标记)的节点,而本题的答案就是找到最短的&字典序最小的母串。
那提到结束标记 End,就应该想到 End 标记的转移,我们在求fail数组时顺便转移。如果每个结束标记都用不同id标识,那么母串只要能包含所有不同的结束标记即可。

既然只有最多12个子串,那么可以考虑用状态压缩节省空间。我们用 bfs 来求最短&字典序最小母串。
由于母串最长600,而AC自动机上节点个数最多600,但是每个节点状态有1<<12种,因此 vis[606][(1<<12)+1] 用于表示该节点的对应状态是否被访问过; path[(1<<12)+1]用于存放由上一个状态转移而来需要添加的字符;s[606]自然就是存放最终母串。
当然还需要一些辅助数组,par[ ip ]记录 ip 的上一个状态的编号,用于查找路径;当然理论上讲path也算辅助数组。
接下来我们就可以通过bfs来实现不同状态间的转移了,实质上是bfs+状态压缩,实现起来还是有些难度的。

代码示例

#include<bits/stdc++.h>
using namespace std;
const int N = 60;
const int SZ = 600+10;//一定要想好大小啊 
char str[N];
int n, fail[SZ];
int trie[SZ][30], End[30], tot = 1;
bool vis[605][(1<<12)+1]; 
void Insert(const char* s,int id){
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'A';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch]; 
	}
	End[p] |= 1<<id;//状压常用 
}
queue<int> q;
void getFail(){
	for(int i = 0;i < 26;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
		for(int i = 0;i < 26;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
				End[trie[p][i]] |= End[fail[trie[p][i]]];//常用技巧 
			}
		}
	}
}
/*用来bfs计算最短母串*/
char path[606*(1<<12)] ,s[606];
int par[606*(1<<12)],len = 0;
struct Node{
	/*分别是节点编号,当前状态,当前字符位置 */ 
	int p,now,ip;
};
queue<Node> q2; 
void bfs(){
	q2.push(Node{1,0,0});
	int cnt = 0;
	while(!q2.empty()){
		Node x = q2.front(); q2.pop();
		int p = x.p,now = x.now,ip = x.ip;
		if(now == (1<<n)-1){
		/*所有子串都包含了,可以输出答案了*/ 
			for(;ip;ip = par[ip]) s[len++] = path[ip];
			return;
		}
		for(int i = 0,nex;i < 26;i++)
			if(!vis[trie[p][i]][nex = now|End[trie[p][i]]]){
				/*当前节点的第i个字符的 nex 状态未被访问过,访问并压入队列*/ 
			vis[trie[p][i]][nex] = 1;
			path[++cnt] = i+'A', par[cnt] = ip;
			/*压入下一个节点的编号,状态,字符结束位置*/ 
			q2.push(Node{trie[p][i],nex,cnt});
		}
	}
}
void solve(){
	getFail(); bfs();
	for(int i = len-1;i >= 0;i--) putchar(s[i]);
}
int main(){
	scanf("%d",&n);
	for(int i = 1;i <= n;i++){
		scanf("%s",str); Insert(str,i-1); 
	}
	solve();
	return 0;
}

POI 2000 病毒

题意简述

二进制病毒审查委员会最近发现了如下的规律:某些确定的二进制串是病毒的代码。如果某段代码中不存在任何一段病毒代码,那么我们就称这段代码是安全的。现在委员会已经找出了所有的病毒代码段,试问,是否存在一个无限长的安全的二进制代码。
示例:例如如果 {011,11,00000} 为病毒代码段,那么一个可能的无限长安全代码就是 010101⋯。如果 {01,11,000000} 为病毒代码段,那么就不存在一个无限长的安全代码。
请写一个程序,读入病毒代码,判断是否存在一个无限长的安全代码,将结果输出。

解题思路
本题要求找出一个无限长的安全的代码,无限长,当且仅当我们有一个安全的子串可以循环使用时才能构成。我们将AC自动机看作树,从根出发,那么叶子节点,即有End标记的节点是不能走的,因为该路径构成了病毒。同样的道理,如果当前节点fail指针所指向的节点有End标记,那么当前节点也不能走,因为它的子串是病毒串,所以我们在更新fail指针时顺便转移End标记(常用技巧)。
所以如果有一条路径,它上面的所有节点都没有End标记,且该路径构成环,那么它就可以构成一个无限长的安全字符串。

于是我们就只需要在AC自动机上通过dfs来找是否存在满足条件的环即可,而用拓扑排序不太好写。
代码示例

#include<bits/stdc++.h>
using namespace std;
const int N = 2100;
const int SZ = 1e5+10;
int n;
string str[N];
int trie[SZ][3],End[SZ],fail[SZ],tot = 1;
void Insert(const string &s){
	int len = s.length(), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'0';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch];
	}
	End[p]++;
}
queue<int> q;
bool f[SZ];
void getFail(){
	trie[0][0] = trie[0][1] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
		for(int i = 0;i < 2;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
				End[trie[p][i]] |= End[trie[fail[p]][i]];//转移标记
			}
		}
	}
}
bool flag = false;
int vis[SZ],ins[SZ];
void dfs(int p){
	vis[p] = ins[p] = 1;    //ins判断环,vis访问标记
	for(int i = 0;i < 2;i++){
		int k = trie[p][i];
		if(ins[k]) flag = true;//构成环了,这里也可以return
		if(!End[k] && !vis[k]) dfs(k);//下一个满足条件的节点,递归访问它
	}
	ins[p] = 0;//释放p节点的标记
}
void solve(){
	for(int i = 1;i <= n;i++) Insert(str[i]);
	getFail();
	dfs(1);
	if(flag) cout << "TAK" << endl;
	else cout << "NIE" << endl; 
}
int main(){
	//freopen("123.txt","r",stdin);
	cin >> n;
	for(int i = 1;i <= n;i++) cin >> str[i];
	solve();
	return 0;
}

文本生成器

题意简述

原题来自:JSOI 2007JSOI
交给队员 ZYX 一个任务,编制一个称之为「文本生成器」的电脑软件:该软件的使用者是一些低幼人群,他们现在使用的是 GW 文本生成器 v6 版。该软件可以随机生成一些文章――总是生成一篇长度固定且完全随机的文章——也就是说,生成的文章中每个字节都是完全随机的。
如果一篇文章中至少包含使用者们了解的一个单词,那么我们说这篇文章是可读的(我们称文章 a 包含单词 b,当且仅当单词 b 是文章 a 的子串)。但是,即使按照这样的标准,使用者现在使用的 GW 文本生成器 v6 版所生成的文章也是几乎完全不可读的。
ZYX 需要指出 GW 文本生成器 v6 生成的所有文本中可读文本的数量,以便能够成功获得 v7 更新版。你能帮助他吗?

解题思路
首先易得所有不同的文本总数为 2 6 m 26^m 26m种,我们有两种容易想到的思路来求解,一个是利用容斥原理直接计算可读的文章总数,另一种是想办法计算不可读的文章数,再用不同的文章总数减去它。第一种思路很难实现,主要来看第二种思路如何实现。
类似上一题,如果一个文章是不可读的,那么从根出发,它不能经过有End标记的节点,也不能经过“fail指针指向的节点有End标记”的节点。所以不可读的文章总数就等于长度为m的合法路径数。所谓的合法路径就是上面提到的,路径上所有节点都没有End标记,且其fail指针指向的节点也没End标记。
我们通过动态规划来求方案总数,设f(i,j)表示从根节点出发,走了 i 步到达节点 j 时的合法方案总数,那么显然 f(0 , 1) = 1,因为节点 1 是AC自动机的根节点。
状态转移方程请结合代码理解。
代码示例

#include<bits/stdc++.h>
using namespace std;
const int P = 1e4+7;
const int SZ = 6000*30;
const int N = 65;
int n,m;
char str[N][110];
int trie[SZ][30],End[SZ],fail[SZ],tot = 1;
void Insert(const char *s){
	int len = strlen(s), p = 1;
	for(int i = 0;i < len;i++){
		int ch = s[i]-'A';
		if(!trie[p][ch]) trie[p][ch] = ++tot;
		p = trie[p][ch];
	}
	End[p]++;
}
queue<int> q;
void getFail(){
	for(int i = 0;i < 26;i++) trie[0][i] = 1;
	q.push(1); fail[1] = 0;
	while(!q.empty()){
		int p = q.front(); q.pop();
		for(int i = 0;i < 26;i++){
			if(!trie[p][i]) trie[p][i] = trie[fail[p]][i];
			else{
				q.push(trie[p][i]);
				fail[trie[p][i]] = trie[fail[p]][i];
				End[trie[p][i]] |= End[trie[fail[p]][i]];//标记转移 
			}
		}
	}
}
int f[110][SZ] ,ans ;
void getAns(){
	f[0][1] = 1;
	for(int i = 1;i <= m;i++){
		for(int j = 1;j <= tot;j++){
			if(End[j]) continue;
			for(int k = 0;k < 26;k++){
		/*f[i][trie[j][k]]的方案总数显然等于它所有父亲的方案总数的和*/
				f[i][trie[j][k]] = (f[i][trie[j][k]]+f[i-1][j])%P;
			}
		}
	}
	int res = 0,tmp = 1;
	for(int i = 1;i <= tot;i++) 
		if(!End[i]) res = (res+f[m][i])%P;
	for(int i = 1;i <= m;i++) tmp = tmp*26%P;
	ans = (tmp-res+P)%P;
}
void solve(){
	for(int i = 1;i <= n;i++) Insert(str[i]);
	getFail();
	getAns();
	printf("%d\n",ans);
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i = 1;i <= n;i++) scanf("%s",str[i]);
	solve();
	return 0;
}
已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 程序猿惹谁了 设计师:白松林 返回首页