欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

AC自动机

程序员文章站 2022-06-06 17:49:52
...

【定义】

ac自动机其实就是一种多模匹配算法,给你很多个单词,然后给你一段字符串,问你有多少个单词在这个字符串中出现过。

【步骤+代码解析】

const int kind = 26; 
struct node
{
    node *fail;       //失败指针
    node *next[kind]; //Tire每个节点的个子节点(最多个字母)
    int count;        //是否为该单词的最后一个节点
    node()            //构造函数初始化
    {
        fail=NULL;
        count=0;
        memset(next,NULL,sizeof(next));
    }
}*q[500001];          //队列,方便用于bfs构造失败指针
char keyword[51];     //输入的单词
char str[1000001];    //模式串
int head,tail;        //队列的头尾指针

一、字典树的构建过程

当要插入许多单词的时候,我们要从前往后遍历整个字符串,当我们发现当前要插入的字符其节点再先前已经建成,直接去考虑下一个字符即可,当发现当前要插入的字符没有再其前一个字符所形成的树下没有自己的节点,我们就要创建一个新节点来表示这个字符,接下往下遍历其他的字符。然后重复上述操作。

she , he ,say, her, sh

AC自动机

void insert(char *str,node *root)
{ 
    node *p=root;
    int i=0,index;
    while(str[i])
    {
        index=str[i]-'a';
        if(p->next[index]==NULL) //要插入的字符没有其前一个字符形成的树下属于自己的节点
           p->next[index]=new node();//创建一个新节点来表示这个字符,
        p=p->next[index];   //往下遍历其他的字符.
        i++;
    }
    p->count++;     //在单词的最后一个节点count+1,代表一个单词
}

二、找Fail指针

在KMP中,当比较到一个字符发现失配的时候我们会通过next数组,找到下一个开始匹配的位置,然后进行字符串匹配,当然KMP算法试用与单模式匹配,所谓单模式匹配,就是给出一个模式串,给出一个文本串,然后看模式串在文本串中是否存在。

在AC自动机中,我们也有类似next数组的东西就是fail指针,当发现失配的字符失配的时候,跳转到fail指针指向的位置,然后再次进行匹配操作,AC自动机之所以能实现多模式匹配,就归功于Fail指针的建立。当前节点t有fail指针,其fail指针所指向的节点和t所代表的字符是相同的。因为t匹配成功后,我们需要去匹配t->child,发现失配,那么就从t->fail这个节点开始再次去进行匹配。
Fail指针用BFS来求得,对于直接与根节点相连的节点来说,如果这些节点失配,他们的Fail指针直接指向root即可,其他节点Fail指针求法如下:假设当前节点为father,其孩子节点记为child。求child的Fail指针时,首先我们要找到其father的Fail指针所指向的节点,假如是t的话,我们就要看t的孩子中有没有和child节点所表示的字母相同的节点,如果有的话,这个节点就是child的fail指针,如果发现没有,则需要找father->fail->fail这个节点,然后重复上面过程,如果一直找都找不到,则child的Fail指针就要指向root。

如下图:①首先root最初会进队,然后root出队,我们把root的孩子的失败指针都指向root因此h,sfail指针都指向root(如红色线条所示,同时h,s进队)②接下来该h出队,我们就找h的孩子的fail指针我们发现h这个节点其fail指针指向root,而root又没有字符为e的孩子,则e的fail指针是空的,如果为空,则也要指向root,(如图中蓝色线所示。并且e进队,此时s要出队)我们再找s的孩子a,h的fail指针,我们发现s的fail指针指向root,而root没有字符为a的孩子,故a的fail指针指向root,a入队,然后找h的fail指针,同样的先看s的fail指针是root,发现root又字符为h的孩子,所以h的fail指针就指向了第二层的h节点。e,a , h 的fail指针的指向如图蓝色线所示。此时队列中有e,a,h,e先出队,找e的孩子r的失败指针,我们先看e的失败指针,发现找到了root,root没有字符为r的孩子,则r的失败指针指向了root,并且r进队,然后a出队,我们也是先看a的失败指针,发现是root,则y的fail指针就会指向root.并且y进队。然后h出队,考虑h的孩子e,则我们看h的失败指针指向第二层的h节点,看这个节点发现有字符值为e的节点,最后一行的节点e的失败指针就指向第三层的e。最后找r的指针,同样看第二层的h节点,其孩子节点不含有字符r则会继续往前找h的失败指针找到了根,根下面的孩子节点也不存在有字符r,则最后r就指向根节点,最后一行节点的fail指针如绿色虚线所示。

AC自动机

 void build_ac_automation(node *root)
{
    int i;
    root->fail=NULL; //root的fail指针指向NULL
    q[head++]=root;  //root入队,进入循环。
    while(head!=tail) //表示队列不为空
    {
        node *temp=q[tail++];
        node *p=NULL;
        for(i=0; i<26; i++)
        {
            if(temp->next[i]!=NULL)   //取出来的这个节点的孩子节点不为空
            {
                if(temp==root)  //取出来的是根节点
                   temp->next[i]->fail=root; //根节点的孩子的fail节点都指向根节点
                else                  //取出来的不是根节点
                {
                    p=temp->fail;   //p等于temp这个节点的fail节点
                    while(p!=NULL)  //temp这个节点的fail节点不为NULL
                    {
                        if(p->next[i]!=NULL)  //temp的fail节点存在,整个树中有temp的孩子节点
                        {
                            temp->next[i]->fail=p->next[i];
                            //temp孩子节点的fail节点 指向 父节点fail节点的孩子节点。
                            break;
                        }
                        p=p->fail;  //否则p指向temp这节点的fail的fail 
                    }
                    if(p==NULL) //由于这个节点的fail指向root,也就是NULL
                        temp->next[i]->fail=root; //并把这个节点孩子节点的fail指针指向root
                }
                q[head++]=temp->next[i];  //然后孩子节点进入队列
            }
        }
    }
}

AC自动机

首先root的fail指针指向NULL,然后root入队,进入循环。第1次循环的时候,我们需要处理2个节点:root->next‘h’-‘a’ 和 root->next‘s’-‘a’。把这2个节点的失败指针指向root,并且先后进入队列,失败指针的指向对应图-2中的(1),(2)两条虚线;第2次进入循环后,从队列中先弹出h,接下来p指向h节点的fail指针指向的节点,也就是root;进入第13行的循环后,p=p->fail也就是p=NULL,这时退出循环,并把节点e的fail指针指向root,对应图-2中的(3),然后节点e进入队列;第3次循环时,弹出的第一个节点a的操作与上一步操作的节点e相同,把a的fail指针指向root,对应图-2中的(4),并入队;第4次进入循环时,弹出节点h(图中左边那个),这时操作略有不同。在程序运行到14行时,由于p->next[i]!=NULL(root有h这个儿子节点,图中右边那个),这样便把左边那个h节点的失败指针指向右边那个root的儿子节点h,对应图-2中的(5),然后h入队。以此类推。

三、文本串的匹配

对照上图,看一下模式匹配这个详细的流程,其中模式串为yasherhs对于i=0,1。Trie中没有对应的路径,故不做任何操作i=2,3,4时,指针p走到左下节点e。因为节点e的count信息为1,所以cnt+1,并且讲节点e的count值设置为-1,表示改单词已经出现过了,防止重复计数最后temp指向e节点的失败指针所指向的节点继续查找,以此类推,最后temp指向root,退出while循环,这个过程中count增加了2。表示找到了2个单词she和he当i=5时,程序进入第5行,p指向其失败指针的节点,也就是右边那个e节点,随后在i=6指向r节点,r节点的count值为1,从而cnt+1,循环直到temp指向root为止。最后i=6,7时,找不到任何匹配,匹配过程结束。

int query(node *root)
{ 
    int i=0,cnt=0,index,len=strlen(str);
    node *p=root; //从根节点开始
    while(str[i])
    {
        index=str[i]-'a';
        while(p->next[index]==NULL && p!=root)//当前字符不为根节点且不匹配
            p=p->fail;                       //去当前节点失败指针所指向的字符继续匹配
        p=p->next[index];
        p=(p==NULL)?root:p;   //如果P=NULL,p=root.
        node *temp=p;
        while(temp!=root && temp->count!=-1)  //temp不是根节点,同样也没有被计算过。
        {
            cnt+=temp->count;         //出现过的单词数加上以这个节点结尾的单词数目。
            temp->count=-1;            //此节点的单词被计算了。
            temp=temp->fail;           //防止重复计算,temp指向这个节点的fail节点继续查找
        }//最后temp指向root,退出while循环
        i++;
    }
    return cnt;
}

大佬传送门:https://www.cnblogs.com/cmmdc/p/7337611.html



下面有一种写法,是把整个算法封装进一个结构体里,而不是一个节点作为一个结构体。

struct Trie
{
    int trieN;
    int ch[maxn][maxm]; //ch[i][j]  结点i的孩子结点j
    int val[maxn];
    int fail[maxn];      
    ……
}ac

把节点作为一个结构体时,可以写一个构造函数代表新建了一个节点,但是把算法封装进一个结构体时,可以重新写一个函数作为成员函数代表新建节点。

int newnode()
{
    memset(ch[++trieN], 0, sizeof(ch[0]));//第一层节点fail都指向root=0,0代表根节点
    val[trieN] = fail[trieN] = 0;//而新建一个节点的时候fail被清空为0(所以节点的fail都默认为0)
    return trieN;
}

求fail数组

void build()
{
    queue<int> q;
    for(int i = 0;i < maxm;i++)
        if (ch[root][i]) 
           q.push(ch[root][i]);//让第一层节点的fail都指向root再放进队列里。
    while(!q.empty())
    {
        int cur = q.front(); 
        q.pop();
        for(int i = 0;i < maxm;i++)
     {
            if (ch[cur][i])  //当节点cur的孩子节点i存在时,要把其放进队列中
      {
                ……
             q.push(ch[cur][i]);
      }
            ……
       }
    }
}
//整个AC自动机结构体
struct Trie
{
	int trieN;              //结点编号
	int ch[maxn][maxm];    //ch[cur][child]结点cur的孩子child
	int val[maxn];         
	int fail[maxn];        //fail[cur]是cur的失配点
	void init()           //初始化整个AC自动机。
	{
		trieN = -1;
		newnode();
	}
	int newnode() 
	{
		memset(ch[++trieN], 0, sizeof(ch[0])); //初始化孩子结点,相当于ch[cur][1]、ch[cur][2]...为0
		val[trieN] = fail[trieN] = 0;         //失配点为0
		return trieN;                        //返回结点编号
	}
	void insert(const string &str)                     //插入单词 相当于字典树
	{
		int cur = 0;                                      //0为深度为1的那层。
		for (int i = 0; str[i]; i++) 
		{
			int d = str[i];		//d = str[i] - 'a'; 
			if (!ch[cur][d])                   
				ch[cur][d] = newnode();
			cur = ch[cur][d];
		}
		val[cur]++;                        //判断是否是单词结尾,若有两个相同的单词那么 val[cur]=2.
	} 
	void build()               //建立fail数组
	{
		for (int i = 0; i < maxm; i++) 
		{
			if (ch[0][i])               //把根节点的孩子结点加入队列
				q.push(ch[0][i]);
		}
		while (!q.empty()) 
		{
			int cur = q.front(); 
			q.pop();                            //取出队头为父结点q
			for (int i = 0; i < maxm; i++)     //看它的孩子结点是否存在。
			{
				if (ch[cur][i])               //  此孩子存在
				{
					fail[ch[cur][i]] = ch[fail[cur]][i];  //最长相同前后缀的前缀的最后一个节点应指向ch[cur][child]的fail
					q.push(ch[cur][i]);        //存在则加入队列。
				}
				else                               //孩子结点不存在
					ch[cur][i] = ch[fail[cur]][i];  //ch[cur][child]应代替fail指针的作用直接指向最长相同前后缀的前缀的最后一个节点相应的孩子节点child。
			}
		}
	}
	int query(const string &str)      
	{
		int res = 0, cur = 0;                                  //从树cur=0开始。
		for (int i = 0; str[i]; i++)      
		{
			int d = str[i];		//d = str[i] - 'a';
			cur = ch[cur][d];                                //让cur等于当前结点的相对应孩子结点
			int tmp = cur;    
			while (tmp && val[tmp] >= 0)  p 
			{ 
				res += val[tmp];   
				val[tmp] = -1;     
				tmp = fail[tmp];
			}
		}
		return res;
	}
}ac;