首页 > 其他分享 >AC 自动机学习笔记

AC 自动机学习笔记

时间:2024-01-16 20:37:37浏览次数:26  
标签:AC int tr 笔记 Trie fail 自动机 节点 指针

AC 自动机学习笔记

AC 自动机可以用于解决字符串上的出现次数,出现位置问题。结合了 Trie 树和 KMP 的思想,在 \(O(n)\) 的时间内完成查询 。相较于 KMP 的好处在于,AC 自动机不仅速度快,而且支持多个模式串同时在一个文本串内查询。

算法

前置知识:Trie 树,KMP,自动机基本概念。

构建 AC 自动机有两个步骤:

1.基础 Trie 结构:将所有模式串构建成一颗 Trie 树。

2.KMP 思想:对 Trie 树上的节点构造失配指针。

然后进行多模式匹配。

构建 Trie 树

直接把所有模式串构建普通的 Trie 树,同时强调,Trie 树中的某个节点表示了某个模式串的前缀。

后文将前缀也成为状态。

失配指针

AC 自动机利用 fail 指针来优化多模式串的匹配。

状态 \(u\) 的 fail 指针指向状态 \(v\),这里状态 \(v\) 是状态 \(u\) 的最长后缀。

如果不存在最长后缀,那么 fail 会指向 Trie 树的根节点。

匹配时,文本串会从根开始沿着和当前字符一样的边向下,如果在某一个节点无法向下,我们称为失配。

在文本串失配时,将会沿着当前节点的 fail 指针前往下一个点继续匹配。

(而且你会发现,文本串到达了某个状态,那么沿着这个状态的 fail 指针遍历,这些遍历的状态也都在文本串中出现过,这个性质后面会用到。)

证明求出 fail 指针呢?

设 \(p\) 为点 \(u\) 的父亲,且深度小于 \(u\) 的点的 fail 指针均已经求出,节点 \(p\) 通过字符 \(c\) 指向 \(u\)。这条边为 \(trie[p,c]\)。

1.如果 \(trie[fail[p],c]\) 存在,u 的 fail 指针直接指向 \(trie[fail[p],c]\)。

2.否则重复使 \(p=fail[p]\)(这里会改变 \(p,u\) 的关系,但是不重要),直到寻找的一个存在的 \(trie[fail[p],c]\),能够让 u 的 fail 指针指向 \(trie[fail[p],c]\)。

3.真的不存在,让 fail 指针指向根。

如此完成 fail 的构建。

字典树与字典图

1.字典树:

void insert(char *s,int num)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
    {
        int v=s[i]-'a';
        if(!trie[u].son[v]) trie[u].son[v]=++cnt;
        u=trie[u].son[v];
    }
    if(!trie[u].flg) trie[u].flg=num;
    rev[num]=trie[u].flg;
}

这里的 flg 指向了到达这个节点的第一个模式串,如果有别的模式串相同,那么我们让这个模式串指向第一个相同的即可。

2.字典图

我们用文本串遍历字典树时,如果失配要沿着 fail 指针找到一个可以匹配的节点,但这个过程往往会耗费很多时间。

考虑能不能优化这个过程,我们设 \(tr[u,c]\) 为 Trie 树上的节点 \(u\) 在末尾添加一个字符 \(c\) 到达的节点,这里 \(tr[u,c]\) 优先指向自己的儿子。

特别的,如果根节点的 \(tr[rt,c]\) 不存在,那么会指向根节点自己。

不难发现,\(fail[u]\) 的深度肯定小于 \(u\),在处理 \(u\) 节点时,所有深度小于 \(u\) 节点的 \(tr\) 已经求出。

如果 \(u\) 节点有通过字符 \(c\) 到达的儿子,那么 \(tr[u,c]\) 指向自己的儿子;如果没有,那么 \(tr[u,c]\) 指向 \(tr[fail[u],c]\)。

这样子我们就省去了遍历 fail 链的时间,直接通过 \(tr[u,c]\) 跳节点即可。

void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;//tr[0,c] 均指向根
    que.push(1);
    trie[1].fail=0;//根的 fail 指针指向 0,方便后面求 tr[u,c]
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            que.push(v);
        }
    }
}

\(trie[u].son[i]\) 就是 \(tr[u,i]\)。

多模式匹配

朴实无华的做法:

设当前的字文本串为 \(s\),是第 \(i\) 个字符,所在节点为 \(u\)。

一开始 \(u\) 节点为根,\(i=1\)。

沿着 \(tr[u,s_i]\) 遍历字典树,遍历一个节点,就沿着这个节点的 fail 链走一次,fail 链上的节点(包括自己)的出现次数 \(+1\)。

这里之前分析过,如果该状态出现过,那么该状态的最长后缀也出现过。为了避免漏掉状态,这也是我们 fail 指针指向最长后缀。

最后输出结束位置的出现次数即可。

华丽的做法:

式证明 fail 链将会形成树。

显然,由于 fail 指针指向比自己深度小的节点,而且不可能有环。得证。

由于每个节点都可以通过 fail 链回到根,那我们可以基于朴实无华的做法分出两种做法。

1.拓扑排序优化

遍历字典树时,只在当前节点出现次数 \(+1\)。

最后,通过在 fail 树上拓扑排序求出答案。

建图这么写(记录入度即可):

void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;
    que.push(1);
    trie[1].fail=0;
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            indeg[trie[fail].son[i]]++;//记录入度
            que.push(v);
        }
    }
}

拓扑排序加查询这么写:

void query(char *s)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
        u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
    for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        vis[trie[u].flg]=trie[u].ans;
        int v=trie[u].fail;
        trie[v].ans+=trie[u].ans;
        if(!(--indeg[v])) que.push(v);
    }
}

然后合起来:

例题:P5357 【模板】AC 自动机

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

const int maxn=8e5+5,maxm=2e6+5;

int n,cnt=1,ans;
int vis[maxn],rev[maxn],indeg[maxn];

char s[maxm];

struct node
{
    int son[27];
    int fail,ans,flg;
    void clr(){memset(son,0,sizeof(son));fail=flg=0;}
}trie[maxn];

void insert(char *s,int num)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
    {
        int v=s[i]-'a';
        if(!trie[u].son[v]) trie[u].son[v]=++cnt;
        u=trie[u].son[v];
    }
    if(!trie[u].flg) trie[u].flg=num;
    rev[num]=trie[u].flg;
}
queue<int>que;
void getfail()
{
    for(int i=0;i<26;i++) trie[0].son[i]=1;
    que.push(1);
    trie[1].fail=0;
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        int fail=trie[u].fail;
        for(int i=0;i<26;i++)
        {
            int v=trie[u].son[i];
            if(!v)
            {
                trie[u].son[i]=trie[fail].son[i];
                continue;
            }
            trie[v].fail=trie[fail].son[i];
            indeg[trie[fail].son[i]]++;
            que.push(v);
        }
    }
}
void query(char *s)
{
    int u=1,len=strlen(s+1);
    for(int i=1;i<=len;i++)
        u=trie[u].son[s[i]-'a'],trie[u].ans++;
}
void topu()
{
    for(int i=1;i<=cnt;i++) if(!indeg[i]) que.push(i);
    while(!que.empty())
    {
        int u=que.front();
        que.pop();
        vis[trie[u].flg]=trie[u].ans;
        int v=trie[u].fail;
        trie[v].ans+=trie[u].ans;
        if(!(--indeg[v])) que.push(v);
    }
}
int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%s",s+1),insert(s,i);

    getfail();
    scanf("%s",s+1);
    query(s);
    topu();
    for(int i=1;i<=n;i++) printf("%d\n",vis[rev[i]]);
}
子树求和

通过上述查询方法,在最后每个节点用 fail 树上信息求和即可。

例题

挖个坑。

推荐阅读

AC 自动机 - OI Wiki

通过 gif 的方式,写的很好。

标签:AC,int,tr,笔记,Trie,fail,自动机,节点,指针
From: https://www.cnblogs.com/binbinbjl/p/17968469

相关文章

  • C语言入门笔记-day1
    C语言笔记-第一天写一些学习的过程中一些不知道的知识点,以防后面遗忘,想起来可以再看。基础第一个C程序—main.c#include<stdio.h>intmain(){//main函数,整个项目文件的入口 printf("Hello,World!\n");//在屏幕上打印Hello,World! return0;//返回值为0......
  • Redis Stack
           ......
  • 2020-2021 ACM-ICPC Latin American Regional Programming Contest J. Job Allocator
    Preface今天因为下午被强行拉回老家了,而且没带电脑回去然后就变成了徐神和祁神两个人写,我拿个手机在后面口胡了3h最后变成了在缺我一个人的前提下还能4h过10题的情况,感觉就算我在的话最多就是快点过H然后把剩下的时间拿去写个J这场因为没啥参与就不写整场的博客了,把赛后写的这......
  • 阅读笔记2
    有一个惊人的数据,设计期间程序员平均每小时会引入1~3个缺陷,编码期间平均每小时引入5~8个缺陷。有许多同样惊人的数据显示,协同构建可以缩短开发周期,通过代码复查检查错误成本比测试更低,而且可以检查到一些更隐蔽的风格、注释等错误。另外,开发者考虑到需要经过代码复查,编写时便......
  • 阅读笔记1
    我在王建民老师的推荐下,购买了这本书,开始进行了研究和学习。这本书涵盖了编程的方方面面(连宗教信仰问题都考虑了~),可以看出作者对每一个问题都进行了深入思考。我是带着目的去读这本书的,下面是我认为对我有思考价值的地方。构建活动是软件开发中的核心活动。把主要精力集中于构......
  • 从0到1:实验室设备借用小程序开发笔记
    概论实验室设备借用小程序,适合各大高校,科技园区,大型企业集团的实验室设备借用流程,通过数字化的手段进一步提升相关单位设备保障水平,规范实验室和设备管理,用户通过手机小程序扫描设备的二维码,可以方便快捷的提交个人资料,办理借用手续,从而大大提高了工作效率功能规划1.设备清单:展......
  • openGauss学习笔记-199 openGauss 数据库运维-常见故障定位案例-Lock wait timeout
    openGauss学习笔记-199openGauss数据库运维-常见故障定位案例-Lockwaittimeout199.1执行SQL语句时,提示Lockwaittimeout199.1.1问题现象执行SQL语句时,提示“Lockwaittimeout”。ERROR:Lockwaittimeout:thread140533638080272waitingforShareLockonrelat......
  • 学习笔记5
    RDD分区RDD是弹性分布式数据集,通常RDD很大,会被分成很多个分区分别保存在不同的节点上,分区的作用:(1)增加并行度(2)减少通信开销。RDD分区原则是使得分区的个数尽量等于集群中的CPU核心(core)数目,对于不同的Spark部署模式而言(本地模式、Standalone模式、YARN模式、Mesos模式),都可以通过设置......
  • C#医院线上预约挂号系统源码,预约挂号小程序源码,可对接院内his、lis、pacs系统
    “移动智慧医院”平台既可以让患者足不出户就可以利用微信进行在线挂号,实现分时段就诊,就诊后也可以直接使用手机微信缴费,还可以通过微信实现查询费用明细及药品清单,检查、检验报告,住院服务等功能。患者可通过微信服务号随时查看名医出诊情况,合理安排个人前往医院就诊的时间,再也不用......
  • Docker 学习笔记 - 3
    Docker镜像1.联合文件系统(UnionFS)UnionFS是一种分层、轻量级并且高性能的文件系统,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下,UnionFS是docker镜像的基础,镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体......