C++学习第九篇——AC自动机

AC自动机,听名字就让人心情愉悦,这个算法能让题目直接自己AC啊!!!
言归正传,在上一篇字典树的学习中,我们见识到了树形结构的优势,那么AC自动机就是在字典树上利用KMP的思想,KMP是字符串匹配算法。主要思想是当前匹配点失败后,立刻转移到下一个需要匹配的地方,在这之间省略一些没有必要的检索,有利于降低时间复杂度。
字典树的模板参照我之前一篇博客C++学习第八篇——字典树,本专栏c++固定算法为主,便于学习,不定时穿插发布。
先解释原理然后再上代码进行分析讲解,
首先,举一个例子,我利用her、he、shr、say、she这五个单词建立了一个字典树。然后给出字符串yasherhs,需要求出上述单词出现的个数。
按照常规思路来看我们每找到一个符合条件的就需要到字典里去找,然后再接着找下一个字母开头的,对于有着相同开头的单词来说,这无疑大大增加了时间复杂度。利用KMP思想来看,如果我把相同的前缀进行标注,那么我回溯的过程就很方便了,不需要遍历相同的前缀即可完成。

AC自动机具体步骤如下:

  • 建字典树
    给出建树代码
    与普通字典树相比,略有改动,补充了注释
struct node
{
	char *s;
	int prefix;
	bool isword;//可省略,利用count代替
	node *next[26];
	node *fail; //fail指针 
	int count;//标记单词个数,如果出现单词重复需使用count代替isword
	node()
	{
		s = NULL;
		fail = NULL;//初始化为空 
		prefix = 0;
		count = 0;//单词数0
		isword = false;
		memset(next,0,sizeof(next));
	}
}*root;
void insert(node *root,char *s)//插入单词
{
	node *p = root;
	for(int i=0;s[i];i++)
	{
		int x = s[i] - 'a';
		p->s=s+i;
		if(p->next[x] == NULL)
			p->next[x] = new node;
		p = p->next[x];
		p->prefix++; 
	}
	p->isword=true;//可省略
	p->count++;//唯一改动,单词数+1
}

接下来图示
在这里插入图片描述
黑色的结点表示,这是一个单词的末端。我们发现,当我们寻找she之后的匹配时其实和he的匹配很相似,为了避免再次对he的搜索,那么可不可以将she的e直接指向he中的e呢?

  • 创建fail指针
    这里就是我们接下来要提到的fail指针,利用它我们可以直接找到和我们接下来相匹配的单词。
    大致指向如下,通过bfs搜索来层序遍历我们的结点构建fail指针
    在这里插入图片描述
    层序遍历如下:root->h->s->e->a->h->r->y->e->r
    root的fail很明显示NULL,那么所有root的next结点肯定没有更好的匹配,都是指回上一级root,接下来e,找e的时候肯定是通过h来寻找的,那么e的fail指针就是通过h(它的父节点)的fail指针来寻找,很明显h的fail指针下没有与e结点,也就是没有满足以e开头的单词,那么e的fail就也指向root结点。a同理,这时候到了另一个h,它与之前的e不一样的是,它父节点(s)的fail指针(root)下有h结点,也就是说当我匹配到这个h的时候如果无法继续往下,我可以向fail指针进行匹配,一旦匹配成功继续往下。r和y与父节点e同理,没有匹配的,指向root。
    最后很关键,这个第二个e是如何操作的呢?当我找到这个e的时候,同样我是通过父节点h去找到的,那么是否有和h匹配的呢,我们来询问一下h的fail指针,它会带我们指向最左侧的h,询问一下这个h有没有和我们e相同的呢,,刚好它下面同样有e那么好了,he同时完成匹配,此时e的fail指针指向最左侧的e,表示匹配。举例,如果我寻找sher的时候,我会发现she之后匹配不下去了,那么回溯的路径就是fail,我走到he的下面,he是一个单词,her又是一个单词,很快完成了匹配。r的过程和之后的搜索类似,请继续往后看下去。
    最后给出代码和注释
void buildfail(node *root)//用来建立fail指针,利用bfs进行层序遍历
{
	root->fail = NULL;//根结点的fail指针为空
	queue<struct node*> q;
	q.push(root);//放入根结点
	while(!q.empty())
	{
		node * p =q.front();q.pop();
		node * f ;
		for(int i=0;i<26;i++)//遍历next数组
		{
			if(p->next[i]!=NULL)//存在分枝结点
			{
				if(p == root)//如果是根结点,那么它分枝的fail必然指向自己
				{
					p->next[i]->fail=root;
				}
				else
				{
					f = p -> fail;//如果不是,那么找到它父节点fail,用f来存
					while(f!=NULL)//直到f指向root的fail
					{
						if(f->next[i]!=NULL)//判断fail的分枝是否出现与我当前相同的next分枝
						{
							p->next[i]->fail=f->next[i];//出现后,将我当前结点搜索到的next
							//的fail结点赋值为fail的分枝,表明匹配成功,可以从这里回溯
							break;
						}
						f=f->fail;//匹配不成功继续往fail指针处寻找
//举例,shr匹配hr再匹配r,直到r可以匹配上,否则从root开始,重新匹配
					}
					if(f==NULL)//当前fail为空了,那么说明已经指向root的fail了,将当前结点fail指向root,意为重新开始吧
					{
						p->next[i]->fail=root;
					}
				}
				q.push(p->next[i]);//层序遍历,添加结点
			}
		}
	}
}
  • 搜索字符串
void search(node *root,char *s)
{
	node *p=root;
	int ans = 0;
	for(int i=0;s[i];i++)//遍历字符串
	{
		int x=s[i]-'a';//将字母转化为下标
		while(p->next[x]==NULL&&p!=root)//如果我指向为空,也就是无法完成匹配,那么进行回溯,指向我的fail
		{
			p = p->fail;
		}
		p = p->next[x];
		if(p==NULL)//这里是root指向了一个不存在的分枝,那么它还是root好了
		{
			p = root; 
		} 
		node * tem = p;//利用tem来完成对单词数的遍历
		while(tem!=root&&tem->count!=-1)//如果此时tem是一个单词那么count肯定不为-1
		{
			ans+=(tem->count);//ans+单词数,这里要注意添加括号比较好
			tem -> count = -1;//标记,表示已访问过
			tem = tem->fail;//回溯寻找与该分枝相匹配的是否有单词,如果存在,回溯
		}
	}
	printf("%d\n",ans);
}

拿先前的yasherhs来举例,开始是root结点,我们找到y,root的next里y结点是NULL,那么跳过,找到a同样,找到s匹配到root下的s结点,并不是单词,那么不加。此时我用来存储当前位置的p指针指向s,继续找到h,完成匹配同样不是单词,没有加,p指向h。接着e,它是一个单词,那么ans+=count,count也就是单词数。将count变为-1,意为我到过这里了,这里没有单词了,同时p指向e,接着进行fail回溯,寻找匹配项是否存在单词,tem回指向最左侧的e,ans+1。然后此时找到r,我们发现当前p的下面没有r,进行回溯,p前往p的fail指针处,也就是最左侧的e,从这里开始,同时往下匹配,her也是单词,ans+=count,结束后这里的p指向r。继续寻找h,此时p(也就是r)的下面没有h,那么前往它的fail指针处寻找,root下面同样没有,p指向root,ans不加,然后单独找到一个s,也并不是一个单词,完成匹配。
第一个while循环使p指针回溯,一旦不匹配寻找最接近匹配项的位置。
第二个while循环temp回溯,寻找匹配项的单词数she中的e指向he中的e,逐级回指,找寻所有匹配项。
学会了的话,就点个赞吧!=-=

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值