AC自动机

前置芝士

  1. kmp
  2. trie

介绍

学算法首先肯定要清楚这个算法是用来解决啥东西的。

AC 自动机是用线性的复杂度来解决多模匹配的算法。
额(⊙o⊙),说人话就是例如给你一堆字符串(称为模式串)和一个字符串(称为文本串),让你求模式串们在文本串出现的总次数。

来直接看模板题:

AC 自动机(简单版)

题目描述

给定 \(n\) 个模式串 \(s_i\) 和一个文本串 \(t\),求有多少个不同的模式串在文本串里出现过。
两个模式串不同当且仅当他们编号不同。

对于 \(100\%\) 的数据,保证 \(1 \leq n \leq 10^6\)\(1 \leq |t| \leq 10^6\)\(1 \leq \sum\limits_{i = 1}^n |s_i| \leq 10^6\)\(s_i, t\) 中仅包含小写字母。

那么好,现在应该很清楚了,这不就是 kmp 的模板题中模式串变多了亿点点吗?如果每个模式串分开匹配,平方级别复杂度,直接爆炸。众所周知:

\[AC自动机=trie+kmp \]

我们要做的是把所有的模式串建立一个 trie,然后在 trie 上跑 kmp。

可以回想一下 kmp 匹配的过程。有一个数组 \(fail\),或者是 \(next\)(以下简称为 \(f\))。

它的含义有很多,本质上 \(f[i]\) 是指:模式串 \(s\) 的下标为 \(i\) 的位置失配时,模式串的指针应该返回的下标。

如果将 \(f\) 数组的所有值向前移动一位,那么他的含义就可以变为:在以 \(s[i]\) 结尾的前缀中,它的真后缀能匹配到的最长前缀的长度。这也是我们求 \(f\) 数组的关键。

这样的话,在失配的情况下,指针回跳的次数就能最少,可以保证线性的复杂度。

类比一下,trie 上的 kmp 也是一样的 \(f[i]\) 是指:下标为 \(i\) 的位置失配时,模式串的指针应该返回的下标。

现在已经匹配完了节点 \(i\),接下来要匹配它的子节点,如果失配(或者匹配完了),说明这一条路就不必继续走了,要换一条。而从根节点到 \(i\) 都是匹配的,我就可以跳到 \(j\) 上,使得 \(root\)\(j\)\(root\)\(i\) 的真后缀能匹配到的从根开始的最长前缀,以保证回跳次数最少。

是不是和 kmp 几乎一样?这样,跳到 \(j\) 的位置,继续匹配 \(j\) 的子节点。其实就是在匹配一个模式串时不行,直接换一个继续。都不行就返回根从头来过。这样解释就十分贴切于 kmp 了。

kmp 其实可以看作 trie 为一条链的 AC 自动机。

举个例子:

she
he
say
shr
her

trie 图为:

绿色的箭头就是 \(fail\) 指针,根据定义:

f[2]=4
f[3]=5
f[6]=0

比如在二号节点失配,那么 \(s\)\(h\) 肯定是匹配好了的,所以返回四号节点,继续匹配。

操作

insert

主要就是建立 trie 的过程,和模板一样的,就不多解释了。

// 这里p为下标,tr为节点编号,tot为节点总数
void insert(string x)
{
    int p=0;
    for(auto c:x)
    {
        int u=c-'a';
        if(!tr[p][u]) tr[p][u]=++tot;
        p=tr[p][u];
    }
    cnt[p]++;//这里要视情况而定
}

build

\(fail\) 的含义上面已经说清楚了,现在就要解决如何去求得 \(fail\) 数组。

先看到 kmp 是如何求的:

int i=1,j=0;
while(i<s.size())
{
    //j=f[i-1];
	while(j&&s[i]!=s[j]) j=f[j];
	if(s[i]==s[j]) j++;
	f[++i]=j;
}

当然会有其他的写法,不过也大同小异。注意看注释掉的一行,注释的原因是因为这就是我们上一次循环求得的 \(j\),不需要再赋一遍值。但加上后这个过程就会更加清晰。

还是拿这个例子。

绿色表示已经匹配好了的可以继续用的,红色表示即将要匹配的。

可以看到,要是红色的匹配成功了,自然绿色部分长度加一;若是不成功,就要保证 \(i\) 位置上前面的绿色部分尽可能多,所以再跳一次。不行的话,继续跳,直到 \(0\) 也不成功。这时 \(f[i]=0\)

也就是说我们要用到上一个位置的 \(f\) 值,递推的去求。而在 trie 上,就可以用 BFS 逐层去求 \(f\) 数组。

至于代码,直接对应 kmp 写就好了,注意第一层的 \(f\) 肯定是 \(0\)

queue<int> q;
void build()
{
    for(int i=0;i<26;i++) 
        if(tr[0][i])
            q.push(tr[0][i]);//根的儿子都为0,直接入队
    while(!q.empty())
    {
        int u=q.front();q.pop();
        for(int i=0;i<26;i++)//遍历下一层的所有儿子
        {
            int v=tr[u][i],j=f[u];//j=f[i-1];
            while(j&&!tr[j][i]) j=f[j];//while(j&&s[i]!=s[j]) j=f[j];
            if(tr[j][i]) j=tr[j][i];//if(s[i]==s[j]) j++;
            f[v]=j;//f[++i]=j;
        }
    }
}

一模一样对吧?等一等,怎么感觉不太对,为啥别人的代码没有 for 里面的 while

其实 AC 自动机有一个优化,就是把这个 while 优化掉的。优化后也叫做 trie 图。也就是我们一般所写的形式,统称为 AC 自动机。优化只是优化常数,优化前后其实都是线性的复杂度。

那么好,问题就在于 while。匹配 \(u\) 的儿子 \(i\) 的时候 \(f[u]\) 的儿子里没有 \(i\),就要往上跳。

这里就可以用类似并查集中路径压缩的方法,将信息存在没有的虚点中,在匹配失败后对号入座就好了。

queue<int> q;
void build()
{
    for(int i=0;i<26;i++) 
        if(tr[0][i])
            q.push(tr[0][i]);
    while(!q.empty())
    {
        int u=q.front();q.pop();
        for(int i=0;i<26;i++)
        {
            int v=tr[u][i];
            if(!v) tr[u][i]=tr[f[u]][i];//若失败,继承信息
            else f[v]=tr[f[u]][i],q.push(v);//若成功,直接赋值
        }
    }
}

这就是 AC 自动机建立的终极形态 QWQ。

查询

明白了重点以后,这个就简单很多了,也是由 kmp 的代码直接推过来。

为避免重复,用 \(-1\) 来标记。

只不过当匹配到一个后,它的前缀也要统计答案,而前缀中可能出现的都应该在 \(f\) 里面,所以一直跳直到根统计答案就好了。

int query(string x)
{
    int p=0,ans=0;
    for(auto c:x)
    {
        int u=c-'a';
        while(p&&!tr[p][u]) p=f[p];//while(j&&t[i]!=s[j]) j=f[j];
        if(tr[p][u]) p=tr[p][u];//if(t[i]==s[j]) j++;

        int j=p;
        while(j&&cnt[j]!=-1)
        {
            ans+=cnt[j];
            cnt[j]=-1;
            j=f[j];
        }
    }
    return ans;
}

和 build 同理,这也可以优化,就直接变成了:

int query(string x)
{
    int p=0,ans=0;
    for(auto c:x)
    {
        int u=c-'a';
        p=tr[p][u];//省去while
        int j=p;
        while(j&&cnt[j]!=-1)
        {
            ans+=cnt[j];
            cnt[j]=-1;
            j=f[j];
        }
    }
    return ans;
}

code

完整代码:

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+5;

queue<int> q;
int n,tot;
int tr[N][26],cnt[N],f[N];
string s;
void insert(string x)
{
    int p=0;
    for(auto c:x)
    {
        int u=c-'a';
        if(!tr[p][u]) tr[p][u]=++tot;
        p=tr[p][u];
    }
    cnt[p]++;
}
void build()
{
    for(int i=0;i<26;i++) 
        if(tr[0][i])
            q.push(tr[0][i]);
    while(!q.empty())
    {
        int u=q.front();q.pop();
        for(int i=0;i<26;i++)
        {
            int v=tr[u][i];
            if(!v) tr[u][i]=tr[f[u]][i];
            else f[v]=tr[f[u]][i],q.push(v);
        }
    }
}
int query(string x)
{
    int p=0,ans=0;
    for(auto c:x)
    {
        int u=c-'a';
        p=tr[p][u];
        int j=p;
        while(j&&cnt[j]!=-1)
        {
            ans+=cnt[j];
            cnt[j]=-1;
            j=f[j];
        }
    }
    return ans;
}
int main ()
{
    cin>>n;
    for(int i=1;i<=n;i++) 
    {
        cin>>s;
        insert(s);
    }
    build();
    cin>>s;
    cout<<query(s)<<"\n";
    return 0;
}

拓扑建图优化

代填的坑 qwq。

一些心得体会

算法学习中,有些可以半懂不懂,比如 kmp,了解 \(fail\) 是啥,怎么写代码就行了,这样反而会更轻松。而有些若想要融汇贯通,则必须充分理解,才能应用到题目中。

写博客总结的过程中,可以顺便梳理整个过程。带着想要教会别人的目标,不知不觉间自己也能更加深刻地理解。更何况,认真写出一篇博客后,成就感是真真切切的。

前路漫漫,未到抬头之时,我只需,低头前行。