強勢 圖解 AC自動機(保證您一次就能學會!)

前置技能

簡介

看dalao們AC自動機的Blog,大多數奆奆都會感性地說: AC_automation = KMP+TRIE<!--more--> 然而在作者重蹈覆轍輾轉反側n次後才明白,這東西說了等於沒說。

  • AC自動機是一種有限狀態自動機(說了等於沒說),它常被用於多模式串的字符串匹配。
  • 在學完AC自動機,筆者也總結出一句說了等於沒說的話: AC自動機是以TRIE的結構爲基礎,結合KMP的思想建立的。

建立AC自動機

建立一個AC自動機通常需要兩個步驟:

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

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

TRIE構建

  • 和trie的insert操作一模一樣(強調!一模一樣!)
  • 因爲我們只利用TRIE的結構,所以只需將模式串存入即可。

構造失配( failfailfail )指針

在講構造以前,先來對比一下這裏的 failfailfail 指針與KMP中的next指針:

  • 共同點-兩者同樣是在失配的時候用於跳轉的指針。
  • 不同點-KMP要求的是最長相同真前後綴,而AC自動機只需要相同後綴即可。
    • 因爲KMP只對一個模式串做匹配,而AC自動機要對多個模式串做匹配。
    • 有可能 failfailfail 指針指向的結點對應着另一個模式串,兩者前綴不同。
    • 也就是說,AC自動機在對匹配串做逐位匹配時,同一位上可能匹配多個模式串。
    • 因此 failfailfail 指針會在字典樹上的結點來回穿梭,而不像KMP在線性結構上跳轉。

下面介紹構建 failfailfail 指針的基礎思想:(強調!基礎思想!基礎!)

  • 構建 failfailfail 指針,可以參考KMP中構造next數組的思想
  • 我們利用部分已經求出 failfailfail 指針的結點推導出當前結點的 failfailfail 指針。具體我們用BFS實現:
    • 考慮字典樹中當前的節點u,u的父節點是p,p通過字符c的邊指向u。
    • 假設深度小於u的所有節點的 failfailfail 指針都已求得。那麼p的 failfailfail 指針顯然也已求得。
    • 我們跳轉到p的 failfailfail 指針指向的結點 fail[p]fail[p]fail[p]
      • 如果結點 fail[p]fail[p]fail[p] 通過字母 ccc 連接到的子結點 www 存在:
        • 則讓u的fail指針指向這個結點 wwwfail[u]=wfail[u]=wfail[u]=w )。
        • 相當於在 pppfail[p]fail[p]fail[p] 後面加一個字符 ccc ,就構成了 fail[u]fail[u]fail[u]
      • 如果 fail[p]fail[p]fail[p] 通過字母 ccc 連接到的子結點 www 不存在:
        • 那麼我們繼續找到 fail[fail[p]]fail[fail[p]]fail[fail[p]] 指針指向的結點,重複上述判斷過程,一直跳 failfailfail 指針直到根節點。
        • 如果真的沒有,就令 fail[u]=fail[u]=fail[u]= 根節點。
  • 如此即完成了 failfailfail 指針的構建。

下面放一張GIF幫助大家理解: 對字典樹{i,he,his,she,hers}構建 failfailfail 指針:

  • 黃色結點表示當前的結點u,綠色結點表示已經BFS遍歷完畢的結點,紅/橙色的邊表示 failfailfail 指針。

  • 2號節點的 failfailfail 指針畫錯了, fail[2]=0fail[2]=0fail[2]=0 . AC_automation_gif_b_3.gif

  • 我們重點分析結點6的 failfailfail 指針構建: AC_automation_6_9.png

  • 找到6的父節點5,5的 failfailfail 指針指向10,然而10結點沒有字母's'連出的邊;

  • 所以跳到10的 failfailfail 指針指向的結點0,發現0結點有字母's'連出的邊,指向7結點;

  • 所以 fail[6]=7fail[6]=7fail[6]=7 .

另外,在構建 failfailfail 指針的同時,我們也對TRIE中模式串的結尾構建 failfailfail 指針。這樣在匹配到結尾後能自動跳轉到下一個匹配項。具體見代碼實現。

從代碼深入剖析

框架

const int N=1000;
struct AC_automaton{
    int tr[N][26],cnt;//TRIE
    int e[N];//標記這個結點是不是字符串結尾
    int fail[N];//fail指針
void insert(char * s){}//插入模式串
void build(){}//構建fail指針
int query(char *t){}//匹配函數

};
AC_automation ac;
}

字典樹與字典圖

  • 關於insert()筆者不做分析,先來看build():
    void build(){
    queue<int>q;
    memset(fail,0,sizeof(fail));
    for(int i=0;i<26;i++)if(tr[0][i])q.push(tr[0][i]);//Q1
    while(!q.empty()){
        int k=q.front();q.pop();
        for(int i=0;i<26;i++){
            if(tr[k][i]){
                fail[tr[k][i]]=tr[fail[k]][i];//Q2
                q.push(tr[k][i]);
            }
            else tr[k][i]=tr[fail[k]][i];//Q3
        }
    }
    }

    首先聲明瞭一個隊列用於BFS,並清空 failfailfail 數組。

這裏的字典樹根節點爲0,我們將根節點的子節點一一入隊。

  • Q1-等等,爲什麼不將根節點入隊,非要將它的子節點入隊?

然後開始BFS:

  • 每次取出隊首的結點k。注意,結點k本身的 failfailfail 指針已經求得,我們要求的是k的子節點們的 failfailfail 指針。
  • 然後遍歷字符集(這裏是0-25,對應a-z):
    • 如果字符i對應的子節點存在,我們就將這個子節點的 fail​fail​fail 指針賦值爲 fail[k]fail[k]fail[k] 的字符i對應的結點。
    • Q2-不是應該用while循環,不停的跳 failfailfail 指針,判斷是否存在字符i對應的結點,然後賦值嗎?怎麼一句話就完了?
    • 否則,k結點沒有字符i對應的子節點,就將 fail[k]fail[k]fail[k] 的字符i對應的子節點編號賦值給k
    • Q3-等等,說好的字典樹呢?怎麼將 fail[k]fail[k]fail[k] 的子節點直接賦成k的子節點去了?

這三個Questions構成了 failfailfail 指針構建的精髓。

Q1

  • 若將根節點入隊,則在第一次BFS的時候,會將根節點的子節點的 failfailfail 指針標記爲本身。
  • 而將根節點的子節點入隊,也不影響算法正確性(因爲 failfailfail 指針初始化爲0)

Q2&Q3

  • Q2與Q3的代碼是相輔相成的。

  • 簡單地來講,我們將 failfailfail 指針跳轉的路徑做了壓縮(就像並查集的路徑壓縮),使得本來需要跳很多次 failfailfail 指針變成跳一次。

  • 而這個路徑壓縮的就是Q3的代碼在做的事情之一

  • 我們將之前的GIF圖改一下: AC_automation_gif_b_pro3.gif

  • 藍色結點表示BFS遍歷到的結點k,深藍色、黑色的邊表示執行完Q3代碼連出的字典樹的邊。

  • 可以發現,衆多交錯的黑色邊將字典樹變成了字典圖。

  • 圖中省略了連向根節點的黑邊(否則會更亂)。

  • 我們重點分析一下結點5遍歷時的情況: AC_automation_b_7.png

  • 顯然,本來應該跳2次才能找到7號結點,但是我們通過10號結點的黑色邊直接通過字母s找到了7號結點。

  • 因此,Q2結合了Q3的代碼,就能在 O(1)O(1)O(1) 的時間內對單個結點構造 failfailfail 指針。

這就是build完成的兩件事:構建 failfailfail 指針和建立字典圖。這個字典圖也會在查詢的時候起到關鍵作用。

多模式匹配

  • 接下來分析匹配函數query():
    int query(char *t){
    int p=0,res=0;
    for(int i=0;t[i];i++){
        p=tr[p][t[i]-'a'];//Q
        for(int j=p;j&&~e[j];j=fail[j])res+=e[j],e[j]=-1;
    }
    return res;
    }
  • 聲明p作爲字典樹上當前匹配到的結點,res即返回的答案
  • 循環遍歷匹配串,p在字典樹上跟蹤當前字符。
  • 利用 failfailfail 指針找出所有匹配的模式串,累加到答案中。然後清0。
  • e[j]e[j]e[j] 取反的操作用來判斷 e[j]e[j]e[j] 是否等於-1。
  • Q-讀者可能納悶了:你這裏的p一直在往字典樹後面走,沒有跳 failfailfail 指針啊!這和KMP的思想不一樣啊,怎麼匹配得出來啊

Answer to Q

  • 還記得剛纔的字典圖嗎?事實上你並不是一直在往後跳,而是在圖上穿梭跳動。比如,剛纔的字典圖: AC_automation_b_13.png

  • 我們從根節點開始嘗試匹配ushersheishis,那麼p的變化將是: AC_automation_gif_c.gif

  • 紅色結點表示p結點,粉色箭頭表示p在字典圖上的跳轉,淺藍色的邊表示成功匹配的模式串,深藍色的結點表示跳 failfailfail 指針時的結點。

  • 其中的部分跳轉,我們利用的就是新構建的字典圖上的邊,它也滿足後綴相同(sher和her),所以自動跳轉到下一個位置。

  • 綜上, failfailfail 指針的意義是,在匹配串同一個位置失配時的跳轉指針,這樣就利用 failfailfail 指針在同一位置上進行多模式匹配,匹配完了,就在字典圖上自動跳轉到下一位置。

總結

到此,你已經理解了整個AC自動機的內容。我們一句話總結AC自動機的運行原理: 構建字典圖實現自動跳轉,構建失配指針實現多模式匹配。

代碼

const int N=1000;
struct AC_automaton{
    int tr[N][26],cnt;//TRIE
    int e[N];//標記字符串結尾
    int fail[N];//fail指針
void insert(char * s){//插入模式串
    int p=0;
    for(int i=0;s[i];i++){
        int k=s[i]-'a';
        if(!tr[p][k])tr[p][k]=++cnt;
        p=tr[p][k];
    }
    e[p]++;
}
void build(){
    queue&lt;int&gt;q;
    memset(fail,0,sizeof(fail));
    for(int i=0;i&lt;26;i++)if(tr[0][i])q.push(tr[0][i]);
        //首字符入隊
        //不直接將0入隊是爲了避免指向自己
    while(!q.empty()){
        int k=q.front();q.pop();//當前結點
        for(int i=0;i&lt;26;i++){
            if(tr[k][i]){
                fail[tr[k][i]]=tr[fail[k]][i];//構建當前的fail指針
                q.push(tr[k][i]);//入隊
            }
            else tr[k][i]=tr[fail[k]][i];
                //匹配到空字符,則索引到父節點fail指針對應的字符,以供後續指針的構建
                //類似並差集的路徑壓縮,把不存在的tr[k][i]全部指向tr[fail[k]][i]
                //這句話在後面匹配主串的時候也能幫助跳轉
        }
    }
}
int query(char *t){
    int p=0,res=0;
    for(int i=0;t[i];i++){
        p=tr[p][t[i]-'a'];
        for(int j=p;j&amp;&amp;~e[j];j=fail[j])res+=e[j],e[j]=-1;
    }
    return res;
}


%%%Sshwy,tql\%\%\%Sshwy,tql

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章