【算法】AC自动机/AC算法 - 多模式串快速匹配

AC自动机

Accepted

Aho-Corasick

性质

AC自动机/AC算法(Aho-Corasick automaton),是著名的多模式串匹配算法。

前置知识

  1. 字典树(重要
  2. KMP算法(了解Next数组的作用)

典例与算法复杂度分析

典型例题是:给定一个主串 S,给定多个模式串 T,问主串 S 中存在多少个给定的模式串

在KMP算法中,一个长度为n的主串一个长度为m的模式串的复杂度为 O(n+m)

而如果直接照搬KMP算法到这种题型下,模式串处理一次就需要匹配一次

如果有t个模式串,则复杂度为 O((n+m)t)

假如主串的长度很大,或给定的模式串很多,即使是KMP算法复杂度也会很高

所以诞生了AC自动机,它能够在 O(n+mt) 的复杂度中求出答案

其中 O(mt) 花费在建立字典树上,O(n) 花费在遍历一遍主串上

所以它的时间复杂度就可以控制在一个小范围内

(缺点继承了字典树的空间复杂度……)

关于fail指针

AC自动机最大的特点就是为每个节点加了一个叫fail的指针

这个指针的作用与KMP算法中Next数组的作用极其相似

只不过KMP算法中是根据模式串匹配的位置 j 来引用Next[ j ] 作为下一次匹配的位置

而AC自动机则是对于每个节点,都有一个fail指针来指向下一个要进行匹配的位置

这也就是自动机能够O(n)完成主串匹配的原因

对于fail指针的构造:

  如果当前在字典树上匹配到了某个节点node,对应主串的第i个位置,发现节点node的子节点中不存在主串第i+1个位置的字符,说明在节点nd发生了失配

  此时我们需要寻找与 从根节点root到此时的node构成的字符串的后缀 相同的最长的模式串前缀

  则node的fail指针就应该指向这个最长前缀的最后一个字符对应的节点

可以发现,除特殊情况外,某个节点代表的字符与它的fail指针指向的节点代表的字符是相同的

为什么要找的是与其后缀相同的最长前缀——

  这里是KMP算法的精髓,此时的后缀是目前处理的模式串的后缀,而前缀是另一个模式串的前缀。只有转移到最长的前缀的位置,才能保证能够把所有的答案都找出来。从KMP算法的角度来看,也就是尽量小幅度地右移模式串的位置以保证不会落下某些正确答案。

  以abcabcabcabc为主串,以abcabcab为模式串为例,对应匹配的位置(也就是答案)有2个:

a b c a b c a b c a b c
a b c a b c a b
a b c a b c a b c a b c
a b c a b c a b

对于模式串而言,前缀与后缀相同的情况有2种:ababcab

此时选择的应该是较长的情况,移动的幅度是 8-5=3,也就是上方表1移动到表2

而如果选择了较短的前后缀,移动幅度为8-2=6,则会变成

a b c a b c a b c a b c
a b c a b c a b

因为模式串超出边界,结束匹配,则结果只会记录表1这一种情况

这也就是KMP的Next数组表示最长的相同前后缀长度的原因

也是AC自动机要找的是与其后缀相同的最长前缀的原因

详细建立过程与细节见下文“建树流程”

关于下文

下文的代码以 HDU 2222 Keywords Search 为例

原题题意为:

给定一个数T,表示T组样例,每组先给出一个数字N (N<=10000),表示有N个单词作为模式串

接下来N行每行一个单词,单词长度不超过50

最后一行为主串,主串长度不超过1000000

对于每组样例,输出主串中存在多少种模式串

(给定的单词可能存在重复,所以重复的单词看成不同种模式串,如果主串出现了一遍这种单词,答案就要加上重复的次数。当然,如果主串中出现同种单词次数大于一次,其后的则不计入答案)

(也就当作是一个个模式串都单独进行一遍KMP会得到的答案)

fail指针建立流程(图解)

假如现在共有下述的四个单词

abs

abi

wasabi

binary

建立字典树如下图所示

建完树就需要开始构建fail指针

首先考虑特殊情况:

  规定——根节点的fail指针指向NULL(作为后面迭代的结束标识)

  根节点的所有存在的子节点的fail指针全部指向根节点(就一个字母,没有什么前缀后缀相同的)

然后考虑后面迭代的情况:

  假设现在处理到了节点node

  如果此时后缀前缀相同的长度为0,说明没有任何后缀存在对应的相同前缀,则node的fail指针应该指向root

  如果此时后缀前缀相同的长度等于1,说明只有node代表的字符存在相同的前缀,则node的fail指针指向的应该是与root相邻的子节点,此时node的父节点的fail指针指向root

  如果此时后缀前缀相同的长度大于1,只要node的父节点的fail指针已经指向了它应该指向的位置,令node代表的字符为c,那么node的fail就可以直接指向 node的父节点的fail指向的节点的子节点c(如果存在)。如果这个子节点c为NULL(说明这个节点不存在),则node的fail应该继续迭代下去,去寻找fail路径上是否存在一个节点拥有子节点c。如果最后迭代到了root的fail指针,即NULL时,直接结束迭代,但此时要手动更改node的fail指向的是root而不是保持NULL

为了保证能够迭代

也就是深度更大的节点能够依据深度小的节点的fail指针来构建自己的fail

且能够发现所有的fail指针指向的节点深度都比原节点深度要小(都往比树根更近的方向指)

所以我们可以使用广度优先搜索来构建fail,从根节点开始扩散出去

首先考虑root和第一层的所有节点,构建fail后如下

在宽度优先搜索每个节点的子节点时,还是会按照字典序从a到z全部搜索一遍是否存在某个字符对应的子节点

手动处理完第一层所有节点后,把第一层所有节点推入队列

先看root->a->b这一条路线

当我们循环‘a‘这个节点的子节点发现存在一个对应字符是‘b‘的子节点时,就对其进行处理

‘b‘的父节点就是这个‘a‘,而‘a‘的fail指针指向root

所以我们就以root来作为b的fail的父节点,观察看看root是否存在一个对应字符是‘b‘的子节点

显然“binary”路径上第一个就是‘b‘,所以是存在的,那么root->a->b的‘b‘的fail就可以指向"binary"的第一个‘b‘了

fail构建完毕后,就把‘b‘推入队列,继续处理下一个,即root->w->a的‘a‘

直到队列中没有元素时,说明整个图的fail指针就全部构建完了

匹配功能的实现

建完树和fail之后就开始匹配主串模式串了

我们让每个单词结束的位置对应的节点上的标记flag加1(flag初始为0)

则对于每个节点而言,flag值情况为

匹配以整棵树作为模式串,从root开始,主串从第一个字符开始

以主串为 "wasabinaryabi" 为例

主串指向第一个字符‘w‘,对应的,发现root节点中存在一个节点代表字符为‘w‘,则树上指针从root移动至‘w‘

一直到第六个字符‘i‘过,一条线下来,发现‘i‘节点的flag值为1,说明主串匹配到了这一个单词,加入答案中后将flag置0以防止重复计数

因为‘i‘节点没有子节点,所以接下来处理的节点是‘i‘的fail指向的节点,如图,即root->a->b->i的最后一个节点‘i‘

发现flag值为1,说明此时又匹配到了一个单词,加入答案后flag置0

然后继续处理这个‘i‘的fail节点,跟着主串中间的 "inary" 最后会处理到图右下角的‘y‘过,加入答案后置0继续

此时‘y‘的fail是直接指向root的,所以直接回到root继续匹配主串

最后"abi"走完后,原本‘i‘的位置flag已经被置0了,所以没有对答案做出贡献

主串匹配完后,输出答案3,指过程中有匹配到 wasabi,abi,binary 这三个单词

需要注意的是,在每一次匹配某一节点时,都需要从这个节点开始走一次fail路径,将路径上的flag全部加入答案,否则会出现下面这种情况

下图中包含两个单词:abcdebcd,这种特殊情况就是,其中一个单词完全包含于另一个单词内,且不包括首尾

假设此时主串为"abcde",如果在每一次指向某个节点时就走一遍fail路径,那么最后匹配完只能遇到‘e‘这个节点,答案只有1

但是很明显,bcd也是包含于"abcde"的,所以必须每次都处理一遍fail路径

只要每次都走一遍,在处理到‘d‘节点时,‘d‘的fail指向"bcd"的‘d‘,此时才能把"bcd"的这个flag加入答案

具体匹配方式见代码

代码实现

首先,对于节点的结构体变量定义如下

struct node
{
    int flag;
    node *next[26],*fail;
};

node *root;

char str[1000050];

其中 flag 记录以当前节点为结尾的单词的个数

flag 与 next 数组跟普通的字典树含义相同

建树 addNode

void addNode()
{
    node *nd=root;
    int i,id;
    for(i=0;str[i]!=‘\0‘;i++)
    {
        id=str[i]-‘a‘;
        if(nd->next[id]==NULL)
        {
            nd->next[id]=new node;
            nd->next[id]->flag=0;
            for(int j=0;j<26;j++)
                nd->next[id]->next[j]=NULL;
        }
        nd=nd->next[id];
    }
    nd->flag++;
}

  与trie树的建树方式相同,此时对fail指针可以不进行初始化

构造fail指针 buildFailPointer

void buildFailPointer()
{
    queue<node*> q; //bfs容器
    root->fail=NULL; //根节点fail置空
    for(int i=0;i<26;i++)
    {
        if(root->next[i]!=NULL) //第一层所有出现过的节点的fail全部指向root,并加入队列准备搜索
        {
            root->next[i]->fail=root;
            q.push(root->next[i]);
        }
    }
    while(!q.empty())
    {
        node *nd=q.front();
        q.pop();
        for(int i=0;i<26;i++)
        {
            if(nd->next[i]!=NULL) //如果这个子节点存在
            {
                node *tmp=nd->fail; //tmp储存当前处理的nd->next[i]的父节点的fail指针
                while(tmp!=NULL) //重复迭代
                {
                    if(tmp->next[i]!=NULL) //直到出现某次迭代的节点存在一个子节点,代表的字符与当前处理的nd->next[i]代表的字符相同时,停止迭代
                    {
                        nd->next[i]->fail=tmp->next[i]; //那么当前处理的节点的fail就可以指向迭代到的这个节点的对应子节点
                        break;
                    }
                    tmp=tmp->fail; //如果上述子节点不存在,继续迭代fail指针
                }
                if(tmp==NULL) //如果最后tmp指向NULL,说明最后一次迭代到了root节点且没有找到答案,说明不存在任何前缀与当前的后缀相同,此时让fail指向root节点即可
                    nd->next[i]->fail=root;
                q.push(nd->next[i]); //推入队列
            }
        }
    }
}

  因为搜索时处理的节点是nd->next[i],所以nd就是nd->next[i]的父节点

匹配树与主串,询问单词种类数 query

int query()
{
    node *nd=root,*tmp;
    int ans=0,i,id;
    for(i=0;str[i]!=‘\0‘;i++)
    {
        id=str[i]-‘a‘;
        while(nd->next[id]==NULL&&nd!=root) //如果nd没有字符为id的子节点的话,说明在这里失配,需要迭代指向fail,如果遇到根节点的话则无法继续迭代直接退出
            nd=nd->fail;
        if(nd->next[id]!=NULL) //针对于nd为根节点的情况,只有存在字符为id的子节点才改变nd的指向,否则nd继续保持指向根节点
            nd=nd->next[id];
        tmp=nd; //从nd开始走一遍fail路径,把所有完全包含于当前字符串的单词情况都考虑进来
        while(tmp!=root)
        {
            if(tmp->flag!=0)
            {
                ans+=tmp->flag;
                tmp->flag=0; //一定要置0
            }
            else
                break;
            tmp=tmp->fail;
        }
    }
    return ans;
}

  nd为当前在字典树上指向的节点,即KMP算法中模式串的光标 j

  走fail路径时,如果遇到某个节点的flag为0,说明这条路径之前已经被走过(或者不是单词的结尾),此时就不需要继续走下去了,节省时间

完整代码 HDU-2222

#include<bits/stdc++.h>
using namespace std;

struct node
{
    int flag;
    node *next[26],*fail;
};

node *root;

char str[1000050];

void addNode()
{
    node *nd=root;
    int i,id;
    for(i=0;str[i]!=‘\0‘;i++)
    {
        id=str[i]-‘a‘;
        if(nd->next[id]==NULL)
        {
            nd->next[id]=new node;
            nd->next[id]->flag=0;
            for(int j=0;j<26;j++)
                nd->next[id]->next[j]=NULL;
        }
        nd=nd->next[id];
    }
    nd->flag++;
}

void buildFailPointer()
{
    queue<node*> q;
    root->fail=NULL;
    for(int i=0;i<26;i++)
    {
        if(root->next[i]!=NULL)
        {
            root->next[i]->fail=root;
            q.push(root->next[i]);
        }
    }
    while(!q.empty())
    {
        node *nd=q.front();
        q.pop();
        for(int i=0;i<26;i++)
        {
            if(nd->next[i]!=NULL)
            {
                node *tmp=nd->fail;
                while(tmp!=NULL)
                {
                    if(tmp->next[i]!=NULL)
                    {
                        nd->next[i]->fail=tmp->next[i];
                        break;
                    }
                    tmp=tmp->fail;
                }
                if(tmp==NULL)
                    nd->next[i]->fail=root;
                q.push(nd->next[i]);
            }
        }
    }
}

int query()
{
    node *nd=root,*tmp;
    int ans=0,i,id;
    for(i=0;str[i]!=‘\0‘;i++)
    {
        id=str[i]-‘a‘;
        while(nd->next[id]==NULL&&nd!=root)
            nd=nd->fail;
        if(nd->next[id]!=NULL)
            nd=nd->next[id];
        tmp=nd;
        while(tmp!=root)
        {
            if(tmp->flag!=0)
            {
                ans+=tmp->flag;
                tmp->flag=0;
            }
            else
                break;
            tmp=tmp->fail;
        }
    }
    return ans;
}

void solve()
{
    root=new node;
    root->flag=0;
    root->fail=NULL;
    for(int i=0;i<26;i++)
        root->next[i]=NULL;
    int n;
    scanf("%d",&n);
    while(n--)
    {
        scanf("%s",str);
        addNode();
    }
    buildFailPointer();
    scanf("%s",str);
    printf("%d\n",query());
}

int main()
{
    int T;
    scanf("%d",&T);
    while(T--)
        solve();

    return 0;
}

原文地址:https://www.cnblogs.com/stelayuri/p/12578889.html

时间: 2024-11-06 07:11:56

【算法】AC自动机/AC算法 - 多模式串快速匹配的相关文章

【暖*墟】 #AC自动机# 多模式串的匹配运用

一.构建步骤 1.将所有模式串构建成 Trie 树 2.对 Trie 上所有节点构建前缀指针(类似kmp中的next数组) 3.利用前缀指针对主串进行匹配 AC自动机关键点一:trie字典树的构建过程 字典树的构建过程是这样的,当要插入许多单词的时候,我们要从前往后遍历整个字符串, 当我们发现当前要插入的字符其节点再先前已经建成,我们直接去考虑下一个字符即可, 当我们发现当前要插入的字符没有再其前一个字符所形成的树下没有自己的节点, 我们就要创建一个新节点来表示这个字符,接下往下遍历其他的字符.

跳跃表,字典树(单词查找树,Trie树),后缀树,KMP算法,AC 自动机相关算法原理详细汇总

第一部分:跳跃表 本文将总结一种数据结构:跳跃表.前半部分跳跃表性质和操作的介绍直接摘自<让算法的效率跳起来--浅谈"跳跃表"的相关操作及其应用>上海市华东师范大学第二附属中学 魏冉.之后将附上跳跃表的源代码,以及本人对其的了解.难免有错误之处,希望指正,共同进步.谢谢. 跳跃表(Skip List)是1987年才诞生的一种崭新的数据结构,它在进行查找.插入.删除等操作时的期望时间复杂度均为O(logn),有着近乎替代平衡树的本领.而且最重要的一点,就是它的编程复杂度较同类

AC自动机 - AC自动机 - 多模式串的匹配运用 --- HDU 3065

病毒侵袭持续中 Problem's Link:http://acm.hdu.edu.cn/showproblem.php?pid=3065 Mean: 中文题,不解释. analyse: AC自动机的运用.这一题需要将模式串都存储下来,还有就是base的取值一定要弄清楚,由于这题的模式串都是大写字母所以我们可以通过剪枝来加速. Time complexity:o(n)+o(ml)  Source code: // Memory Time // 1347K 0MS // by : Snarl_js

AC自动机 - 多模式串的匹配运用 --- HDU 2896

病毒侵袭 Problem's Link:http://acm.hdu.edu.cn/showproblem.php?pid=2896 Mean: 中文题,不解释. analyse: AC自动机的运用,多模式串匹配.就是有几个细节要注意,在这些细节上卡了半天了. 1)输出的网站编号和最终的病毒网站数不是一样的: 2)next指针要设128,不然会爆栈: 3)同理,char转换为int时,base要设为31: Time complexity:o(n)+o(ml)  Source code: // M

HDOJ-2222(AC自动机+求有多少个模板串出现在文本串中)

Keywords Search HDOJ-2222 本文是AC自动机的模板题,主要是利用自动机求有多少个模板出现在文本串中 由于有多组输入,所以每组开始的时候需要正确的初始化,为了不出错 由于题目的要求是有多少字符串出现过,而不是出现过多少次,所以出现过的模板串就不能再计数了,所欲需要置-1. 不要忘记build函数应该在insert函数之后调用,也不要忘记调用. //AC自动机,复杂度为O(|t|+m),t表示文本串的长度,m表示模板串的个数 #include<iostream> #incl

BZOJ 题目3172: [Tjoi2013]单词(AC自动机||AC自动机+fail树||后缀数组暴力||后缀数组+RMQ+二分等五种姿势水过)

3172: [Tjoi2013]单词 Time Limit: 10 Sec  Memory Limit: 512 MB Submit: 1890  Solved: 877 [Submit][Status][Discuss] Description 某人读论文,一篇论文是由许多单词组成.但他发现一个单词会在论文中出现很多次,现在想知道每个单词分别在论文中出现多少次. Input 第一个一个整数N,表示有多少个单词,接下来N行每行一个单词.每个单词由小写字母组成,N<=200,单词长度不超过10^6

AC自动机(AC automation)

字典树+KMP 参考自: http://www.cppblog.com/mythit/archive/2009/04/21/80633.html 1 const int MAXN = 26; //字典大小 2 3 //定义结点 4 struct node{ 5 node* fail; 6 node* child[MAXN]; 7 int count; 8 node(){ 9 fail = NULL; 10 count = 0; 11 memset(child, NULL, sizeof(chil

POJ 2778 DNA Sequence(AC自动机确定DFA转移图+矩阵快速幂)

这道题极好的展示了AC自动机在构造转移图DFA上的应用 DFA转移图就是展示状态的转移过程的图,DFA图构造出来后就可以用DP求出任何DNA长度下,任何状态的个数 本题用自动机求出DFA矩阵,那么有 | dp[n][0] dp[n][1] ... dp[n][m] |=|dp[1][0] dp[1][1] ... dp[1][m] | * DFA^(n-1)    (m指状态总数) DP边界矩阵|dp[1][0] dp[1][1] ... dp[1][m] | 也就是DFA的第一行,所以dp[n

HDU - 6096 :String (AC自动机,已知前后缀,匹配单词,弱数据)

Bob has a dictionary with N words in it. Now there is a list of words in which the middle part of the word has continuous letters disappeared. The middle part does not include the first and last character. We only know the prefix and suffix of each w