AC自动机 图文介绍

预备知识

  • Trie(字典树)
  • KMP字符串匹配算法

AC自动机求解问题的类型

一句话概括就是:多模匹配
KMP求解的问题是在一个字符串S中找到字符串T出现的位置,例如:在"Iloveyou"中寻找字符串"ove"此时称S为目标串,称T为模式串。因此KMP属于单模匹配
多模匹配顾名思义就是要和目标串匹配的模式串不止一个。这时就要请出来AC自动机解决这个问题。

图文介绍

先上个图。之后结合这幅图来讲。

图1

假设模式串集合为{"say", "she", "shr", "he", "her"}
目标串是"yasherhs"。
建立AC自动机的方法就是把所有模式串放到一个Trie上,如上图。
但是相对于一般字典树的两个基本属性:
1.son[x]表示点x的儿子集合。
2.data[x]=k表示root到x所表示的字符串出现k次。
还有一个神奇的属性:
3.fail[x]表示x的失配指针。具体含义就是(建议看了下面的图再来理解这句话):设root到x表示的字符串是S,root到fail[x]表示的字符串是T,那么T就应该是S最长的后缀
下图虚线展示了fail指针的连接方式:
图2

例如对于字符串"shr",其最长的后缀在Trie里没有出现,所以其fail指针指向root。对于字符串"she",其最长的后缀"he"出现在Trie中,于是其就fail指针就指向'e'那个点。

那么这个fail指针究竟是何方神圣,有何神通呢?我们回想KMP进行匹配的过程:next[i]表示模式串前i个字符中,最长的后缀=前缀的长度。现在我们的模式串不止一个了,因此其fail指针还有可能指向别的字符串上的点。这样就相当于把原来一个模式串的next扩展到了多个模式串的next,意义就扩展为所有的模式串的前i个字符中最长的后缀=前缀的长度。正确性就可以保证了。至于复杂度的证明方式和kmp类似。

现在的问题是,如何求fail指针?联系kmp的next数组的意义,容易发现root的每个儿子的fail都指向root(前缀和后缀是不会包含整个串的)。也就是上图中root所连的's'和'h'的fail都指向root。若已经求得'sh'所在点的fail,我们来考虑如何求'she'所在点的fail。根据'sh'所在点的fail得到'h'是'sh'的最长后缀,而'h'又有儿子'e',因此'she'的最长后缀应该是'he',其fail指针就指向'he'所在点。

概括AC自动机求fail指针的过程:
1.对整个字典树进行bfs(宽度优先搜索)遍历。
2.若当前搜索到点x,那么对于x的第i个儿子(也就是代表字符i的儿子),一直往x的fail跳,直到跳到某个点也有i这个儿子,x的第i个儿子的fail就指向这个点的儿子i。

上述过程类似于kmp求next的过程,可以根据代码理解。
过程getfail用于求出AC自动机的fail指针(C++版):

struct node
{
    node* fail; node* son[26];
    int data;
    void init()
    {
        data = 0, fail = NULL;
        memset(son, 0, sizeof(son));
    }
};
node* root;

int head, tail;
node* que[30007];
void getfail()
{
    head = 1, que[tail = 1] = root; //数组实现队列
    while (head <= tail)
    {
        node* x = que[head++];
        for (int i = 0; i < 26; i++)
            if (x->son[i]) //x有儿子i
            {
                if (x == root) x->son[i]->fail = root; //x是root,其儿子的fail都指向root
                else
                {
                    node* tmp = x->fail;
                    while (tmp) //一直往fail跳
                    {
                        if (tmp->son[i]) { x->son[i]->fail = tmp->son[i]; break; } //这个点也有儿子i
                        tmp = tmp->fail;
                    }
                    if (!tmp) x->son[i]->fail = root;
                }
                que[++tail] = x->son[i];
            }
    }
}

求出来fail指针后,我们就很容易依照kmp的匹配过程写出AC自动机的匹配过程了:
1.若当前匹配到目标串的第i个字符。判断当前在Trie上的点有没有表示字符i的儿子,有就跳过去。如果没有就一直往fail跳,直到有一个点有表示字符i的儿子为止。如果没有任何一个点有表示字符i的儿子,那就重新回到根。
2.开一个临时点tmp,并从tmp一直往tmp的fail跳,若root到tmp形成了一个单词(模式串),就加上tmp的data。

还是看代码吧(晕):

int match(char *s)
{
    int ret = 0;
    node* now = root;
    while (*s != '\0')
    {
        while (!now->son[*s - 'a'] && now != root) now = now->fail;
        now = now->son[*s - 'a'];
        if (!now) now = root;
        node* tmp = now;
        while (tmp != root) ret += tmp->data, tmp = tmp->fail;
        s++;
    }
    return ret;
}

汇总一下AC自动机的代码(指针版):

#include <cstdio>
#include <cstring>
#include <cstdlib>

struct node
{
    node* fail; node* son[26];
    int data;
    void init()
    {
        data = 0, fail = NULL;
        memset(son, 0, sizeof(son));
    }
};
node* root;

int n, m;
char str[2000007];
void insert(char* s)
{
    node* now = root;
    while (*s != '\0')
    {
        if (!now->son[*s - 'a']) now->son[*s - 'a'] = new node, now->son[*s - 'a']->init();
        now = now->son[*s - 'a'];
        s++;
    }
    now->data++;
}
int head, tail;
node* que[30007];
void getfail()
{
    head = 1, que[tail = 1] = root;
    while (head <= tail)
    {
        node* x = que[head++];
        for (int i = 0; i < 26; i++)
            if (x->son[i])
            {
                if (x == root) x->son[i]->fail = root;
                else
                {
                    node* tmp = x->fail;
                    while (tmp)
                    {
                        if (tmp->son[i]) { x->son[i]->fail = tmp->son[i]; break; }
                        tmp = tmp->fail;
                    }
                    if (!tmp) x->son[i]->fail = root;
                }
                que[++tail] = x->son[i];
            }
    }
}
int match(char *s)
{
    int ret = 0;
    node* now = root;
    while (*s != '\0')
    {
        while (!now->son[*s - 'a'] && now != root) now = now->fail;
        now = now->son[*s - 'a'];
        if (!now) now = root;
        node* tmp = now;
        while (tmp != root) ret += tmp->data, tmp = tmp->fail;
        s++;
    }
    return ret;
}

int main()
{
    root = new node, root->init();
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) scanf("%s", str), insert(str);
    getfail();
    scanf("%s", str);
    printf("%d\n", match(str));
    return 0;
}

上一道例题加强理解:

3172. 【GDOI2013模拟4】贴瓷砖

Time Limits: 4000 ms Memory Limits: 524288 KB
Description
A镇的主街是由N个小写字母构成,镇长准备在上面贴瓷砖,瓷砖一共有M种,第i种上面有Li个小写字母,瓷砖不能旋转也不能被分割开来,瓷砖只能贴在跟它身上的字母完全一样的地方,允许瓷砖重叠,并且同一种瓷砖的数量是无穷的。
问街道有多少字母(地方)不能被瓷砖覆盖。

Input
第一行输入街道长度N(1<=N<=300,000)。
第二行输入N个英文小写字母描述街道的情况。
第三行输入M(1<=M<=5000),表示瓷砖的种类。
接下来M行,每行描述一种瓷砖,长度为Li(1<=Li<=5000),全部由小写字母构成。

Output
输出有多少个地方不能被瓷砖覆盖。

Sample Input
输入1:
6
abcbab
2
cb
cbab
输入2:
4
abab
2
bac
baba
输入3:
6
abcabc
2
abca
cab

Sample Output
输出1: 2
输出2: 4
输出3: 1
数据范围:N(1<=N<=300,000)

首先对于所有模式串建立AC自动机,将目标串放到上面匹配。若目标串在第i位时成功匹配,那么就把所有成功匹配的子串全部打上标记,最后没打标记的就是无法被覆盖的部分。但是这样子效率是很低的,因为我们把每个成功匹配的子串都打了标记,实际上只需要对最长的那个子串打标记即可。而且打标记是对于一个区间的,直接暴力标记可能超时(尽管已经有人水过去了)。正确的做法是使用差分数组,O(1)区间加法,最后O(n)求出每个位置的值。
但是这样还有一个问题,在上面AC自动机的这个过程中:

node* tmp = now;
while (tmp != root) ret += tmp->data, tmp = tmp->fail;

这样跳本来是为了保证目标串能够被多个模式串匹配到,可我们仅仅关心其中最长的一个。因此需要给每个点加一个属性mx[x]表示从x一直往fail[x]跳,路径上最长的单词长度是多少。这是可以预处理的。在字典树上,一个点的深度就是root到这个点形成的字符串的长度。

代码:

#include <queue>
#include <cstdio>
#include <cstring>
#include <cstdlib>
using namespace std;

const int N = 3e5 + 7, M = 5e3 + 7, L = 807; //卡内存的题目,不要开满空间

int root, tot, fail[M * L], son[M * L][26];
short data[M * L], dep[M * L], mx[M * L];

int n, m, c[N];
char str[N], str1[M];

void insert(char *s)
{
    int now = root;
    while (*s != '\0')
    {
        if (!son[now][*s - 'a']) son[now][*s - 'a'] = ++tot;
        now = son[now][*s - 'a'], s++;
    }
    data[now]++; //该处形成了一个模式串
}
queue<int> que; //STL省空间
void getfail() //求fail指针
{
    que.push(root);
    while (!que.empty())
    {
        int x = que.front(); que.pop();
        for (int i = 0; i < 26; i++)
            if (son[x][i])
            {
                dep[son[x][i]] = dep[x] + 1;
                if (x == root)
                    fail[son[x][i]] = root, mx[son[x][i]] = data[son[x][i]] ? 1 : 0; //对于根的每个儿子mx,如果其形成了模式串就为1,否则为0
                else
                {
                    int tmp = fail[x];
                    while (tmp)
                    {
                        if (son[tmp][i]) { fail[son[x][i]] = son[tmp][i]; break; }
                        tmp = fail[tmp];
                    }
                    if (!tmp) fail[son[x][i]] = root;
                    if (data[son[x][i]]) mx[son[x][i]] = dep[son[x][i]]; //x的这个儿子形成了一个模式串,由于fail指针是往深度比x更小的点跳的,因此mx就是x这个儿子的深度
                    else mx[son[x][i]] = mx[fail[son[x][i]]]; //不然就是其fail指针的mx
                }
                que.push(son[x][i]);
            }
    }
}
void match()
{
    int now = 1;
    for (int i = 1; i <= n; i++)
    {
        while (!son[now][str[i] - 'a'] && now != root) now = fail[now];
        now = son[now][str[i] - 'a'];
        if (!now) now = root;
        c[i + 1]--, c[i - (mx[now]) + 1]++;  //差分数组上打标记
    }
}

int main()
{
    root = tot = 1;
    scanf("%d%s%d", &n, str + 1, &m);
    while (m--) scanf("%s", str1), insert(str1);
    getfail(), match();
    int ans = 0;
    for (int i = 1, sum = 0; i <= n; i++) { sum += c[i]; if (sum <= 0) ans++; } //统计答案
    printf("%d\n", ans);
    return 0;
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335

推荐阅读更多精彩内容

  • 文不达意,口齿不清,思想混乱,令人喷饭。(估计只有我自己才能看懂我在说什么)简书没有mathjax公式没法愉快显示...
    njzwj阅读 1,164评论 1 2
  • 字符串匹配KMP算法详解 1. 引言 以前看过很多次KMP算法,一直觉得很有用,但都没有搞明白,一方面是网上很少有...
    张晨辉Allen阅读 2,364评论 0 3
  • hash kmp和ac自动机 后缀数组,后缀自动机,后缀树 扩展kmp manacher算法 回文自动机 可删改的...
    bplusb阅读 647评论 2 2
  • - 1 - 记得有一次在作家汤小小的公众号里看过一篇文章。文章讲她基本是个有问必回的人,这点我很赞同,因为汤小小是...
    瑞和她的浅岛繁花阅读 2,280评论 0 5
  • 按照老家的习俗,要在十二点时烧鞭炮,之后再到村头的古井挑水回。随着现在家家打水井,村头的古井早就荒废不用了,...
    长洲041陈建孔阅读 195评论 16 11