AC自动机!

AC自动机

AC自动机,顾名思义,就是一种把题目输入后,能够自动生成AC代码的机器

AC自动机的名字来源于贝尔实验室的研究人员 Alfred V. Aho 和 Margaret J.Corasick,常常应用于模式匹配中。

举例子:对于某个字母串wcadqcareal中,我们想知道care, real, apple, cat等子串一共出现了几次。AC自动机便可以实行效率较高的模式匹配。

构建Trie树

对于如上例子,我们先构建一棵Trie树,如下图:

这里我们注意到,除了根节点,每个节点都保存了一个字母,并且从根节点的子节点向下走到某个位置时,这串路径可以表示一个子串。

这里需要注意一点,在构建的过程中并不能认为走到叶节点才算一个子串。比如car和care, car是care的前缀,走到e结尾的话,子串car会被忽略。因此需要记录一个节点是不是一个单词的末尾。

构建Trie树的过程,便是从上往下找,如果字母子节点已经存在,则继续往下走,否则创建子节点。

用数组模拟链表,相关代码如下:

点击查看代码
int tr[N][26], idx, cnt[N]; //tr数组的第一维表示节点编号,26表示不同小写字母的下一个走向,idx类似于指针,cnt数组表示这个子节点总共出现的次数。
bool st[N];标志某个节点是不是单词结尾

void insert(char str[]){
    int p = 0;//默认根节点编号为0
    for(int i = 0;str[i];i ++){
        int t = str[i] - 'a';
        if(!tr[p][t])tr[p][t] = ++ idx;//如果对应子节点不存在则创建子节点
        p = tr[p][t];//走向子节点
        cnt[p] ++;
    }
    st[p] = true;
}

AC自动机的fail指针

接下来会提到i和j两个指针. 其中i是正在匹配的字母串中的第一个字母,j是正在匹配的字母串中的最后一个字母。

比如例子中wcadqcareal, 我们正在从第六个字母c开始往后匹配,匹配到了r,那么i指向字母c,j指向字母r.

那我们便开始进行匹配。匹配的过程是,如果j指向的字母在Trie树中存在对应的子节点,那么j节点便指向对应的子节点,不然匹配失败,没有子串,需要将i指针在字母串中的位置向前移动一位,并将j的指针重新指向i.

比如我们正在从第二个字母c开始匹配,那么i和j都指向c, 接下来j指针从根节点向下查找,发现Trie树中存在对应子节点,那么j指针指向树中对应节点,再从对应节点中查找下一个字母d, 发现不存在相对应的子节点,那么匹配失败了,需要将i指向下一个字母a, 并让指针j指向i对应的a重新匹配。

那对于这个双指针算法,我们在对例子进行匹配的起初一段长度内都认为是无法匹配的,直到走完了care, 出现了一个匹配。那么接下来,需要让j回溯到a. 接下来继续匹配,可以匹配到real.

可是我们注意到一个问题,明明care的后缀和real的前缀存在相同部分re,那我们能不能想想办法,让i能够直接跳到Trie中后缀与前缀最长相同的地方,从而不进行指针j的回溯呢?

这就是我们接下来要构造的fail指针。fail指针,顾名思义,就是在匹配失败时,让指针指向的地方。

我们在这里规定一个单词的后缀或者前缀长度必须小于整个单词的长度

不妨就拿care这个词来举例子。首先是第一个字母,第一个字母构造fail指针的意义不大,让其直接指向root节点。

接下来是a这个节点。我们注意到对于ca这个子串的后缀a, 在Trie树中存在的确存在部分前缀与它相同,是appple的部分前缀。

那可以让a的fail指针指向apple的节点a。

接下来是r的fail指针。我们注意到存在相同最长后缀r, 是real的部分前缀,那我们可以让care中r的fail指针的指向real的节点r.

接下来是e的fail指针,我们注意到存在相同最长后缀re, 是real的部分前缀,那我们可以让care中e的fail指针的指向real的节点e.

那么我们构造出来的fail指针(红色线条)是这样的:

那我们可以对Trie树做进一步的处理:如果某个根节点在当前节点中不存在,但是在相同的前缀部分出现了,那我们就把当前节点向那个子节点连一条边。如下所示(蓝色是部分新连的边):

这是通过这种代码实现的:

点击查看代码
for(int i = 0;i < 26;i ++){
        int &p = tr[t][i];
        if(!p) p = tr[fail[t]][i];
}

前方已经说明了,fail[t]对应节点的字母与t节点对应字母是相同的。如果p节点不存在,那么只需让这个节点p指向前缀相同一部分后的姐节点就好了。那么可能将原本不匹配的情况变为匹配的情况。

那如何构建fail指针?

我们假设前方的fail指针已经构造好了。由于AC自动机里的后缀与前缀的重合部分是最大重合部分,那我只需要让新一个节点的fail指针指向下一个它父节点fail指针下对应节点的子节点即可。

听起来有点绕。举个例子:我们假设care下的r的fail指针已经指向了real的r对应节点,那么只需让e的fail指针指向real中的e对应节点。而这real中r节点对应的确存在e对应节点,那么便让care中e的fail指针,指向r的fail指针指向的real的r节点下的子节点e即可。

即fail[p] = tr[fail[t]][i]

其中p是t的子节点,i是小写字母字母转换的数值。

如果某一个子串前缀只有r但是没有re, 那么怎么办?

由于全局变量初始化为0, 那么没有re的部分,对应的tr指针会指向0, fail指针也会跟着对应指向0, 即根节点,这就实现了没有相同的后缀与前缀时从根节点重新开始寻找。
所以我们刚才在care这个子串的建立时时少加了一条边(绿色):

由于子串的后缀小于整个单词,我们在进行初始化时,只要按照树的深度依次添加字母,便可以实现Trie树的更新。这里通过队列BFS实现。

点击查看代码
int q[N];//模拟队列

void build(){
    int hh = 0, tt = -1;
    for(int i = 0;i < 26;i ++)
        if(tr[0][i])
            q[++ tt] = tr[0][i];//把与根节点联通的子节点加入队列
            
    while(hh <= tt){
        int t = q[hh ++];
        for(int i = 0;i < 26;i ++){
            int &p = tr[t][i];
            if(!p) p = tr[fail[t]][i];//如果子节点不存在,就让子节点指向父节点fail指针指向节点的对应节点
            else{//如果子节点存在
                fail[p] = tr[fail[t]][i];//建立fail指针
                q[++ tt] = p;//将该节点加入队列中以更新其他点。
            }
        }
    }
}

这样Trie树就被重建了。在进行匹配时,便不需要j的回溯了,i会一直沿着待匹配串一直走下去,提高了效率。

总结

AC自动机的核心在于构造fail指针(虽然这里完全采取了数组模拟)。将fail指针构造出来后,便可以实现树的重建。在不同的题目中,fail指针也会有其他作用,这个就要看具体题目了。

不过我们学AC自动机的一个目的就是希望能像名字一样做题AC嘛

感悟

写博客中途一定要保存。这次博客写到一般电脑卡机了,博客没有保存。随着电脑崩了,我的心态也崩了。

参考网站

AcWing

posted @ 2022-12-31 17:44  20221312付安旭  阅读(644)  评论(1编辑  收藏  举报