【字符串算法】AC自動機

國慶後面兩天划水,甚至想接着發出咕咕咕的叫聲。咳咳咳,這些都不重要!最近學習了一下AC自動機,發現其實遠沒有想像中的那麼難。

AC自動機的來歷

我知道,很多人在第一次看到這個東西的時侯是非常興奮的。(別問我為什麼知道)

但AC自動機並不是能自動AC的程序。。。

AC自動機之所以叫AC自動機,是因為這個算法原名叫 Aho-Corasick automaton,是一個叫Aho-Corasick 的人發明的。

所以AC自動機也叫做 Aho-Corasick 算法

該算法在1975年產生於貝爾實驗室,是著名的多模匹配算法。

AC自動機的用處

那麼有的同學可能就有疑問了,AC自動機又不能自動AC,有什麼作用呢?

其實AC自動機和KMP的用法相似,都是用來解決字符串的匹配問題的;但不一樣的是,AC自動機更多的被用來解決多串的匹配問題,換言之,就是有多個子串需要匹配的KMP問題。

例如,例如給幾個單詞 acbs,asf,dsef;
再給出一個 很長的文章(句子),acbsdfgeasf
問在這個文章中,總共出現了多少個單詞,或者是單詞出現的總次數,這就是AC自動機要解決的問題了。

AC自動機的實現方法

AC 自動機是 以 Trie 的結構為基礎 ,結合 KMP 的思想 建立的。

簡單來說,建立一個 AC 自動機有兩個步驟:

  1. 基礎的 Trie 結構:將所有的模式串構成一棵 Trie
  2. KMP 的思想:對 Trie 樹上所有的結點構造失配指針。

然後就可以利用它進行多模式匹配了。

不明白trie的同學可以 點擊這裡學習

不了解KMP的同學可以點擊這裡學習

所以就讓我們一起來一步一步實現AC自動機吧!

定義一顆字典樹

首先我們需要定義一顆字典樹,我們用struct來實現各個節點的定義:

struct node
{
    int next[27];
    int fail;
    int count;
    void init()
    {
        memset(next,-1,sizeof(next));
        fail=0;
        count=0;
    }
}s[1100001];

存儲後驅值的next[]數組

next[]數組就是正常Trie樹里用來存儲每個字符的後一個字符在s數組裡的位置,比如我們讀入一個字符串APPLE,那麼:

s【1】存儲的是A,它的next【P】=2,其餘為-1;
s【2】存儲的是P,它的next【P】=3,其餘為-1;
s【3】存儲的是P,它的next【L】=4,其餘為-1;
s【4】存儲的是L,它的next【E】=5,其餘為-1;
s【5】存儲的是E,它的next都為-1。

fail:失敗指針

fail為失敗指針,這個在後面的構造會講到如何快速構造,那麼有什麼用呢?

我們來舉個例子,這個例子這隻顯示了e的失配指針:
我們假設讀入了she,shr,say,her四個單詞,於是我們就得到了一棵可愛的字典樹:

然後我們就只先構造一個失敗指針:

例如匹配文章:sher,我們剛開始從s開始一直向左邊走,走到e後發現:呀,沒有路繼續走了,如果暴力的從h開始又開始一輪匹配就極為浪費時間;這時我們就像,能不能利用之前的匹配信息呢?可以的!her的前綴he剛好和she的he相同,所以我們在she匹配失敗的時候,就跳到了he後面繼續匹配,發現r與r匹配!這就是fail指針的用處,是不是發現和KMP的next數組非常類似啊!

記錄結尾的count

如果我插入一個單詞APPLE,插入到最後E了,發現這個單詞再也沒有後面的字母了,這時我們就在這個E的count裏面加上一個1,表示有1個單詞以這個e作為結尾。

初始化的init()

我們在這裡還定義了一個初始化函數init(),就是在開創到一個新起點時用來初始化一下的。

在字典樹中插入單詞

我們還是結合程序來講解:

int ins()
{
    int len=strlen(str+1);
    int ind=0,i,j;

    for(int i=1;i<=len;i++)
    {
        j=str[i]-'a'+1;
        if(s[ind].next[j]==-1)
        {
            num++;
            s[num].init();
            s[ind].next[j]=num;
        }
        ind=s[ind].next[j];

    }
    s[ind].count++;
    return 0;
}

首先str數組就是我們要讀入的字符串,ind表示我現在在s【】數組中的位置;接下來我們開始循環——對於每一個點:

如果他的前一個字母的next沒有指向他的字母,那麼我們就開創一個新點來存儲這個字母,並且讓他前一個字母的next指向它;

如果有直接指向它的字母的位置,那就直接跳過去就好了!

最後別忘了在每個單詞的末尾的count加上1。

重點!!!快速構造fail指針

fail指針有什麼用

首先,fail指針有什麼用?我們繼續使用上一個例子:

我們發現,左邊的e的fail指針指向l最右側的e,那麼這個指針的含義是什麼呢?我們不妨當一個點i指向了一個點j時,我們設從j開始,向上走L個字符就到了最頂點,其中從頂點到j的字符串為s;

在這個例子中,s為「he」,長度為L,也就是2;接着從i開始,向上再走L-1個字符,得到一個字符串ss,在這個例子中,ss也為「he」!

這時我們就驚訝的發現,s與ss相同!!

我們得知,當i的fail指針指向j,頂點到j的字符串s是頂點到i的字符串的後綴!

這樣如果i繼續往下匹配失敗的話,就可以不用從頭開始匹配,而是直接從他的fail開始匹配!節省了大量時間!這就是fail指針的精髓所在!

fail指針如何構造

我們先貼上代碼:

int make_fail()
{
    int head=1,tail=1;
    int ind,ind_f;
    for(int i=1;i<=26;i++)
    {
        if(s[0].next[i]!=-1)
        {
            q[tail]=s[0].next[i];
            tail++;
        }
    }
    while(head<tail)
    {
        ind=q[head];
        for(int i=1;i<=26;i++)
        {
            if(s[ind].next[i]!=-1)
            {
                q[tail]=s[ind].next[i];
                tail++;

                ind_f=s[ind].fail;

                while(ind_f>0 && s[ind_f].next[i]==-1)
                ind_f=s[ind_f].fail;

                if(s[ind_f].next[i]!=-1)ind_f=s[ind_f].next[i];
                s[s[ind].next[i]].fail=ind_f;
            }
        }
        head++;
    }
    return 0;
}

首先我們需要開啟一個隊列q,存儲需要處理的點;

接着我們把所有與頂點相連的點加入到隊列里,然後我們對於隊列里的每個數進行操作:

首先將他的所有兒子都加到隊列尾部,然後作為一個負責任的父親節點,肯定不能只把兒子們丟到隊尾就完事了,還有做好工作——幫兒子們做好fail指針——

首先假如我是那個父親節點x,對於字母a子節點,我先看一下我的fail指針指向的節點y,看一下y有沒有字母a子節點z,如果有,就太好了,我就讓我的子節點的fail指針指向z;

如果沒有,那就從y出發,繼續看他fail指向的點的有沒有字母a的子節點……直到找到滿足條件的點。

如果實在沒辦法,就算fail一路跳到0號節點也找不到,那就沒辦法了,我的字母a子節點的fail就只好指向0號節點了【因為初始化就為0,所以此時就不用操作了】

我們舉個具體的栗子來看看:

a1.JPG
a2.JPG
a3.JPG
a4.JPG
a5.JPG
a6.JPG
a7.JPG
a8.JPG
a9.JPG

所以這樣操作就可以快速構造fail指針了!

進行樹上KMP

我們先看一下代碼:

int find()
{
    int len=strlen(des+1);
    int j,ind=0;
    for(int i=1;i<=len;i++)
    {
        j=des[i]-'a'+1;
        while(ind>0 && s[ind].next[j]==-1)ind=s[ind].fail;

        if(s[ind].next[j]!=-1)
        {
            ind=s[ind].next[j];
            p=ind;
            while(p>0 && s[p].count!=-1)
            {
                ans=ans+s[p].count;
                s[p].count=-1;
                p=s[p].fail;
            }
        }
    }
    return 0;
}

一樣的,ind表示我當前匹配好了的點,如果當前點不繼續和IND的任何一個子節點相同,那麼我就跳到ind的fail指針指向的點……知道找到與當前點匹配,或者跳到了根節點,與KMP十分相同!

需要注意的是由於這道題是求解哪些點在母串中出現,所以我們進行了一層優化:

while(p>0 && s[p].count!=-1)
 {
     ans=ans+s[p].count;
     s[p].count=-1;
     p=s[p].fail;
 }

就是當我們匹配好到一個串s【從根節點到IND的串】的時候,我們就往它的fail一直跳,由於他的fail到根節點的字符串ss一定是s的後綴,所以ss在母串中也一定出現,這時就加上它的count再設置為-1,防止後續重複訪問就好了!

模板題

[Luogu p3808]
題目背景
這是一道簡單的AC自動機模板題。
用於檢測正確性以及算法常數。
為了防止卡OJ,在保證正確的基礎上只有兩組數據,請不要惡意提交。
管理員提示:本題數據內有重複的單詞,且重複單詞應該計算多次,請各位注意
題目描述
給定n個模式串和1個文本串,求有多少個模式串在文本串里出現過。
輸入輸出格式
輸入格式:
第一行一個n,表示模式串個數;
下面n行每行一個模式串;
下面一行一個文本串。
輸出格式:
一個數表示答案
輸入輸出樣例
輸入樣例#1: 複製
2
a
aa
aa
輸出樣例#1: 複製
2
說明
subtask1[50pts]:∑length(模式串)<=10^6,length(文本串)<=10^6,n=1;
subtask2[50pts]:∑length(模式串)<=10^6,length(文本串)<=10^6;

就是模板題,下面給出模板:

#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<algorithm>
using namespace std;
struct node
{
    int next[27];
    int fail;
    int count;
    void init()
    {
        memset(next,-1,sizeof(next));
        fail=0;
        count=0;
    }
}s[1100001];

int i,j,k,m,n,o,p,js,jl,jk,len,ans,num;
char str[1100000],des[1100000];
int q[1100000];

int ins()
{
    int len=strlen(str+1);
    int ind=0,i,j;

    for(int i=1;i<=len;i++)
    {
        j=str[i]-'a'+1;
        if(s[ind].next[j]==-1)
        {
            num++;
            s[num].init();
            s[ind].next[j]=num;
        }
        ind=s[ind].next[j];

    }
    s[ind].count++;
    return 0;
}

int make_fail()
{
    int head=1,tail=1;
    int inf,inf_f;
    for(int i=1;i<=26;i++)
    {
        if(s[0].next[i]!=-1)
        {
            q[tail]=s[0].next[i];
            tail++;
        }
    }
    while(head<tail)
    {
        inf=q[head];
        for(int i=1;i<=26;i++)
        {
            if(s[inf].next[i]!=-1)
            {
                q[tail]=s[inf].next[i];
                tail++;

                inf_f=s[inf].fail;

                while(inf_f>0 && s[inf_f].next[i]==-1)
                inf_f=s[inf_f].fail;

                if(s[inf_f].next[i]!=-1)inf_f=s[inf_f].next[i];
                s[s[inf].next[i]].fail=inf_f;
            }
        }
        head++;
    }
    return 0;
}

int find()
{
    int len=strlen(des+1);
    int j,ind=0;
    for(int i=1;i<=len;i++)
    {
        j=des[i]-'a'+1;
        while(ind>0 && s[ind].next[j]==-1)ind=s[ind].fail;

        if(s[ind].next[j]!=-1)
        {
            ind=s[ind].next[j];
            p=ind;
            while(p>0 && s[p].count!=-1)
            {
                ans=ans+s[p].count;
                s[p].count=-1;
                p=s[p].fail;
            }
        }
    }
    return 0;
}

int main()
{
    scanf("%d",&m);

    num=0;s[0].init();
    for(int i=1;i<=m;i++)
    {
        scanf("%s",str+1);
        ins();
    }

    scanf("%s",des+1);

    ans=0;

    make_fail();

    find();

    printf("%d",ans);
    return 0;
}

結語

通過這篇博客相信你一定已經學會了AC自動機!希望你喜歡這篇blog!!!