浅析 AC 自动机

哈喽大家好,我是 doooge,今天来点大家想看的东西啊。

\[\Huge \sf 浅析~AC~自动机 \]

前置知识:Trie,不需要 KMP。

1. AC 自动机的构造与匹配

所谓 AC 自动机,是结合了 Trie 和 KMP 思想的自动机,简单来说就是一个 Trie 图,用于解决字符串多模式匹配的问题。

AC 自动机由两个部分构成:原本的 Trie 和若干个失配指针 \(fail\),下面给一道它的例题。

有一个文本串 \(T\)\(n\) 个模式串 \(S_i\),求有几个模式串是否在文本串中出现过。
\((n,|T|\le 10^6,\sum_{i=1}^{n}|S_i|\le 10^6)\),字符串中只包含小写字母。

如果用暴力匹配,复杂度是 \(O(n^2)\) 的,不够优秀。直接讲 AC 自动机的做法。

1.1 建树

构建 AC 自动机的第一步是将所有的模式串加入 Trie,跟普通的 Trie 一样。

例如,有 \(3\) 个字符串 ABCDABDBD,建完的树是这样的,记 \(x\) 的字符为 \(y\) 为子节点为 \(t_{x,y}\)

值得注意的是,Trie 上的每一个节点都是一个模式串的前缀(这也是字典树的性质)

但是,就算建好了这棵树,我们也不好匹配,因为我们不知道模式串是在文本串的哪个位置出现的,这个时候,就要用到我们的失配指针 \(fail\) 了。

1.2 失配指针

定义 \(i\) 属于的子串为从根节点 \(Root\) 所出发到节点 \(i\),途中经过的字符组成的字符串。

失配指针是用于辅助文本串转移的,记 \(fail_x\)\(x\)\(fail\) 指针。

我的理解是,\(fail_i\) 表示的是,\(i\) 所属于的子串的后缀是某个模式串的前缀,且前缀 / 后缀尽可能的长,这个前缀所对应的字符串在 Trie 中的节点为 \(j\)

很抽象,用图来解释吧:

因为节点 \(5\) 属于的子串为 ABD,节点 \(7\) 属于的子串为 BD 且是图中所有最长的后缀(当然也只有它一个),所以 \(fail_5=7\)

如果多了一个模式串 D,它们之间的 \(fail\) 指针是这样的:

那么,我们如何才能求出 \(fail\) 指针呢?我们假设已经知道 \(fa\)\(fail\) 指针,如何才能求出子节点 \(fail\) 的指针呢?

我们知道子节点想要后缀最大,肯定是继承父节点 \(fail\) 指针最优,比如说下图:

首先,我们先查找节点 \(5\) 的父节点的 \(fa_5=2\),因为 \(2\) 号节点的 \(fail\) 节点 \(6\) 有相邻为 D 的子节点,所以 \(fail_5=t_{6,D}=7\)

但是还有一个特殊情况,这里同样给个例子

首先,我们同样查找节点 \(3\) 的父节点的 \(fa_3=2\),因为 \(2\) 号节点的 \(fail\) 节点 \(6\) 并没有相邻边为 D 的子节点,所以我们想要答案最优,就得继续跳,我们跳到 \(fail_6\),也就是根节点,虽然这里仍然没有相邻为 D 的边,但是此时的根节点已经跳不动了,所以 \(fail_3=0\),也就是根节点。

但是,如果一直暴力跳 \(fail\),复杂度最坏是 \(O(n)\) 的,我们如何才能优化呢?我们可以建若干个虚拟节点,而这个节点指向它的 \(fail\)

如图,我们在 \(4\) 号节点下建一个用 C 边连接的虚拟节点 BC,指向的是 BC\(fail\),我们要建 ABC\(fail\) 时就能直接查询到 BC 这个节点,进而查到 C 这个 \(fail\),因为原本的 Trie 上没有 BC 这个节点,所以一定能保证后续我们通过虚拟节点查到的 \(fail\) 一定时最优的。

下面给了个更加复杂的例子,这里就不作过多解释了。

进一步,我们可以用 BFS 来求,使得求到 \(x\)\(fail\) 时深度比 \(x\) 小的能先求出来。

于是,构建 \(fail\) 指针的步骤是这样的:

  1. 将根节点的所有子节点入队,根节点的虚拟节点的 \(fail\) 都指向根。
  2. 每次取出队首的节点 \(x\),枚举每一个字符 \(i\),若 \(t_{x,i}\) 是空的,建出 \(x\) 的所有虚拟节点指向那个节点的 \(fail\),可以写成:

\[t_{x,i}=fail_{t_{x,i}}=t_{fail_x,i} \]

此时 \(t_{x,i}\)\(fail\) 指针一定不会是空的,因为深度小于 \(x\) 的节点的虚拟节点已经求完了。

  1. 否则,建出 \(t_{x.i}\)\(fail\) 指针并将其入队:

\[ail_{t_{x,i}}=t_{fail_x,i} \]

  1. 重复 \(2\)\(3\) 操作,直到队列为空停止。

下面用图解 \(fail\) 指针建造过程(模式串只有 ABC 三种字符构成):

首先,我们将根节点的子节点 \(1,5,7\) 入队(忘画 \(4\) 节点见谅):

先取出节点 \(1\),构建 \(1\) 的虚拟节点和子节点 \(2\)\(fail\),将 \(2\) 入队:

再取出节点 \(5\),建出虚拟节点和子节点 \(6\)\(fail\)(这里 \(6\)\(fail\) 画成虚边了见谅,后面改回来了),将 \(6\) 入队:

如法炮制节点 \(7,2,6,3\),然后就画成了这么一幅图:

1.3 AC 自动机的匹配

AC 自动机的匹配过程如下(文本串为 \(T\)):

  1. 初始 \(pos\) 节点为根(\(0\))。
  2. \(i\)\(1\)\(|T_i|\),重复 \(2,3\) 操作,每次寻找与 \(pos\) 节点相邻为 \(T_i\) 的边,\(pos \gets t_{pos,T_i}\)
  3. \(k\)\(pos\),每次暴力往上面跳 \(k=fail_k\),过程中打上标记 \(vis_k=1\),只要发现 \(vis_k\) 之前被标记过了就停止,途中记录经过了哪些节点在 Trie 上是某个模式串的结尾节点。
  4. 最后看有哪些模式串所在的节点被经过了就是答案。

匹配过程如下(还是之前的那一幅图):

时间复杂度:\(O(26n)\) 也就是 \(O(n)\),实际跑起来速度跟 \(O(n \log n)\) 差不多。

至此,你已经掌握 AC 自动机的大部分了!

2. 代码

模板题:P3808

#include<bits/stdc++.h>
using namespace std;
struct ll{
	int flag,fail,son[30];
	//flag表示这个节点有几个模式串的尾节点 
}t[1000010];
int cnt=1,ans;//根节点从1开始,方便等下fail处理 
bool vis[1000010];
void build(string s){//就是普通的Trie插入 
	int pos=1;
	for(int i=0;i<s.size();i++){
		if(!t[pos].son[s[i]-'a']){
			t[pos].son[s[i]-'a']=++cnt;
		}
		pos=t[pos].son[s[i]-'a'];
	}
	t[pos].flag++;
	return;
}
void build_fail(){
	queue<int>q;
	for(int i=0;i<26;i++)t[0].son[i]=1;
	t[1].fail=0;
	q.push(1);//我习惯这么写,当然可以把根的子节点都丢进去 
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		for(int i=0;i<26;i++){
			if(!t[tmp].son[i]){//构建虚拟节点 
				t[tmp].son[i]=t[t[tmp].fail].son[i];
				continue;//这时就不用入队了 
			}
			t[t[tmp].son[i]].fail=t[t[tmp].fail].son[i];
			q.push(t[tmp].son[i]);//处理子节点 
		}
	}
	return;
}
void query(string s){
	int pos=1;
	for(int i=0;i<s.size();i++){
		pos=t[pos].son[s[i]-'a'];
		int k=pos;
		while(!vis[k]){//跳fail 
			ans+=t[k].flag,vis[k]=true;
			k=t[k].fail; 
		} 
	}
	return;
}
int main(){
	int n;
	string s;
	cin>>n;
	for(int i=1;i<=n;i++){
		string x;
		cin>>x;
		build(x);
	}
	build_fail(); 
	cin>>s;
	query(s);
	cout<<ans<<endl;
	return 0;
}

3. 拓扑建图优化

还是放一道例题(P5357):

有一个文本串 \(T\)\(n\) 个模式串 \(S_i\),求每个模式串在文本串中出现的次数。
\((n,|T|\le 10^6,\sum_{i=1}^{n}|S_i|\le 10^6)\),字符串中只包含小写字母。

求出每个模式串的个数可不好搞,因为一个模式串尾节点可能被访问过多次,只用 \(vis\) 数组标记肯定是不正确的,但是暴力跳 \(fail\) 的时间实在太长。

我们可以回想一下刚刚的查询是怎么找答案的,我们能否优化这个跳 \(fail\) 的过程呢?答案是肯定的,比如说下面这幅图(蓝色表示经过的节点):

我们发现 \(fail\) 指针只会指向深度比自己低的节点,也就是说一直跳 \(fail\) 不会遇到一个环,这不就是一张有向无环图吗?

我们把 \(fail\) 指针看成边,然后就可以进行拓扑来求到答案,因为我们知道如果能够匹配到 \(x\),也一定能够匹配到 \(fail_x\)

完整代码如下:

#include<bits/stdc++.h>
using namespace std;
const int Root=1;//Root=1
struct ll{//trie
	int fail,flag,ans,son[35];//flag表示有几个模式串的结尾在这个节点 
}t[200010];
int ans[200010],vis[200010],in[200010],mp[200010];
int cnt=1;//节点数量 
void build(string s,int q){//trie内添加s 
	int pos=Root;
	for(int i=0;i<s.size();i++){
		if(t[pos].son[s[i]-'a']==0){
			t[pos].son[s[i]-'a']=++cnt;
		}
		pos=t[pos].son[s[i]-'a'];
//		cout<<pos<<endl;
	}
	if(!t[pos].flag)t[pos].flag=q;
    mp[q]=t[pos].flag;
	return;
}
void build_fail(){//0的子节点指向Root,Root的Fail指针指向0 
	fill(t[0].son,t[0].son+26,Root);
	queue<int>q;
	t[Root].fail=0;
	q.push(Root);
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
//		cout<<tmp<<endl;
		for(int i=0;i<26;i++){//父亲转移儿子
			int v=t[tmp].son[i];
			if(v==0){//没有这个儿子 
				t[tmp].son[i]=t[t[tmp].fail].son[i];//t[tmp].son[i]更新为t[t[tmp].son[i]].fail
				continue;
			}
			t[v].fail=t[t[tmp].fail].son[i];//t[tmp].fail一定有i这个儿子 
			in[t[v].fail]++;//x->fail[x]
			q.push(v);
		}
	}
	return;
}
void query(string s){//寻找 
	int pos=Root;
	for(int i=0;i<s.size();i++){
		pos=t[pos].son[s[i]-'a'];
		t[pos].ans++;
	}
	return;
}
void topo(){
	queue<int>q;
	for(int i=1;i<=cnt;i++){
		if(in[i]==0)q.push(i);
	}
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		vis[t[tmp].flag]=t[tmp].ans;
		int x=t[tmp].fail;
		in[x]--,t[x].ans+=t[tmp].ans;
		if(in[x]==0)q.push(x);
	}
	return;
}
int main(){
	ios::sync_with_stdio(0);
	int n;
	string s;
	cin>>n;
	for(int i=1;i<=n;i++){
		string x;
		cin>>x;
		build(x,i);
	}
	build_fail();
//	cout<<"input end.\n";
	cin>>s;
	memset(ans,0,sizeof(ans));
	query(s);
	topo();
	for(int i=1;i<=n;i++){
		cout<<vis[mp[i]]<<endl;
	}
	return 0;
}

4. AC 自动机上 DP

先给个例题(P3041):

\(n\) 个模式串 \(s_i\),构造一个长度为 \(k\) 的字符串,使得所有模式串在这个字符串中出现次数之和最大,询问这个最大的值是多少。
\(n\le20\)\(|s_i|\le15\)\(k\le10^3\)),字符串只有 ABC 这三个字符组成。

直接来想 DP 如何做这道题,我们先想想状态如何构造。

不难想到,我们可以设字符串的长度 \(i\),字符为 \(j\) 结尾最大的答案为 \(dp_{i,j}\)

但是怎么转移呢?因为我们只知道最后字符的长度,假设我们枚举的状态 \(dp_{i,B}\)...B,此时来了一个 ABA 的模式串,\(dp_{i+1,A}\) 肯定会加上这段贡献,但是我们如何才能区分 \(i-1\) 填的是什么呢?所以这个状态设计的不对。

我们来考虑在 DP 上套 AC 自动机,首先,长度这个状态肯定是要保留的,我们可以新增加一个维度表示此时在 Trie 上的哪个节点,也就是说,\(dp_{i,j}\) 表示的是长度为 \(i\)\(1\)\(i\) 所构成的字符串上以节点 \(j\) 结尾。

那么转移也就很简单了:

\[dp_{i+1,t_{j,k}}=dp_{i,j}+cnt_{t_{j,k}}(k\in \{A,B,C\}) \]

\(cnt_x\) 表示的是多少个模式串是节点 \(x\) 属于的子串的后缀,这个可以暴力跳 \(fail\) 或者在处理 \(fail\) 指针是就将其处理好。

答案在 \(dp_{n,i}\) 中取 \(\max\) 就行了!

完整代码:

#include<bits/stdc++.h>
using namespace std;
struct ll{
	int fail,flag,son[20];
}t[310];
int dp[1010][310],cnt=1;
void build(string s){
	int pos=1;
	for(int i=0;i<s.size();i++){
		if(t[pos].son[s[i]-'A']==0){
			t[pos].son[s[i]-'A']=++cnt;
		}
		pos=t[pos].son[s[i]-'A'];
	}
	t[pos].flag++;
	return;
}
void build_fail(){
	for(int i=0;i<3;i++){
		t[0].son[i]=1;
	}
	t[1].fail=0;
	queue<int>q;
	q.push(1);
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		for(int i=0;i<3;i++){
			int x=t[tmp].son[i];
			if(x==0){
				t[tmp].son[i]=t[t[tmp].fail].son[i];
				continue;
			}
			t[x].fail=t[t[tmp].fail].son[i];
			q.push(x);
		}
		t[tmp].flag+=t[t[tmp].fail].flag;
	}
	return;
}
int main(){
	int n,m,ans=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		string s;
		cin>>s;
		build(s);
	}
	build_fail();
	for(int i=0;i<=m;i++){
		for(int j=2;j<=cnt;j++){
			dp[i][j]=-1e9;
		}
	}
	for(int i=1;i<=m;i++){
		for(int j=1;j<=cnt+1;j++){
			for(int k=0;k<3;k++){
				dp[i][t[j].son[k]]=max(dp[i][t[j].son[k]],dp[i-1][j]+t[t[j].son[k]].flag);
			}
		}
	}
	for(int i=0;i<=cnt;i++){
		ans=max(ans,dp[m][i]);
	}
	cout<<ans<<endl;
	return 0;
}

总结:AC 自动机上的 DP 一般都会有长度和节点位置这两项状态。

5. 例题

5.1 P2444 [POI 2000] 病毒

\(n\) 个由 \({0,1}\) 组成的模式串,问能否构造一个长度无限只包含 \({0,1}\) 的字符串不包含任何的模式串,如果能,输出 TAK,否则输出 NIE
\(1\le n\le 2\times 10^3\),所有模式串的长度不超过 \(3\times 10^4\)

首先,构造一个满足条件无限长的字符串,一定可以按一种循环结尾。

比如说这个例子:

我们想想一个循环的字符串在匹配过程中是什么样子的。例如 100100..,我们依次经过的结点是:Root,1,4,Root,1,4...,不难发现,这是在绕着一个环来走,当然可以举更多的例子:111111...,这个例子与刚刚的有些不同,它经过的结点是 Root,1,2,2,2...,最后也是绕着一个环来走。

我们可以得出结论:一个循环的字符串在匹配过程中,指针最后一定会绕着某个环来走,这个证明不难,我在这里就不写了其实是我懒

我们想要一个满足条件的字符串,在匹配时就不能经过模式串尾节点或直接 / 间接通过 \(fail\) 能到达的模式串尾节点的节点,这个能在处理 \(fail\) 指针的时候就搞好,于是这道题的做法也就想出来了:在自动机上找到一个不包含任何一个直接或间接通过 \(fail\) 指针到达某个模式串的尾节点(当然尾节点自己也不行),这个东西用 DFS 就好了。

完整代码:

#include<bits/stdc++.h>
using namespace std;
int n,cnt=1;
bool vis[30010],f[30010];
struct Trie{
	int fail,son[5];
	bool flag;
}t[30010];
void build(string s){
	int pos=1;
	for(int i=0;i<s.size();i++){
		if(!t[pos].son[s[i]-'0']){
			t[pos].son[s[i]-'0']=++cnt;
		}
		pos=t[pos].son[s[i]-'0'];
	}
	t[pos].flag=true;
}
void build_fail(){
	for(int i:{0,1})t[0].son[i]=1;
	t[1].fail=0;
	queue<int>q;
	q.push(1);
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		for(int i:{0,1}){
			if(!t[tmp].son[i]){
				t[tmp].son[i]=t[t[tmp].fail].son[i];
				continue;
			}
			t[t[tmp].son[i]].fail=t[t[tmp].fail].son[i];
			t[t[tmp].son[i]].flag|=t[t[t[tmp].fail].son[i]].flag;
			q.push(t[tmp].son[i]);
		}
	}
	return;
}
void dfs(int x){
	if(vis[x]){
		cout<<"TAK\n";
		exit(0);
	}
	vis[x]=1;
	for(int i:{t[x].son[0],t[x].son[1]}){
		if(f[i]||t[i].flag)continue;
		dfs(i);
		f[i]=1;
	}
	vis[x]=0;
	return;
}
int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		string s;
		cin>>s;
		build(s);
	}
	build_fail();
	dfs(1);
	cout<<"NIE\n";
	return 0;
}

5.2 P2414 [NOI2011] 阿狸的打字机

阿狸喜欢收藏各种稀奇古怪的东西,最近他淘到一台老式的打字机。打字机上只有 \(28\) 个按键,分别印有 \(26\) 个小写英文字母和 BP 两个字母。经阿狸研究发现,这个打字机是这样工作的:

  • 输入小写字母,打字机的一个凹槽中会加入这个字母 (这个字母加在凹槽的最后)。
  • 按一下印有 B 的按键,打字机凹槽中最后一个字母会消失。
  • 按一下印有 P 的按键,打字机会在纸上打印出凹槽中现有的所有字母并换行,但凹槽中的字母不会消失。
    我们把纸上打印出来的字符串从 \(1\) 开始顺序编号,一直到 \(n\)。有 \(m\) 次询问,每次询问第 \(x\) 次被打印出来的字符串在第 \(y\) 次被打印出来的字符串中出现过多少次。

这道题考察了对 \(fail\) 指针的理解。

我们知道若 \(fail_x=y\),那么 \(x\) 属于的子串一定包含 \(y\) 所属的子串。

所以,这道题就变成了,属于模式串 \(y\) 的节点中,有多少个节点的 \(fail\) 直接或间接的指向 \(x\) 的尾节点。

进一步我们可以用 \(fail\) 指针构成一棵 \(fail\) 树,此时 \(fail\) 树的节点还是原 Trie 的节点,但只有 \(fail_x\) 连向 \(x\) 的边:

于是,这道题就变成了:在 \(fail\) 树种以模式串 \(x\) 结尾所在的节点的子树中,有多少个节点在 Trie 中是属于模式串 \(y\) 的。

这样看起来似乎还是不太好搞,但是我们知道一条性质:子树内的 \(dfn\) 是连续的,我们似乎能通过这个 \(dfn\) 来维护信息,而这个可以用树状数组来维护。

我们先将询问存储 \(x,y\) 存到模式串 \(x\) 尾节点里,然后呢,我们可以 DFS 整个 \(fail\) 树,找到每个节点在 \(fail\) 树中所对应的 \(dfn\) 和每个子树的大小。

然后,我们可以再根据输入的信息来模拟:每次输入小写字母时就更新树状数组在 \(dfn_x\) 的位置上打上标记,遇到 B 时就先将打上的标记撤销,令 \(x\gets fa_x\),这个 \(fa\) 数组可以处在插入模式串时就理好,最后,遇到询问操作 P 的时候就直接枚举这个节点存过的询问,一一回答就行了。

时间复杂度:\(O(n\log n)\),当然因为常数问题速度跟 \(O(n\log n^2)\) 差不多了。

完整代码:

#include<bits/stdc++.h>
using namespace std;
struct node{
	int flag,fa,fail,son[30];
}t[100010];
struct Query{
	int x,id;
};
int tr[100010],dfn[100010],siz[100010],dep[100010],ans[100010],endpos[100010],cnt=1,pos=1,cntq,tot,q;
vector<int>v[100010];
vector<Query>Q[100010];
int lowbit(int x){
	return x&-x;
}
void build(int x,int y){
	for(int i=x;i<=cnt+1;i+=lowbit(i)){
		tr[i]+=y;
	}
	return;
}
int query(int x){
	int ans=0;
	for(int i=x;i;i-=lowbit(i)){
		ans+=tr[i];
	}
	return ans;
}
void insert(char ch){
	if(!t[pos].son[ch-'a'])t[pos].son[ch-'a']=++cnt;
	t[t[pos].son[ch-'a']].fa=pos;
	pos=t[pos].son[ch-'a'];
	return;
}
void build_fail(){
	for(int i=0;i<26;i++){
		t[0].son[i]=1;
	}
	t[1].fail=0;
	queue<int>q;
	q.push(1);
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		for(int i=0;i<26;i++){
			if(!t[tmp].son[i]){
				t[tmp].son[i]=t[t[tmp].fail].son[i];
				continue;
			}
			t[t[tmp].son[i]].fail=t[t[tmp].fail].son[i];
			q.push(t[tmp].son[i]);
		}
	}
	return;
}
void build_graph(){
	for(int i=1;i<=cnt;i++){
		v[i].push_back(t[i].fail);
		v[t[i].fail].push_back(i);
	}
	return;
} 
void dfs(int x,int fa){
	dfn[x]=++tot,siz[x]=1;
	for(int i:v[x]){
		if(i==fa)continue;
		dfs(i,x);
		siz[x]+=siz[i];
	}
	return;
}
int main(){
	string opt;
	cin>>opt>>q;
	for(char ch:opt){
		if(ch=='B'){
			pos=t[pos].fa;
		}else if(ch=='P'){
			t[pos].flag=++cntq;
			endpos[cntq]=pos;
		}else{
			insert(ch);
		}
	} 
	build_fail();
	build_graph();
	dfs(0,-1);
	for(int i=1;i<=q;i++){
		int x,y;
		cin>>x>>y;
		Q[endpos[y]].push_back({endpos[x],i});
	}
	pos=1;
	for(char ch:opt){
		if(ch=='B'){
			build(dfn[pos],-1);
			pos=t[pos].fa;
		}else if(ch=='P'){
			for(Query i:Q[pos]){
				ans[i.id]=query(dfn[i.x]+siz[i.x]-1)-query(dfn[i.x]-1);
			}
		}else{
			pos=t[pos].son[ch-'a'];
			build(dfn[pos],1);
		}
	}
	for(int i=1;i<=q;i++){
		cout<<ans[i]<<endl;
	}
	return 0;
} 

5.3 P2292 [HNOI2004] L 语言

提醒:作者这个做法的复杂度时错的,能 AC 纯粹就是靠玄学优化卡过去的。

给定 \(n\) 个模式串 \(s_i\),定义一个字符串能够被理解为这个字符串可以拆成若干个模式串拼接而成,有 \(m\) 次询问,第 \(i\) 次询问 \(T_i\) 最长能够被理解的前缀子串的长度。
\(n\le 20,m\le 50,1\le |s|\le 20,\sum_{i=1}^{m}|T_i|\le 10^6\)\(s\)\(t\) 中均只含小写英文字母。

这题的正解时状压但是我没用也过了

我们设朴素的 \(dp_i\) 为询问字符串中以第 \(i\) 位结尾的子串能否被读懂。

转移的时候只要枚举每个模式串,暴力跳 \(fail\) 查询就行了,时间复杂度 \(O(n\sum|T_i|)\),凭借着庞大的常数成功 TLE。

于是我们可以做几个小优化:

  • 在跳 \(fail\) 时,如果发现 \(dp_i\) 已经为真就直接跳出循环。
  • 如果 \(dp_{i-mx}\)\(dp_i\) 都不为真,那么直接就能跳出循环了(\(mx\) 为最长的模式串的长度即 \(\max_{i=1}^{n}|s_i|\))。

然后这道题就能过了,完整代码如下:

#include<bits/stdc++.h>
using namespace std;
bool dp[2000010];
struct ll{
	int son[30],fail,flag;
}t[2010];
int cnt=1;
void build(string s){
	int pos=1;
	for(int i=0;i<s.size();i++){
		if(!t[pos].son[s[i]-'a']){
			t[pos].son[s[i]-'a']=++cnt;
		}
		pos=t[pos].son[s[i]-'a'];
	}
	t[pos].flag=s.size();
	return;
}
void build_fail(){
	for(int i=0;i<26;i++)t[0].son[i]=1;
	t[1].fail=0;
	queue<int>q;
	q.push(1);
	while(!q.empty()){
		int tmp=q.front();
		q.pop();
		for(int i=0;i<26;i++){
			if(!t[tmp].son[i]){
				t[tmp].son[i]=t[t[tmp].fail].son[i];
				continue;
			}
			t[t[tmp].son[i]].fail=t[t[tmp].fail].son[i];
			q.push(t[tmp].son[i]);
		}
	}
	return;
}
int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	int n,m,mx=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		string s;
		cin>>s;
		build(s);
		mx=max(mx,int(s.size()));
	}
	build_fail();
	while(m--){
		string s;
		cin>>s;
		s=' '+s;
		fill(dp,dp+s.size(),false);
		int pos=1,last=0,ans=0;
		bool flag=false;
		dp[0]=true;
		for(int i=1;i<s.size();i++){
			pos=t[pos].son[s[i]-'a'];
			int k=pos;
			while(k>1){
				dp[i]|=dp[i-t[k].flag];
				if(dp[i])break;
				k=t[k].fail;
			}
			if(dp[i])last=i;
			else{
				if(i-last>mx){
					ans=last;
					flag=true;
					break;
				}
			}
		}
		if(flag)cout<<ans<<'\n';
		else{
			for(int i=1;i<s.size();i++){
				if(dp[i])ans=i;
			}
			cout<<ans<<'\n';
		}
	}
	return 0;
}

6. 作业

  1. P3796 AC 自动机(简单版 II)
  2. P3121 [USACO15FEB] Censoring G
  3. P4052 [JSOI2007] 文本生成器

7. 闲话

说实话,这篇文章的图画了我很长时间尤其是画完之后发现是错的真的崩溃了

蒟蒻不才,膜拜大佬,如果文章有任何错字等问题,请在评论区提醒我。

posted @ 2025-10-02 08:20  doooge  阅读(8)  评论(0)    收藏  举报