这篇文章上次修改于 275 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

算法学习笔记(14):AC自动机

这名字听起来也太高级了,但实际上就是KMP+TIRE树。

所以不妨别管这个名字(Aho–Corasick Automaton)

实际上目的是多字符串匹配查找。

对于字符串,先要了解一些术语,为了方便之后描述。

  • 模式匹配:也就是字符串匹配问题,在主串中寻找子串。
  • 模式串:用于查找的字符串,例如114514中找45,45就是模式串
  • 待匹配串:114514
  • 多串匹配:多个模式串和一个待匹配串的匹配问题

AC自动机解决的是多串匹配问题。

思想构建

首先由于是多串问题,对于多串匹配,也就是多模式匹配,我们需要存储多个模式,例如n个串,在匹配的过程中我们要存储n个匹配状态,以及保存n个模式串的内容。

其次再考虑匹配问题,AC自动机还是离不开KMP算法的思想,回顾KMP算法的思路,KMP主要注重减少重复的匹配,何为重复的匹配,也就是我们匹配的过程中,能否省略掉已经匹配的部分,减少匹配次数。

多串问题解决

对于多个串,我们可以利用字典树,字典树利用的是字符串的字符种类个数有限的特性,对于多个模式串统一到了字典树之中,此时再讨论匹配状态,可以发现所有的匹配状态都统一了。对于一个起点,一个一个字符的匹配就相当于走树,走到某个节点就是匹配到了哪一个部分,走过了哪个模式串就是这个模式串匹配成功。字典树很好的处理了多串问题,将多串化繁为简。

而且如果两个模式串都有公共前缀abc,那么在匹配的时候就会被同时考虑。

匹配问题解决

使用了字典树把问题转移到了枚举每个起点,来寻找匹配。这是肉眼可见的暴力的,这是可以优化的。优化思路同KMP,减少匹配次数,利用字符串的重复性。

先回顾KMP算法再来这里开始看。

回顾KMP:在ABABABCAA 中寻找 ABABC,在被匹配串匹配到第五个字符A和模式串C不匹配,下一步从第三个字符开始比对。因为第34个字符是AB,依靠next数组直接跳到了第三个字符进行比较。

对于字典树能否也找到减少匹配的优化点呢?(甚至比KMP更好理解)

假设我已经匹配了前面的部分 ABCDE,下一个字符F无法匹配了。此时,我们寻找是否有模式串以BCDE开头;如果有,从BCDE开始匹配,如果没有,看看CDE,没有再看看DE,E,都没有,决定从头开始。

不同于KMP,多串匹配有更多的可能性可以重复利用已经比较过的部分,KMP只能通过ABAB,自身的相同来省略。例如多模式匹配,可能有ABCDE,BCDE,CDE,可以重复利用的机会更大。

步骤上来说,实际上是不断放弃前缀,看看能否利用,能利用,则跳转过去。

这种跳转的行为模式 和 KMP的next数组跳转 是一样的,目的就是利用已经匹配的部分。

在KMP中被称作next,在AC自动机中我们称作失陪指针,又叫fail指针,当这次匹配失败,就该到哪里开始。

根据以上思想,我们可以有以下图(from BV14v4y1Z7fu):

pic

左边的词就是我们的多个模式子串,而右边就是我们构造的字典树,其中虚线就是我们的失配指针(fail指针),虚线看起来事很复杂繁乱的,但我们只需要注意其中的几个。

例如节点5,当我们匹配到节点5失败后,应当跳转到节点7:

因为到5的时候已经有hi成功,这时候放弃前缀h,看看有没有i开始的,也就是从节点7开始。

例如节点6,当我们匹配到节点6的时候,应当跳转到节点8:

因为到6的时候已经满足了his,放弃前缀h,看看有没有is开始的,也就是8.

例如节点11,应当跳转到节点1:

到11的时候满足了you,放弃前缀,看看ou有没有,没有则看看u有没有,没有就从头开始。

实际上很好理解,接下来考虑fail指针如何构建。

失配指针构建(fail指针)

由上可知,失配指针永远都是从深度更深到更低的,也就是说,我们在处理深度更深的指针时,已经处理了深度更小的指针。因此我们采用BFS的思路来构建。

再考虑子节点的指针和父节点指针的关系:

例如上图中的5,6,我们可以发现,6的指针就是在5的指针下,走了s的一步。

但如果没有s的这一步呢(没有7 - 8 是 s 的边)?如果7-8 是 p,那么6的指针就是起点。

根据我们的放弃前缀解释,我们要找放弃最短前缀(转移后最长)的一次转移。(强调)

见代码中体现:

// 失配指针数组
int failPtr[MAXS]; 
  
// g[a][ch] 的意思是从状态点a 通过字符ch的边到达的状态值
int g[MAXS][MAXC]; 
 
int buildMatchingMachine(string arr[], int k) 
{ 
    // 初始化
    memset(out, 0, sizeof out); 
    memset(g, -1, sizeof g); 
  
    // 一开始我们只有0个状态值
    int states = 1; 

    // 构造字典树
    for (int i = 0; i < k; ++i) 
    { 
        const string &word = arr[i]; 
        int currentState = 0; 
        for (int j = 0; j < word.size(); ++j) 
        { 
            int ch = word[j] - 'a'; 
            if (g[currentState][ch] == -1) 
                g[currentState][ch] = states++; 
            currentState = g[currentState][ch]; 
        }
        // 可以为最后一个字符位置加标记。
		 }
  
    // 对于所有在Trie中没有根边(或状态0)的字符,在状态0本身添加一条goto边
    for (int ch = 0; ch < MAXC; ++ch) 
        if (g[0][ch] == -1) 
            g[0][ch] = 0; 
  
    // 开始构建失配指针
  
    memset(failPtr, -1, sizeof failPtr); 
  
    // 失配指针构建使用BFS构建
    queue<int> q; 
  
    // 迭代所有可能的字符
    for (int ch = 0; ch < MAXC; ++ch) 
    { 
        // 深度为1的失配指针都是指向0的起点
        if (g[0][ch] != 0) 
        { 
            failPtr[g[0][ch]] = 0; 
            q.push(g[0][ch]); 
        } 
    } 
  
    while (q.size()) 
    { 
        int state = q.front(); 
        q.pop(); 
  
        // 对于已删除状态,为所有未定义goto函数的字符查找失败函数。
        for (int ch = 0; ch <= MAXC; ++ch) 
        { 
            if (g[state][ch] != -1) 
            { 
                int failure = failPtr[state]; 
                
                // 找到从根到当前状态的字符串的适当后缀标记的最深节点
                while (g[failure][ch] == -1) 
                      failure = failPtr[failure]; 
  
                failure = g[failure][ch]; 
                failPtr[g[state][ch]] = failure; 

                q.push(g[state][ch]); 
            } 
        } 
    } 
    return states; 
} 

tps:仅供参考学习,不建议作为板子

查询就十分简单了:

int findNextState(int currentState, char nextInput) 
{ 
    int answer = currentState; 
    int ch = nextInput - 'a'; 
    
    while (g[answer][ch] == -1) 
        answer = failPtr[answer]; 
        
    return g[answer][ch]; 
} 
  

void searchWords(string arr[], int k, string text) 
{ 
    buildMatchingMachine(arr, k); 
    int currentState = 0; 
    for (int i = 0; i < text.size(); ++i) 
    { 
        currentState = findNextState(currentState, text[i]); 
			  // 如果当前状态值是被标记的状态值,就代表匹配了一次字符串
    } 
} 

参考代码2:(计算所有子串的出现次数之和)

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

int tr[N][26], tot;
int strEnd[N], fail[N];

void insert(const string & str)
{
    int cur = 0;
    for (char ch : str)
    {
        if (!tr[cur][ch - 'a']){
            tr[cur][ch - 'a'] = ++tot;
        }
        cur = tr[cur][ch - 'a'];
    }
    strEnd[cur]++;
}

void build()
{
    queue<int> BFSque;
    for (int i = 0; i < 26; i++)
        if (tr[0][i])
            BFSque.push(tr[0][i]);
    
    while (BFSque.size())
    {
        int cur = BFSque.front(); BFSque.pop();
        
        for (int i = 0; i < 26; i++)
        {
            if (tr[cur][i])
            {
                fail[tr[cur][i]] = tr[fail[cur]][i];
                BFSque.push(tr[cur][i]);
            }
            else{
                tr[cur][i] = tr[fail[cur]][i];
            }
        }
    }
}

int query(const string & text)
{
    int u = 0, res = 0;
    for (char c:text)
    {
        u = tr[u][c - 'a'];
        for (int j = u; j && strEnd[j] != -1; j = fail[j])
        {
            res += strEnd[j], strEnd[j] = -1;
        }
    }
    return res;
}

对于这个问题,当我们匹配成功了ABCD的时候实际上BCD也成功匹配了,需要把所有后缀都进行计数。 为什么不在fail了之后再加呢?因为走进下一步的时候已经是ABCDE了,无法计数CD,故在走到ABCD的时候就需要对所有后缀进行统计,而此时的后缀恰恰好就是我们fail指针指向的地方。

优化AC自动机

这两份参考代码如果认真看,可以发现第二份中并没有第一份的while循环,第二份代码把这个while的步骤交给了移动的时候处理。

而对于字符串的计数问题:

这个while的步骤,也就是fail数组往上不断跳跃的部分,这个部分导致了运算的时候反复经过fail跳跃。跳跃不要紧,重要的是要计算的贡献的时候要不断跳跃,计算ABCD的时候要计数BCD,CD, D,计数BBCD的时候也要计数BCD,CD,D。他们走了相同的步骤,所以是一个很大的重复计算。

为了避免不断地向上重复记录,采用懒惰标记,在走到一个fail的时候添加懒惰标记,再在最后把所有fail情况进行一个跳跃讨论。

同时建图的时候考虑fail指针形成的是一个树,并且拓扑排序恰好对应了这个统计过程。

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

namespace ACAUTO
{
char s[maxn], T[maxn];
int n, status, vis[200051], ans, in[maxn], Map[maxn];

struct Node
{
    int son[26], fail, flag, ans;
    void clear() { memset(son, 0, sizeof(son)), fail = flag = ans = 0; }
};

Node trie[maxn];
const int ROOT = 1;
const int NONE = 0;

queue<int> q;
void insert(const string & str, int strId)
{   
    int cur = ROOT;
    for (char ch: str)
    {
        int v = ch - 'a';
        if (trie[cur].son[v] == NONE)
            trie[cur].son[v] = ++status; // 为自动机添加状态
        
        cur = trie[cur].son[v];
    }
    if (not trie[cur].flag)
        trie[cur].flag = strId;

    Map[strId] = trie[cur].flag;
}

void getFail()
{
    for (int i = 0; i < 26; i++)
        trie[0].son[i] = ROOT;

    q.push(ROOT);
    while (not q.empty())
    {
        int cur = q.front();
        q.pop();

        int Fail = trie[cur].fail;
        for (int i = 0; i < 26; i++)
        {
            int son = trie[cur].son[i];
            if (trie[cur].son[i] == NONE){
                // 如果没有说明已经匹配失败了,往上跳
                trie[cur].son[i] = trie[Fail].son[i];
            }else{
                // 如果有,就是爹的fail的 相同son位置
                trie[son].fail = trie[Fail].son[i];
                q.push(son);

                in[trie[son].fail]++; // 记录 son 的入度
            }
        }
    }
}

void topu()
{
    for (int i = 1; i <= status; i++)
        if (in[i] == 0)
            q.push(i);
    
    while (!q.empty())
    {
        int cur = q.front();
        q.pop();
        vis[trie[cur].flag] = trie[cur].ans;
        int v = trie[cur].fail;
        in[v]--;
        trie[v].ans += trie[cur].ans;
        if (in[v] == 0)
            q.push(v);
    }
}

void query(const string & str)
{
    int cur = ROOT;
    for (char ch : str){
        cur = trie[cur].son[ch - 'a'];
        trie[cur].ans++;
    }
}

} // namespace ACAUTO

int main()
{   
    using namespace ACAUTO;
    scanf("%d", &n);
    status = 1;
    for (int i = 1; i <= n; i++)
    {
        scanf("%s", s);
        insert(s, i);
    }
    getFail();
    scanf("%s", T);
    query(T);
    topu();
    for (int i = 1; i <= n; i++)
        printf("%d\\n", vis[Map[i]]);
}

学会使用AC自动机后,要能对代码进行各种方面的修改,才算是真正理解。

何为自动机,可以看做是一个机器,机器在工作的时候有诸多状态,比如查询字符串abcd,查询到b位置是一个状态,查询到c位置是一个状态,所以对于我们的AC自动机,字典树上的每个节点就是一个状态。自动机就是在状态之间不断变化而称作自动机。