AC自动机——优化总结


提示:本文写作目的在于总结和优化,不打算加图解,不过代码特意加上了逐行的注释,但如果完全不懂的还是建议先去看带图的博客或者视频,纯文字对学习ac算法很不友好

前置知识

  1. Trie树
    这个前置很简单,就是把共同前缀放到一个子节点上的树,不知道的可以简单理解一下代码就足够了。
  2. KMP算法
    如果没有深入搞懂KMP的next指针对本文理解有很大影响,建议去详细阅读一下相关博客,因为图示会更清晰,本文重点不在这里,就简要提一下next指针的意义和核心。意义:利用已匹配部分存在相同的前缀和后缀,使得下一次匹配开始的字符做一定调整,最大化利用已知信息。
    举例来说就是对于目的串abcabcabd,匹配串abcabd时,当匹配了前5个字符,最后的字符d无法与字符c匹配,此时无需在去检查bcabcabd和abcabd是否匹配,因为已匹配部分存在相同的前后缀abc,则只需要去看后面的abd和匹配串的后面abd是否相同即可。极大的减少匹配重复次数。
    核心:遍历目的串,一次性获得next数组,当处于第i位置时,则已知i-1的next值,若为j则代表[0 to j-1]=[i-j to i-1],若[j]=[i]则[0 to j]=[i-j to i],因此next[i]=j+1。否则获取next[j]的值k,代表[0 to k-1]=[j-k to j-1],又已知[0 to j-1]=[i-j to i-1]则显然[0 to k-1]=[i-k to i-1],因此同上判断[k]是否等于[i]。若等则next[i]=k+1,否则继续去获取next[next[k]]。
  3. BFS
    也是去理解一下代码就足够了。
  4. 拓扑图(优化时需要)
    很简单,意义就是将一个有向图变成一条链,保证在访问这条链时所有的前置节点已经访问过了。核心就是去看节点的入度(或出度),循环把入度为0的节点加入(前置节点没有或者访问过了),每次加入把后置的节点入度减一,表示有一个前置节点被访问。

AC树的构建

和Trie树构建一样,每次看儿子节点是不是被建立了,有就跳转到儿子节点,否则创建儿子节点。这里可以简单的使用数组来存储,每个节点只需记录儿子节点的下标。比如举一个只包含ab两个字母的例子,插入aa,ab,a,ba,bab后的状态即为:

0123456
142356
nulla*aa*ab*bba*bab*

第一行是数组下标,有*标记表示是一个完整的字符串,第二行是儿子节点,第一个位置对应字符a,第二个位置是字符b。这样一个Trie树就构建出来了。当然儿子节点的处理当不全是小写字母时也可以用map来处理。

Fail链的构建

Fail链时ac算法最核心的部分,其思路与KMP的next数组很相似。fail的意思是树中存在的最长子串,使得子串是当前串的后缀。还是以上面的例子,对于节点6即串bab,他的fail值为3,也就是串ab。首先是采用BFS的方式来遍历Trie树,这样就可以保证比当前深度小的节点一定是已经遍历过了,在Trie树中,节点的深度就相当于对应字符串的长度,而前缀后缀的长度一定是小于当前串的,也就是前后缀的串一定是已经遍历过的。类别KMP就是当位于i时,next[0 to i-1]全是知道的。那么当我位于树上深度为i的节点上时,他的深度为i-1的父节点的fail值也是知道的。也就是从根节点到fail节点的串恰好是根节点到父节点的后缀,那么如果fail节点恰好有一个儿子节点等于当前节点,则当前节点的fail节点就是该儿子节点。否则就继续寻找fail的fail,也就是一段更小的后缀。特别的,根节点的儿子节点fail值为0,也就是根节点。

0123456
142356
null-1a*0aa*1ab*4b0ba*1bab*3

由上例即可很容易看出fail值的含义。

匹配过程

匹配过程和Trie树的匹配过程相同,唯一区别的就是要一直去跳fail链。每次匹配一个字符后就要跳一遍fail链,避免错过任何匹配串(当然别忘了回溯)。同样以上例做解释。对于串aababcb,在匹配的时候跳转过程如下:(其中a或b表示从匹配串读取的字符,f表示跳fail,r表示回溯,n表示无法继续匹配下去,e表示结束,清空并读取下一个字符,星号表示成功匹配串。) 0->a->1->a*->f->0->r->1->a->2->aa*->f->1->a*->f->0->r->2->b->n->f->1->3->ab*->f->4->f->0->r->3->a->n->f->4->5->ba*->f->1->a*->f->0->r->5->b->6->bab*->f->3->ab*->f->1->a*->f->0->r->6->c->n->f->3->n->f->1->n->f->0->n->e->b->4->f->0
因此,共计获得串a 3次,串aa 一次,串ab 两次,串ba 一次,串bab 一次。

代码实战

using namespace std;
#include <bits/stdc++.h>
struct AC {
	map<char, int > son; //采用map存储,节约空间
	int fail;
	int cnt;
}ac[2000005];
int ans[200005];
int idx = 0;
map<char, int >::iterator it;
void init(string x,int num)
{
	int p = 0;                                 //从根出发
	for (int i = 0; i < x.length(); i++)       //遍历特征串建树
	{
		it = ac[p].son.find(x[i]);
		if (it == ac[p].son.end())             //儿子节点未建
		{
			ac[p].son[x[i]] = ++idx;           //注册儿子节点标号
			p = idx;                           //跳转到儿子节点上
		}
		else
		{
			p = it->second;                   //跳转到儿子节点上
		}
	}
	ac[p].cnt = num;                      //到叶子节点了,记录是第几个匹配串
}
void fail()
{
	queue<int > que;
	ac[0].fail = -1;                          //根节点fail设-1方便后面取反恰好为0
	for(it = ac[0].son.begin();it != ac[0].son.end(); it++)
	{
		ac[it->second].fail = 0;             //根节点的儿子fail指向根节点
		que.push(it->second);
	}
	while(que.size())
	{
		int f = que.front(); que.pop();      //bfs遍历
		for(it = ac[f].son.begin();it != ac[f].son.end(); it++)
		{
			int ffail = ac[f].fail;                                                  //父节点的fail节点
			while(~ffail && ac[ffail].son.find(it->first) == ac[ffail].son.end())    //不是根且父节点的fail节点没有对应儿子
				ffail = ac[ffail].fail;                                              //继续跳fail
			if (~ffail)                                                              //找到第一个有相同儿子节点的。
			{
				ac[it->second].fail = (ac[ffail].son.find(it->first))->second;       //设置fail为对应儿子节点
			}
			else
			{
				ac[it->second].fail = 0;     //!!!!!                                //到根还没有找到,设成根
			}
			que.push(it->second);                                                   //bfs遍历代码
		}
	}
}
void search(string x)
{
	int p = 0;                                                                     //从根出发
	for (int i = 0; i < x.length(); i++)
	{
		while(p && ac[p].son.find(x[i]) == ac[p].son.end()) p = ac[p].fail;       //不是根或者没找到对应儿子节点,一直跳fail
		it = ac[p].son.find(x[i]);
		if (it != ac[p].son.end())                                               //找到可以跳的儿子节点
		{
			p = it->second;                                                      //跳转
			int p2 = p;                                                          //不改动p,用于回溯
			while(p2)                                                            //非根节点
			{
				ans[ac[p2].cnt]++;                                              //匹配到串,记录结果
				p2 = ac[p2].fail;                                               //一直跳fail
			}
		}
	}
}
int main (void)
{
	ios::sync_with_stdio(0);
	int n;cin>>n;
	string x;
	for (int i=1;i<=n;i++)
	{
		cin>>x;
		init(x,i);
	}
	fail();
	cin>>x;search(x);
	for (int i=1;i<=n;i++)
	{
		cout<<ans[i]<<'\n';
	}
}

应该是对的吧,用洛谷P5357测试了一下,结果
在这里插入图片描述
QAQ有超时就罢了,怎么还有错误的。。。于是就走上了优化修正之路,也是本文的重中之重。

错误修改

本题的错误原因是没有考虑到特征串可能相同的情况,这个错误看起来很容易改,只需要将结构体中的cut换成数组来记录就好,但却造成了后续简单优化无法消除超时甚至超内存的情况,难顶。。。
修改结果

struct AC {
	//int cnt;
	vector<int > cnt;
}ac[2000005];
void init(string x,int num)
{
	//ac[p].cnt = num;
	ac[p].cnt.push_back(num);          //对于相同串,采用放到一个vector的方式存储
}
void search(string x)
{
	//ans[ac[p2].cnt]++;
	for (int j=0;j<ac[p2].cnt.size();j++)
		ans[ac[p2].cnt[j]]++;         //全部都加到ans里面
}

在这里插入图片描述

超时优化

有一个空间换时间优化是把map变成数组的形式存储,这样确实会快一些,毕竟少了一个log复杂度,但对最后正解来说影响不大,因此不放这个代码了,后面可以看到修改的结果。
这里最核心可以优化的地方其实就是跳fail过程中产生的重复计算,还是用最开始的个例子来说,0->a->1->a*->f->0->r->1->a->2->aa*->f->1->a*->f->0->r->2->b->n->f->1->3->ab*->f->4->f->0->r->3->a->n->f->4->5->ba*->f->1->a*->f->0->r->5->b->6->bab*->f->3->ab*->f->1->a*->f->0->r->6->c->n->f->3->n->f->1->n->f->0->n->e->b->4->f->0,可以很显然的看到0,1,3,4节点,尤其是0,1节点被反复的跳fail到达,但这显然是不必要的。因此,第一波优化就来了。

记忆化搜索

既然有些节点被反复遍历,那就加用一块内存,在第一次跳fail时就存下该节点被跳fail时要做的修改就好了。我又忽然想到可以不用等到跳的时候再去搜索,在构建fail树的时候就可以直接顺次得到了,因为构建和跳fail正好是反过程嘛。刚开始,我还想新弄一个mem二维数组来存储,但后来发现,好像只用更新cnt数组就够了,那就直接上cnt数组的修改部分代码啦。

void fail()
{
	if (~ffail)
	{
		//ac[it->second].fail = (ac[ffail].son.find(it->first))->second;
		int tfail = (ac[ffail].son.find(it->first))->second;
		ac[it->second].fail = tfail;
		ac[it->second].cnt.insert(ac[it->second].cnt.end(),ac[tfail].cnt.begin(),ac[tfail].cnt.end());
	}
}
void search(string x)
{
	if (it != ac[p].son.end())
	{
		p = it->second;
		for (int j=0;j<ac[p].cnt.size();j++)
			ans[ac[p].cnt[j]]++;
//		for (int j=0;j<mem[p].size();j++)
//			ans[mem[p][j]]++;
//		int p2 = p;
//		while(p2)
//		{
//			for (int j=0;j<ac[p2].cnt.size();j++)
//				ans[ac[p2].cnt[j]]++;
//			p2 = ac[p2].fail;
//		}
}

在这里插入图片描述
虽然多AC了许多,但很不幸,依旧有超时,甚至出现了内存超限。这个其实很好理解,就是我是更新了整棵树,但如果有些树枝在查询的时候根本没有走到过,但是我却更新了。另一方面,我在更新树和计算ans时,采用的是for循环的方式,这对于时间复杂度来说是不小的开销,如果存在一个叶子节点,他被迭代造成其cnt数组很大,如果多次查询它就会造成频繁的for循环累加,增加时间复杂度。因此依然存在超时和内存超限的情况。
这个试错现在反思让我忽然意识到一点,我这么做,其实并不能算是空间换时间,倒应该说是递归变成遍历,但正如后面借花献佛篇章中所说,可以多ac几个用例完全是因为用例的强度不够,这种for循环可以解决掉一些无用递归,造成时间消耗相对小一些,和后面的优化点本质上是一样的。
至于在第一次跳fail时就存下该节点被跳fail时要做的修改这个优化,其实来说和现在的优化相比来说效果不大,因为这样对于未访问过的分支确实可以减少空间复杂度,但时间上却很难有本质的提升。因为超时的真正原因是在于查询的时候。
但正如一开始说的报错误,如果最后求解的是一共访问了多少次字符串,而非每个字符串访问了多少次,这个算法就可以达到最优的状态了。fail函数中不需要挨个insert,search函数中也不需要全部遍历加总,空间复杂度也降到O(N)。不会出现超时超内存的情况了。而这个才算是真正的空间换时间!!!
所以普通的优化对此效果并不明显,更进一步优化开始了。

借花献佛

这个优化已经可以AC一些非特殊设计的用例了,虽然不是标答,但确实很有参考的意义。他的思路就是在可以继续匹配时跳fail链,如果跳到不是一个匹配串的位置上,那这次跳fail是不会对结果造成影响的,既然这样,只需要跳到匹配串对应的位置上就好了。比如有待匹配串abcd、bcde、cd、de,正常构建fail链应该是abcd的b、c、d分别指向bcde串的b、c、d,其中c、d继续递归指向cd串的c、d,d指向de串的d。当匹配串为abcd时,走到b需要去跳bcde,但显然对结果无效,走到c又要去跳bcde和cd,显然还是无效,走到d要依次跳bcde,cd,def,可以看到只有cd串是对结果有用的。那么我们为什么还要去构建这么完整的fail树呢?很多地方完全可以剪枝。abcd串中的b、c完全不需要跳fail,d可以直接跳到cd串,不需要先跳bcde串,之后也不需要跳de串了。可以看到,对于此例子来说,这个剪枝优化是非常明显的。
正如前面加粗部分描述,必须是在当前串才可以继续匹配时才能这样去跳转,那对于无法继续匹配的节点该怎么办呢?这里有一个很巧妙的处理方法,就是利用fail链加上儿子节点,让其可以始终正常匹配下去。举例来说对于待匹配串为abcd、bd、de来说,匹配串abde,刚开始走abcd分支,但走到d发现没有办法匹配,只好跳fail链到bd,由匹配e发现依然无法匹配,只好跳fail链到de。这个跳转过程我们仔细分析一下就会发现,其实可以把fail链直接构建到树上的。也就是abcd的b节点让他再有一个儿子节点d,这个d就是bd串的d节点,同理bd串的d节点也不是叶子节点了,而有一个儿子节点e。这样就把对于无法匹配时的fail链直接放到树上了。
这样操作之后,就可以很好的实现这个优化了。当然这个优化依然有可以被hack掉的地方,对于待匹配串是a、aa、aaa、aaaa这种,这个优化是完全没有用处的。再次复杂度退化到N*M。其他用例它的效果还是可以的。那直接上别人的代码了,加上了一些我自己理解的注释。

#include<bits/stdc++.h>
using namespace std;
const int N=200006,M=2000006;
int n,trie[M][26],f[M],last[M],ans[M],to[N],sta[N],top,cnt;
bool ed[M];
queue <int> que;
char s[N],t[M];
int main(){
    freopen("hack.in","r",stdin);
    freopen("ans.out","w",stdout);
    cin>>n;
    for (int i=1;i<=n;i++){                           //插入部分没什么可以说的,和之前的思路一样。
        cin>>s;
        int len=strlen(s),p=0;
        for (int j=0;j<len;j++){
            if (!trie[p][s[j]-'a']) trie[p][s[j]-'a']=++cnt;
            p=trie[p][s[j]-'a'];
        }
        ed[p]=1;                                        //完整串标记
        to[i]=p;
    }
    cin>>t;
    for (int i=0;i<26;i++){                             //同样设置根节点的儿子节点的fail是根节点
        int u=trie[0][i];
        if (u){ que.push(u);f[u]=0,last[u]=0;} 
    }
    while (!que.empty()){
        int h=que.front();                               //父节点
        for (int i=0;i<26;i++){
            int u=trie[h][i];                            //儿子节点
            if (!u){trie[h][i]=trie[f[h]][i];continue;}  //不存在对应儿子节点时,用fail链加上儿子节点
            que.push(u);                                 //真的存在儿子节点时可以用于后续bfs
            f[u]=trie[f[h]][i];                          //构建fail链
            last[u]=ed[f[u]]>0?f[u]:last[f[u]];          //跳转到fail链中最近的是完整串的位置。
        }
        que.pop();
    }
    int len=strlen(t),j=0;
    for (int i=0;i<len;i++){
        j=trie[j][t[i]-'a']; 
        for (int e=j;e>0;e=last[e]) ans[e]++;            //每次只跳完整串
    }
    for (int i=1;i<=n;i++) cout<<ans[to[i]]<<endl;

    return 0;
}

标答优化

本题的最终优化思路就是多次查询时只打标记,最后一次输出把所有标记全部走下去,这样保证查询时不跳fail,结束时每个节点跳fail链只跳一次,对于所以的情况都可以把时间复杂度固定在O(n),空间复杂度也只有建树时消耗。当然这样也有缺点,就是无法实时输出结果。只在需要的时候输出,类似线段树的lazy标记。
这次优化用到的知识主要是拓扑,用fail链构建时的顺序获得fail树的拓扑序。这样在跳fail链时,可以保证先跳到的节点不会被后跳到的节点的fail链跳转到。
另一个值得学习的地方就是对于相同串的小优化,思路和并查集相似,把同样串的父节点置为同一个值。具体的参考代码吧。

using namespace std;
#include <bits/stdc++.h>
struct AC {
	//map<char, int > son;
	int son[27]={0};
	int fail;
	int cnt;
	int ans;                                            //ans用于记录该节点被搜索过多少次,在最后回溯时加总
}ac[200005];
int ans[200005];
int Map[200005];                                       //把相同待搜索串合并只搜索一遍。
int in[200005];                                        //每个节点在fail树上的入度
int vis[200005];                                       //配合Map数组,记录每种串对应的结果
int idx = 0;
queue<int > que;
//map<char, int >::iterator it;
void init(string x,int num)
{
	int p = 0;
	for (int i = 0; i < x.length(); i++)
	{//cout<<x[i]-'a'<<'\n';
		if (ac[p].son[x[i]-'a'] == 0)
			ac[p].son[x[i]-'a'] = ++idx;
		p = ac[p].son[x[i]-'a'];
	}
	if(!ac[p].cnt) ac[p].cnt = num;                   //每种串第一次被访问
	Map[num] = ac[p].cnt;                             //用Map记录相同串的共同id
}
void fail()
{
	ac[0].fail = -1;
	for(int i = 0; i < 26; i++)
	{
		int temp = ac[0].son[i];
		if (temp)
		{	
			ac[temp].fail = 0;
			que.push(temp);
		}
	}
	while(que.size())
	{
		int f = que.front(); que.pop();
		for(int i = 0; i < 26; i++)
		{
			int ffail = ac[f].fail;
			int son = ac[f].son[i];
			if (!son)                                    //没有对应的儿子节点
			{
				ac[f].son[i] = ac[ffail].son[i];        //用fail链生成儿子节点,此时不需要继续bfs这个儿子
			}
			else
			{
				ac[son].fail = ac[ffail].son[i];        //记录fail链
				in[ac[son].fail]++;                     //更新当前节点的入度
				que.push(son);
			}
//			if (ac[f].son[i]==0) continue;
//			int ffail = ac[f].fail;
//			while(~ffail && ac[ffail].son[i]==0) 
//				ffail = ac[ffail].fail;
//			if (~ffail)
//			{
//				int tfail = ac[ffail].son[i];
//				ac[ac[f].son[i]].fail = tfail;
//				ac[ac[f].son[i]].cnt.insert(ac[ac[f].son[i]].cnt.end(),ac[tfail].cnt.begin(),ac[tfail].cnt.end());
//			}
//			else
//			{
//				ac[ac[f].son[i]].fail = 0;     //!!!!!
//			}
//			que.push(ac[f].son[i]);
		}
	}
}
void search(string x)
{
	int p = 0;
	for (int i = 0; i < x.length(); i++)
	{
		p = ac[p].son[x[i]-'a'];
		ac[p].ans++;                                          //记录节点访问过的次数,由于前面已经做儿子节点一定存在(这种处理后儿子节点也可能是root)的处理,就不需要判空了。
//		while(p && ac[p].son[x[i]-'a']==0) p = ac[p].fail;
//		int temp = ac[p].son[x[i]-'a'];
//		if (temp)
//		{
//			p = temp;
//			for (int j=0;j<ac[p].cnt.size();j++)
//				ans[ac[p].cnt[j]]++;
				
//			int p2 = p;
//			while(p2)
//			{
//				for (int j=0;j<ac[p2].cnt.size();j++)
//					ans[ac[p2].cnt[j]]++;
//				p2 = ac[p2].fail;
//			}
//		}
	}
}
void ToPu()                                       //此时才真正计算命中次数
{
	for (int i=0;i<=idx;i++)
		if (!in[i])
			que.push(i);                        //入度为零的入队
	while(que.size())
	{
		int u = que.front(); que.pop();
		int v = ac[u].fail;                     //一条u->v的fail链
		in[v]--;                                //更新入度
		if (!in[v])
			que.push(v);                        //入度为零的入队
		vis[ac[u].cnt] += ac[u].ans;            //当前节点访问次数即为该串的访问次数
		ac[v].ans += ac[u].ans;                 //模拟跳fail,更新v节点访问次数
	}
}
int main (void)
{
	//ios::sync_with_stdio(0);
	freopen("hack.in","r",stdin);
	int n;cin>>n;
	string x;
	for (int i=1;i<=n;i++)
	{
		cin>>x;
		init(x,i);
	}
	fail();
	cin>>x;search(x);
	ToPu();                                    //搜素后统一跳fail
	for (int i=1;i<=n;i++)
	{
		cout<<vis[Map[i]]<<'\n';              //用vis来记录每种串的访问次数
	}
//	int ma=0;
//	for (int i=1;i<=n;i++)
//	{
//		//ma=max(ma,ans[i]);
//		cout<<ans[i]<<'\n';
//		//ma+=(ans[i]==0?0:1);
//	}
//	cout<<ma<<'\n';
//	for (int i=1;i<=n;i++)
//	{
//		if(ma==ans[i]) cout<<x[i]<<'\n';
//		ans[i]=0;
//	}
//	for (int i=0;i<=idx;i++)
//	{
//		ac[i].cnt=0;
//		ac[i].fail=0;
//		ac[i].son.clear();
//	}
//	idx=0;
//	init("she");
//	init("her");
//	init("he");
//	init("this");
//	init("his");
//	init("is");
//	fail();
//	for (int i=0;i<=idx;i++)
//	{
//		for (int j = 0; j < ac[i].cnt.size(); j++)
//		{
//			cout<<ac[i].cnt[j]<<' ';
//		}
//		cout<<'\n'<<ac[i].fail<<'\n';
//		for(int j = 0; j < 26; j++)
//		{
//			if (ac[j].son[i])
//				cout<<char(j+'a')<<' '<<ac[j].son[i]<<' ';
//		}
//		cout<<endl;
//	}
	return 0;
//	cout<<search("sherthis");
}

创新优化

在尝试将两个优化结合起来时,结果却并没有想象中那么好的效果,甚至有一些样例时间更长了,这是合理的,因为二轮优化是把跳fail变到只有一次,而一轮优化是把跳fail的节点数变少,但在初始化的时候需要多花费一些内存和时间,虽然复杂度没有增加,但却增加了常数时间。因此一轮优化显示出优势就需要跳fail的次数较多,但被优化成只跳一次后,这个优化就失去优势了。

总结

ac自动机的本质就是一颗Trie树和一颗反向的fail树。在访问Trie树的过程中需要不停跳fail链来确保不存在后缀子串未被记录。当访问不了后续节点时就去跳fail看看当前已经识别过的后缀有没有可以继续访问下去的。这不断的跳fail就会造成存在优化空间,两种优化时及时性的,第一个需要大内存支持,但访问可以及时获得答案且完全不需要使用fail,第二种时间和空间性能更优,但和第一种一样,对于匹配串设计不好时存在退化的可能(也就是大部分后缀子串都是完整待匹配串)。标答的优化是非及时型的,只有在要输出答案时才统一跳fail,但不存在退化,且时空复杂度都很低。
怕什么真理无穷,进一步有一寸欢喜。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值