AC自动机

AC自动机

简介

AhoCorasick automaton ,该算法在 1975 年产生于贝尔实验室,是著名的多模匹配算法。下面我们会举一个具体问题来讲解AC自动机。

前提知识

  • KMP 算法
    对于问题:
    找出长度 l 的字符串在长度为m的字符串中出现次数?
    KMP 给出了 O(l+m) 的做法
  • Trie
    n 个单词,按照单词的前缀来把n个单词构成一棵树,拥有相同前缀的单词在同一个分支上。
    举个例子:
    对于单词 say,her,he,shr,she
    我们构建 Trie
    这里写图片描述

问题

给出 n 个单词p1,p2,p3,......,pn,每个单词的长度为 l1,l2,l3,......,ln 。再给出一段包含 m 个字符的文章S,让你找出有多少个单词在文章里出现过。

分析

不妨假设每一个单词的长度是 O(l) 级别的长度

  1. 原始想法
    我们将每一次单词 pi S 进行匹配,然后判断这个单词在不在该文章中。此时我们可以看出来这样做的复杂度是O(ni=1piS)=O(nlS)。显然这个复杂度太高了。
  2. 优化
    每一次我们都需要将 S 与单词进行匹配,这个时候我们是可以使用KMP算法来完成。那么我们就可以把整个复杂度降低到 O(n(l+S)) ,显然这个复杂度要优化很多。
  3. 再优化
    我们是不是真的需要独立地去匹配每一个 pi S ?如果我们在所有模式串中找到了一定的联系,当S与其中一个匹配失败时,能够直接(或者说快速地)指向另外一个模式串,那我们将大大降低复杂度。再次利用 KMP 思想,我们把所有的模式串构建成一棵 Trie 树,然后 KMP 中失配时的 next 指针进行改成映射到树中某一个节点的 fail 指针。这样再进行匹配,那么总的复杂度就会变成 O(nl+S)

AC自动机

AC 自动机的算法过程:

  1. n 个模式串构建Trie 复杂度 O(nl)
  2. 构建 fail 指针 O(nl)
  3. 进行匹配 O(nl+S)

构建 Trie

我们如上图所示建立一棵 Trie ,代码如下:

void insert(char* str){
    int len = strlen(str);
    int u = rt;
    FOR(i,0,len){
        if(ch[u][str[i]-'a'] == -1){
            newnode();
            ch[u][str[i]-'a'] = sz;
        }
        u = ch[u][str[i]-'a'];
    }
    ++ val[u];
}

val 数组用来记录该节点是不是结尾节点,即该分支是不是一个单词。很容易看出来,建立过程复杂度为 O(nl)

构建 fail 指针

我们用 bfs 来构建 fail 指针,首先了解 fail 指针干了一件什么事情? fail 指针保证了能以 O(1) 的复杂度找到能与当前分支所构成的字符串匹配的最长公共后缀。
换句话说,如果当前分支为 roota1a2a3......al ,其中 fail[al]=bp ,那么 fail 指针指向的分支为 rootb1b2b3......bp fail 指针保证了 ali==bpi 对于任意 i<p 恒成立。
接下来我们用 bfs 来构造 fail 指针。
首先第一层的所有节点的 fail 指针都指向 root ,分别进入队列,然后 s 进入队列,查看子节点a,找到 fail[s] 的位置,看看 ch[fail[s]][a] 是否存在,如果存在,直接指向 fail[s] 下面的 a 子节点,不然指向fail[fail[s]],继续查看,直到 fail[s]=root 仍然匹配失败,就指向 root ,否则指向 ch[fail[s]][a] ,即 fail[ch[s][a]]=ch[fail[s]][a] 。根据这个规则,我们发现 a 指向root,然后 a 进队列,查看h子节点,发现 rooth 则,指向 root 下的 h 节点。依次构建,形成了下图中的fail指针图。
这里写图片描述
以下代码做了做了优化:

void build(){
    queue <int> q;
    FOR(i,0,26){
        if(ch[rt][i] == -1){
            ch[rt][i] = rt;
        }
        else{
            fail[ch[rt][i]] = rt;
            q.push(ch[rt][i]);
        }
    }
    while(!q.empty()){
        int u = q.front();  q.pop();
        FOR(i,0,26){
            if(ch[u][i] == -1){
                ch[u][i] = ch[fail[u]][i];
            }
            else{
                fail[ch[u][i]] = ch[fail[u]][i];
                q.push(ch[u][i]);
            }
        }
    }
}

很容易看出复杂度为 O(nl)

匹配

设我们要记录的答案为 ans
以字符串 S=yasherhes 为例,我们扫描 S 串。设扫描到了S[i]点,当前 Trie 所在的节点为 u 。分为两种情况:

  1. ch[u][S[i]]存在,那么继续向下扫描, u 变成ch[u][i] S[i] 点变成 S[i+1]

    • ch[u][S[i]] 不存在,那么 u=fail[u] ,继续匹配 ch[u][S[i]] ,直到:
      2.1 有一个节点匹配,那么 u 变成ch[u][i], S[i] 点变成 S[i+1]
      2.2 如果没有节点匹配,那么 u 变成root S[i] 点变成 S[i+1]
    • 按照以上规则匹配字符串 S S[0]=y字符失配,此时 u=root=0 。接下来匹配 S[1]=a ,继续失配。匹配 S[2]=s ,此时 u=1 。继续匹配 S[3]=h ,此时 u=7 。继续匹配 S[4]=e ,此时 u=9 ,发现这个节点是一个单词结尾,即 en[9]!=0 ,那么我们可以修改答案 ans=ans+1 ,修改 en[9]=0 。继续匹配 S[5]=r ,失配,那么 u=fail[9]=5 ,继续失配, u=fail[5]=0 ,失配, u=root 。匹配 S[6]=h u=4 。继续匹配 S[7]=e u=5 ans=ans+1 。匹配 S[8] ,失配。结束,那么答案就是 2
      附上代码:

      int query(char* str){
          int len = strlen(str);
          int now = rt;
          int res = 0;
          FOR(i,0,len){
              now = ch[now][str[i]-'a'];
              int temp = now;
              while(temp != rt){
                  res += val[temp];
                  val[temp] = 0;
                  temp = fail[temp];
              }
          }
          return res;
      }

      这里的复杂度就是O(nl+S)

      练习

      HDU 2222 Keywords Search

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值