学习笔记:AC自动机

发布时间 2023-08-12 23:03:41作者: g1ove

0.前言

emmmm我也是一知半解,写篇笔记梳理思路 毒瘤夏令营真不把人看啊一天两三个难度算法

1.产生原因

kmp,一个串匹配另一个串的线性高效写法

但是如果是多个匹配串呢?

跑kmp可以达到\(O(nm)\)的复杂度

太美丽啦kmp!还是看一下远处的AC自动机吧家人们

但是如果是AC自动机就可以做到\(O(|S|)\)的复杂度

2.流程

1.失配数组

我们发现匹配时如果失败就重跑实在是太费时间了!

我们可以借鉴kmp的思想,搞一个失配数组

先把所有的匹配串扔进一颗字典树里面

匹配失败只要跳到和当前字符串有相同最长后缀尽节点上是最优的

和kmp不一样,kmp求的是相同前后缀

例子

(网图 侵删)

看,左边是abcd失配后应该跳到相同后缀bcd上
bcd失配后又到cd上 十分合理

而虽然abd那里也有d,可是它们得有相同的后缀啊!

eg

可以发现,两个s指向的都是7节点。

  • 找到6的父节点5,5的fail指针指向10,然而10结点没有字母's'连出的边;

  • 所以跳到10的fail指针指向的结点0,发现0结点有字母's'连出的边,指向7结点;

  • 所以fail6=7.

接下是创建过程(Copy)

构造失配(fail)指针

在讲构造以前,先来对比一下这里的fail指针与KMP中的next指针:


共同点-两者同样是在失配的时候用于跳转的指针。
不同点-KMP要求的是最长相同真前后缀,而AC自动机只需要相同后缀即可。
因为KMP只对一个模式串做匹配,而AC自动机要对多个模式串做匹配。
有可能fail指针指向的结点对应着另一个模式串,两者前缀不同。
也就是说,AC自动机在对匹配串做逐位匹配时,同一位上可能匹配多个模式串。
因此fail指针会在字典树上的结点来回穿梭,而不像KMP在线性结构上跳转。


下面介绍构建fail指针的基础思想:(强调!基础思想!基础!)


构建fail指针,可以参考KMP中构造next数组的思想。
我们利用部分已经求出fail指针的结点推导出当前结点的fail指针。具体我们用BFS实现:
如果结点fail[p]通过字母c连接到的子结点w存在:
如果fail[p]通过字母c连接到的子结点w不存在:
则让u的fail指针指向这个结点w(fail[u]=w)。
相当于在p和fail[p]后面加一个字符c,就构成了fail[u]。
那么我们继续找到fail[fail[p]]指针指向的结点,重复上述判断过程,一直跳fail指针直到根节点。
如果真的没有,就令fail[u]=根节点。
考虑字典树中当前的节点u,u的父节点是p,p通过字符c的边指向u。
假设深度小于u的所有节点的fail指针都已求得。那么p的fail指针显然也已求得。
我们跳转到p的fail指针指向的结点fail[p];
如此即完成了fail指针的构建

说人话就是:

  • 1.若当前有节点,他的后继就是祖先的失配节点的同一后继(也许没有后继,后面讲)
  • 2.若祖先没有节点,只能再往上跳失配值找到有的节点
    可以发现是能用递推推的 只需要现在的儿子指向祖先的失配节点的同一后继
  • 3.因为可能跳去失配节点会去别的树枝,但深度不会低,所以应该选择bfs而不是dfs

Code:

void get_fail()
{
	l=1;//queue:l~r
	for(int i=1;i<=26;i++)
		if(tr[0][i])//预处理
		{
			int to=tr[0][i];
			fail[to]=0;
			q[++r]=to;
		}
	while(l<=r)
	{
		int x=q[l++];
		for(int i=1;i<=26;i++)
		{
			int to=tr[x][i];
			if(to)//步骤1
			{
				fail[to]=tr[fail[x]][i];
				q[++r]=to;
			}
			else//步骤2
				tr[x][i]=tr[fail[x]][i];
		}
	}
}

2.求答案

你已经搞了字典树,可以直接开始便历了

  • 从根节点开始
  1. 有子节点 ->直接跳过去
  2. 没有子节点 fail数组预处理时已经处理好了失配跳的值,实际上直接跳就行了

考虑到了 \(x\)

  • 1.直接按照fail数组网上跳就好啦 因为如果一个字符串匹配成功了,那么它的后缀一定也是成功匹配的,所以它的失配点也是可以匹配的
  • 2.如果不匹配,那么可以往失配数组前移,为啥叫失配数组嘛,失配了就缩小范围。

  • 3.因为一直跳到尾,重复只算一次,所以要打标记看来过没有

  • 4.原理其实就是在从1~len遍历每个字符,然后在查询的过程其实就是在当前i时,求出来的最长符合的后缀。没有符合的?往上跳!

…<---------->

…………<---->

…………… <->

差不多就是减少长度 像kmp一样

Code:

int query(string s)
{
	int now=0,sum=0;
	for(int i=0;i<s.size();i++)
	{
		now=tr[now][s[i]-'a'+1];
		int t=now;
		while(t&&end[t]!=-1)
		{
			sum+=end[t];
			end[t]=-1;
			t=fail[t];
		}
	}	
	return sum;
}

完整code:

#include<bits/stdc++.h>
#define ll long long
#define MAXN 1000005
using namespace std;
int n;
string s;
int cnt;
int q[MAXN],l,r;
int tr[MAXN][28],end[MAXN],fail[MAXN];
void insert(string s)
{
	int now=0;
	for(int i=0;i<s.size();i++)
	{
		int c=s[i]-'a'+1;
		if(!tr[now][c]) tr[now][c]=++cnt;
		now=tr[now][c];
	}
	end[now]++;
}
void get_fail()
{
	l=1;//queue:l~r
	for(int i=1;i<=26;i++)
		if(tr[0][i])
		{
			int to=tr[0][i];
			fail[to]=0;
			q[++r]=to;
		}
	while(l<=r)
	{
		int x=q[l++];
		for(int i=1;i<=26;i++)
		{
			int to=tr[x][i];
			if(to)
			{
				fail[to]=tr[fail[x]][i];
				q[++r]=to;
			}
			else
				tr[x][i]=tr[fail[x]][i];
		}
	}
}
int query(string s)
{
	int now=0,sum=0;
	for(int i=0;i<s.size();i++)
	{
		now=tr[now][s[i]-'a'+1];
		int t=now;
		while(t&&end[t]!=-1)
		{
			sum+=end[t];
			end[t]=-1;
			t=fail[t];
		}
	}	
	return sum;
}
int main()
{ 
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>s,insert(s);
	cin>>s;
	get_fail();
	cout<<query(s);
	return 0;
}

对应板子:AC自动机模板

初级版学习完毕 头好痒,要长脑子了

AC自动机加强版

好了你已经学会初级版了,接下来是进阶版

传送门:加强版

其实这个也是十分简单啦,只要你了解了模板暴力跳就彳亍了,这题的\(N\)也十分的小,才70,每次跳也70,我们可以暴力得到\(O(GN|T|)\)的复杂度,加几个读入优化就跑过去了

Code:

#include<bits/stdc++.h>
#define MAXN 155
#define MAXM 155*70
using namespace std;
int n,sum[MAXN];
int q[MAXM],l,r;
string s[MAXN],str;
int cnt,tr[MAXM][28],fail[MAXM],id[MAXM];
void init()
{
	cnt=l=r=0;
	fill(&tr[0][0],&tr[MAXM-5][0],0);
	fill(fail,fail+MAXM-5,0);
	fill(id,id+MAXM-5,0);
	fill(sum,sum+MAXN-2,0);
}
void insert(string s,int num)
{
	int now=0;
	for(int i=0;i<s.size();i++)
	{
		int c=s[i]-'a'+1;
		if(!tr[now][c]) tr[now][c]=++cnt;
		now=tr[now][c];
	}
	id[now]=num;
}
void get_fail()
{
	for(int i=1;i<=26;i++)
	if(tr[0][i]) q[++r]=tr[0][i];
	while(l<r)
	{
		int now=q[++l];
		for(int i=1;i<=26;i++)
		if(tr[now][i])
		{
			fail[tr[now][i]]=tr[fail[now]][i];
			q[++r]=tr[now][i];
		}
		else tr[now][i]=tr[fail[now]][i];
	}
}
void AC_query(string s)
{
	int now=0;
	for(int i=0;i<s.size();i++)
	{
		now=tr[now][s[i]-'a'+1];
		int t=now;
		while(t)
		{
			sum[id[t]]++;
			t=fail[t];
		}
	}
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0),cout.tie(0);
	while(cin>>n)
	{
		if(n==0) break;
		init();
		for(int i=1;i<=n;i++)
			cin>>s[i],insert(s[i],i);
		get_fail();
		cin>>str;
		AC_query(str);
		int maxx=-1;
		for(int i=1;i<=n;i++)
			if(sum[i]>maxx) maxx=sum[i];
		cout<<maxx<<"\n";
		for(int i=1;i<=n;i++)
			if(sum[i]==maxx) cout<<s[i]<<"\n";
	}
	return 0;
}

加强版就完啦 加强了个寂寞

真正的加强版:AC自动机加强加强版

传送门:加强加强版

这就是真正的难题了奥

要是像加强版一样做\(O(|S||T|)\) \(1e11\)的复杂度直接T飞

那咋办?

我们仔细联想一下,就像我们做初级版时,经过一个点给它打个标记 那我们是不是也能这样呢?把经过的点打好标记,最后扫一遍不就好了嘛

我们知道fail数组指向的点的深度一定是比自己小的,然后往自己小的点上跳

诶,指向深度小,不会指回来,\(n\)个点 \(n-1\) 条边,联通性,这不就是树嘛

跑一边dfs之后,求自己的子树和不就是答案了嘛

时间复杂度很好的被优化到了\(O(|T|+|S|)\)

Code:

#include<bits/stdc++.h>
#define MAXN 200005
using namespace std;
int n,cnt,sum[MAXN];
int q[MAXN],l,r;
string s[MAXN],op;
int tr[MAXN][28],fail[MAXN],id[MAXN];
int head[MAXN],tot=1;
struct edge{
	int to,next;
}e[MAXN];
void add(int u,int v)
{
	e[tot].to=v;
	e[tot].next=head[u];
	head[u]=tot++;
}
void insert(string s,int num)
{
	int now=0;
	for(int i=0;i<s.size();i++)
	{
		int c=s[i]-'a'+1;
		if(!tr[now][c]) tr[now][c]=++cnt;
		now=tr[now][c];
	}
	id[num]=now;
}
void getfail()
{
	for(int i=1;i<=26;i++)
		if(tr[0][i]) q[++r]=tr[0][i];
	while(l<r)
	{
		int now=q[++l];
		for(int i=1;i<=26;i++)
		if(tr[now][i])
		{
			fail[tr[now][i]]=tr[fail[now]][i];
			q[++r]=tr[now][i];
		}
		else tr[now][i]=tr[fail[now]][i];
	}
}
void AC_query(string s)
{
	int now=0;
	for(int i=0;i<s.size();i++)
	{
		now=tr[now][s[i]-'a'+1];
		sum[now]++;
	}
}
void dfs(int now)
{
	for(int i=head[now];i;i=e[i].next)
	{
		int son=e[i].to;
		dfs(son);
		sum[now]+=sum[son];
	}
}
int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0),cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>s[i],insert(s[i],i);
	getfail();
	for(int i=1;i<=cnt;i++)
		add(fail[i],i);
	cin>>op;
	AC_query(op);
	dfs(0);
	for(int i=1;i<=n;i++)
		cout<<sum[id[i]]<<"\n";
	return 0;
}

好了你已经走出AC自动机的新手村了,接下来就是各种毒瘤变种题目了!

练习题:click here (题解)

DP题:here