AC Automaton

0.什么是自动机

点我查看

1.实现原理

TRIE+KMP,详细戳这里

本质:字符串图论

这里重点看代码实现

#include<bits/stdc++.h>
#define N 1000005
using namespace std;
int T,n;
char s[N],t[N];//模式串、文本串
namespace AC
{
	int tot;
	int tr[N][27];//字典树(图) ,u ->i-> tr[u][i] i是字母的编号 
	int rev[N];//对应的编号,主要是应对重复串:把重复了的第j个串对应的编号赋成前面出现过的第i个串 
	int    fail[N],      idx[N],            in[N],           vis[N],              ans[N];
//点u的   fail指针、是第几个串的结尾、(fail指针的)入度    答案数组     插入时被字符串"经过"的次数 
	void init()
	{
		memset(tr,0,sizeof(tr));
		memset(fail,0,sizeof(fail));
		memset(idx,0,sizeof(idx));
		memset(in,0,sizeof(in));
		memset(vis,0,sizeof(vis));
		memset(ans,0,sizeof(ans));
		memset(rev,0,sizeof(rev));//初始化(可能会T(doge))
		tot = 1;//根节点编号为1,后续节点编号从2开始 
	}   
	void insert(char x[],int id)//建字典树 
	{
		int u = 1;
		int l = strlen(x + 1); 
		for(int i = 1;i <= l;i++)
		{
			if(!tr[u][x[i] - 'a']) tr[u][x[i] - 'a'] = ++tot;
			u = tr[u][x[i] - 'a'];
		}
		if(!idx[u]) idx[u] = id;//记录首次以u结尾的串的编号 
		rev[id] = idx[u];//处理重复串,当前第id个串重了的话对应的就是第idx[u]个串 
		return;
	}
	queue<int> q;
	void build()//拓扑排序优化用fail指针建字典图
	{
		for(int i = 0;i < 26;i++) tr[0][i] = 1;
		q.push(1);
		fail[1] = 0;//从根开始 
		while(!q.empty())
		{
			int u = q.front();
			q.pop();
			for(int i = 0;i < 26;i++)
			{
				int v = tr[u][i];
				if(!v)
				{
					tr[u][i] = tr[fail[u]][i];//后面没了直接拿fail当新边 
					continue;
				}
				fail[v] = tr[fail[u]][i];//KMP思想,把v的指针指入tr[fail[u]][i]
				in[tr[fail[u]][i]]++;//对应上一行,tr[fail[u]][i]的指针入度增加了一个fail[v] 
				//cout << in[tr[fail[u]][i]] << endl;
				q.push(v);
			}
		}
	}
	void topo()//拓扑搞答案
	{
		for(int i = 1;i <= tot;i++)
			if(!in[i]) q.push(i);//拓扑套路 
		while(!q.empty())
		{
			int u = q.front();
			q.pop();
			vis[idx[u]] = ans[u];
			int v = fail[u];
			ans[v] += ans[u];//合并点的答案 
			in[v]--;
			if(!in[v]) q.push(v); 
		}
	}
	void query(char x[])
	{
		int u = 1,len = strlen(x + 1);
		for(int i = 1;i <= len;i++) u = tr[u][x[i] - 'a'],ans[u]++;//更新ans数组 
	}
	void getans()
	{
		int sum = 0;
		for(int i = 1;i <= n;i++) printf("%d\n",vis[rev[i]]);
	}
}
int main()
{
	scanf("%d",&n);
	AC::init();
	for(int i = 1;i <= n;i++) scanf("%s",s + 1),AC::insert(s,i);
	AC::build();
	scanf("%s",t + 1);
	AC::query(t);
	AC::topo();
	AC::getans();
	return 0;
}

对应的是板子题

2,应用

纯自动机

P3966 [TJOI2013] 单词

click

开始没看懂题

结合英文写作常识才知道文章是(样例为例)

a aa aaa

有空格隔开的不能算做一个单词

那是不是就这样每输入一个单词就加个空格来生成文本串最后再统一与模式串(单词)匹配即可?

按理来说是这样的,得到了90pts(模拟空格的是"{",即ASCII表中z的后一个字符)

后来才发现这样做文本串会膨胀到106+200,还得多开个200多的空间

#define N 1005005//内存开大
...
void init(char x[])
{
	int l = strlen(x + 1);
	for(int i = 1;i <= l;i++) t[++cnt] = x[i];
	t[++cnt] = '{';
}
...
//剩下的都是上道题的板子

P5231 [JSOI2012] 玄武密码

click

这里要求的是最长前缀,本来想着只靠TRIE也行,后来发现缺的要素太多,比如当前匹配不上要记录长度时该把答案归到哪个模式串头上,同时沿着树向下进行类dfs操作时不把答案记乱的一些细节等等

那我们不妨利用下AC自动机里的成分

这里,fail指针成为一个利器,因为结合含义,他可以用来记录哪些前缀(不一定最长)出现在了文本串中(fail可能跨串,如下图,也可能像KMP一样在同串之间跳)

那么用fail打完标记后,对于每个模式串,就可以通过在TRIE上找标记点来得到最长的前缀了

void build()
{
	for(int i = 1;i <= 4;i++) tr[0][i] = 1;
	q.push(1);
	fail[1] = 0;
	while(!q.empty())
	{
		int u = q.front();
		q.pop();
		for(int i = 1;i <= 4;i++)
		{
			int v = tr[u][i];
			if(!v)
			{
				tr[u][i] = tr[fail[u]][i];
				continue;
			}
			fail[v] = tr[fail[u]][i];
			q.push(v);
		}
	}//板子
	int p = 1;
	for(int i = 1;i <= lent;i++)
	{
		p = tr[p][getid(t[i])];
		for(int k = p;k && !vis[k];k = fail[k]) vis[k] = 1;
	}//用文本串跑trie,通过fail标记出现了的前缀
}
void getans()
{
	for(int i = 1;i <= m;i++)
	{
		//每个文本串跑一边trie找最远的标记点
		int res = 0;
		int l = strlen(s[i] + 1);
		int p = 1;
		for(int j = 1;j <= l;j++)
		{
			p = tr[p][getid(s[i][j])];
			if(vis[p]) res = j;//j单增,就这样写
		}
		printf("%d\n",res);
	}
}

P2444 [POI2000] 病毒

click

输出TAK白拿60

有点莫名其妙

画了个大图才明白

一个病毒出现的充要条件就是当前点p跳到了某个串的尾巴上,那么如果不想让病毒出现,就需要找到一条长度无限的路径,该路径不经过所有病毒串的尾巴节点

长度无限,就是在环里兜圈子

所以就是看字典图上是否存在一个环,该环不经过所有病毒串的尾巴节点

以题干中的一组病毒为例

那么用搜索找符合条件的环即可

//超原始代码
//tr已经经过AC::build()了
void dfs(int x)
{
	if(vis[x] == 1) 
	{
		ans++;
		return;
	}
	vis[x] = 1;		
	for(int i = 0;i <= 1;i++)
	{
		int u = tr[x][i];
		if(vis[u] == 2) continue;
		dfs(u);
		vis[u] = 0;
	}
}
//44pts

优化:另开一个数组记录当前点是否在搜索路径上,在搜索完毕时进行重置

void dfs(int x)
{
	ifvis[x] = 1;//将当前点记为在路径上
	for(int i = 0;i <= 1;i++)
	{
		int u = tr[x][i];
		if(ifvis[u])//下一步就在该路径上
		{
			ans++;//有合法环
			return;
		}
		if(!wei[u] && !vis[u])//
		{
			vis[u] = 1;
			dfs(u);//继续沿该路径找
		}
	}
   //此时出了循环,说明上一条路径搜完了,重置标记
	ifvis[x] = 0;
}

怎么才76pts

这和上面的一张图有关

如果下面一行的后一个节点是尾巴节点呢?

说明上面一行的最后一个即使不是尾巴节点也不能走

所以要根据fail多打标记

if(!v) 
{
	tr[u][i] = tr[fail[u]][i];
	if(vis[fail[u]] == 2) vis[u] = 2;//根据fail打标记
	continue;
}
fail[v] = tr[fail[u]][i];
if(vis[fail[u]] == 2) vis[v] = 2;//根据fail打标记
q.push(v);

以此可见fail指针的一个重要含义:指向的是当前已匹配的最长后缀

奇美拉

dp常用定义:dpi,j表示当前文本串长度为i,走到了自动机上标号为j的点

可附加其他状态

P2322 [HNOI2006] 最短母串问题

click

dp+状压+字符串匹配

状压有点像Bill的挑战

现在就是要把这东西放到自动机上

众所周知匹配上一个串就是经过他的尾巴点,那么我们就在跳到尾巴时把对应编号位搞成1

S[u] = 1 << (id - 1);

这里再结合fail的含义,还要把fail两端节点的状态合并一下(不写80pts

S[u] |= S[fail[u]];

然后bfs,每走到一个点就继承(按位或)一下他的状态,直到继承完所有模式串结束的状态

然后就得到超暴力50分代码

很显然,存储方式太low

事实上,一般的dp问的是最小长度,那很明显,这题还要用记录路径的方法来搞答案

原先的代码就像dfs一样把信息都扔进了队列,浪费空间,所以采用新的方法

不妨记录每次的跳动是在第几次的跳动上进行的,所以设fai表示第i次跳动是在第fai次跳动进行的

分散的跳动都聚在循环内部,所以出了循环才相当于进行了一次大的跳动(换了一个分散点)

最后再根据路径还原串,然后倒序输出即可

这里还用到了bfs的优势:考虑到每一次循环都是按字典序来的,所以一旦找到了第一个答案,毫无疑问一定是最短且字典序最小,此时输出完后直接退出即可

void solve()
{
	vis[1][0] = 1;//vis[u][s]表示节点u的状态为s的情况是否已经遍历
	node tmp;
	tmp.idx = 1,tmp.nowS = 0;
	q1.push(tmp);
	p = 0;//记录大的跳跃
	while(!q1.empty())
	{
		node u = q1.front();
		q1.pop();
		if(u.nowS == ((1 << n) - 1))
		{
			while(p)
			{
				//printf("p:%d\n",p);
				ans[++sum] = path[p];
				p = fa[p];
			}//回溯路径
			for(int i = sum;i >= 1;i--) printf("%c",ans[i] + 'A');
			return;//直接退出
		}
		for(int i = 0;i < 26;i++)//这里面所有的跳跃(push了的)都是在第p次跳跃的基础上进行的
		{
			int v = tr[u.idx][i];
			if(!vis[v][u.nowS | S[v]])
			{
				vis[v][u.nowS | S[v]] = 1;//打标记
				fa[++cnt] = p;//意义如上
				path[cnt] = i;//记录该次跳跃对应的字符
				node x;
				x.idx = v,x.nowS = u.nowS | S[v];
				q1.push(x);//push新节点
			}
		}
		p++;//小跳完了是大跳
	}
}

坑点:重复串(不写90pts)

S[u] |= 1 << (id - 1);

P4052 [JSOI2007] 文本生成器

click

这题写bfs10分

dpi,j表示当前串长为i,在自动机上走到点j时的方案数

转移:dpi,jdpi+1,trj,s

这样想了好久,后来发现无论如何都带有严重的后效性(因为不管能配上多少个,贡献只有一),所以要换个思路

显然总方案数就是26m,那么可以尝试求出不包含可读词的串数

那就可以参照上面病毒的解法,把能配上的点打上标记,dp时直接绕过这些点就行了

式子:dpi+1,trj,s+=dpi,j

初始化:dp0,1=1,即在起点处,方案有一个(空串)

答案:26mdpm,i

坑点:写在新namespace里的变量不会像全局变量一样自动重置,得手动memset

void solve()
{
	int ans1 = 1;
	for(int i = 1;i <= m;i++) ans1 = (ans1 * 26) % mod;//总数
	memset(dp,0,sizeof(dp));//手动清零
	dp[0][1] = 1;
	for(int i = 0;i < m;i++)
	{
		for(int j = 1;j <= tot;j++)
		{
			for(int s = 0;s < 26;s++)
			{
				int k = tr[j][s];
				if(vis[k]) continue;//能匹配上就跳过
				dp[i + 1][k] = (dp[i + 1][k] + dp[i][j]) % mod;//dp
			}
		}
	}
	int ans = 0;
	for(int i = 1;i <= tot;i++) ans = (ans + dp[m][i]) % mod;//求和
	int y = (ans1 - ans) % mod;
	printf("%d",(y % mod + mod) % mod);
}

P4045 [JSOI2009] 密码

click

数量+方案,鉴定为最短母串大爸

不同的是,这道题加了母串长度的限制,所以可能一些重叠部分要展开,比如母串长为7时就要是goodday

所以需要大力dp

仿照上题,定义dpi,j,S表示当前串长度为i,走到了自动机上j号点,匹配上的串集合为S

式子和上题也极为相似

dpi+1,k,S|sk+=dpi,j,S

如果不考虑方案可以得到50pts,且根据WA的地方都是line 2可得第一行的总数都是对的

record

接下来考虑求得具体的串

题目说了,只在ans42时输出串,而数量是LL级别的,所以不能在dp的同时记录路径,考虑直接搜索出串

首先,模拟dp,标记出可行的dp路径,然后再沿着标记走得到答案

bool dfs(int len,int idx,int nows)//找到路径
{
	//vis:是否访问过(简直)
	//can:是否在可行的路径上
	if(len == L)
	{
		vis[len][idx][nows] = 1;
		can[len][idx][nows] = (bool)(nows == (1 << n) - 1);//到了路径终点,看看是不是答案路径
		return can[len][idx][nows];
	}
	bool u = 0;//记录从该点出发有没有在路径上的点
	if(vis[len][idx][nows]) return can[len][idx][nows];//剪枝
	else vis[len][idx][nows] = 1;
	for(int i = 0;i < 26;i++)
		u |= dfs(len + 1,tr[idx][i],nows | S[tr[idx][i]]);//找延伸出去的点中有没有路径点,找到至少一个u就是1,所以用|
	can[len][idx][nows] = u;//记录是否找到
	return u;
}
void getans(int l,int idx,int nows)//搞答案
{
	if(!can[l][idx][nows]) return;//该点不在路径上的话直接叫停
	if(l == L)
	{
		for(int i = 1;i <= L;i++) printf("%c",anss[i] + 'a');
		printf("\n");//由于是顺序搜索,所以正序输出
	}
	for(int i = 0;i < 26;i++)
	{
		anss[l + 1] = i;//记录可能路径上的字母
		getans(l + 1,tr[idx][i],nows | S[tr[idx][i]]);//按延伸点搜索
	}
}
void solve()
{
	memset(dp,0,sizeof(dp));
	dp[0][1][0] = 1;//初始化,和上题一致
	for(int i = 0;i < L;i++)
	{
		for(int j = 1;j <= tot;j++)
		{
			for(int s = 0;s <= (1 << n) - 1;s++)
			{
				for(int t = 0;t < 26;t++)
				{
					int k = tr[j][t];
					dp[i + 1][k][s | S[k]] += dp[i][j][s];//也和上一道题一致
				}
			}
		}
	}
	for(int i = 1;i <= tot;i++) ans += dp[L][i][(1 << n) - 1];//同理
	printf("%lld\n",ans);
	if(ans > 42) return;//ans大时不必搞串
	memset(anss,0,sizeof(anss));
	memset(vis,0,sizeof(vis));
	memset(can,0,sizeof(can));
	dfs(0,1,0);//先找路径
	getans(0,1,0);//再找答案
}

P4569 [BJWC2011] 禁忌

click

标签最难蚌的一集

翻译:用前alpha个小写字母等概率生成一个长为len的串T,定义贡献w(T)=x为将其分成(没有重叠) 若干部分,最多含有x个模式串,求E(T)

结合自动机是图论的暴论思想,这就是一个图上概率dp,每个点的度都是alpha

老定义:设dpi,j表示文本串长度为i,走到了j点时的期望值

结合没有重复的限制,我们可以贪心的想到:如果要最大化w(T),肯定是配上一个后直接在后面拼一个尽量短的模式串

放在自动机上,就是直接返回根开启新一轮的配

我们可以根据这一点写出图上的期望dp

trj,s=k

如果k是一个尾巴节点,则配完后回到根加新的

dpi+1,1+=dpi.j+1alpha

否则

dpi+1,k+=dpi,jalpha

但是len109,肯定开不出来

我们不妨找到一个最小单元,再用这个单元还原出整个串

这个套路在矩阵乘法中十分常用

上面的dp就是从j走到k或者回到1

因此可以结合图论在矩阵上的体现形式来构造矩阵

首先搞一个tot×tot的矩阵,一般情况下j,k之间有边要走,就把对应的值赋成边权,也就是1alpha

接下来要考虑如何转化这个东西:

dpi+1,1+=dpi.j+1alpha

重点是分子多加了个1,不是纯乘积,要想办法处理

先分割一下

dpi+1,1+=dpi,jalpha+1alpha

前半部分就是老办法,把j1对应部分加一个1alpha

对于后半部分,考虑使用图论表示法

众所周知Ai,k×Ak,j表示ijk,那么对于+1alpha,可以拆成1alpha×1,也就是要走过权值为1alpha1的边,那么中间点选什么呢?

首先1tot是不能用的,那些用来转移dp数组,使用其中的点会将两个和项混为一谈,那么不妨引入一个新点x来专门存储和项,那么走向就是ixx,边权分别为1alpha1

  • 为什么不是iix?

因为ii会影响dp的转移

  • 为什么边权不是11alpha?

首先经过尝试这样是不行的,那么具体原因要结合因果关系:ix才是dp下的概率事件,xx是必然的,因为x就这一条出边

还有一种方式理解就是这里的x代替的就是tri,s,是属于dp系统里面的东西,所以要把边权赋成概率

  • 答案?

由于是以走完一遍图为最小单元来做矩阵乘法,所以答案就是Alen之后根节点的答案

到这里,其实矩阵大部分的思想都和GT考试如出一辙,所以在答案方面也会想着类比

原先的答案是1totdplen,i,换成矩阵就是1totA1,i

不是的

我们再次想一想先前提到的一个重要东西——额外点x

结合dp,可以发现所有的和项都存入了x,由此可推测x应当与答案有关

事实上,做矩阵乘法时,会有Ai,x×Ax,x,i[1,tot],这里相当于所有点都向x走了一遭,所以x更大的功能就是一个虚拟汇点

或者说,答案是在走的时候顺带着的,走到哪里,哪里就是答案聚集处,所以总路径就是/,所以答案在根和x处,那么需要在这两点之间再走一步来汇总,就能得到答案

所以

ans=A1,x

附:这里的x是大于tot的任意值

//x = tot + 2
matrix init()
{
	matrix A;
	for(int i = 1;i <= tot;i++)
	{
		for(int s = 0;s < alpha;s++)
		{
			int k = tr[i][s];
			if(!vis[k]) A.m[i][k] += 1.0 / alpha;//非尾巴点在(i,k)间赋边权
			else A.m[i][tot + 2] += 1.0 / alpha,A.m[i][1] += 1.0 / alpha;//要给(i,x),(i,1)赋边权
		}
	}
	A.m[tot + 2][tot + 2] = 1.0;//那个小自环
	return A;
}
int main()
{
	scanf("%d%d%d",&n,&len,&alpha);
	for(int i = 1;i <= n;i++)
	{
		scanf("%s",s + 1);
		AC::insert(s,i);
	}
	AC::build();
	matrix A = init();
	matrix sum = qpow(A,len - 1);//快速幂
	printf("%lf",sum.m[1][tot + 2]);//答案
	return 0;
}

啸细节:自动机buff

vis[u] |= vis[fail[u]];

[BZOJ 2905]背单词

click

这题自动机优势明显

也就是说,若TS的子串,那么总能通过跳fail调到T的尾巴节点

但是出现了一个小问题:序列是从短到长,而fail很明显方向反了

那就不妨把fail反着建一遍就行了,方便并答案

那么此时起点反而是子串S的尾巴点,终点是以S为子串的T中的某个节点- ---- ①

接下来我们考虑怎么转移答案

首先,反建fail后,一根串s会有一大堆fail指入该串字符对应的结点si,结合①,可得选了s,转移范围就是所有si的父亲(还有祖先),区间查询最大值后再决定加不加vals,得到anss

那么得到了anss所有以s为子串的串T自然也能得到这个答案,再结合①,可知还要对s的儿子,而且是由尾巴点的fail指向的儿子 进行修改,区间修改

这些操作可以用dfs序+线段树维护

while(T--)
{
	init();//清空
	scanf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		int v;
		scanf("%s",s + 1);
		scanf("%d",&v);
		insert(s,i);//插入
		val[i] = v;
	}
	build();//建fail
	for(int i = 1;i <= tot;i++) add(fail[i],i);//反建fail
	ST.build(1,tot,1);
	dfs(1);//获取dfs序
	int ans = 0;//答案
	int sum = 0;//一条串的答案
	for(int i = 1;i <= n;i++)
	{
		sum = 0;
		int u = vis[i];//从尾巴点开始遍历整个串
		while(u)
		{
			sum = max(sum,ST.query(1,dfn[u],1,tot,1));//更新max
			u = fa[u];//这里的fa是在insert()中更新的,就是一条串内一个字符结点的上级节点
		}
		u = vis[i];//只有尾巴点的子节点才能修改
		sum = max(sum,sum + val[i]);
		ans = max(ans,sum);
		ST.add(dfn[u],out[u],1,tot,1,sum);//修改
	}
	printf("%d\n",ans);
}

然后喜提24pts

才没有什么query没return,add的比大小打成赋值,多测少清几个这种东西

仔细想想可以发现上面两个操作似乎有重叠的地方:修改操作就已经把T中节点的max修好了,那么查询的时候不必再找这些点的父亲,直接在节点中找max就行了

sum = max(sum,ST.query(dfn[u],dfn[u],1,tot,1));

喜提64pts

CaO

发现输出的答案都比std大,肯定还有重叠操作

后来发现真是重叠了——

TMD某个傻逼竟然用build()函数清空线段树,很明显由于每次tot不一样所以线段树大小不同,那肯定清不干净

6

void cle(){memset(t,0,sizeof(t));}

[ABC305G] Banned Substrings

click

病毒+禁忌,边权改为1,不走尾巴点即可

不同的是,最终答案可以表示为1id,但这一步只有一次,不像禁忌的虚拟汇点,所以不能加入A,必须另外乘一次汇总一次答案

matrix sum;
sum.m[1][1] = 1;
sum = cal(sum,qpow(A,n - 1));
posted @   why?123  阅读(6)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示