我知道,很多人在第一次看到這個東西的時侯是非常興奮的。(別問我爲什麼知道)不過這個自動機啊它叫作 Automaton
,不是 Automation
,讓萌新失望啦。切入正題。似乎在初學自動機相關的內容時,許多人難以建立對自動機的初步印象,尤其是在自學的時侯。而這篇文章就是爲你們打造的。筆者在自學 AC 自動機後花費兩天時間製作若干的 gif,呈現出一個相對直觀的自動機形態。儘管這個圖似乎不太可讀,但這絕對是在作者自學的時侯,畫得最妙不可讀的 gif 了。另外有些小夥伴問這個 gif 拿什麼畫的。筆者用 Windows 畫圖軟件製作。
概述
AC 自動機是 以 TRIE 的結構爲基礎 ,結合 KMP 的思想 建立的。
簡單來說,建立一個 AC 自動機有兩個步驟:
- 基礎的 TRIE 結構:將所有的模式串構成一棵 。
- KMP 的思想:對 樹上所有的結點構造失配指針。
然後就可以利用它進行多模式匹配了。
字典樹構建
AC 自動機在初始時會將若干個模式串丟到一個 TRIE 裏,然後在 TRIE 上建立 AC 自動機。這個 TRIE 就是普通的 TRIE,該怎麼建怎麼建。
這裏需要仔細解釋一下 TRIE 的結點的含義,儘管這很小兒科,但在之後的理解中極其重要。TRIE 中的結點表示的是某個模式串的前綴。我們在後文也將其稱作狀態。一個結點表示一個狀態,TRIE 的邊就是狀態的轉移。
形式化地說,對於若干個模式串 ,將它們構建一棵字典樹後的所有狀態的集合記作 。
失配指針
AC 自動機利用一個 fail 指針來輔助多模式串的匹配。
狀態 的 fail 指針指向另一個狀態 ,其中 ,且 是 的最長後綴(即在若干個後綴狀態中取最長的一個作爲 fail 指針)。對於學過 KMP 的朋友,我在這裏簡單對比一下這裏的 fail 指針與 KMP 中的 next 指針:
- 共同點:兩者同樣是在失配的時候用於跳轉的指針。
- 不同點:next 指針求的是最長 Border(即最長的相同前後綴),而 fail 指針指向所有模式串的前綴中匹配當前狀態的最長後綴。
因爲 KMP 只對一個模式串做匹配,而 AC 自動機要對多個模式串做匹配。有可能 fail 指針指向的結點對應着另一個模式串,兩者前綴不同。
沒看懂上面的對比不要急(也許我的腦回路和泥萌不一樣是吧),你只需要知道,AC 自動機的失配指針指向當前狀態的最長後綴狀態即可。
AC 自動機在做匹配時,同一位上可匹配多個模式串。
構建指針
下面介紹構建 fail 指針的 基礎思想 :(強調!基礎思想!基礎!)
構建 fail 指針,可以參考 KMP 中構造 Next 指針的思想。
考慮字典樹中當前的結點 , 的父結點是 , 通過字符 c
的邊指向 ,即 。假設深度小於 的所有結點的 fail 指針都已求得。
- 如果 存在:則讓 u 的 fail 指針指向 。相當於在 和 後面加一個字符
c
,分別對應 和 。 - 如果 不存在:那麼我們繼續找到 。重複 1 的判斷過程,一直跳 fail 指針直到根結點。
- 如果真的沒有,就讓 fail 指針指向根結點。
如此即完成了 的構建。
例子
下面放一張 GIF 幫助大家理解。對字符串 i
he
his
she
hers
組成的字典樹構建 fail 指針:
- 黃色結點:當前的結點 。
- 綠色結點:表示已經 BFS 遍歷完畢的結點,
- 橙色的邊:fail 指針。
- 紅色的邊:當前求出的 fail 指針。
我們重點分析結點 6 的 fail 指針構建:
找到 6 的父結點 5, 。然而 10 結點沒有字母 s
連出的邊;繼續跳到 10 的 fail 指針, 。發現 0 結點有字母 s
連出的邊,指向 7 結點;所以 。最後放一張建出來的圖
字典樹與字典圖
我們直接上代碼吧。字典樹插入的代碼就不分析了(後面完整代碼裏有),先來看構建函數 build()
,該函數的目標有兩個,一個是構建 fail 指針,一個是構建自動機。參數如下:
tr[u,c]
這個有兩種理解方式。我們可以簡單理解爲字典樹上的一條邊,即 ;也可以理解爲從狀態(結點) 後加一個字符c
到達的狀態(結點),即一個狀態轉移函數 。下文中我們將用第二種理解方式繼續講解。q
隊列,用於 BFS 遍歷字典樹。fail[u]
結點 的 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):
- 如果 存在,我們就將 的 fail 指針賦值爲 。這裏似乎有一個問題。根據之前的講解,我們應該用 while 循環,不停的跳 fail 指針,判斷是否存在字符
i
對應的結點,然後賦值。不過在代碼中我們一句話就做完這件事了。 - 否則, 不存在,就讓 指向 的狀態。
接下來解答一下上文提出的問題。細心的同學會發現, else
語句的代碼會修改字典樹的結構。沒錯,它將不存在的字典樹的狀態鏈接到了失配指針的對應狀態。在原字典樹中,每一個結點代表一個字符串 ,是某個模式串的前綴。而在修改字典樹結構後,儘管增加了許多轉移關係,但結點(狀態)所代表的字符串是不變的。
而 相當於是在 後添加一個字符 c
變成另一個狀態 。如果 存在,說明存在一個模式串的前綴是 ,否則我們讓 指向 。由於 對應的字符串是 的後綴,因此 對應的字符串也是 的後綴。
換言之在 TRIE 上跳轉的時侯,我們只會從 跳轉到 ,相當於匹配了一個 ;但在 AC 自動機上跳轉的時侯,我們會從 跳轉到 的後綴,也就是說我們匹配一個字符 c
,然後捨棄 的部分前綴。捨棄前綴顯然是能匹配的。那麼 fail 指針呢?它也是在捨棄前綴啊!試想一下,如果文本串能匹配 ,顯然它也能匹配 的後綴。所謂的 fail 指針其實就是 的一個後綴集合。
tr
數組還有另一種比較簡單的理解方式:如果在 的位置失配,我們會跳轉到 的位置。所以我們可能沿着 數組跳轉多次才能來到下一個能匹配的位置。所以我們可以用 tr
數組直接記錄記錄下一個能匹配的位置,這樣就能節省下很多時間。
這樣修改字典樹的結構,使得匹配轉移更加完善。同時它將 fail 指針跳轉的路徑做了壓縮(就像並查集的路徑壓縮),使得本來需要跳很多次 fail 指針變成跳一次。
好的,我知道大家都受不了長篇敘述。上圖!我們將之前的 GIF 圖改一下:
- 藍色結點:BFS 遍歷到的結點 u
- 藍色的邊:當前結點下,AC 自動機修改字典樹結構連出的邊。
- 黑色的邊:AC 自動機修改字典樹結構連出的邊。
- 紅色的邊:當前結點求出的 fail 指針
- 黃色的邊:fail 指針
- 灰色的邊:字典樹的邊
可以發現,衆多交錯的黑色邊將字典樹變成了 字典圖 。圖中省 s 略了連向根結點的黑邊(否則會更亂)。我們重點分析一下結點 5 遍歷時的情況,再妙不可讀也請大家硬着頭皮去讀。我們求 的 fail 指針:
本來的策略是找 fail 指針,於是我們跳到 發現沒有 s
連出的字典樹的邊,於是跳到 ,發現有 ,於是 ;但是有了黑邊、藍邊,我們跳到 之後直接走 就走到 號結點了。其實我知道沒人會仔細看這鬼扯的兩張圖片的 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;
}
聲明 作爲字典樹上當前匹配到的結點, 即返回的答案。循環遍歷匹配串, 在字典樹上跟蹤當前字符。利用 fail 指針找出所有匹配的模式串,累加到答案中。然後清 0。對 取反的操作用來判斷 是否等於 -1。在上文中我們分析過,字典樹的結構其實就是一個 函數,而構建好這個函數後,在匹配字符串的過程中,我們會捨棄部分前綴達到最低限度的匹配。fail 指針則指向了更多的匹配狀態。最後上一份圖。對於剛纔的自動機:
我們從根結點開始嘗試匹配 ushersheishis
,那麼 p 的變化將是:
- 紅色結點:p 結點
- 粉色箭頭:p 在自動機上的跳轉,
- 藍色的邊:成功匹配的模式串
- 藍色結點:示跳 fail 指針時的結點(狀態)。
總結
希望大家看懂了文章。其實總結一下,你只需要知道 AC 自動機的板子很好背就行啦。
時間複雜度: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;
}
#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)是由
- 狀態集合 ;
- 字符集 ;
- 狀態轉移函數 ,即 ;
- 一個開始狀態 ;
- 一個接收的狀態集合 。
組成的五元組 。
那這東西你用 AC 自動機理解,狀態集合就是字典樹(圖)的結點;字符集就是 a
到 z
(或者更多);狀態轉移函數就是 的函數(即 tr[u,c]
);開始狀態就是字典樹的根結點;接收狀態就是你在字典樹中標記的字符串結尾結點組成的集合。
KMP 自動機
KMP 自動機就是一個不斷讀入待匹配串,每次匹配時走到接受狀態的 DFA。如果共有 個狀態,第 個狀態表示已經匹配了前 個字符。那麼我們定義 表示狀態 讀入字符 後到達的狀態, 表示 prefix function ,則有:
(約定 )
我們發現 只依賴於之前的值,所以可以跟 KMP 一起求出來。
時間和空間複雜度: 。一些細節:走到接受狀態之後立即轉移到該狀態的 。
對比之下,AC 自動機其實就是 Trie 上的自動機。(雖然一開始丟給你這句話可能不知所措)