【複習筆記】重習 AC 自動機
發現已經忘了許多。。。。於是複習一下
基礎要點概況
-
AC 自動機基於 Trie 樹 的結構,即構建 AC 自動機前需要先建 Trie。
-
一個狀態中除了轉移 \(\delta\) 之外還有失配指標 \(fail\)。\(fail(x)\) 對於的字串是 \(x\) 對應字串的 最長真字尾。
-
要求出 \(fail\) 我們可以 bfs 實現。對於當前狀態 \(x\),設其父親 \(f\) 通過一個 \(c\) 轉移連向 \(x\),那麼我們先看看 \(fail(f)\) 是否存在 \(c\) 轉移,如果有那麼 \(fail(x)\gets \delta(fail(f),c)\),否則就看 \(fail(fail(f))\)
-
但實際上我們都會直接寫成 Trie 圖,如果一個轉移 \(\delta(x, c)\) 不存在,那麼就 \(\delta(x, c)\gets\delta(fail(x), c)\)。從而一些查詢 & 構建 的時候就根本不用直接跳 \(fail\) 應付失配,優化了效率和程式碼難度。
-
構建 AC 自動機的程式碼非常簡潔(複雜度 \(O(n\times |\Sigma|)\),\(n\) 為狀態個數,下同):
void init_fail() { for (int i = 0; i < S; i++) ch[0][i] = 1; for (Q.push(1); !Q.empty(); ) { int x = Q.front(); Q.pop(); for (int i = 0; i < S; i++) if (!ch[x][i]) ch[x][i] = ch[fail[x]][i]; else Q.push(ch[x][i]), fail[ch[x][i]] = ch[fail[x]][i]; } }
-
AC 自動機最經典的應用就是 多模式串匹配 了:Luogu P3808 【模板】AC自動機(簡單版)。先對所有模式串建 AC 自動機,然後從根開始跑文字串:每到達一個點,沿著自己的 \(fail\) 向上跳一遍,答案加上沿途遇到的終止狀態的個數。當然,為避免重複統計,可以給走過的位置打一個標記。
-
詢問的參考程式碼(複雜度 \(O(n)\)):
int query(char* s) { int ans = 0; for (int x = 1, i = 0; s[i]; i++) { x = ch[x][s[i] - 'a']; for (int y = x; y && ~cnt[y]; y = fail[y]) ans += cnt[y], cnt[y] = -1; } return ans; }
-
然而這樣做一些多次詢問的題會被卡爆成 \(O(n\times Q)\),比如要求所有串分別出現的次數時,遇到
aaaaaa...aa
這種,一次轉移就要跳 \(O(n)\) 次失配指標。於是引入 \(fail\) 樹:對於每個非根狀態 \(x\),都從 \(fail(x)\) 連過來一條邊,最終形成 \(fail\) 樹。 -
\(fail\) 樹將我跳失配指標的過程實體化了,那麼一個狀態能更新到 \(x\),那麼說明這個狀態在 \(x\) 的 \(fail\) 樹上的子樹內。這就好辦了,我們先將文字串在 AC 自動機上跑一邊,沿途更新計數器,然後一個狀態對應的 \(fail\) 樹 子樹和 即為出現次數。
-
於是這樣就是真的線性了,Luogu P5357 【模板】AC自動機(二次加強版) 程式碼:
std::vector<int> adj[N]; int dfs(int x) { for (int i = 0; i < (int)adj[x].size(); i++) cnt[x] += dfs(adj[x][i]); return cnt[x]; } void query(char* s) { for (int x = 1, i = 0; s[i]; i++) ++cnt[x = ch[x][s[i] - 'a']]; dfs(1); }
進階應用(套路)
套路 1:AC 自動機相關 dp
【JSOI2007】文字生成器:給你若干個模式串,求至少包含一個模式串的長度為 \(m\) 的文字串個數。
-
首先一個簡單的容斥,答案為 \(m^{|\Sigma|}\) 減去不包含任何一個模式串的個數。
-
然後令 \(f(i,j)\) 為當前長度為 \(i\) 且走到狀態 \(j\) 的方案數。那麼轉移顯然是 \(\forall \delta(j,c)\ne \text{null}: f(i,j) \to f(i+1,\delta(j,c))\),並且 不能轉移到有結束標記 的狀態。
-
但這樣還不行,要得到一個字串,我們不只有這一個狀態可以作為終點。如果當前代表的字串的最長真字尾 \(fail(x)\) 不能走,那麼當前狀態 \(x\) 也不能走,因為 前者必然被後者所包含。那麼考慮稍微更改一下構建的實現:
void build() { std::queue<int> Q; for (int i = 0; i < S; i++) ch[0][i] = 1; for (Q.push(1); !Q.empty(); ) { int x = Q.front(); Q.pop(); for (int i = 0; i < S; i++) { if (!ch[x][i]) { ch[x][i] = ch[fail[x]][i]; continue; } Q.push(ch[x][i]); fail[ch[x][i]] = ch[fail[x]][i]; end[ch[x][i]] |= end[fail[ch[x][i]]]; // <- } } }
-
那麼 dp 的過程就比較顯然了:先 \(1\to m\) 列舉長度,再考慮所有的狀態,對於每個狀態列舉所有可行轉移。
f[0][1] = 1; for (int i = 1; i <= m; i++) for (int j = 1; j <= total; j++) for (int c = 0; c < S; c++) if (!end[ch[j][c]]) (f[i][ch[j][c]] += f[i - 1][j]) %= mod;
-
時間複雜度 \(O(m\times \sum_i|s_i|)\)。
套路 2:套路 1 的矩陣優化
【POJ 2778】DNA Sequence:給定 \(n\) 個禁止串,求長度為 \(m\) 且不含任何一個禁止串的字串個數。\(1\le m\le 2\times 10^9\)
-
現在 \(m\) 的規模邊的很大,怎麼辦?我們先把問題做一步轉化:從根狀態結點走 \(m\) 步到任意 非禁止狀態 的方案數。那麼我們將建出的 Trie 圖看做一個 有向圖。然後就是經典的“從 \(s\) 走 \(m\) 條邊到 \(t\) 的走法數”問題。
-
很顯然地考慮 鄰接矩陣(\(g\)) 表示這個圖,然後對其做 \(m\) 次冪。那麼 \(g_{i,j}\) 就是 \(i\) 走 \(m\) 步到達 \(j\) 的方案數。
-
那麼答案即為 \(\sum_{x\in{\text{ACAM}}}g_{Q,x}\),其中 \(Q\) 為根狀態。
-
再用 矩陣快速冪 優化冪運算,複雜度為 \(O((\sum_i|s_i|)^3\log m)\):
Matrix f, g; for (int i = 1; i <= total; i++) if (!end[i]) for (int j = 0; j < S; j++) if (!end[ch[i][j]]) ++f.e[i][ch[i][j]]; for (int i = 1; i <= total; i++) g.e[i][i] = 1; for (; m; m >>= 1, f = f * f) if (m & 1) g = g * f; int ans = 0; for (int i = 1; i <= total; i++) (ans += g.e[1][i]) %= mod; printf("%d\n", ans);
套路 3:轉化為樹上統計問題
【Codeforces 163E】e-Government:給定 \(k\) 個字串 \(s_1, s_2, \cdots, s_k\),要求維護一個字串集 \(S\),一開始 \(k\) 個字串都在 \(S\) 中,現有 \(n\) 次操作,每次會加入或移除 \(k\) 個字串中的一個,或者詢問一個文字串求出 \(S\) 中每個串匹配次數之和。
- 首先 AC 自動機並不能很方便地支援動態加,更何況刪除,顯然是一開始就要建好 AC 自動機。
- 然後不能想到修改時直接在對應位置的計數器 \(\pm 1\),然後統計貢獻直接暴跳 \(fail\)。然而這個 Naive 的想法早就被卡了。
- 於是想二次加強版一樣考慮建出 \(fail\) 樹,然後就是跳祖先累加貢獻,也就是 鏈上求和。
- 所以說現在要維護一顆樹,支援鏈求和 & 單點修改。樹剖或括號序加樹狀陣列都可,複雜度 \(O(n\log n)/O(n\log^2 n)\)。
- 以及 【NOI2011】阿狸的打字機 也用了類似的思想,推薦寫一下。
雜題選做
【POI2000】病毒
給定 \(n\) 個禁止串,求是否存在無限長的串,不包含任意一個禁止串。
- 這個題非常神奇,它要求儘量不匹配。
- 於是我們將計就計,在 AC 自動機上跑的時候,儘量避開禁止狀態。注意,這裡“禁止”的處理也需要想“文字生成器”那樣修改構建函式。
- 然而“儘量避開”是個很模糊的概念,不過在這裡顯然是指可以在 AC 自動機下無限地走下去。
- 那麼,其實只要找到一個 經過根狀態的環即可。一次 Dfs 搞定。
【Codeforces 1202E】You Are Given Some Strings...
給定一個字串 \(t\) 以及 \(n\) 個模式串 \(s_1, s_2, \cdots, s_n\)。設 \(f(s, t)\) 為字串 \(t\) 在 \(s\) 中的出現次數,\(s_i+s_j\) 表示 \(s_i\) 在後面追加 \(s_j\) 所得到的字串。求 \(\sum_{i,j}f(t, s_i+s_j)\)。
- 首先,如果其中一個 \(s_i+s_j\) 匹配上了,那麼必然在 \(t\) 中存在一個 斷點,使得前半部分的一個字尾為 \(s_i\),後半部分的一個字首為 \(s_j\)。
- 那麼考慮列舉這個斷點 \(x\),記 \(f(x)\) 為 \(t\) 的字首 \(1\sim x\) 中有幾個模式串可以作為其後綴,同理對字尾 \(x\sim |t|\) 定義 \(g\) 表示幾個可以作為字首。答案可以表示為 \(\sum_{i\in[1, n)} f(i)\times g(i+1)\)。
- 由於 \(f\) 將字串翻轉就是 \(g\),這裡只提一下 \(f\) 的求法。首先對 \(s_1,s_2, \cdots,s_n\) 建 AC 自動機,然後 \(t\) 在上面跑轉移。走到一個位置,當前 \(f\) 的值就是 \(fail\) 樹上的子樹和。\(g\) 的話就把所有串翻轉再跑一遍。
- 複雜度 \(O(\sum_i|s_i|+|t|)\)