【C++】AC自動機

我知道,很多人在第一次看到這個東西的時侯是非常興奮的。(別問我爲什麼知道)不過這個自動機啊它叫作 Automaton ,不是 Automation ,讓萌新失望啦。切入正題。似乎在初學自動機相關的內容時,許多人難以建立對自動機的初步印象,尤其是在自學的時侯。而這篇文章就是爲你們打造的。筆者在自學 AC 自動機後花費兩天時間製作若干的 gif,呈現出一個相對直觀的自動機形態。儘管這個圖似乎不太可讀,但這絕對是在作者自學的時侯,畫得最妙不可讀的 gif 了。另外有些小夥伴問這個 gif 拿什麼畫的。筆者用 Windows 畫圖軟件製作。

概述

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

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

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

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

字典樹構建

AC 自動機在初始時會將若干個模式串丟到一個 TRIE 裏,然後在 TRIE 上建立 AC 自動機。這個 TRIE 就是普通的 TRIE,該怎麼建怎麼建。

這裏需要仔細解釋一下 TRIE 的結點的含義,儘管這很小兒科,但在之後的理解中極其重要。TRIE 中的結點表示的是某個模式串的前綴。我們在後文也將其稱作狀態。一個結點表示一個狀態,TRIE 的邊就是狀態的轉移。

形式化地說,對於若干個模式串 s1,s2sns_1,s_2\dots s_n ,將它們構建一棵字典樹後的所有狀態的集合記作 QQ

失配指針

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

狀態 uu 的 fail 指針指向另一個狀態 vv ,其中 vQv\in Q ,且 vvuu 的最長後綴(即在若干個後綴狀態中取最長的一個作爲 fail 指針)。對於學過 KMP 的朋友,我在這裏簡單對比一下這裏的 fail 指針與 KMP 中的 next 指針:

  1. 共同點:兩者同樣是在失配的時候用於跳轉的指針。
  2. 不同點:next 指針求的是最長 Border(即最長的相同前後綴),而 fail 指針指向所有模式串的前綴中匹配當前狀態的最長後綴。

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

沒看懂上面的對比不要急(也許我的腦回路和泥萌不一樣是吧),你只需要知道,AC 自動機的失配指針指向當前狀態的最長後綴狀態即可。

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

構建指針

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

構建 fail 指針,可以參考 KMP 中構造 Next 指針的思想。

考慮字典樹中當前的結點 uuuu 的父結點是 pppp 通過字符 c 的邊指向 uu ,即 trie[p,c]=utrie[p,c]=u 。假設深度小於 uu 的所有結點的 fail 指針都已求得。

  1. 如果 trie[fail[p],c]trie[fail[p],c] 存在:則讓 u 的 fail 指針指向 trie[fail[p],c]trie[fail[p],c] 。相當於在 ppfail[p]fail[p] 後面加一個字符 c ,分別對應 uufail[u]fail[u]
  2. 如果 trie[fail[p],c]trie[fail[p],c] 不存在:那麼我們繼續找到 trie[fail[fail[p]],c]trie[fail[fail[p]],c] 。重複 1 的判斷過程,一直跳 fail 指針直到根結點。
  3. 如果真的沒有,就讓 fail 指針指向根結點。

如此即完成了 fail[u]fail[u] 的構建。

例子

下面放一張 GIF 幫助大家理解。對字符串 i he his she hers 組成的字典樹構建 fail 指針:

  1. 黃色結點:當前的結點 uu
  2. 綠色結點:表示已經 BFS 遍歷完畢的結點,
  3. 橙色的邊:fail 指針。
  4. 紅色的邊:當前求出的 fail 指針。

在這裏插入圖片描述

我們重點分析結點 6 的 fail 指針構建:

在這裏插入圖片描述
找到 6 的父結點 5, fail[5]=10fail[5]=10 。然而 10 結點沒有字母 s 連出的邊;繼續跳到 10 的 fail 指針, fail[10]=0fail[10]=0 。發現 0 結點有字母 s 連出的邊,指向 7 結點;所以 fail[6]=7fail[6]=7 。最後放一張建出來的圖

在這裏插入圖片描述

字典樹與字典圖

我們直接上代碼吧。字典樹插入的代碼就不分析了(後面完整代碼裏有),先來看構建函數 build() ,該函數的目標有兩個,一個是構建 fail 指針,一個是構建自動機。參數如下:

  1. tr[u,c] 這個有兩種理解方式。我們可以簡單理解爲字典樹上的一條邊,即 trie[u,c]trie[u,c] ;也可以理解爲從狀態(結點) uu 後加一個字符 c 到達的狀態(結點),即一個狀態轉移函數 trans(u,c)trans(u,c) 。下文中我們將用第二種理解方式繼續講解。
  2. q 隊列,用於 BFS 遍歷字典樹。
  3. fail[u] 結點 uu 的 fail 指針。
void build() {
	for (int i = 0; i < 26; i++)
		if (tr[0][i]) q.push(tr[0][i]);
	while (q.size()) {
		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];
		}
	}
}

關愛萌新,筆者大力復讀一下代碼:Build 函數將結點按 BFS 順序入隊,依次求 fail 指針。這裏的字典樹根結點爲 0,我們將根結點的子結點一一入隊。若將根結點入隊,則在第一次 BFS 的時候,會將根結點兒子的 fail 指針標記爲本身。因此我們將根結點的兒子一一入隊,而不是將根結點入隊。

然後開始 BFS:每次取出隊首的結點 u。fail[u]指針已經求得,我們要求 u 的子結點們的 fail 指針。然後遍歷字符集(這裏是 0-25,對應 a-z):

  1. 如果 trans(u,i)trans(u,i) 存在,我們就將 trans(u,i)trans(u,i) 的 fail 指針賦值爲 trans(fail[u],i)trans(fail[u],i) 。這裏似乎有一個問題。根據之前的講解,我們應該用 while 循環,不停的跳 fail 指針,判斷是否存在字符 i 對應的結點,然後賦值。不過在代碼中我們一句話就做完這件事了。
  2. 否則, trans(u,i)trans(u,i) 不存在,就讓 trans(u,i)trans(u,i) 指向 trans(fail[u],i)trans(fail[u],i) 的狀態。

接下來解答一下上文提出的問題。細心的同學會發現, else 語句的代碼會修改字典樹的結構。沒錯,它將不存在的字典樹的狀態鏈接到了失配指針的對應狀態。在原字典樹中,每一個結點代表一個字符串 SS ,是某個模式串的前綴。而在修改字典樹結構後,儘管增加了許多轉移關係,但結點(狀態)所代表的字符串是不變的。

trans(S,c)trans(S,c) 相當於是在 SS 後添加一個字符 c 變成另一個狀態 SS' 。如果 SS' 存在,說明存在一個模式串的前綴是 SS' ,否則我們讓 trans(S,c)trans(S,c) 指向 trans(fail[S],c)trans(fail[S],c) 。由於 fail[S]fail[S] 對應的字符串是 SS 的後綴,因此 trans(fail[S],c)trans(fail[S],c) 對應的字符串也是 SS' 的後綴。

換言之在 TRIE 上跳轉的時侯,我們只會從 SS 跳轉到 SS' ,相當於匹配了一個 SS' ;但在 AC 自動機上跳轉的時侯,我們會從 SS 跳轉到 SS' 的後綴,也就是說我們匹配一個字符 c ,然後捨棄 SS 的部分前綴。捨棄前綴顯然是能匹配的。那麼 fail 指針呢?它也是在捨棄前綴啊!試想一下,如果文本串能匹配 SS ,顯然它也能匹配 SS 的後綴。所謂的 fail 指針其實就是 SS 的一個後綴集合。

tr 數組還有另一種比較簡單的理解方式:如果在 uu 的位置失配,我們會跳轉到 fail[u]fail[u] 的位置。所以我們可能沿着 failfail 數組跳轉多次才能來到下一個能匹配的位置。所以我們可以用 tr 數組直接記錄記錄下一個能匹配的位置,這樣就能節省下很多時間。

這樣修改字典樹的結構,使得匹配轉移更加完善。同時它將 fail 指針跳轉的路徑做了壓縮(就像並查集的路徑壓縮),使得本來需要跳很多次 fail 指針變成跳一次。

好的,我知道大家都受不了長篇敘述。上圖!我們將之前的 GIF 圖改一下:

在這裏插入圖片描述

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

可以發現,衆多交錯的黑色邊將字典樹變成了 字典圖 。圖中省 s 略了連向根結點的黑邊(否則會更亂)。我們重點分析一下結點 5 遍歷時的情況,再妙不可讀也請大家硬着頭皮去讀。我們求 trans(5, s )=6trans(5,\text{ s })=6 的 fail 指針:

在這裏插入圖片描述

本來的策略是找 fail 指針,於是我們跳到 fail[5]=10fail[5]=10 發現沒有 s 連出的字典樹的邊,於是跳到 fail[10]=0fail[10]=0 ,發現有 trie[0, s ]=7trie[0,\text{ s }]=7 ,於是 fail[6]=7fail[6]=7 ;但是有了黑邊、藍邊,我們跳到 fail[5]=10fail[5]=10 之後直接走 trans(10, s )=7trans(10,\text{ s })=7 就走到 77 號結點了。其實我知道沒人會仔細看這鬼扯的兩張圖片的 QAQ

這就是 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;
}

聲明 uu 作爲字典樹上當前匹配到的結點, resres 即返回的答案。循環遍歷匹配串, uu 在字典樹上跟蹤當前字符。利用 fail 指針找出所有匹配的模式串,累加到答案中。然後清 0。對 e[j]e[j] 取反的操作用來判斷 e[j]e[j] 是否等於 -1。在上文中我們分析過,字典樹的結構其實就是一個 transtrans 函數,而構建好這個函數後,在匹配字符串的過程中,我們會捨棄部分前綴達到最低限度的匹配。fail 指針則指向了更多的匹配狀態。最後上一份圖。對於剛纔的自動機:
在這裏插入圖片描述

我們從根結點開始嘗試匹配 ushersheishis ,那麼 p 的變化將是:

在這裏插入圖片描述

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

總結

希望大家看懂了文章。其實總結一下,你只需要知道 AC 自動機的板子很好背就行啦。

時間複雜度:AC 自動機的時間複雜度在需要找到所有匹配位置時是 O(s+m)O(|s|+m) ,其中 s|s| 表示文本串的長度, mm 表示模板串的總匹配次數;而只需要求是否匹配時時間複雜度爲 O(s)O(|s|)

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

#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 6;
int n;

namespace AC {

    int tr[N][26], tot;
    int e[N], fail[N];

    void insert(char *s) {
        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]++;
    }

    queue<int> q;

    void build() {
        for (int i = 0; i < 26; i++)
            if (tr[0][i]) q.push(tr[0][i]);
        while (q.size()) {
            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];
            }
        }
    }

    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;
    }

}  // namespace AC

char s[N];

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%s", s + 1), AC::insert(s);
    scanf("%s", s + 1);
    AC::build();
    printf("%d", AC::query(s));
    return 0;
}

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

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

const int N = 156, L = 1e6 + 6;

namespace AC {

    const int SZ = N * 80;
    int tot, tr[SZ][26];
    int fail[SZ], idx[SZ], val[SZ];
    int cnt[N];  // 記錄第 i 個字符串的出現次數

    void init() {
        memset(fail, 0, sizeof(fail));
        memset(tr, 0, sizeof(tr));
        memset(val, 0, sizeof(val));
        memset(cnt, 0, sizeof(cnt));
        memset(idx, 0, sizeof(idx));
        tot = 0;
    }

    void insert(char *s, int id) {  // id 表示原始字符串的編號
        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'];
        }
        idx[u] = id;
    }

    queue<int> q;

    void build() {
        for (int i = 0; i < 26; i++)
            if (tr[0][i]) q.push(tr[0][i]);
        while (q.size()) {
            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];
            }
        }
    }

    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; j = fail[j]) val[j]++;
        }
        for (int i = 0; i <= tot; i++)
            if (idx[i]) res = max(res, val[i]), cnt[idx[i]] = val[i];
            return res;
    }
}  // namespace AC

int n;
char s[N][100], t[L];

int main() {
    while (~scanf("%d", &n)) {
        if (n == 0) break;
        AC::init();
        for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1), AC::insert(s[i], i);
        AC::build();
        scanf("%s", t + 1);
        int x = AC::query(t);
        printf("%d\n", x);
        for (int i = 1; i <= n; i++)
            if (AC::cnt[i] == x) printf("%s\n", s[i] + 1);
    }
    return 0;
}

拓展

確定有限狀態自動機

如果大家理解了上面的講解,那麼做爲拓展延伸,文末我們簡單介紹一下自動機與 KMP 自動機。(現在你再去看百科上自動機的定義就會好懂很多啦)

有限狀態自動機(deterministic finite automaton,DFA)是由

  1. 狀態集合 QQ
  2. 字符集 Σ\Sigma
  3. 狀態轉移函數 δ:Q×ΣQ\delta:Q\times \Sigma \to Q ,即 δ(q,σ)=q, q,qQ,σΣ\delta(q,\sigma)=q',\ q,q'\in Q,\sigma\in \Sigma
  4. 一個開始狀態 sQs\in Q
  5. 一個接收的狀態集合 FQF\subseteq Q

組成的五元組 (Q,Σ,δ,s,F)(Q,\Sigma,\delta,s,F)

那這東西你用 AC 自動機理解,狀態集合就是字典樹(圖)的結點;字符集就是 az (或者更多);狀態轉移函數就是 trans(u,c)trans(u,c) 的函數(即 tr[u,c] );開始狀態就是字典樹的根結點;接收狀態就是你在字典樹中標記的字符串結尾結點組成的集合。

KMP 自動機

KMP 自動機就是一個不斷讀入待匹配串,每次匹配時走到接受狀態的 DFA。如果共有 mm 個狀態,第 ii 個狀態表示已經匹配了前 ii 個字符。那麼我們定義 transi,ctrans_{i,c} 表示狀態 ii 讀入字符 cc 後到達的狀態, nextinext_{i} 表示 prefix function ,則有:

transi,c={i+1,if bi=ctransnexti,c,else trans_{i,c} = \begin{cases} i + 1, & \text{if $b_{i} = c$} \\[2ex] trans_{next_{i},c}, & \text{else} \end{cases}

(約定 next0=0next_{0}=0

我們發現 transitrans_{i} 只依賴於之前的值,所以可以跟 KMP 一起求出來。

時間和空間複雜度: O(mΣ)O(m|\Sigma|) 。一些細節:走到接受狀態之後立即轉移到該狀態的 nextnext

對比之下,AC 自動機其實就是 Trie 上的自動機。(雖然一開始丟給你這句話可能不知所措)

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