AC自动机——讲解

关于AC自动机
AC自动机:Aho-Corasickautomation,该算法在1975年产生于贝尔实验室,是著名的多模匹配算法之一。一个常见的例子就是给出n个单词,再给出一段包含m个字符的文章,让你找出有多少个单词在文章里出现过。要搞懂AC自动机,先得有模式树(字典树)Trie和KMP模式匹配算法的基础知识。AC自动机算法分为3步:构造一棵Trie树,构造失败指针和模式匹配过程。
简单来说,AC自动机是用来进行多模式匹配(单个主串,多个模式串)的高效算法。
AC自动机的构造过程
使用Aho-Corasick算法需要三步:
建立模式串的Trie
给Trie添加失败路径
根据AC自动机,搜索待处理的文本
我们以下面这个例子来介绍AC自动机的运作过程
这里以 hdu 2222 KeywordsSearch 这一道题最为例子进行讲解,其中测试数据如下:
给定5个单词:say she shr he her,然后给定一个字符串 yasherhs。问一共有多少单词在这个字符串中出现过。
确定数据结构
首先,我们需要确定AC自动机所需的数据存储结构,它们的用处之后会讲到。

struct Node  
{  
    int cnt;//是否为该单词的最后一个结点   
    Node *fail;//失败指针   
    Node *next[26];//Trie中每个结点的各个节点   
}*queue[500005];//队列,方便用BFS构造失败指针   
char s[1000005];//主字符串   
char keyword[55];//需要查找的单词   
int head,tail;  
Node *root;//头结点   

第一步:构建Trie
根据输入的 keyword 一 一 构建在Trie树中

void Build_trie(char *keyword)//构建Trie树   
{  
    Node *p,*q;  
    int i,v;  
    int len=strlen(keyword);  
    for(i=0,p=root;i<len;i++)  
    {  
        v=keyword[i]-'a';  
        if(p->next[v]==NULL)  
        {  
            q=(struct Node *)malloc(sizeof(Node));  
            Init(q);  
            p->next[v]=q;//结点链接   
        }  
        p=p->next[v];//指针移动到下一个结点   
    }  
    p->cnt++;//单词最后一个结点cnt++,代表一个单词   
}  

构建完成后的效果如下图:
这里写图片描述
构建失败指针
构建失败指针是AC自动机的关键所在,可以说,若没有失败指针,所谓的AC自动机只不过是Trie树而已。
失败指针原理:
构建失败指针,使当前字符失配时跳转到另一段从root开始每一个字符都与当前已匹配字符段某一个后缀完全相同且长度最大的位置继续匹配,如同KMP算法一样,AC自动机在匹配时如果当前字符串匹配失败,那么利用失配指针进行跳转。由此可知如果跳转,跳转后的串的前缀必为跳转前的模式串的后缀,并且跳转的新位置的深度(匹配字符个数)一定小于跳之前的节点(跳转后匹配字符数不可能大于跳转前,否则无法保证跳转后的序列的前缀与跳转前的序列的后缀匹配)。所以可以利用BFS在Trie上进行失败指针求解。
失败指针利用:
如果当前指针在某一字符s[m+1]处失配,即(p->next[s[m+1]]==NULL),则说明没有单词s[1…m+1]存在,此时,如果当前指针的失配指针指向root,则说明当前序列的任何后缀不是是某个单词的前缀,如果指针的失配指针不指向root,则说明当前序列s[i…m]是某一单词的前缀,于是跳转到当前指针的失配指针,以s[i…m]为前缀继续匹配s[m+1]。
对于已经得到的序列s[1…m],由于s[i…m]可能是某单词的后缀,s[1…j]可能是某单词的前缀,所以s[1…m]中可能会出现单词,但是当前指针的位置是确定的,不能移动,我们就需要temp临时指针,令temp=当前指针,然后依次测试s[1…m],s[i…m]是否是单词。

简单来说,失败指针的作用就是将主串某一位之前的所有可以与模式串匹配的单词快速在Trie树中找出。
第二步:构建失败指针
在构造完Tire树之后,接下去的工作就是构造失败指针。构造失败指针的过程概括起来就一句话:设这个节点上的字母为C,沿着它父亲节点的失败指针走,直到走到一个节点,它的子结点中也有字母为C的节点。然后把当前节点的失败指针指向那个字母也为C的儿子。如果一直走到了root都没找到,那就把失败指针指向root。具体操作起来只需要:先把root加入队列(root的失败指针指向自己或者NULL),这以后我们每处理一个点,就把它的所有儿子加入队列。
观察构造失败指针的流程:对照图来看,首先root的fail指针指向NULL,然后root入队,进入循环。从队列中弹出root,root节点与s,h节点相连,因为它们是第一层的字符,肯定没有比它层数更小的共同前后缀,所以把这2个节点的失败指针指向root,并且先后进入队列,失败指针的指向对应图中的(1),(2)两条虚线;从队列中先弹出h(右边那个),h所连的只有e结点,所以接下来扫描指针指向e节点的父节点h节点的fail指针指向的节点,也就是root,root->next[‘e’]==NULL,并且root->fail==NULL,说明匹配序列为空,则把节点e的fail指针指向root,对应图中的(3),然后节点e进入队列;从队列中弹出s,s节点与a,h(左边那个)相连,先遍历到a节点,扫描指针指向a节点的父节点s节点的fail指针指向的节点,也就是root,root->next[‘a’]==NULL,并且root->fail==NULL,说明匹配序列为空,则把节点a的fail指针指向root,对应图中的(4),然后节点a进入队列。接着遍历到h节点,扫描指针指向h节点的父节点s节点的fail指针指向的节点,也就是root,root->next[‘h’]!=NULL,所以把节点h的fail指针指向右边那个h,对应图中的(5),然后节点h进入队列…由此类推,最终
这里写图片描述
构建失败指针的代码:

void Build_AC_automation(Node *root)  
{  
    head=0,tail=0;//队列头、尾指针   
    queue[head++]=root;//先将root入队   
    while(head!=tail)  
    {  
        Node *p=NULL;  
        Node *temp=queue[tail++];//弹出队头结点   
        for(int i=0;i<26;i++)  
        {  
            if(temp->next[i]!=NULL)//找到实际存在的字符结点   
            { //temp->next[i] 为该结点,temp为其父结点   
                if(temp==root)//若是第一层中的字符结点,则把该结点的失败指针指向root   
                    temp->next[i]->fail=root;  
                else  
                {  
                    //依次回溯该节点的父节点的失败指针直到某节点的next[i]与该节点相同,  
                    //则把该节点的失败指针指向该next[i]节点;   
                    //若回溯到 root 都没有找到,则该节点的失败指针指向 root  
                    p=temp->fail;//将该结点的父结点的失败指针给p   
                    while(p!=NULL)  
                    {  
                        if(p->next[i]!=NULL)  
                        {  
                            temp->next[i]->fail=p->next[i];  
                            break;  
                        }  
                        p=p->fail;  
                    }  
                    //让该结点的失败指针也指向root   
                    if(p==NULL)  
                        temp->next[i]->fail=root;  
                }  
                queue[head++]=temp->next[i];//每处理一个结点,都让该结点的所有孩子依次入队   
            }  
        }  
    }  
}  

为什么上述那个方法是可行的,是可以保证从root到所跳转的位置的那一段字符串长度小于当前匹配到的字符串长度且与当前匹配到的字符串的某一个后缀完全相同且长度最大呢?

显然我们在构建失败指针的时候都是从当前节点的父节点的失败指针出发,由于Trie树将所有单词中相同前缀压缩在了一起,所以所有失败指针都不可能平级跳转(到达另一个与自己深度相同的节点),因为如果平级跳转,很显然跳转所到达的那个节点肯定不是当前匹配到的字符串的后缀的一部分,否则那两个节点会合为一个,所以跳转只能到达比当前深度小的节点,又因为是由当前节点父节点开始的跳转,所以这样就可以保证从root到所跳转到位置的那一段字符串长度小于当前匹配到的字符串长度。另一方面,我们可以类比KMP求NEXT数组时求最大匹配数量的思想,那种思想在AC自动机中的体现就是当构建失败指针时不断地回到之前的跳转位置,然后判断跳转位置的下一个字符是否包含当前字符,如果是就将失败指针与那个跳转位置连接,如果跳转位置指向NULL就说明当前匹配的字符在当前深度之前没有出现过,无法与任何跳转位置匹配,而若是找到了第一个跳转位置的下一个字符包含当前字符的的跳转位置,则必然取到了最大的长度,这是因为其余的当前正在匹配的字符必然在第一个跳转位置的下一个字符包含当前字符的的跳转位置深度之上,而那样的跳转位置就算可以,也不会是最大的(最后一个字符的深度比当前找到的第一个可行的跳转位置的最后一个字符的深度小,串必然更短一些)。
第三步:匹配
这样就证明了这种方法构建失败指针的可行性。
第三步:匹配
最后,我们便可以在AC自动机上查找模式串中出现过哪些单词了。匹配过程分两种情况:(1)当前字符匹配,表示从当前节点沿着树边有一条路径可以到达目标字符,此时只需沿该路径走向下一个节点继续匹配即可,目标字符串指针移向下个字符继续匹配;(2)当前字符不匹配,则去当前节点失败指针所指向的字符继续匹配,匹配过程随着指针指向root结束。重复这2个过程中的任意一个,直到模式串走到结尾为止。
对例子来说:其中模式串为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节点,随后在第6行指向r节点,r节点的count值为1,从而count+1,循环直到temp指向root为止。最后i=6,7时,找不到任何匹配,匹配过程结束。
AC自动机时间复杂性为:O(L(T)+max(L(Pi))+m)其中m是模式串的数量
匹配代码:

int query(Node *root)  
{ //i为主串指针,p为模式串指针   
    int i,v,count=0;  
    Node *p=root;  
    int len=strlen(s);  
    for(i=0;i<len;i++)  
    {  
        v=s[i]-'a';  
        //由失败指针回溯查找,判断s[i]是否存在于Trie树中   
        while(p->next[v]==NULL && p!=root)  
            p=p->fail;  
        p=p->next[v];//找到后p指针指向该结点   
        if(p==NULL)//若指针返回为空,则没有找到与之匹配的字符   
            p=root;  
        Node *temp=p;//匹配该结点后,沿其失败指针回溯,判断其它结点是否匹配   
        while(temp!=root)//匹配结束控制   
        {  
            if(temp->cnt>=0)//判断该结点是否被访问   
            {  
                count+=temp->cnt;//由于cnt初始化为 0,所以只有cnt>0时才统计了单词的个数   
                temp->cnt=-1;//标记已访问过   
            }  
            else//结点已访问,退出循环   
                break;  
            temp=temp->fail;//回溯 失败指针 继续寻找下一个满足条件的结点   
        }  
    }  
    return count;  
}  

转载自这里写链接内容

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值