//https://img2018.cnblogs.com/blog/1646268/201908/1646268-20190806114008215-138720377.jpg

AC自动机学习笔记

AC 自动机

此博客参考 Luckyblock 的博客,以及此博客还有以及pokefunc的讲解
由于我太弱了所以我花了近一天的时间来理解这个 \(fail\) 的含义以及运作原理,还有大家不要一直换着博客看,不同博客的 \(fail\) 可能解释的不一样由于我一开始看了好几篇导致我现在脑子里还是混乱的
我已经傻了所以我没讲明白以后再来

前置知识

KMP 算法

字典树 Trie

AC 自动机可以处理一个字符串在一篇文章也就是多个字符串中的出现情况。

算法流程

AC 自动机需要提前知道所有需要进行匹配的字符串,例如:say,she,shr,her。接下来我们需要把他们建成一棵字典树。

image

就像上面这张图一样,构造出了一棵字典树,然后我们就可以进行下一步——求失配指针。

这一步也是最重要的一步,我们都知道 KMP 算法里面是有一个 \(next\) 数组来减少时间复杂度,省略没有意义的查找过程,在 AC 自动机里面的失配指针 \(fail\) 也是如此,它可以帮助我们减少一些不必要的比较来加速查询过程。先来看一下求好的图:

image

我们在匹配的过程中一旦匹配到当前位置没有成功匹配的话,就会直接跳转到当前点的失配指针指向的点继续进行匹配。

\(fail\) 存放的就是一个下标,求他的过程你也可以想像成把当前点代表的字符串在字典树上匹配,如果匹配不到就把头的一个字符给去掉再匹配,不断重复直到匹配成功,然后将当前的串的末尾字符的下标给存放到里面。

先来瞅一下代码。

inline void get_fail()
{
	int l=0,r=0;
	for(int i=0;i<26;i++)
	{
		if(e[0].ch[i]!=0)
		{
			e[e[0].ch[i]].fail=0;
			q[++r]=e[0].ch[i];
		}
	}
	while(l<r)
	{
		int u=q[++l];
		for(int i=0;i<26;i++)
		{
			int v=e[u].ch[i];
			if(v)
			{
				e[v].fail=e[e[u].fail].ch[i];
				q[++r]=v;
			}
			else e[u].ch[i]=e[e[u].fail].ch[i];
		}
	}
}

对于第二层的节点,最大的特点就是要特殊处理,因为第二层的点的在字典树上出现的最大后缀肯定是当前点的单个字符,也就是说 \(fail\) 是指向自己的,如果在后面用 \(fail\) 一直在这里面反复横跳就死循环了,所以将第二层的 \(fail\) 全修改为 \(0\)

然后这时我们需要一个队列来存放已经有 \(fail\) 的点,然后我们利用类似于 BFS 的思想来往下一层一层的寻找,也就是说当我们在寻找这一层的节点的 \(fail\) 的值的时候,上一层的都是已经确定了 \(fail\) 的值了,我们可以放心大胆的调用。所以从上方的代码可以得知,当前点的 \(fail\) 值都是由前几层的推出来的,所以我们可以知道,当前节点的 \(fail\) 指向的点只能是在他的上面几层的点,然后我们可以发现如果要是当前点是空的,也就是上面的第二种情况,我们可以直接在这个点上修改成父亲节点 \(fail\) 指向的点的子节点中的对应的值,如果是空的的话 \(fail\) 就是 \(0\) 了。

也不知道我讲明白了没有

在查询的过程中我们需要把大串一个一个的去匹配,这样才能让每一个点都正确的匹配上,如果当前点有 \(fail\) 指针的话就继续往跳 \(fail\) 指针,直至到达不能再跳的点,具体实现看下面代码。

P3808 【模板】AC 自动机(简单版)

#include<bits/stdc++.h>
#define int long long
#define N 1010000
using namespace std;
int n,cnt,q[N];
struct sb{int fail,num,ch[30];}e[N];
string s;
inline void build()
{
	int len=s.size(),u=0;
	for(int i=0;i<len;i++)
	{
		int c=s[i]-'a';
		if(e[u].ch[c]==0)e[u].ch[c]=++cnt;
		u=e[u].ch[c];
	}
	e[u].num++;//标记最后一个字符的位置 
}
inline void get_fail()
{
	int l=0,r=0;
	for(int i=0;i<26;i++)
	{
		if(e[0].ch[i]!=0)
		{
			e[e[0].ch[i]].fail=0;
			q[++r]=e[0].ch[i];
		}
	}
	while(l<r)
	{
		int u=q[++l];
		for(int i=0;i<26;i++)
		{
			int v=e[u].ch[i];
			if(v)
			{
				e[v].fail=e[e[u].fail].ch[i];
				q[++r]=v;
			}
			else e[u].ch[i]=e[e[u].fail].ch[i];
		}
	}
}
inline int ACzdj()
{
	int len=s.size();
	int u=0,ans=0;
	for(int i=0;i<len;i++)
	{
		int c=s[i]-'a';
		u=e[u].ch[c];
		int v=u;
		while(v&&e[v].num!=-1)
		{
			ans+=e[v].num;
			e[v].num=-1;
			v=e[v].fail; 
		}
	}
	return ans;
}
signed main()
{
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>s;
		build();
	}
	e[0].fail=0;
	get_fail();
	cin>>s;
	cout<<ACzdj()<<endl;
	return 0;
}

P3796 【模板】AC 自动机(加强版)

因为题目中说过了保证不出现重复的字符串,所以我们要想在最后查询是哪个或者哪些字符串的话,我们可以在建字典树的时候将原来表示个数的 \(num\) 数组给改成存放当前字符串的编号即可,最后匹配的时候每次当前点匹配成功就在 \(ans\) 数组里面加一,最后遍历一边那个 \(ans\) 大即为第一个的答案,然后再遍历一遍,把 \(ans\) 的值与其相同的字符串全给输出即可。

#include<bits/stdc++.h>
#define int long long
#define N 300100
using namespace std;
int n,cnt,maxn,num[N],ch[N][30],fail[N],ans[N];
string s1[N],s2;
inline void qk()
{	
	memset(num,0,sizeof(num));
	memset(ans,0,sizeof(ans));
	memset(ch,0,sizeof(ch));
	memset(fail,0,sizeof(fail));
	cnt=0;maxn=0;
}
inline void insert(string s,int k)
{
	int u=0;
	for(int i=0;i<s.size();i++)
	{
		int c=s[i]-'a';
		if(ch[u][c]==0)ch[u][c]=++cnt;
		u=ch[u][c];
	}
	num[u]=k;
}
inline void get_fail()
{
	int now=0;
	queue<int>q;
	for(int i=0;i<26;i++)
	  if(ch[0][i])
	    q.push(ch[0][i]);
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		for(int i=0;i<26;i++)
		{
			int v=ch[u][i];
			if(v)
			{
				fail[v]=ch[fail[u]][i];
				q.push(v);
			}
			else ch[u][i]=ch[fail[u]][i];
		}
	}
}
inline void ACzdj(string s)
{
	int u=0;
	for(int i=0;i<s.size();i++)
	{
		u=ch[u][s[i]-'a'];
		for(int j=u;j;j=fail[j])
			ans[num[j]]++;
	}
}
signed main()
{
	while(cin>>n)
	{
		if(n==0)break;
		qk();
		for(int i=1;i<=n;i++)
		  cin>>s1[i],insert(s1[i],i);
		get_fail();
		cin>>s2;
		ACzdj(s2);
		for(int i=1;i<=n;i++)
//		  cout<<ans[i]<<' ',
		  maxn=max(maxn,ans[i]);
//		cout<<endl;
		cout<<maxn<<endl;
		for(int i=1;i<=n;i++)
		  if(ans[i]==maxn)
		    cout<<s1[i]<<endl;
	}
	return 0;
}

失配树(拓展)

image

lps学长的博客

据学长所说失配树就是在 AC 自动机上令 \(i\) 连向 \(fail_{i}\) 所形成的树。

来看一下学长博客里的图:

image

image

上图里的 \(nxt\) 就相当于上文的 \(fail\),第二张图为建好了的树。

长度为 \(4\) 和长度为 \(6\) 的后缀的最长公共 \(border\) 长为 \(2\),在树上的关系是什么?

可以看出,\(2\)\(4\)\(6\) 除了自己之外的 LCA。

这棵树也就是所谓的失配树,通过对于一个字符串建出这棵树,我们可以快速找出其多组长度不同的前缀的最长公共 \(border\) 长度。

解释一下原理:

如果 \(C\)\(B\)\(border\)\(B\)\(A\)\(border\),那么 \(C\)\(A\)\(border\)

也就是说处理出 \(nxt\) 数组之后,\(A\) 可以不断跳 \(nxt\)\(B\)\(B\) 也可以不断跳 \(nxt\)\(C\)

所以说,如果两个前缀能通过跳 \(nxt\) 跳到同一个位置去,那么第一个跳到的相同的位置就是它们的最长公共 \(border\) 长度。

这个过程和我们树上找 LCA 的方式很像,所以我们可以将其建成一棵树。

我承认就是从学长博客搬过来的

P5829 【模板】失配树

#include<bits/stdc++.h>
#define int long long
#define N 1000010
using namespace std;
string s;
int n,q,fail[N],dis[N],fa[N][25],a,b;
inline void init()
{
	int p=0;
	for(int i=2;i<=n;i++)
	{
		while(p&&s[p+1]!=s[i])
		  p=fail[p];
		if(s[p+1]==s[i])
		  p++;
		fail[i]=p;
		dis[i]=dis[p]+1;
		fa[i][0]=p;
	}
	for(int k=1;k<=21;k++)
	{
		for(int i=1;i<=n;i++)
		  fa[i][k]=fa[fa[i][k-1]][k-1];
	}
}
inline int up(int x,int h)
{
	if(dis[x]<=h)return x;
	for(int i=21;i>=0;i--)
	{
		if(dis[fa[x][i]]>h)
		  x=fa[x][i];
	}
	return fa[x][0];
}
inline int LCA(int x,int y)
{
	if(dis[x]<dis[y])
	  swap(x,y);
	x=up(x,dis[y]);
	for(int i=21;i>=0;i--)
	{
		if(fa[x][i]!=fa[y][i])
		{
			x=fa[x][i];
			y=fa[y][i];
		}
	}
	return fa[x][0];
}
signed main()
{
	cin>>s>>q;
	n=s.size();
	s.insert(s.begin(),' ');
	init();
	while(q--)
	{
		cin>>a>>b;
		cout<<LCA(a,b)<<endl;
	}
	return 0;
}

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


#include<bits/stdc++.h>

#define N 201000

using namespace std;

int n, num[N], ch[N][30], fail[N], ans[N], cnt;
//num存放当前编号的字符串在字典树中结尾节点的编号,ans存放当前串出现的次数 
string s1[N], s2;//s2是主串,s1是模式串 
vector<int> e[N];//存fail树的边 

inline void insert(string s, int x)
{
	int u = 0, len = s.size();
	for(int i = 0; i < len; i ++)
	{
		int c = s[i] - 'a';
		if(!ch[u][c]) ch[u][c] = ++ cnt;
		u = ch[u][c];
	}
	num[x] = u;//和上面说的对应,记录当前编号的字符串在字典树中的节点 
    return ;
}

inline void get_fail()//正常的求fail过程 
{
	queue<int> q;
	for(int i = 0; i < 26; i ++)
	    if(ch[0][i]) q.push(ch[0][i]);
	while(!q.empty())
	{
		int u = q.front(); q.pop();
		for(int i = 0; i < 26; i ++)
		{
			int v = ch[u][i];
			if(v) fail[v] = ch[fail[u]][i], q.push(v);
			else ch[u][i] = ch[fail[u]][i];
		}
	}
    return ;
}

inline void AC()//在这里不乱跳fail指针了,由后面在fail树上的dfs一起处理 
{
	int len = s2.size(), u = 0;
	for(int i = 0; i < len; i ++)
	{
		int v = s2[i] - 'a';
		u = ch[u][v];
		ans[u] ++;//不标记而是直接只存下当前节点的值
	}
    return ;
}

inline void dfs(int u)
{
	for(auto v : e[u])
	{
		dfs(v);//先搜索,在回溯的过程中从后往前给他搜出来 
		ans[u] += ans[v];//因为fail树上面节点出现的次数一定是字数节点出现次数的和 
	}
    return ;
}

signed main()
{
	cin >> n;
	for(int i = 1; i <= n; i ++)
	{
		cin >> s1[i];
		insert(s1[i], i);
	}
	get_fail();//求fail指针 
	for(int i = 1; i <= cnt; i ++)//枚举每一个节点 
	    e[fail[i]].push_back(i);//建一条由fail_{i}到i的有向边,建立fail树 
	cin >> s2;
	AC();
	dfs(0);
	for(int i = 1; i <= n; i ++)
	    cout << ans[num[i]] << endl;
	return 0;
}
posted @ 2023-01-29 17:16  北烛青澜  阅读(36)  评论(2编辑  收藏  举报