AC自动机

AC自动机

引入:

我们之前对于字符串,学过了\(kmp\)匹配和\(trie\)树的写法,但是我们知道,这样的写法只能适用于两个字符串相互比较,并不能多个。

因此,我们引入了AC自动机这个概念。

AC自动机的意思并不是让你自动AC,而是一种匹配字符串的方法。

实例:

给定 \(n\) 个模式串和 \(1\) 个字符串,问有多少个模式串在字符串中出现过。

首先肯定是先建 \(trie\) 树。

如果我们直接进行匹配,那么就会出现一个很尴尬情况:到达最底部或者无法匹配。

按照原本的想法,我们只能从根开始继续匹配。

但是跟这种虫豸一样的暴力呆在一起,怎么能学好 \(OI\)

我们根据 \(KMP\) 的思想,更好的算法是从另一棵树上的相同最后一位匹配的字符开始匹配。

怎么确定从哪个点开始匹配,这就用到了 \(AC\) 自动机中最重要的——\(Fail\)指针

\(Fail\)

\(Fail\) 指针的含义:最长的当前字符串的后缀在 \(Trie\) 上可以查找到的末尾编号。

首先我们可以确定:每一个点 \(i\)\(fail\) 指针指向的点的深度一定是比 \(i\) 小的。

第一层 \(fail\) 就是根节点。

接下来的解决过程:

我们先把这一层所有的存在的节点加入队列,然后枚举队列里每一个元素:

首先搜索这个节点对应的子 \(trie\) 树:

  1. 存在子节点,让这个节点的失败指针指向(((他父亲节点)的失败指针所指向的那个节点)的下一个节点
if(c[x][i]){
    fail[c[x][i]]=c[fail[x]][i];
    q.push(c[x][i]);//存在子树,就压入队列
}
  1. 不存在这个子节点,就让这个子节点指向当前节点 \(fail\) 指针的子节点。
c[x][i]=c[fail[x]][i];

这样枚举完,我们就成功建好了一个 \(AC\) 自动机!


void ins(char *s){
    int len=strlen(s);int now=0;//计算字符串长度
    for(int i=0;i<len;i++){//字符串一位一位去搜索
        int x=s[i]-'a';//赋值
        if(!c[now][x]) c[now][x]=++cnt;//如果本身不存在,把它弄到一个新的地址,可以说是再建一棵树,同时记录拓扑序
        now=c[now][x];//指向地址(旧/新)
    }
    val[now]=1;//标记单词结尾
}
void build(){
    for(int i=0;i<26;i++)//第一层是根节点0
        if(c[0][i])//遍历第二层,搜索存在的子树
            fail[c[0][i]]=0,q.push(c[0][i]);//fail指向根节点
    while(q.size()){
        int x=q.front();q.pop();
        for(int i=0;i<26;i++){//搜索这一棵树
            if(c[x][i]){
                fail[c[x][i]]=c[fail[x]][i];//让这个节点的失败指针指向(((他父亲节点)的失败指针所指向的那个节点)的下一个节点)
                q.push(c[x][i]);//存在子树,就压入队列
            }
            else
                c[x][i]=c[fail[x]][i];//否则就让这个子节点指向当前节点fail指针的子节点 
        } 
    }
}

剩下的\(query\)查询操作因题而异,但基本上就是在\(trie\)树的基础上去寻找字符串。

int query(char *s){
    int len=strlen(s);int now=0,ans=0;
    for(int i=0;i<len;i++){//遍历文本串
        now=c[now][s[i]-'a'];//从s[i]点开始寻找
        for(int t=now;t&&~val[t];t=fail[t])//一直向下寻找,直到匹配失败
            ans+=val[t],val[t]=-1;//将遍历过的节点标记,防止重复
    }
    return ans;
}

例题:

洛谷P3796

题意:

给你\(n\)个字符串,再给你一个字符串,要求求出这 \(n\) 个字符串中在后面给的那个字符串中出现最多次的次数,以及输出对应的字符串。

解题方法:

我们可以将这 \(n\) 个字符串经行建造 \(ac\) 自动机。

然后让这个字符串在这个 \(ac\) 自动机中去跑,用 \(val\) 记录一下每个字符串的末端。如果跑到一次末端,就在对应末端的 \(ans[i]++\)

代码:

#include<bits/stdc++.h>
using namespace std;
const int N=50005;
int n,ans[N];
queue<int> q;
char p[152][1005];
struct AC{
    int fail[N*10],val[N],cnt,c[N][26];
    //......模板部分,直接Ctrl+C前面构造部分即可
    void query(char *s){
        int now=0,len=strlen(s);
        for(int i=0;i<len;i++){
            now=c[now][s[i]-'a'];
            for(int j=now;j;j=fail[j])
                ans[val[j]]++;//有单词结尾就++;
        }
    }
}AC;
void init(){
    while(q.size()) q.pop();AC.cnt=0;memset(ans,0,sizeof(ans));
    memset(AC.fail,0,sizeof(AC.fail));memset(AC.c,0,sizeof(AC.c));memset(AC.val,0,sizeof(AC.val));
}
char ch[1000005];   
int maxx=0;
int main()
{
    while(scanf("%d",&n)){
        init();
        if(n==0) break;
        for(int i=1;i<=n;i++) scanf("%s",p[i]),AC.ins(p[i],i);
        AC.build();
        scanf("%s",ch);
        AC.query(ch);
        for(int i=1;i<=n;i++)  maxx=max(maxx,ans[i]);
        cout<<maxx<<endl;
        for(int i=1;i<=n;i++)
            if(ans[i]==maxx)
                printf("%s\n",p[i]);
    }
    system("pause");
    return 0;
}

优化:

分析时间复杂度,暴力跳 \(fail\) 指针最坏时间复杂度为 \(O(len(原串)*len(现串))\)

如果我们每一个 \(trie\) 上的点不止经过一次(即重复算),那么时间复杂度会炸。

考虑优化:

拓扑排序:

我们把指针看成有向边,如果按照之前的方式,例如有一个 \(fail\) 指针为:

\[3->5->7 \]

我们操作 \(3\),同时也要更新 \(5,7\) 。 操作 \(5\) ,也要更新 \(7\).

当所有标记一次性传递上来,更新其他点的答案,会减少很多时间。

还是刚才那个例子,在 \(3\) 打一个 \(ans=1\) ,在 \(5\) 打一个 \(ans=1\), 最后直接跳\(fail\) 赋值,得到三个节点答案:\(1,2,2\).

上面构造时,我们记录了每个节点的 \(dfs\) 序:

if(!c[now][x]) c[now][x]=++cnt;

打了标记后,肯定是从深度大的点开始更新上去的,考虑拓扑排序,将每一个点向其 \(fail\) 指针连一条边,一定是个 \(DAG\)

直接遍历图,即可更新答案。

例题:

P5357 【模板】AC自动机(二次加强版)

前面没什么不同,运用这个优化:

建出 \(fail\) 树,对当前匹配串进行打标记,也就是标记匹配的情况,然后进行标记的推广(如果在同一条路径上,说明都能进行匹配,只是省略了直接算的过程,类似于前缀和)

记录自动机上的每个状态被匹配了几次,最后求出每个模式串在 \(Trie\) 上的终止节点在 \(fail\) 树上的子树总匹配次数就可以了。

#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n;char p[N*10],s[N*10];    
int c[N][26],cnt=1,fail[N],val[N];
queue<int> q;
struct AC{
    void ins(char *s,int v){
        int len=strlen(s);int now=1;
        for(int i=0;i<len;i++){
            int x=s[i]-'a';
            if(!c[now][x]) c[now][x]=++cnt;
            now=c[now][x];
        }
        val[v]=now;//标记单词结尾
    }
    void build(){
        for(int i=0;i<26;i++) c[0][i]=1;
        q.push(1);
        while(q.size()){
            int x=q.front();q.pop();
            for(int i=0;i<26;i++){
                if(c[x][i])
                    fail[c[x][i]]=c[fail[x]][i],q.push(c[x][i]);
                else
                    c[x][i]=c[fail[x]][i];
            }
        }
    }
}AC;
int times[N];
int nxt[N],head[N],ver[N],tot;
void add(int x,int y){
    ver[++tot]=y;
    nxt[tot]=head[x];
    head[x]=tot;
}
void dfs(int x){
    for(int i=head[x];i;i=nxt[i]){
        int y=ver[i];
        dfs(y);
        times[x]+=times[y];//记录自动机上的每个状态被匹配了几次
    }
}

int main()
{
    cin>>n;
    for(int i=1;i<=n;i++)
        scanf("%s",p),AC.ins(p,i);
    AC.build();
    scanf("%s",s);
    for(int now=1,i=0;s[i];i++){
        now=c[now][s[i]-'a'];
        ++times[now];//记录匹配的次数
    }
    for(int i=2;i<=cnt;i++) add(fail[i],i);
    dfs(1);
    for(int i=1;i<=n;i++) printf("%d\n",times[val[i]]);//每个模式串在 Trie 上的终止节点在 fail 树上的子树总匹配次数
    system("pause");
    return 0;
}
posted @ 2021-03-01 11:28  Evitagen  阅读(130)  评论(0)    收藏  举报