AC 自動機學習筆記

Fire_Raku發表於2024-07-28

preface

第一次寫 ACAM 模版是 2023.7.02,現在重新回顧了一下,還是有不少新的理解的,或者說一些概念更加清晰了。

1.引入

思考這樣一個問題:

給若干模式串,求詢問串中出現了多少個模式串。

暴力肯定是一一比對,複雜度是 \(O(n^2)\) 以上的,可以雜湊一下,那複雜度就是 \(O(n^2)\)

回想一下 kmp 演算法解決的題目,暴力匹配也是 \(O(n^2)\),但 kmp 演算法利用了詢問串匹配時的最長相等前字尾來加快匹配,充分利用了詢問串上的資訊,做到優秀的 \(O(n)\) 複雜度。那上面這題可不可以用上面的思想呢。

首先可以把模式串都搬到 trie 樹上,這樣各個模式串相互的字首資訊就清楚了。顯然 trie 樹上每個節點都對應了一條到根節點的字首。我們在 trie 樹上也維護每個節點的最長相等前字尾,但這裡的最長相等前字尾就不侷限於這一條到根節點的字首了,而是整棵 trie 樹。那麼現在可以簡單理解為把 kmp 演算法搬到 trie 樹就有了 ACAM 演算法。

2.實現

插入和 trie 樹一樣,不講。

與 kmp 演算法相同,ACAM 也有失配陣列 \(fail_u\),方便我們在失配後找到一個最長的可以匹配的字首。得到 \(fail\) 陣列可以在 trie 樹上 bfs。

void getfail() {
	std::queue<int> q;
	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];
		}
	} 
}

做到這一步就算建出了 ACAM 了,那麼前面的題怎麼做呢。

3.作用

嘗試在 ACAM 上走詢問串,假如走到節點 \(u\),它當然對應著一個字首,並且意味著你成功匹配到了當前位置,即模式串中存在這麼一段字首。如果有一個模式串到這裡就是終點,那麼此時增加貢獻。否則往上跳到 \(fail_u\),也對應一個字首,並且它與 \(u\) 的字尾相同且最長,加上他的貢獻,繼續往上跳 \(fail_{fail_u}\)

這樣做是不重不漏的。因為這個過程相當於你固定了詢問串上的右端點,查詢從右端點開始有沒有這樣一段字尾是模式串。

更進一步,用 \((fail_u,u)\) 的邊建出一張圖,那麼一定仍是一棵樹。每個節點對應一段字首,當前節點代表的字串一定包含於子節點代表的字串且是其一段字尾。

上面的暴力跳祖先複雜度太高,怎麼最佳化?這個過程不就是求根到節點的鏈和嘛,求一下 \(fail\) 樹上字首和即可。

4.延伸

假如題目變成這樣:

給若干模式串,求模式串中出現了多少個詢問串。(保證詢問串在模式串中出現過)

可以在 fail 樹上考慮這個問題。因為題目的限制,他一定對應一個樹上一個節點,那麼它子樹中的所有節點一定包含它(換句話說,如果子樹中節點失配的話,一定會跳到它),所以就轉化為子樹求和做了。

5.思考

其實仔細想想,kmp 不就是一個模式串的 ACAM 嗎?它的 trie 樹是一條鏈,本質是相同的。

fail 樹的包含關係非常重要。

如果加入一些修改操作,都有 fail 樹了,就用資料結構維護樹上問題唄。通常可以用 dfs 序轉化為序列問題。

比如引入的問題中,如果加刪字串(前提是 ACAM 建出來了),就是一個單點加,求鏈和的問題,然後這個問題可以用樹上差分轉化為更簡單的子樹加,求單點,用樹狀陣列維護。

延伸的問題裡,同樣的操作,只是修改變成了若干個單點修改,詢問變成了求子樹和。

ACAM 又提供了一個很好的狀態表示,可以和 DP 結合。套路的設 \(f_{i,j}\) 表示考慮前 \(i\) 個字元,目前在 ACAM 上的 \(j\) 節點的答案。本質上是利用 ACAM 統計的模式串的某些資訊來進行轉移。

6.習題

P3041 [USACO12JAN] Video Game G

經典 ACAM + DP

\(f_{i,j}\) 表示考慮前 \(i\) 個字元,目前在 ACAM 上的 \(j\) 節點的最高分。轉移就需要知道下一個字元能夠匹配多少模式串,可以預處理出來。

複雜度 \(O(k\times tot)\)

CF163E e-Government

經典的資料結構維護 ACAM 的題目,用上引出中的經典轉化,需要實現子樹加,單點求值。

CF1400F x-prime Substrings

ACAM + DP

需要發現 \(x-prime\) 字串是很少的,因此將所有 \(x-prime\) 字串一起建 ACAM,然後考慮 dp。設 \(f_{i,j}\) 表示考慮了前 \(i\) 個位置,在 ACAM 上狀態為 \(j\),最少的刪除次數使得原字串不存在 \(x-prime\) 區間。轉移不更新 \(x-prime\) 字串的狀態即可。

CF1202E You Are Given Some Strings...

考慮列舉斷點 \(x\),前面給 \(s_i\),後面給 \(s_j\)。前者需要求出字串 \(t\) 的每個字首有多少 \(s\) 是其字尾,後者需要求出字串 \(t\) 的每個字尾有多少 \(s\) 是其字首。

前者是經典的 fail 樹問題,後者翻轉做一遍同樣的事即可。

CF547E Mike and Friends

考慮離線,然後拆貢獻,詢問 \(s_k\)\(s_{1...r}\) 中出現次數,詢問 \(s_k\)\(s_{1...l-1}\) 中出現次數,兩者相減即可。

一邊插入(若干單點加),一邊計算答案即可(子樹求和)。

CF587F Duff is Mad

上一題反過來,一下子不好做了。

如果是暴力的話,每次都要跑一遍 \(s_k\) 詢問每個位置的價值,而 \(k\) 是可以重複的,於是 \(T\) 了。然後你想這個暴力一定能透過 \(s_k\) 長度小一點的資料,又觀察到 \(\sum|s_i|\le 10^5\),所以考慮根號分治。

長度小跑暴力即可,長度大需要換一種角度。

如果考慮現在變成列舉每一個長度大的 \(s_k\),求它在區間 \([l,r]\) 的匹配情況。經典做法是從 \(s_k\) 的每個位置往上跳,看它可以跳到多少模式串,它可以轉變為求所有模式串被詢問串的所有位置跳到的次數和。如果長度記為 \(B\),那麼這樣的字串總共不超過 \(\frac{m}{B}\),於是你標記 \(s_k\) 的所有位置,對整個 ACAM 求子樹和,單次 \(O(m)\),詢問字首和後可以 \(O(1)\) 解決,於是就有了 \(O(\frac{m^2}{B})\) 的做法。

前面的做法複雜度是 \(O(qB\log m)\)。兩者儘量均勻,解得 \(B=\frac{m}{\sqrt{q\log m}}\)

總複雜度 \(\mathcal{O}(m\sqrt{q\log m})\)

CF710F String Set Queries

二進位制分組線上 ACAM

相關文章