首页 > 技术文章 > AC自动机感性瞎扯

Troverld 2020-04-26 17:26 原文

大家好,今天我们来扯自动AC机AC自动机了。

I.前置知识

trie树。(那些说需要kmp的,不会也没事,不过还是会方便理解一点)。

II.用途

AC自动机可以在\(O(\Sigma|S|)\)的时间内预处理,并在\(O(|S|)\)内求出一组模式串集在一个文本串中的出现次数。

换句话说,给你\(n\)个模式串\(s_1\)\(s_n\),和一个文本串\(t\),我们可以在\(O(|T|)\)时间将每个\(s_i\)\(t\)做一次kmp。而用暴力的kmp是\(O(|T|\Sigma|S_i|)\)的。

III.操作

首先,我们建出一颗trie树。

如果我们暴力匹配,失配就暴力回溯的话,复杂度甚至还不如暴力kmp。

借鉴kmp的\(next\)思想,如果失配,我们是不是可以略过重复部分,只做有用的匹配呢?

我们引出\(fail\)数组。这个神奇的数组的意思是:如果在节点\(i\)处失配了,我们可以直接跳到点\(fail_i\),避免回溯。

IV.求\(fail\)

显然,如果失配,我们希望这个\(fail\)的位置尽量更深一点,以加快匹配。

并且,\(i\)\(fail_i\)必定要有相同的前缀,不然这次跳跃就是不合法的。

我们现在采取暴力bfs的方法求\(fail\)

IV.i.根的子节点

显然,对于根的子节点,它们不可能有一个合法的\(fail\)。而由于深度足够浅,暴力回溯也没有问题。因此,我们将它们的\(fail\)全都指向根。之后,将它们入队。

另外,对于没有出现在trie树中的子节点,将它们全部设成指向根,即默认失配。

IV.ii.普通节点

我们取出队首节点,设为\(x\)

枚举它的所有子节点:

IV.ii.1.已有节点(设为\(y\)

因为\(x\)\(fail_x\)有共同的前缀,所以当前缀由\(x\)延伸到\(y\)时,\(fail_x\)也可以向相同方向延伸。

关键代码(\(t\):trie数组,\(ch\):子节点编号,\(fail\)\(fail\)数组):

t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);

非常易记,因为刚好是内外互换。

IV.ii.2.空节点

默认直接失配,直接指向原本的\(fail\)

关键代码:

 t[x].ch[i]=t[t[x].fail].ch[i];

IV.iii.总代码

void build(){
	for(int i=0;i<26;i++){
		if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
		else t[1].ch[i]=1;
	}
	while(!q.empty()){
		int x=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
			else t[x].ch[i]=t[t[x].fail].ch[i];
		}
	}
}

IV.iv.感性理解

第一步,根的子节点(未出现节点未标出)入队。

现在,我们到了第\(1\)层(根为\(0\)层)的第一个节点\(A\)

考虑它的儿子,第\(2\)层的第一个节点\(B\)

它指向\(A\)\(fail\)(根)的儿子\(B\)

即有:

\(2\)层的第一个节点\(B\)并无儿子,忽略它的操作。

考虑第\(2\)层的第二个节点\(C\)

它指向根的儿子\(C\)。但是,这个\(C\)并不存在,它被暴力连到了根。

即有:

\(2\)层的第一个节点\(C\)并无儿子,忽略它的操作。

考虑第\(1\)层的第二个节点\(B\)

它唯一的儿子,第\(2\)层的第三个节点\(B\)。这个节点应指向根的\(B\)儿子——它的父亲,第\(1\)层的第二个节点\(B\)

考虑第\(3\)层的第一个节点\(A\)

它父亲的\(fail\)(第\(1\)层的第二个节点\(B\))并无\(A\)儿子。但是这个\(A\)儿子已经被赋成第\(1\)层的第一个节点\(A\)。可是是,这个第\(1\)层的第一个节点\(A\)也没有\(A\)儿子,则它被赋成了根(这个节点的\(fail\))的\(A\)儿子——第\(1\)层的第一个节点\(A\)。(也就是说,第\(1\)层的第一个节点\(A\)\(A\)儿子是它自己!)

则第\(3\)层的第一个节点\(A\)\(fail\)连向了第\(1\)层的第一个节点\(A\)。(好好理解一下!)

最后有:

V.匹配

不知道大家还记不记得我们一开始的结论:

我们引出\(fail\)数组。这个神奇的数组的意思是:如果在节点\(i\)处失配了,我们可以直接跳到点\(fail_i\),避免回溯。

然后我们就可以暴力跳了。

见代码:

void merge(){
	int x=1;
	for(int i=0;i<S;i++){
		x=t[x].ch[s[i]-'a'];
		for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
	}
}

\(fin\):该节点是哪条字符串的结尾;\(occ\):统计答案的计数器)

为什么跳到一个节点,要对它所有的祖先\(fail\)也增加答案呢?

我也不知道。

其实是因为它所有的祖先\(fail\),当到达该节点时,也又出现了一次(因为\(fail\)是最长相同前缀)。

那为什么暴力跳就可以,不用担心失配呢?

因为所有未出现的节点,都在求\(fail\)时被我们重连了!

所以暴力跳就行。

VI.大礼包

(代码:【模板】AC自动机(加强版)

#include<bits/stdc++.h>
using namespace std;
int n,cnt,occ[210],S,mx;
char dict[210][100],s[1001000];
struct node{
	int ch[26],fin,fail;
}t[20100];
void ins(int id){
	int x=1;
	for(int i=0;i<S;i++){
		if(!t[x].ch[dict[id][i]-'a'])t[x].ch[dict[id][i]-'a']=++cnt;
		x=t[x].ch[dict[id][i]-'a'];
	}
	t[x].fin=id;
}
queue<int>q;
void build(){
	for(int i=0;i<26;i++){
		if(t[1].ch[i])q.push(t[1].ch[i]),t[t[1].ch[i]].fail=1;
		else t[1].ch[i]=1;
	}
	while(!q.empty()){
		int x=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]);
			else t[x].ch[i]=t[t[x].fail].ch[i];
		}
	}
}
void merge(){
	int x=1;
	for(int i=0;i<S;i++){
		x=t[x].ch[s[i]-'a'];
		for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;
	}
}
int main(){
	while(scanf("%d",&n)){
		if(!n)return 0;
		memset(t,0,sizeof(t)),memset(occ,0,sizeof(occ)),mx=0,cnt=1;
		for(int i=1;i<=n;i++)scanf("%s",dict[i]),S=strlen(dict[i]),ins(i);
		build();
		scanf("%s",s),S=strlen(s);
		merge();
		for(int i=1;i<=n;i++)mx=max(mx,occ[i]);
		printf("%d\n",mx);
		for(int i=1;i<=n;i++)if(mx==occ[i])printf("%s\n",dict[i]);
	}
	return 0;
}

VII.复杂度

\(ins\)函数单次复杂度为\(O(|S|)\)

\(build\)函数复杂度为\(O(size)\),其中\(size\)为trie树的大小(可看作\(O(\Sigma |S|)\))。

\(merge\)函数最大单次复杂度为\(O(size|S|)\)

什么?不是说复杂度为\(O(|S|)\)吗?那个\(size\)是从哪里冒出来的?

看这段代码。

for(int y=x;y!=1;y=t[y].fail)if(t[y].fin)occ[t[y].fin]++;

它更新了节点\(x\)的所有祖先\(fail\)

明显,单次跳\(fail\),最少令层次上升一层。则暴力跳的话,复杂度为\(O(dep)\),其中\(dep\)为深度。

有没有什么办法优化它呢?

VIII.拓扑优化

因为\(fail_i\)的层次必定比\(i\)的层次要低,则依靠\(fail\)关系建成的\(fail\)图必定是一张\(DAG\)

提到\(DAG\),我们一下就能想到拓扑排序

因此我们可以在更新时,不暴力跳\(fail\),转而在\(i\)处打上标记。

最终用拓扑排序统计子树和。

IX.具体实现

\(ins\)完全相同。

\(build\)函数:

void build(){
	for(int i=0;i<26;i++){
		if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
		else t[1].ch[i]=1;
	}
	while(!q.empty()){
		int x=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
			else t[x].ch[i]=t[t[x].fail].ch[i];
		}
	}
}

唯一的区别就是在建\(fail\)时统计了每个节点的入度,便于拓扑排序。

\(merge\)函数:


void merge(){
	int x=1;
	for(int i=0;i<S;i++){
		x=t[x].ch[s[i]-'a'];
		t[x].tms++;
	}
}

可以看到,暴力跳\(fail\)的操作已经被修改\(tag\)(代码中的\(tms\))取代。

最后是拓扑排序的\(topo\)函数:

void topo(){
	for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
	while(!q.empty()){
		int x=q.front();q.pop();
		t[t[x].fail].tms+=t[x].tms;
		t[t[x].fail].in--;
		if(!t[t[x].fail].in)q.push(t[x].fail);
		for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
	}
}

总体就是个大拓扑。

关键就是可能有相同的字符串,因此用\(vector\)来存编号。另外在拓扑中顺便维护子树和

即关键代码

t[t[x].fail].tms+=t[x].tms;

X.大礼包II

(代码:【模板】AC自动机(二次加强版)

#include<bits/stdc++.h>
using namespace std;
int n,cnt=1,S,occ[200100];
char s[2001000];
struct AC_Automaton{
	int ch[26],fail,tms,in;
	vector<int>fin;
}t[200100];
void ins(int id){
	int x=1;
	for(int i=0;i<S;i++){
		if(!t[x].ch[s[i]-'a'])t[x].ch[s[i]-'a']=++cnt;
		x=t[x].ch[s[i]-'a'];
	}
	t[x].fin.push_back(id);
}
queue<int>q;
void build(){
	for(int i=0;i<26;i++){
		if(t[1].ch[i])t[t[1].ch[i]].fail=1,q.push(t[1].ch[i]),t[1].in++;
		else t[1].ch[i]=1;
	}
	while(!q.empty()){
		int x=q.front();q.pop();
		for(int i=0;i<26;i++){
			if(t[x].ch[i])t[t[x].ch[i]].fail=t[t[x].fail].ch[i],q.push(t[x].ch[i]),t[t[t[x].fail].ch[i]].in++;
			else t[x].ch[i]=t[t[x].fail].ch[i];
		}
	}
}
void merge(){
	int x=1;
	for(int i=0;i<S;i++){
		x=t[x].ch[s[i]-'a'];
		t[x].tms++;
	}
}
void topo(){
	for(int i=1;i<=cnt;i++)if(!t[i].in)q.push(i);
	while(!q.empty()){
		int x=q.front();q.pop();
		t[t[x].fail].tms+=t[x].tms;
		t[t[x].fail].in--;
		if(!t[t[x].fail].in)q.push(t[x].fail);
		for(int i=0;i<t[x].fin.size();i++)occ[t[x].fin[i]]=t[x].tms;
	}
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++)scanf("%s",s),S=strlen(s),ins(i);
	build();
	scanf("%s",s),S=strlen(s);
	merge();
	topo();
	for(int i=1;i<=n;i++)printf("%d\n",occ[i]);
	return 0;
} 

推荐阅读