算法總結篇---AC自動機


寫在前面

鳴謝:
OiWiki
「筆記」AC 自動機---LuckyBlock
字符串四姐妹---KnightL
AC自動機講解超詳細---某不知名大佬

Q:AC自動機?是能自己AC題目的算法嗎?(興奮)
A:不不不,那叫自動AC機,通過打開答案文件輸出答案的一種小手段,在比賽中使用還會有禁賽三年的獎勵,而AC自動機是一個字符串匹配算法

AC自動機,全稱\(Aho-Corasick\ automaton\),是一種用來處理字符串多模式匹配的算法

本人將盡可能詳細的解釋AC自動機的算法流程(其實大部分抄的Oiwiki,這是一個幫助我們共同理解的過程,畢竟作者也是個萌新。開始接受的過程可能比較困難,但多回顧幾遍還是有助於理解的

算法流程

前置知識:Trie樹以及KMP算法的思想

什么是自動機?(粘個鏈接,感性理解就好,不要過於執着)

引例:

給定 \(n\) 個模式串 \(s_i\) 和一個文本串 \(t\),求有多少個不同的模式串在文本串里出現過。
兩個模式串不同當且僅當他們編號不同。

概述:

結合Trie的結構KMP的思想建立,建立一個AC自動機主要通過兩個步驟:

  • 1、建立Trie樹;

  • 2、對Trie樹上的所有結點構造失配指針

Trie樹的構建(第一步)

這個Trie樹就是普通的Trie樹,該怎么建怎么建

解釋一下Trie樹結點的含義:表示某個模式串的前綴
后文也將稱作狀態。一個結點表示一個狀態,Trie樹的邊就是狀態的轉移

形式化的說,對於若干個模式串 \(s_1,s_2,s_3···s_n\),將它們構建一個Trie樹后的所有狀態的集合記為 \(Q\)

失配指針(第二步)

AC 自動機利用一個 fail 指針來輔助多模式串的匹配。

狀態 \(u\) 的 fail 指針指向另一個狀態 \(v\) ,其中 \(v \in Q\) ,且 \(v\)\(u\) 的最長后綴(即在若干個后綴狀態中取最長的一個作為 fail 指針)。

注意和KMP的next指針的區別:

兩者都是在失配的時候用於跳轉的指針;
next指針求的是最長的border(最長的 相同的 前后綴),而fail指針指向所有模式串的前綴中匹配當前狀態的最長后綴

因為 KMP 只對一個模式串做匹配,而 AC 自動機要對多個模式串做匹配。有可能 fail 指針指向的結點對應着另一個模式串,兩者前綴不同。但是另一個模式串的一定是這個模式串到這里的一個后綴。

AC 自動機在做匹配時,同一位上可匹配多個模式串。

構建失配指針

(可以參考KMP中構建next指針的思想(

考慮更新 \(fail_u\)\(u\) 的父節點是 \(p\) , \(p\) 通過字符 \(c\) 的邊指向 \(u\) ,即 \(tr[p,c] = u\) 。假設深度小於 \(u\) 的所有結點的 \(fail\) 指針均已求得。

如果 \(tr[fail_p,c]\) 存在:則讓 \(fail_u\) 指向 \(tr[fail[p],c]\) 。相當於在 \(p\)\(fail\) 后面加一個字符 c ,分別對應 \(u\)\(fail_u\)
如果 \(tr[fail_p,c]\) 不存在:那么我們繼續找到 \(tr[fail_{fail_p},c],c]\) 。重復 \(1\) 的判斷過程,一直跳 \(fail_u\) 指針指到根結點。
如果真的沒有,就讓 \(fail_u\) 指針指向根結點。

舉個例子,對字符串 i, he, his, she, hers 組成的字典樹構建 fail 指針:

給張圖,其中黃色點表示當前結點 \(u\),綠色點表示已經 bfs 完成的點,橙邊是 fail 指針,紅邊是當前求出的 fail 指針。

重點分析一下 \(6\) 的構建

通過瞪眼法不難看出 \(fail[6]\) 應該指向結點 \(7\)

分析一下算法流程,找到 \(6\) 的父節點 \(5\)\(fail[5] = 10\) 然而沒有 \(s\) 的出邊,所以繼續跳 \(fail\) 指針,\(fail[10] = 0\),發現 \(0\)\(s\) 的出邊並指向 \(7\),所以 \(fail[6] = 7\)

全部建完后的圖是這樣的:

這樣就完成了 \(fail\) 的構建,並得到一份比較暴力的構建方式,我們來看優化

字典樹和字典圖

先來看構建函數 build() ,該函數的目標有兩個,一個是構建 fail 指針,一個是構建自動機。

void build(){
    for(int i = 0; i < 26; ++i) if(tr[0][i]) q.push(tr[0][i]); // tr[0][i] 都指向 0 結點,所以不用賦初值
    //如果存在這個邊就入隊
    while(!q.empty()){
        int u = q.front(); q.pop();
        for(int i = 0; i < 26; ++i){
            if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
            //按照上面所說的方式更新fail指針
            else tr[u][i] = tr[fail[u]][i];//這是那個優化,后面會講
        }
    }
}

我們是通過 bfs 構建 fail 指針的,而 fail 指針一定是由深度深的點指向深度淺的點。
我們由已經構建完成的點去構建未構建的點,假設我們當前的已經構建完成的一個狀態是 \(u\),通過字符 \(i\) 指向下一個狀態。

  • 如果存在下一個狀態 \(tr[u][i]\),那么就讓 \(fail[tr[u][i]]\) 指向 \(tr[fail[u]][i]\)
    正確性應該比較顯然,如果 \(u\) 失配了會指向 \(fail[u]\),那么在 \(u\) 后面接了一個狀態 \(i\) 后再失配就應該指向 \(fail[u]\) 在后面接一個狀態 \(i\) 在失配的位置。
    那如果沒有這個位置怎么辦?\(fail[u]\) 的深度一定比 \(u\) 的深度淺,所以它已經被處理完了,如果 \(tr[fail[u]][i]\) 有值,那么要么是它在 \(fail[u]\) 的模式串下在后面接一個字符 \(i\),要么是在 \(fail[fail[u]]\) 的模式串下在后面接一個字符 \(i\),...。不斷這么遞歸,就算最終也沒有,那也只能說明指向了根節點,並不影響正確性。
  • 如果不存在下一個狀態 \(tr[u][i]\),我們讓它指向 \(tr[fail[u]][i]\),就是為了保證上面第一種情況的正確性。並且通過這步操作,我們可以在匹配的過程中自動跳 \(fail\) 指針,不必再單獨進行判斷。

原來的構建方法可以通過 \(while\) 循環尋找 \(fail\) 結點實現,循環太多次導致復雜度太高
上面提到的優化就是通過else語句的代碼修改了字典樹的結構。
而它將不存在的字典樹狀態鏈連接到失配指針的對應狀態。使得再次遍歷這里的時候會繼續向下跳轉,起到一個通過繼續開鏈來壓縮路徑的效果,這樣就能節省很多時間。
這樣 AC 自動機修改字典樹結構連出的邊就會使字典樹變為字典圖。

如果有人想看更雜亂更加形象的圖的話:

其中:

  • 藍色結點:BFS 遍歷到的結點 u
  • 藍色的邊:當前結點下,AC 自動機修改字典樹結構連出的邊。
  • 黑色的邊:AC 自動機修改字典樹結構連出的邊。
  • 紅色的邊:當前結點求出的 fail 指針
  • 黃色的邊:fail 指針
  • 灰色的邊:字典樹的邊

可以發現,眾多交錯的黑色邊將字典樹變成了 字典圖。圖中省略了連向根結點的黑邊(否則會更亂)。我們重點分析一下結點 5 遍歷時的情況。我們求 \(tr[5][s]\) 的 fail 指針:

本來的策略是找 fail 指針,於是我們跳到 \(fail[5] = 10\) 發現沒有 s 連出的字典樹的邊,於是跳到 \(fail[10] = 0\),發現有 \(tr[0][s] = 7\) ,於是 \(fail[6] = 7\) ;但是有了黑邊、藍邊,我們跳到 \(fail[5] = 10\) 之后直接走 \(tr[10][s] = 7\) 就走到 \(7\) 號結點了。

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

在貼一個最終狀態的圖。這張圖真是令人作嘔

多模式匹配

(這只是對於引例的query函數,具體題目的函數寫法可能不太相同)

int query(char *t){
        int u = 0, res = 0;
        for(int i = 1; t[i]; ++i){
            u = tr[u][t[i] - 'a'];
            for(int j = u; j && e[j] != -1; j = fail[j]){
                res += e[j], e[j] = -1;
            }
        }
        return res;
    }

這里 \(u\) 作為字典樹上當前匹配到的結點, \(res\) 即返回的答案。循環遍歷匹配串, \(u\) 在字典樹上跟蹤當前字符。利用 \(fail\) 指針找出所有匹配的模式串,累加到答案中。然后清零。對 \(cnt[j]\) 取反的操作用來判斷 \(cnt[j]\) 是否等於 \(-1\)。在上文中我們分析過,字典樹的結構其實就是一個 \(trans\) 函數,而構建好這個函數后,在匹配字符串的過程中,我們會舍棄部分前綴達到最低限度的匹配。\(fail\) 指針則指向了更多的匹配狀態。

更加形象的匹配過程:

其中:

  • 紅色結點: \(p\) 結點
  • 粉色箭頭: \(p\) 在自動機上的跳轉,
  • 藍色的邊:成功匹配的模式串
  • 藍色結點:示跳 fail 指針時的結點(狀態)。

例題

P3808 【模板】AC自動機(簡單版)

P3808 【模板】AC自動機(簡單版)

做法即上面的引例,這里不再詳細介紹。

P3796 【模板】AC自動機(加強版)

P3796 【模板】AC自動機(加強版)

Solution

保證不會存在相同的字符串,那么在插入的末尾標記一下這是第幾個字符串。
在詢問的時候,對於每個狀態不斷跳 \(fail\) 指針找到它的所有狀態,把遇到的末尾標記對應記錄一個 \(cnt\) 進行累加統計。
最后掃一遍 \(cnt\) 的數組找出最大值

Code

/*
Work by: Suzt_ilymics
Knowledge: ??
Time: O(??)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e6+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int n;
char s[220][77];
char t[MAXN];

int read(){
    int s = 0, f = 0;
    char ch = getchar();
    while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
    while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
    return f ? -s : s;
}

namespace AC {
    int tr[MAXN][26], tot = 0;
    int e[MAXN], cnt[MAXN], fail[MAXN];
    queue<int> q;
    void Clear() {
        memset(tr, false, sizeof tr);
        memset(e, false, sizeof e);
        memset(cnt, false, sizeof cnt);
        memset(fail, false, sizeof fail);
        tot = 0;
    }
    void Insert(char *s, int id) {
        int now_ = 0;
        for(int i = 1; s[i]; ++i) {
            if(!tr[now_][s[i] - 'a']) tr[now_][s[i] - 'a'] = ++tot;
            now_ = tr[now_][s[i] - 'a'];
        }
        e[now_] = id;
    }
    void Get_fail() {
        for(int i = 0; i < 26; ++i) if(tr[0][i]) q.push(tr[0][i]);
        while(!q.empty()) {
            int u = q.front(); q.pop();
            for(int i = 0; i < 26; ++i) {
                if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
                else tr[u][i] = tr[fail[u]][i];
            }
        }
    }
    void Query(char *t) {
        int u = 0;
        for(int i = 1; t[i]; ++i) {
            u = tr[u][t[i] - 'a'];
            for(int j = u; j; j = fail[j]) cnt[e[j]]++;
        }
    }
}

int main()
{
    while(true) {
        AC::Clear();
        n = read();
        if(!n) return 0;
        for(int i = 1; i <= n; ++i) scanf("%s", s[i] + 1), AC::Insert(s[i], i);
        scanf("%s", t + 1);
        AC::Get_fail();
        AC::Query(t);
        int Max = -1;
        for(int i = 1; i <= n; ++i) Max = max(Max, AC::cnt[i]);
        printf("%d\n", Max);
        for(int i = 1; i <= n; ++i) if(Max == AC::cnt[i]) printf("%s\n", s[i] + 1);
    }
    return 0;
}

P5357 【模板】AC自動機(二次加強版)

P5357 【模板】AC自動機(二次加強版)

Solution

顯然此時暴跳已經不能滿足我們的需求,畢竟隨便一個 \(aaa...aaa\) 的串就能把我們卡成 \(O(n^2)\) 的。

我們發現 \(fail\) 指針總是由深度深的點指向深度淺的點,那么所有 \(u\)\(fail[u]\) 的連邊就恰好構成了一個 \(DAG\),那么我們可以在匹配到每個節點的時候先不調 \(fail\) 指針,只是標記一下,留到最后一起跳。那樣就可以做到線性的復雜度了。

Code

/*
Work by: Suzt_ilymics
Problem: 不知名屑題
Knowledge: 垃圾算法
Time: O(能過)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<queue>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 2e6+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int n;
char s[MAXN];

int read(){
    int s = 0, f = 0;
    char ch = getchar();
    while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
    while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
    return f ? -s : s;
}

namespace AC {
    int tr[MAXN][26], tot = 0;
    int fail[MAXN], e[MAXN], cnt[MAXN], pre[MAXN], id[MAXN];
    queue<int> q;
    void Insert(char *s, int bh) {
        int u = 0;
        for(int i = 1; s[i]; ++i) {
            if(!tr[u][s[i] - 'a']) tr[u][s[i] - 'a'] = ++ tot;
            u = tr[u][s[i] - 'a'];
        }
        e[u]++;
        pre[bh] = u;
    }
    void Get_fail() {
        for(int i = 0; i < 26; ++i) if(tr[0][i]) q.push(tr[0][i]);
        while(!q.empty()) {
            int u = q.front(); q.pop();
            for(int i = 0; i < 26; ++i) {
                if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]), id[fail[tr[u][i]]]++;
                else tr[u][i] = tr[fail[u]][i];
            }
        }
    }
    void Query(char *s) {
        int u = 0;
        for(int i = 1; s[i]; ++i) {
            u = tr[u][s[i] - 'a'];
            cnt[u]++;
        }
    }
    void Topsort() {
        for(int i = 1; i <= tot; ++i) if(!id[i]) q.push(i);
        while(!q.empty()) {
            int u = q.front(); q.pop();
            int v = fail[u];
            cnt[v] += cnt[u];
            if(!--id[v]) q.push(v);
        }
    }
}

int main()
{
    n = read();
    for(int i = 1; i <= n; ++i) {
        scanf("%s", s + 1);
        AC::Insert(s, i);
    }
    AC::Get_fail();
    scanf("%s", s + 1);
    AC::Query(s);
    AC::Topsort();
    for(int i = 1; i <= n; ++i) {
        printf("%d\n", AC::cnt[AC::pre[i]]);
    }
    return 0;
}

P2444 [POI2000]病毒

P2444 [POI2000]病毒

Solution

如何確定它是安全的?
找到一個循環節,使他永遠不會循環到一個結尾結點。可以用 dfs 判環。
注意,如果一個結點的 \(fail\) 指針被標記,那么它也是危險的,也應該被標記。
因為這個結點的 \(fail\) 指針指向的模式串是這個結點所對應的模式串的后綴。

Code

/*
Work by: Suzt_ilymics
Problem: 不知名屑題
Knowledge: 垃圾算法
Time: O(能過)
*/
#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
#include<queue>
#define LL long long
#define orz cout<<"lkp AK IOI!"<<endl

using namespace std;
const int MAXN = 1e6+5;
const int INF = 1e9+7;
const int mod = 1e9+7;

int n;
char s[MAXN];

int read(){
    int s = 0, f = 0;
    char ch = getchar();
    while(!isdigit(ch))  f |= (ch == '-'), ch = getchar();
    while(isdigit(ch)) s = (s << 1) + (s << 3) + ch - '0' , ch = getchar();
    return f ? -s : s;
}

namespace AC {
    int tr[MAXN][2], tot = 0;
    int fail[MAXN], e[MAXN];
    bool vis[MAXN], f[MAXN];
    queue<int> q;
    void Insert(char *s) {
        int u = 0;
        for(int i = 1; s[i]; ++i) {
            if(!tr[u][s[i] - '0']) tr[u][s[i] - '0'] = ++ tot;
            u = tr[u][s[i] - '0'];
        }
        e[u] ++;
    }
    void Get_fail() {
        for(int i = 0; i < 2; ++i) if(tr[0][i]) q.push(tr[0][i]);
        while(!q.empty()) {
            int u = q.front(); q.pop();
            for(int i = 0; i < 2; ++i) {
                if(tr[u][i]) {
                    fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
                    if(e[fail[tr[u][i]]]) e[tr[u][i]]++;
                }
                else tr[u][i] = tr[fail[u]][i];
            }
        }
    }
    bool dfs(int u) {
        vis[u] = true;
        for(int i = 0; i < 2; ++i) {
            if(vis[tr[u][i]]) return true;
            if(!e[tr[u][i]] && !f[tr[u][i]]) {
                f[tr[u][i]] = true;
                if(dfs(tr[u][i])) return true;
            }
        }
        vis[u] = false;
        return false;
    }
}

int main()
{
    n = read();
    for(int i = 1; i <= n; ++i) scanf("%s", s + 1), AC::Insert(s);
    AC::Get_fail();
    AC::dfs(0) ? puts("TAK") : puts("NIE");
    return 0;
}

其他例題

P5231 [JSOI2012]玄武密碼
P2292 [HNOI2004]L語言
P3121 [USACO15FEB]Censoring G
P3311 [SDOI2014] 數數 AC 自動機 + 數位 DP
CF163E e-Government AC 自動機 + 線段樹
P7582 「RdOI R2」風雨(rain) 比 CF163E 更惡心
P2414 [NOI2011] 阿狸的打字機

寫在后面

如有不懂或錯誤煩請指出,我會在最快的時間處理。

最后,屑題單求收藏qwq


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM