1. 程式人生 > 實用技巧 >【複習筆記】重習 AC 自動機

【複習筆記】重習 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|)\)