AC 自动机

发布时间:2022-07-05 发布网站:脚本宝典
脚本宝典收集整理的这篇文章主要介绍了AC 自动机脚本宝典觉得挺不错的,现在分享给大家,也给大家做个参考。

AC 自动机

学习 AC 自动机的第一要义:记住它不能帮你自动 AC !!!

  • AC 自动机(以下简称 ACam ),是一种多模式串匹配算法,它是由贝尔实验室的两位研究人员 Alfred V. Aho 和 Margaret J.Corasick 于1975年发明。

提到模式串匹配算法,你也许会想到大名鼎鼎的 KMP 算法。没错,它是最常用的单模式串匹配算法,但是如果要在一段文本串中一次性匹配 (m) 个模式串,KMP 就只能哈哈的跑 (m) 次,这个效率是我们无法接受的。为了解决“多模式串快速匹配”问题,人们发明了 ACAM。

Part 1 AC 自动机原理

在了解 ACAM 之前,你最好要了解以下前置知识:

  • KMP 模式串匹配(其实个人觉得不了解 KMP 也能学会 ACAM )
  • Trie (熟练掌握)

学习 ACAM 怎么能没有图呢,让我们从图入手,构建一个 ACAM !

假设你现在有 3 只模式串:bbaa,aa,abaa 。

第一步:构建 Trie 树

这一步很简单,就是把所有的模式串插入到一棵 Trie 里。构建出的 Trie 应该是这样的:( Linux 画图还是挺费劲的,大家凑合凑合)

AC 自动机

插入的同时,可以在每个模式串的结尾打一个标记(红圈),表示这个模式串到结尾了。

Code

struct Trie{
  int fail,num;
  int ch[28];
}tr[maxn];

void Build(){
  int len=std::strlen(ch),u=0;
  for(int i=0;i<len;++i){
    int s=ch[i]-'a';
    if(tr[u].ch[s]==0) tr[u].ch[s]=++cnt;//没有就新建一个结点
    u=tr[u].ch[s];
  }
  tr[u].num++;//标记结尾结点
}

第二步:构建 fail 指针

这是 ACAM 的核心操作。

有些博客中说 fail 指针告诉我们如果当前结点失配了,该从哪里继续匹配,这固然是对的,但是不全面。

fail 指针的定义应该是:指向 可以与 以当前结点为结尾的 字符串的 某一个后缀 匹配的 最长的前缀的 末尾结点的 位置。

知道这有点套娃,我们用图来理解会好一点,还是上面那个图:

AC 自动机

(为了方便区分,这里给结点编号 1 到 9 。我们用 xy 表示一个结点,x 表示这个结点的字符,y 表示这个结点的编号)

譬如这样,a3 是串 bba 的一个后缀,我们发现 a5 是某个串的一个前缀,而且和 bba 的后缀相同,而且最长。于是把 a3 的 fail 指向 a5 。

再来看 a4 ,显然 a4 的 fail 指针好像也可以指向 a5 。但是以 a4 为结尾的串 bbaa 有一个后缀是 aa ,而 a5,a6 正好构成了一个与之匹配的更长的前缀,于是 a4 的 fail 指针应该指向 a6 。

这样看起来比较无厘头,因为我们没有按照顺序求解 fail 指针,实际上,它是通过一发由根出发的 bfs 求出来的。

首先,规定根结点的儿子们的 fail 为根(因为它除了自己不可能有与它匹配的前缀),把根结点的儿子们入队。

每次取出队头 (u) ,扫描队头结点的所有非空的儿子 (v)(v) 的 fail 指针是 (u) 的 fail 指针指向结点的 (v) 儿子。

  • Q:为什么?

    A:显然,已经入队的结点的 fail 指针一定已经求好了,也就是我们找到了一个匹配的最长的前缀。现在 (u) 后面又来了一个字符 (v) ,那我们看看刚才匹配上的那个前缀存不存在一个结点 (v') 与新来的 (v) 相同,如果有,我们就可以继续匹配这个前缀,就像例子中 3a,4a 的匹配那样。

  • Q:如果父节点 (u) 的 fail 指针没有与新来的 (v) 匹配的字符呢?

    A:这就需要我们保证它有一个这样的结点 (v) ,在下面会提到如何操作。

现在 (u) 还有一些空的儿子,我们在下一次求解 fail 的时候如果连向了这些空儿子,就相当于直接连向了根,不能保证“最长前缀”的性质,这样不好。所以我们需要保证 (u) 的每个儿子都非空。但是我们显然不可以无中生有给他造出来一个儿子,此时应该向别的结点“借”儿子。

假设现在有一个结点 (w) 要把它的 fail 边连向 (u) 的某个空儿子,那么分析一波性质:

  1. 根据定义,以 (u) 为结尾的前缀一定匹配以 (w) 的父亲为结尾的某个后缀。

  2. 考虑 (u) 的 fail 指针指向的结点 (x) ,以 (x) 结尾的前缀一定匹配以 (u) 为结尾的某个后缀。

那么可以推出:以 (x) 为结尾的前缀匹配以 (w) 的父亲为结尾的某个后缀。

大呼卧槽?没关系,看看下面的图,这有点像 KMP 跳 next 数组。

AC 自动机

假设以 (u) 为结尾的前缀,匹配了以 (w) 的父亲(记作 (w') )为结尾的某个后缀(红色部分为匹配上的字符),而以 (x) 为结尾的前缀又匹配了以 (u)​ 为结尾的某个后缀(蓝色部分为匹配上的字符),显然三段字符的蓝色部分都应该相等,也就是以 (x) 为结尾的前缀匹配以 (w) 的父亲为结尾的某个后缀。

总的来说,意思就是如果 (w) 要连接 fail 边的 (v)(u) 的儿子中没有,应该到 (x) 中找 (v) 并连接 fail 边,因为以 (x) 为结尾的前缀匹配以 (w') 为结尾的后缀。

但是写代码的时候,我们不可能每次都往上跳啊跳,直到找到一个有 (v) 儿子的结点吧,这样不仅麻烦,时间上也花费不起。所以为了方便,我们直接把 (x)(v) 儿子 “借给” (u) ,也就是把 (u)(v) 儿子连向 (x)(v) 儿子。

  • Q:那如果 (x) 也没有 (v) 儿子呢?

    A:你怎么这么多屁话? 我们处理 fail 基于 bfs,也就是说,在求 (u) 之前,一定已经求解完了 (x) 的 fail 指针。此时 (x) 的儿子该借的都借完了,不可能有空的。不过特别的,如果真的找不到结点借的话,就说明满足条件的结点不存在,我们也就不用考虑满足“最长”的性质了,直接把这个儿子连向根即可。

现在我们按照上面的方法,把上面图中的 fail 指针画出来吧:

AC 自动机

Code

void make_fail(){
  std::queue<int>q;
  for(int i=0;i<26;++i){
    if(tr[0].ch[i]!=0){
      tr[tr[0].ch[i]].fail=0;
      q.push(tr[0].ch[i]);
    }
  }//显然根节点的所有儿子的fail指针应该是0,把这些儿子入队
  while(q.size()){
    int u=q.front();//取出队头
    q.pop();
    for(int i=0;i<26;++i){
      int v=tr[u].ch[i];//枚举它的每个儿子
      if(v){//如果这个儿子非空
        tr[v].fail=tr[tr[u].fail].ch[i];
        //这个儿子的fail指针应该指向它父亲的fail指针指向的节点的这个儿子
        q.push(v);
      }else tr[u].ch[i]=tr[tr[u].fail].ch[i];
      //去fail指针指向的结点借儿子
    }
  }
}

第三步:匹配文本串

Mission:给你一个文本串,求出模式串中有几个出现在这个文本串中。

假设文本串是 bbaabaa 。

先拿出来刚才构建好的 Trie 图(由于我们有“借儿子”这种操作,它现在已经不是一棵树,而是一个 DAG 了话说自动机是不是都相当于构建了一个 DAG,雾)。

从根结点出发,开始匹配,每次在 Trie 图尝试往匹配文本串的方向跳,假设跳到了 (u)

(u) 出发,不停的跳 fail 边,直到跳到根,统计这期间跳到的模式串有哪些

哈?为什么要跳 fail 边?因为以 fail 指针指向结点 (w) 为结尾的前缀是可以匹配以 (u) 为结尾的某个后缀的,而以 (u) 为结尾的后缀已经在文本串中出现了,也就相当于以 (w) 为结尾的前缀也在文本串中出现了,而这里面可能包含了一些模式串,如果不跳就会漏掉哦!

我们先跳前 4 个字符吧,蓝色是我们跳文本串的路径,而在跳到第四个字符的时候,我们沿着 fail 边跳的时候(红色),找到了一个模式串 aa 。

AC 自动机

接着匹配,发现 4a 好像没有儿子了,那怎么办,回根重新找?显然不对,如果回到根的话,你会发现 abaa 没被计入答案。

我们希望可以继续找到一个 b 字符以继续匹配。记得我们之前“借”的儿子吗?这时候他们又派上用场了,其实这里 4a 是有字符为 b 的儿子的,只是我们没画出来。在上面“借”儿子的过程中,6a 从 5a 借到了 7b 这个儿子,然后 4a 找到 6a 借,也借到了 7b 这个儿子,所以我们下一次应该去 7b 继续匹配。

由于上面我们已经把“借儿子”的过程处理好了,所以这里我们直接访问 4a 的字符为 b 的儿子就可以直接走到 7b 继续匹配了(没错就是啥也不干,躺平就完了)。

一直跳到文本串的结尾,匹配上了 9a ,于是 3 个模式串都出现在了文本串中,答案为 3 ,大功告成!如果细心的话你会发现,在 9a 跳 fail 边的时候又找到了一次模式串 aa 哦!(即为图中橙色边)

AC 自动机

既然我们只想知道文本串是不是出现过,就可以在跳 fail 边的时候打个标记,表示这个结点已经访问过了,这时跳出循环,节约了时间。

这种做法是基于 fail 边构成了一棵树的性质,我们称之为 fail 树。

  • Q:为什么 fail 是一棵树?

    A:每个结点有且仅有一条 fail 指针,又因为 Trie 树是联通的,所以这个东西满足“联通”与“有 n-1 条边”这两个性质,所以是树。

如果我们访问了树上的一条到根的链,那么我们就标记了这条链。下一次从别的结点开始跳的时候,可能会跳到这条链上,此时再要跳到根,实际上与之前跳过的路径是一样的。如果这一段重合路径中出现了模式串,那么在之前跳的时候一定已经被统计上了,所以这个做法是不会漏统计的。

Code

int AC_automaton(){
  int len=std::strlen(ch);
  int u=0,ans=0;
  for(int i=0;i<len;++i){
    int s=ch[i]-'a';//取出文本串的这一位
    u=tr[u].ch[s];//u跳到当前节点的这个儿子
    int v=u;
    while(v && tr[v].num!=-1){
      //不停跳fail指针直到跳到根,统计这一位匹配多少模式串
      ans+=tr[v].num;
      tr[v].num=-1;//标记已经访问过了
      v=tr[v].fail;
    }
  }
  return ans;//返回找到的模式串数量
}

时空复杂度

@R_591_1304@ (O(N+sum |s_i|)) 其中 (N) 为文本串长度,(sum s_i) 为模式串长度之和。

证明显然是我们扫描了文本串,在 fail 树上每个结点只会访问一次,实际上因为两个模式串存在公共前缀的原因,这个复杂度不可能跑满。

空间复杂度 (O(N+sum |s_i|)) 这是记录下文本串和构建 Trie 的复杂度。(或许你可以一边读文本串一边跑 ACAM ,这样就是 (O(sum |s_i|)) 的。。。

Part 2 总代码

其实上面给出的代码已经挺全面了,为了方便一些键盘上只有 Ctrl C V 的小伙伴,这里贴出总代码吧。

下面给出的参考代码以 洛谷P3808【模版】AC 自动机 为实现目标。

#include<bITs/stdtr1c++.h>

namespace zhang_tao{
  #define inf 0x7f7f7f7f
  template <tyPEname _T>
  inline _T const&amp; read(_T &x){
    x=0;int fh=1;
    char ch=getchar();
    while(!isdigit(ch)){
      if(ch=='-')
        fh=-1;
      ch=getchar();
    }
    while(isdigit(ch)){
      x=x*10+ch-'0';
      ch=getchar();
    }
    return x*=fh;
  }
  void write(int x){
    if(x<0) putchar('-'),x=-x;
    if(x>9) write(x/10);
    putchar(x%10+48);
  }
  inline int _gcd(const int x,const int y){
    return y?_gcd(y,x%y):x;
  }
  inline void _swap(int &x,int &y){
    x^=y^=x^=y;
  }
  #undef inf
} // namespace zhang_tao

using namespace zhang_tao;
const int maxn=1000005;

int n,cnt;
char ch[maxn];

struct Trie{
  int fail,num;
  int ch[28];
}tr[maxn];

void Build(){
  int len=std::strlen(ch),u=0;
  for(int i=0;i<len;++i){
    int s=ch[i]-'a';
    if(tr[u].ch[s]==0) tr[u].ch[s]=++cnt;
    u=tr[u].ch[s];
  }
  tr[u].num++;
}

void make_fail(){
  std::queue<int>q;
  for(int i=0;i<26;++i){
    if(tr[0].ch[i]!=0){
      tr[tr[0].ch[i]].fail=0;
      q.push(tr[0].ch[i]);
    }
  }//显然根节点的所有儿子的fail指针应该是0,把这些儿子入队
  while(q.size()){
    int u=q.front();//取出队头
    q.pop();
    for(int i=0;i<26;++i){
      int v=tr[u].ch[i];//枚举它的每个儿子
      if(v){//如果这个儿子不是0
        tr[v].fail=tr[tr[u].fail].ch[i];
        //这个儿子的fail指针应该指向它父亲的fail指针指向的节点的这个儿子
        q.push(v);
      }else tr[u].ch[i]=tr[tr[u].fail].ch[i];
      //这个节点直接失配了,去u的fail指针继续尝试匹配
    }
  }
}

int AC_automaton(){
  int len=std::strlen(ch);
  int u=0,ans=0;
  for(int i=0;i<len;++i){
    int s=ch[i]-'a';//取出文本串的这一位
    u=tr[u].ch[s];//u跳到当前节点的这个儿子
    int v=u;
    while(v && tr[v].num!=-1){
      //不停跳fail指针直到跳到0,统计这一位匹配多少模式串
      ans+=tr[v].num;
      tr[v].num=-1;
      v=tr[v].fail;
    }
  }
  return ans;
}

signed main(){
  read(n);
  for(int i=1;i<=n;++i){
    scanf("%s",ch);
    Build();
  }
  tr[0].fail=0;
  make_fail();
  scanf("%s",ch);
  write(AC_automaton()),putchar('n');
  return 0;
}

结语

关于 ACAM 的基础知识的分享就到这里了,如果您觉得不明白的话,可以参考上面的图文,进行手动画图推导,亲测这样十分有助于理解。

如果您觉得我讲的不错的话,可以点个赞吗?/kel

如果您有疑问的话,欢迎评论或者加笔者邮箱(在侧边栏里)。

PS:如果你只会 ACAM 模版的话,那你也就只能做 ACAM 模版题了。

关于 ACAM 的一些做题技巧,我会再写一篇博客,分享给大家。

脚本宝典总结

以上是脚本宝典为你收集整理的AC 自动机全部内容,希望文章能够帮你解决AC 自动机所遇到的问题。

如果觉得脚本宝典网站内容还不错,欢迎将脚本宝典推荐好友。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
如您有任何意见或建议可联系处理。小编QQ:384754419,请注明来意。