常見字符串算法 II:自動機相關


CHANGE LOG

  • 2021.12.25:新增 ACAM 部分。
  • 2021.12.26:新增 SAM 部分。
  • 2022.2.9:計划重構文章。
  • 2022.2.20:重構完成,增加部分例題。

建議先學習 確定有限狀態自動機,上接 常見字符串算法

基本定義與約定:

  • 稱字符串 \(T\) 匹配 \(S\)\(T\)\(S\) 中出現。
  • 模式串:相當於題目給出的 字典,用於匹配的字符串。下文也稱 單詞
  • 文本串:被匹配的字符串。
  • 更多約定見 常見字符串算法。

1. AC 自動機 ACAM

前置知識:字典樹,KMP 算法與 動態規划 思想。

AC 自動機是一類確定有限狀態自動機,這說明它有完整的 DFA 五要素,分別是起點 \(s\)(Trie 樹根節點),狀態集合 \(Q\)(Trie 樹上所有節點),接受狀態集合 \(F\)(所有以某個單詞作為后綴的節點),字符集 \(\Sigma\)(題目給定)和轉移函數 \(\delta\)(類似 KMP 求解)。

AC 自動機全稱 Aho-Corasick Automaton,簡稱 ACAM。它的用途非常廣泛,是重要的字符串算法(\(8\) 級)。

1.1 算法詳解

AC 自動機用於解決 多模式串 匹配問題:給定 字典 \(s\) 和文本串 \(t\),求每個單詞 \(s_i\)\(t\) 中出現的次數。當然,它的實際應用十分廣泛,遠超這一基本問題。ACAM 與 KMP 的不同點在於后者僅有一個模式串,而前者有多個。

朴素的基於 KMP 的暴力時間復雜度為 \(|t|\times N + \sum |s_i|\),其中 \(N\) 是單詞個數。因為進行一次匹配的時間復雜度為 \(|s_i| + |t|\)。當單詞數量 \(N\) 較大時,無法接受。

多串問題自然首先考慮建出字典樹。根據其定義,字典樹上任意節點 \(q\in Q\) 與所有單詞的某個前綴 一一對應。設節點(節點也稱狀態)\(i\) 表示的字符串為 \(t_i\)

借鑒 KMP 算法的思想,我們考慮對於每個狀態 \(q\),求出其 失配指針 \(fail_q\)。類似 KMP 的失配數組 \(nxt\),失配指針的含義為:\(q\) 所表示字符串 \(t_q\)最長真后綴 \(t_q[j, |t_q|]\ (2\leq j\leq |t_q| + 1)\),使得該后綴作為某個單詞的前綴出現。這說明 \(t_q[j, |t_q|]\) 恰好對應了字典樹上某個狀態,因此一個狀態的失配指針指向另一個長度比它短的狀態。注意,這樣的后綴 可能不存在,因此失配指針可能指向表示空串的根節點。

\(q\) 向字符串 \(fail_q\) 連一條有向邊,就得到了 ACAM 的 fail 樹

  • 例如,當 \(s = \{\texttt{b},\ \texttt{ab}\}\) 時,\(\tt ab\) 會向 \(\tt b\) 連邊,因為 \(\tt ab\) 最長的(也是唯一的)在 \(s_i\) 中作為前綴出現的后綴為 \(\tt b\)
  • 再例如,當 \(s = \{\texttt{aba},\ \texttt {baba}\}\) 時,\(\tt ab\) 會向 \(\tt b\) 連邊, \(\tt bab\) 會向 \(\tt ab\) 連邊,\(\tt aba\) 會向 \(\tt ba\) 連邊,而 \(\tt baba\) 會向 \(\tt aba\) 連邊。對於每一條有向邊 \(q \to fail_q\),后者是前者的后綴,也是 \(s_i\) 的前綴。

考慮用類似 KMP 的算法求解失配指針:首先令 \(fail_q\gets fail_{fa_q}\)。若當前的 \(fail_q\) 沒有 \(fa_q\to q\) 這條(字典樹上的)邊所表示的字符 \(c\) 的轉移,則令 \(fail_q\gets fail_{fail_q}\),否則 \(fail_q = \mathrm{trans}(fail_q, c)\),即字典樹上在 \(fail_q\) 處添加字符 \(c\) 后到達的狀態。若 \(fail_q\) 已經指向根,但還是沒找到出邊,則 \(fail_q\) 最終就指向根。


失配指針已經足夠強大,但這並不是 AC 自動機的完全體。我們嘗試將每個狀態的所有字符轉移 \(\delta(i, c)\) 都封閉在狀態集合 \(Q\) 里面。把 KMP 自動機的轉移拎出來觀察

\[\delta(i, c) = \begin{cases} i+1 & s_{i + 1} = c \\ 0 & s_{i + 1} \neq c \land i = 0 \\ \delta(nxt_i, c) & s_{i + 1} \neq c \land i \neq 0 \\ \end{cases} \]

設字典樹的根為節點 \(0\),AC 自動機的轉移可類似地寫為:

\[\delta(i,c) = \begin{cases} \mathrm{trans}(i, c) & \mathrm{if}\ \mathrm{trans}(i, c)\ \mathrm{exist} \\ 0 & \mathrm{if}\ \mathrm{trans}(i, c)\ \mathrm{doesn't\ exist} \land i = 0\ (\mathrm{which\ is \ root}) \\ \delta(fail_i, c) & \mathrm{if}\ \mathrm{trans}(i, c)\ \mathrm{doesn't\ exist} \land i \neq 0 \end{cases} \]

\(\delta(i,c)\) 表示往狀態 \(i\) 后面添加字符 \(c\),所得字符串的 最長的\(s_i\) 前綴 匹配的 后綴 所表示的狀態。也可理解為從 \(i\) 開始跳 \(fail\) 指針,遇到的第一個有字符 \(c\) 的轉移對應轉移到的節點:若 \(i\) 本身有轉移,則 \(\delta(i, c)\) 就等於 \(\mathrm{trans}(i, c)\),否則向上跳一層 \(fail\) 指針,等於 \(\delta(fail_i, c)\)

根據已有信息遞推,這是 動態規划 的核心思想。即求解 \(\delta\) 函數的的過程本質上是一類 DP。

\(\mathrm{trans}(i, c)\) 存在時,設其為 \(q\), 則有 \(fail_q = \delta(fail_i, c)\)。因為根據求 \(fail_q\) 的方法,我們會先令 \(fail_q \gets fail_i\),然后跳到第一個有字符 \(c\) 的位置,令 \(fail_q\) 等於該位置添加 \(c\) 轉移到的狀態。這和 \(\delta(fail_i, c)\) 的定義等價。

有了這一性質,我們就不需要預先求出失配指針,而是在建造 AC 自動機的同時一並求出。由於我們需要保證在計算一個狀態的轉移時,其失配指針指向的狀態的轉移已經計算完畢,又因為失配指針長度小於原串長度,故使用 BFS 建立 AC 自動機。一般形式的 AC 自動機代碼如下:

int node, son[N][S], fa[N];
void ins(string s) { // 建出 trie 樹
	int p = 0;
	for(char it : s) {
		if(!son[p][it - 'a']) son[p][it - 'a'] = ++node;
		p = son[p][it - 'a'];
	}
}
void build() { // 建出 AC 自動機
	queue <int> q;
	for(int i = 0; i < S; i++) if(son[0][i]) q.push(son[0][i]); // 對於第一層特判,因為 fa[0] = 0,此處即轉移的第二種情況
	while(!q.empty()) { // 求得的 son[t][i] 就是文章中的轉移函數 delta(t, i),相當於合並了 trie 和 AC 自動機的轉移函數
		int t = q.front(); q.pop();
		for(int i = 0; i < S; i++)
			if(son[t][i]) fa[son[t][i]] = son[fa[t]][i], q.push(son[t][i]); // 轉移的第一種情況:原 trie 圖有 trans(t, i) 的轉移
			else son[t][i] = son[fa[t]][i]; // 轉移的第三種情況
	}
}

特別的,在 ACAM 上會有一些 終止節點 \(p\),代表一個單詞或以一個單詞結尾,即 \(p\) 對應的字符串 \(t_p\) 的某個 后綴 在字典 \(s\) 中作為 單詞 出現。 若狀態 \(p\) 本身表示一個單詞,即 \(t_p\in s\),則稱為 單詞節點。所有終止節點 \(p\) 對應着 DFA 的 接受狀態集合 \(F\):ACAM 接受且僅接受以給定詞典中的某一個單詞結尾的字符串。


總結一下我們使用到的約定和定義:

  • 節點也被稱為 狀態
  • 設字典樹上狀態 \(i\) 所表示的字符串為 \(t_i\)
  • 失配指針 \(fail_q\) 的含義為 \(q\) 所表示字符串 \(t_q\) 的最長真后綴 \(t_q[j, |t_q|]\ (2\leq j\leq |t_q| + 1)\) 使得該后綴作為某個單詞的前綴出現。
  • \(\delta(i,c)\) 表示往狀態 \(i\) 后添加字符 \(c\),所得字符串的 最長的 與某個單詞的 前綴 匹配的 后綴 所表示的狀態。它也是從 \(i\) 開始,不斷跳失配指針直到遇到一個有字符 \(c\) 轉移的狀態 \(p\),添加字符 \(c\) 后得到的狀態 \(\mathrm{trans}(p, c)\)
  • 終止節點 \(p\) 代表一個單詞,或以一個單詞結尾。
  • 所有終止節點 \(p\) 組成的集合對應着 DFA 的 接受狀態集合 \(F\)
  • 若狀態 \(p\) 本身表示一個單詞,即 \(t_p\in s\),則稱為 單詞節點

1.2 fail 樹的性質與應用

AC 自動機的核心就在於 fail 樹。它有非常好的性質,能夠幫我們解決很多問題。

  • 性質 0:它是一棵 有根樹,支持樹剖,時間戳拍平,求 LCA 等各種樹上路徑或子樹操作。
  • 性質 1:對於節點 \(p\) 及其對應字符串 \(t_p\),對於其子樹內部所有節點 \(q\in \mathrm{subtree}(p)\),都有 \(t_p\)\(t_q\) 的后綴,且 \(t_p\)\(t_q\) 的后綴 當且僅當 \(q\in \mathrm{subtree}(p)\)。根據失配指針的定義易證。
  • 性質 2:若 \(p\) 是終止節點,則 \(p\) 的子樹全部都是終止節點。根據 fail 指針的定義,容易發現對於在 fail 樹上具有祖先 - 后代關系的點對 \(p,q\)\(t_p\)\(t_q\) 的 Border,這意味着 \(t_p\)\(t_q\) 的后綴。因此,若 \(t_p\) 以某個單詞結尾,則 \(t_q\) 也一定以該單詞結尾,得證。
  • 性質 3:定義 \(ed_p\) 表示作為 \(t_p\) 后綴的單詞數量。若單詞互不相同,則 \(ed_p\) 等於 fail 樹從 \(p\) 到根節點上單詞節點的數量。若單詞可以重復,則 \(ed_p\) 等於這些單詞節點所對應的單詞的出現次數之和。
  • 常用結論:一個單詞在匹配串 \(S\) 中出現次數之和,等於它在 \(S\)所有前綴中作為后綴出現 的次數之和。

根據性質 3,有這樣一類問題:單詞有帶修權值,多次詢問對於某個給定的字符串 \(S\),所有單詞的權值乘以其在 \(S\) 中出現次數之和。根據常用結論,問題初步轉化為 fail 樹上帶修點權,並對於 \(S\) 的每個前綴,查詢該前綴所表示的狀態到根的權值之和。

通常帶修鏈求和要用到樹剖,但查詢具有特殊性質:一個端點是根。因此,與其單點修改鏈求和,不如 子樹修改單點查詢。實時維護每個節點的答案,這樣修改一個點相當於更新子樹,而查詢時只需查單點。轉化之前的問題需要樹剖 + 數據結構 \(\log ^ 2\) 維護,但轉化后即可時間戳拍平 + 樹狀數組單 \(\log\) 小常數解決。

補充:對於普通的鏈求和,只需差分轉化為三個到根鏈求和也可以使用上述技巧。鏈加,單點查詢 也可以通過轉化變成 單點加,子樹求和。只要包含一個單點操作,一個鏈操作,均可以將鏈操作轉化為子樹操作,即可將時間復雜度更大的樹剖 BIT 換成普通 BIT。

  • 性質 4:把字符串 \(t\) 放在字典 \(s\) 的 AC 自動機上跑,得到的狀態為 \(t\) 的最長后綴,滿足它是 \(s\) 的前綴。

1.3 應用

大部分時候,我們借助 ACAM 刻畫多模式串的匹配關系,求出文本串與字典的 最長匹配后綴。但 ACAM 也可以和動態規划結合:在利用動態規划思想構建的自動機上進行 DP,這是 DP 自動機 算法。

1.3.1 結合動態規划

ACAM 除了能夠進行字符串匹配,還常與動態規划相結合,因為它精確刻畫了文本串與 所有 模式串的匹配情況。同時,\(\delta\) 函數自然地為動態規划的轉移指明了方向。因此,當遇到形如 “不能出現若干單詞” 的字符串 計數或最優化 問題,可以考慮在 ACAM 上 DP,將 ACAM 的狀態寫進 DP 的一個維度。

例如非常經典的 [JSOI2007]文本生成器。題目要求至少包含一個單詞,補集轉化相當於求 不包含任何一個單詞 的長為 \(m\) 的字符串數量。考慮到我們只關心當前字符串的長度,和它與所有單詞的匹配情況,設 \(f_{i,j}\) 表示長為 \(i\) 且放到所有單詞建出的 ACAM 上能夠轉移到狀態 \(j\) 的字符串數量。轉移即枚舉下一個字符 \(c\) 是什么,\(f_{i,j}\to f_{i+1,\delta(j,c)}\)。根據限制,需要保證 \(j\)\(\delta(j,c)\) 都不是終止節點,最終答案即 \(26^m-\sum_{\\ q\in Q\land q\notin F} f_{m, q}\)。時間復雜度 \(\mathcal{O}(nm|\Sigma||s_i|)\)

1.3.2 結合矩陣快速冪

在上一部分的基礎上,若 \(\sum |s_i|\) 很小而轉移輪數非常多,可以將轉移寫成矩陣的形式。\(\delta(p, c)\) 為我們提供了轉移矩陣:添加一個字符后,從狀態 \(p\) 轉移到 \(q\) 的方案數為 \(\sum_\limits{c} [\delta(p, c) = q]\),即 \(A_{i, j} = \sum_\limits c [\delta(i, c) = j]\)

具體轉移方式視題目而定。矩陣乘法也可以是廣義矩陣乘法,如例 XII.

1.4 注意點

  • 建出字典樹后不要忘記調用 build 建出 ACAM。
  • 注意模式串是否可以重復。
  • 在構建 ACAM 的過程中,不要忘記遞推每個節點需要的信息。如 \(ed_p\)\(ed_{fa_p}\) 和狀態 \(p\) 所表示的單詞數量相加得到。

1.5 例題

I. P3808 【模板】AC 自動機(簡單版)

本題相同編號的串多次出現僅算一次,因此題目相當於求:文本串 \(t\) 在模式串 \(s_i\) 建出的 ACAM 上匹配時經過的所有節點到根的路徑的並上單詞節點的個數。

設當前狀態為 \(p\),每次跳 \(p\) 的失配指針,加上經過節點表示的單詞個數(單詞可能相同)並標記,直到遇到標記節點 \(q\),說明 \(q\) 到根都已經被考慮到。注意上述過程並不改變 \(p\) 本身。時間復雜度線性。

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

const int N = 1e6 + 5;
const int S = 26;
int n, node, son[N][S], fa[N], ed[N];
string s;
void ins(string s) {
	int p = 0;
	for(char it : s) {
		if(!son[p][it - 'a']) son[p][it - 'a'] = ++node;
		p = son[p][it - 'a'];
	} ed[p]++;
}
void build() {
	queue <int> q;
	for(int i = 0; i < S; i++) if(son[0][i]) q.push(son[0][i]);
	while(!q.empty()) {
		int t = q.front(); q.pop();
		for(int i = 0; i < S; i++)
			if(son[t][i]) fa[son[t][i]] = son[fa[t]][i], q.push(son[t][i]);
			else son[t][i] = son[fa[t]][i];
	}
}
int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> s, ins(s);
	int p = 0, ans = 0; cin >> s, build();
	for(char it : s) {
		int tmp = p = son[p][it - 'a'];
		while(ed[tmp] != -1) ans += ed[tmp], ed[tmp] = -1, tmp = fa[tmp];
	} cout << ans << endl;
	return 0;
}

II. P2292 [HNOI2004] L 語言

首先我們有個顯然的 DP:設 \(f_i\) 表示 \(i\) 前綴能否理解,那么若 存在 \(f_j = 1 \land t[j + 1,i]\in D\),則 \(f_i = 1\)。否則 \(f_i = 0\)。對 \(D\) 建出 ACAM,設 \(t[1,i]\) 跳到了狀態 \(p\),我們只需要知道 \(p\) 的哪些長度的后綴是單詞,這樣就可以 \(\mathcal{O}(|t||s|)\) 回答單次詢問,但不夠快。

注意到 \(|s|\leq 20\),因此考慮狀壓,設 \(msk_p\):若 \(p\) 的長度為 \(l\) 的后綴是單詞,則 \(msk_p\)\(l\) 位為 \(1\)。這樣,再用 \(S\) 記錄 \(f_{i - 20}\sim f_{i - 1}\) 的狀態,就可以通過位運算快速得到當前 \(f_i\) 的結果,並更新 \(S\)

時間復雜度 \(\mathcal{O}(n|s||\Sigma| + m|t|)\),其中 \(|\Sigma|\) 表示字符集大小。

*III. P2414 [NOI2011] 阿狸的打字機

由於刪去一個字符和添加一個字符對字典樹大小的影響均為 \(1\),因此盡管單詞長度之和可能很大,但建出的字典樹大小僅有 \(m\)。設第 \(i\) 個單詞在 trie 上的節點為 \(f_i\),根據應用 1,求 \(x\)\(y\) 中的出現次數可以在 \(y\) 到根的每個節點上打標記,查詢 \(x\) 的子樹內有標記的節點個數。

因此將詢問離線,按 \(y\) 從小到大的順序處理詢問(為保證修改標記的總次數線性),套上 BIT 即可。時間復雜度線性對數。代碼

IV. P5357 【模板】AC 自動機(二次加強版)

根據 fail 樹的性質 1,文本串 \(S\) 在 AC 自動機上每經過一個節點就將其權值增加 \(1\),則每個單詞 \(T_i\)\(S\) 中的出現次數即 \(T_i\) 在 fail 樹上的子樹節點權值和。時間復雜度線性對數。

*V. P4052 [JSOI2007]文本生成器

ACAM 與 DP 相結合的例題。

VI. P3041 [USACO12JAN]Video Game G

非常套路的 ACAM 上 DP:設 \(f_{i, j}\) 表示長度為 \(i\) 且在 ACAM 上轉移到狀態 \(j\) 的字符串的最大權值,有轉移 \(f_{i, j} + ed_{\delta(j, c)} \to f_{i + 1,\delta(j, c)}\)。時間復雜度 \(\mathcal{O}(nk|s_i||\Sigma|)\)

*VII. CF1202E You Are Given Some Strings...

還算有趣的一道題目。對於同時與兩個字符串相關的問題,考慮 在拼接處計算貢獻,即求出 \(f_i\) 表示有多少單詞是 \(t[1, i]\) 的后綴,\(g_i\) 表示有多少單詞是 \(t[i, n]\) 的前綴。\(f_i\)\(g_i\) 都可以用 ACAM 求出。最終答案為 \(\sum\limits_{i = 2} ^ {|t|} f_{i - 1} g_i\),時間復雜度線性。代碼

VIII. CF163E e-Government

裸題。對 \(s\) 建出 ACAM,根據應用 1,使用性質 3 部分所給出的技巧:單點修改鏈上求和轉化為子樹修改單點求和(前提是一個端點為樹根),BIT 維護即可。時間復雜度線性對數。代碼

*IX. P7456 [CERC2018] The ABCD Murderer

由於單詞可以重疊(否則就不可做了),我們只需求出對於每個位置 \(i\),以 \(i\) 結尾的最長單詞的長度 \(L_i\)。因為對於相同的出現位置,用更短的單詞去代替最長單詞並不會讓答案更優。使用 ACAM 即可求出 \(L_i\)

最優化問題考慮 DP:設 \(f_i\) 表示拼出 \(s[1,i]\) 的最小代價。不難得到轉移 \(f_i = \min_{\\j = i - L_i} ^ {i - 1} f_j\)。特別的,若 \(L_i\) 不存在(即沒有單詞在 \(s\) 中以 \(i\) 為結束位置出現)則 \(f_i\) 為無窮大。若 \(f_n\) 為無窮大則無解。可以線段樹解決。

如果不想寫線段樹,還有一種方法:從后往前 DP。這樣,每個位置可以轉移到的地方是固定的(\(i-L_i\sim i - 1\)),所以用小根堆維護,懶惰刪除即可。時間復雜度均為線性對數。

X. P3121 [USACO15FEB]Censoring G

非常經典的 AC 自動機題目。對 \(t\) 建出 SAM 加速匹配,每次加入一個字符,用棧在線維護字符串 \(s\) 即可。時間復雜度線性。

XI. P3715 [BJOI2017]魔法咒語

二合一屑題。考慮在 ACAM 上 DP,對於前 \(50\%\) 的數據,由於 \(L\) 很小,所以可以暴力 DP,時間復雜度 \(\mathcal{O}(L \times \sum |s_i| \times \sum |t_i|)\)。對於后 \(50\%\) 的數據,由於基本詞匯長度 \(\leq 2\),故直接把 \(f_i\)\(f_{i - 1}\) 放到矩陣里面遞推即可。時間復雜度 \(\mathcal{O}((\sum |t_i|) ^ 3\log L)\)

XII. CF696D Legen...

非常套路地設 \(f_{i, j}\) 表示長度為 \(i\) 且 ACAM 上狀態為 \(j\) 時的最大貢獻,令 \(ed_i\) 表示狀態 \(i\) 所有后綴對應的所有單詞權值之和,即不停跳 \(\mathrm{fail}\) 到達的所有節點權值之和,一個字典樹上節點的權值為其所表示的所有單詞權值之和。

顯然有轉移:\(f_{i, j} + ed_{\delta(j, c)}\to f_{i + 1, \delta(j, c)}\),使用矩陣快速冪優化即可。時間復雜度 \(\mathcal{O}((\sum |s_i|) ^ 3\log L)\)代碼

*XIII. P5840 [COCI2015]Divljak

由於 \(T\) 的形態會改變,所以考慮對 \(S\) 建出 ACAM。根據 fail 樹的性質,問題即轉化為對給定節點 \(p\ (t_p = S_x)\) 求存在多少個 \(P\in T\) 使得 \(p\) 的子樹內存在 \(P\) 的每個前綴在 ACAM 上匹配到的節點。這相當於在添加 \(P\) 時,求出其依次匹配到的節點 \(q_1, q_2, \cdots, q_{|P|}\),在 fail 樹上對所有 \(q_i\) 到根的 鏈並 上的所有節點加 \(1\)

上述經典問題可以通過將 \(q_i\) 按 dfs 序排序后,對 \(q_1\) 到根執行鏈加,然后對於每個 \(q_i\ (i > 1)\),對 \(q_i\)\(\mathrm{lca}(q_{i - 1}, q_i)\) 包含 \(q_i\) 的兒子執行鏈加。

考慮使用 1.2 提到的技巧,將鏈加和單點查詢轉化為單點修改,子樹查詢,此時只需對所有 \(q_i\) 加上 \(1\),所有 \(\mathrm{lca}(q_{i - 1}, q_i)\ (i > 1)\) 減去 \(1\) 即可。時間復雜度線性對數。

2. 后綴自動機 SAM

后綴自動機全稱 Suffix Automaton,簡稱 SAM,是一類極其有用但難以真正理解的字符串后綴結構(\(10\) 級)。它是筆者一年以前學習的算法,現在進行復習並重構學習筆記,看看能不能悟到一些新的東西。

2.1 基本定義與引理

SAM 相關的定義非常多,需要牢記並充分理解它們,否則學習 SAM 會非常吃力,因為符號化的語言相較於直觀的圖片和實例更難以理解。

首先,我們給出 SAM 的定義:一個長為 \(n\) 的字符串 \(s\) 的 SAM 是一個接受 \(s\) 的所有 后綴最小 的有限狀態自動機。具體地,SAM 有 狀態集合 \(Q\),每個狀態是有向無環圖上的一個節點。從每個狀態出發有若干條或零條 轉移邊,每條轉移邊都 對應一個字符(因此,一條路徑表示一個 字符串),且從一個狀態出發的轉移互不相同。根據 DFA 的定義,SAM 還存在 終止狀態集合 \(F\),表示從初始狀態 \(T\) 到任意終止狀態的任意一條路徑與 \(s\) 的一個 后綴 一一對應。

SAM 最重要,也是最基本的一個性質:從 \(T\) 到任意狀態的所有路徑與 \(s\)所有 子串 一一對應。我們稱狀態 \(p\) 表示字符串 \(t_p\),當且僅當存在一條 \(T\to p\) 的路徑使得該路徑所表示的字符串為 \(t_p\)。根據上述性質,\(t_p\)\(s\) 的子串。

  • 定義轉移邊 \(p\to q\) 表示的字符為 \(c_{p, q}\)
  • 定義 \(\delta(p, c)\) 表示狀態 \(p\) 添加字符 \(c\) 轉移到的狀態。
  • 定義 前綴 狀態集合 \(P\) 由所有前綴 \(s[1, i]\) 對應的狀態組成。
  • SAM 的有向無環轉移圖也是有向無環單詞圖(DAWG, Directed Acyclic Word Graph)。

  • \(\mathrm{endpos}(t)\)字符串 \(t\)\(s\) 中所有出現的 結束位置集合。例如,當 \(s = \texttt{"abcab"}\) 時,\(\mathrm{endpos}(\texttt{"ab"}) = \{2, 5\}\),因為 \(s[1 : 2] = s[4 : 5] = \texttt{"ab"}\)
  • \(\mathrm{substr}(p)\)狀態 \(p\) 所表示的所有子串的 集合
  • \(\mathrm{shortest}(p)\)狀態 \(p\) 所表示的所有子串中,長度 最短 的那一個子串。
  • \(\mathrm{longest}(p)\)狀態 \(p\) 所表示的所有子串中,長度 最長 的那一個子串。
  • \(\mathrm{minlen}(p)\)狀態 \(p\) 所表示的所有子串中,長度 最短 的那一個子串的 長度\(\mathrm{minlen}(i) = |\mathrm{shortest}(i)|\)
  • \(\mathrm{len}(i)\)狀態 \(p\) 所表示的所有子串中,長度 最長 的那一個子串的 長度\(\mathrm{len}(i)=|\mathrm{longest}(i)|\)

兩個字符串 \(t_1, t_2\)\(\mathrm{endpos}\) 可能相等。例如當 \(s = \texttt{"abab"}\) 時,\(\mathrm{endpos}(\texttt{"b"}) = \mathrm{endpos}(\texttt{"ab"})\)。這樣,我們可以將 \(s\) 的子串划分為若干 等價類,用一個狀態表示。SAM 的每個狀態對應若干 \(\mathrm{endpos}\) 集合相同的子串。換句話說,\(\forall t\in \mathrm{substr}(p)\)\(\mathrm{endpos}(t)\) 相等。因此,SAM 的狀態數等於所有子串的等價類個數(初始狀態對應空串)。

讀者應該有這樣的直觀印象:SAM 的每個狀態 \(p\) 都表示一個獨一無二的 \(\mathrm{endpos}\) 等價類,它對應着在 \(s\) 中出現位置相同的一些子串 \(\mathrm{substr}(p)\)\(\mathrm{shortest}(p),\mathrm{longest}(p),\mathrm{minlen}(p)\)\(\mathrm{len}(p)\) 描述了 \(\mathrm{substr}(p)\) 最短和最長的子串及其長度。

轉移邊與 \(\mathrm{substr}\) 的聯系:任意一條 \(T\to p\) 的路徑 \(P\) 所表示的字符串 \(t_{P}\in \mathrm{substr}(p)\)


在引出 SAM 的核心定義「后綴鏈接」前,我們需要證明關於上述概念的一些性質。下列引理的內容部分來自 OI-wiki,相關鏈接見 Part 2.4.

引理 1:考慮兩個非空子串 \(u\)\(w\)(假設 \(|u|\leq |w|\))。要么 \(\mathrm{endpos}(u)\cup \mathrm{endpos}(w)=\varnothing\),要么 \(\mathrm{endpos}(w) \subseteq \mathrm{endpos}(u)\),取決於 \(u\) 是否為 \(w\) 的一個后綴:

\[\begin{cases} \mathrm{endpos}(w) \subseteq \mathrm{endpos}(u) & \mathrm{if} \ u\ \mathrm{is\ a\ suffix\ of}\ w \\ \mathrm{endpos}(u) \cup \mathrm{endpos}(w) = \varnothing & \mathrm{otherwise} \end{cases} \]

證明:若存在位置 \(i\) 滿足 \(i\in \mathrm{endpos}(u)\)\(i\in \mathrm{endpos}(w)\),說明 \(u\)\(w\)\(i\) 為結束位置在 \(s\) 中出現。由於 \(|u|\leq |w|\),所以 \(u\) 必然是 \(w\) 的后綴,因此 \(w\) 出現的位置 \(u\) 必然以 \(w\) 的后綴形式出現,即對於任意 \(i\in \mathrm{endpos}(w)\)\(i\in \mathrm{endpos}(u)\)。否則不存在這樣的位置 \(i\),即 \(\mathrm{endpos}(u) \cup \mathrm{endpos}(w) = \varnothing\)

引理 2:考慮一個狀態 \(p\)\(p\) 所表示的所有子串長度連續,且 較短者總是較長者的后綴

證明:根據引理 1,若兩個子串 \(\mathrm{endpos}\) 相同(這也說明它們屬於相同狀態),則較短者總是較長者的后綴,后半部分得證。

對於前半部分考慮反證:假設 \(\mathrm{longest}(p)\) 長為 \(L\ (\mathrm{minlen}(p) < L < \mathrm{len}(p))\) 的后綴 \(t_L\notin \mathrm{substr}(p)\)。由於 \(t_L\)\(\mathrm{longest}(p)\)真后綴,故 \(\mathrm{endpos}(\mathrm{longest}(p)) \subseteq \mathrm{endpos}(t_L)\)。根據假設,\(\mathrm{endpos}(\mathrm{longest}(p)) \neq \mathrm{endpos}(t_L)\)。又因為 \(\mathrm{shortest}(p)\)\(t_L\)真后綴,故 \(\mathrm{endpos}(t_L) \subseteq \mathrm{endpos}(\mathrm{shortest}(p))\),因此 \(|\mathrm{endpos}(\mathrm{longest}(p))| < |\mathrm{endpos}(t_L)| \leq |\mathrm{endpos}(\mathrm{shortest}(p))|\),這與 \(\mathrm{endpos}(\mathrm{longest}(p)) = \mathrm{endpos}(\mathrm{shortest}(p))\) 矛盾,證畢。

簡單地說,對於一個子串 \(t\) 的所有后綴,其 \(\mathrm{endpos}\) 集合大小隨着后綴長度減小而單調不降。這很好理解:后綴越長,在 \(s\) 中出現的位置就越少

推論 1:對於子串 \(t\) 的所有后綴,其 \(\mathrm{endpos}\) 集合大小隨后綴長度減小而單調不降,且 較小的 \(\mathrm{endpos}\) 集合包含於較大的 \(\mathrm{endpos}\) 集合


引理 2 是非常重要的性質。有了它,我們就可以定義后綴鏈接了。

  • 定義狀態 \(p\)后綴鏈接 \(\mathrm{link}(p)\) 指向 \(\mathrm{longest}(p)\) 最長 的一個后綴 \(w\) 滿足 \(w\notin \mathrm{substr}(p)\) 所在的狀態。換句話說,一個后綴鏈接 \(\mathrm{link}(p)\) 連接到對應於 \(\mathrm{longest}(p)\) 最長的處於另一個 \(\mathrm{endpos}\) 等價類的后綴所在的狀態。根據引理 2,\(\mathrm{minlen}(i) = \mathrm{len(link}(i))+1\)

引理 3:所有后綴鏈接形成一棵以 \(T\) 為根的樹。

證明:對於任意不等於 \(T\) 的狀態,沿着后綴鏈接移動總能達到一個所表示字符串更短的狀態,直到 \(T\)

  • 定義 后綴路徑 \(p\to q\) 表示在后綴鏈接形成的樹上 \(p\to q\) 的路徑。

引理 4:通過 \(\mathrm{endpos}\) 集合構造的樹(每個子節點的 \(\mathrm {subset}\) 都包含在父節點的 \(\mathrm{subset}\) 中)與通過后綴鏈接 \(\mathrm{link}\) 構造的樹相同。

根據推論 1 與后綴鏈接的定義容易證明。因此,后綴鏈接構成的樹本質上是 \(\mathrm{endpos}\) 集合構成的一棵樹。

上圖圖源 OI-wiki。我們給出每個狀態的 \(\mathrm{endpos}\) 集合以便更好理解引理 4:\(\mathrm{endpos}(\texttt{"a"}) = \{1\}\)

\[\begin{aligned} \mathrm{endpos}(\texttt{"ab"}) = \{2\} \\ \mathrm{endpos}(\texttt{"abcb", "bcb", "cb"}) = \{4\} \\ \end{aligned} \subsetneq \mathrm{endpos}(\texttt{"b"}) = \{2, 4\} \\ \]

\[\begin{aligned} \mathrm{endpos}(\texttt{"abc"}) = \{3\} \\ \mathrm{endpos}(\texttt{"abcbc", "bcbc", "cbc"}) = \{5\} \\ \end{aligned} \subsetneq \mathrm{endpos}(\texttt{"bc", "c"}) = \{3, 5\} \\ \]

2.2 關鍵結論

我們還需要以下定理確保構建 SAM 的算法的正確性,並使讀者對上述定義形成感性的直觀的認知。

結論 1.1:從任意狀態 \(p\) 出發跳后綴鏈接到 \(T\) 的路徑,所有狀態 \(q\in p\to T\)\([\mathrm{minlen}(q),\mathrm{len}(q)]\) 不交,單調遞減且並集形成 連續 區間 \([0,\mathrm{len}(p)]\)

證明:根據后綴鏈接的性質 \(\mathrm{len}(\mathrm{link}(p)) + 1 = \mathrm{minlen}(p)\) 即證。

結論 1.2:從任意狀態 \(p\) 出發跳后綴鏈接到 \(T\) 的路徑,所有狀態 \(q\in p\to T\)\(\mathrm{substr}(q)\) 的並集為 \(\mathrm{longest}(p)\)所有后綴

證明:由結論 1.1 和后綴鏈接的定義易證。

結論 2.1\(\forall t_p\in \mathrm{substr}(p)\),若存在 \(p\to q\)轉移邊,則 \(t_p + c_{p,q}\in \mathrm{substr}(q)\)

證明:根據 \(\mathrm{substr}\) 的定義可得。

結論 2.2\(\forall t_q\in \mathrm{substr}(q)\),若存在 \(p\to q\) 的轉移邊,則 \(\exist t_p\in \mathrm{substr}(p)\) 使得 \(t_p+c_{p,q} = t_q\)

證明:結論 2.1 的逆命題。這很好理解,因為對於任意 \(t_q\in \mathrm{substr}(q)\),若不存在這樣的 \(t_p + c_{p,q} = t_q\),那么就不存在 \(T\to q\) 的路徑使得其所表示字符串為 \(t_p + c_{p,q}\),這與 \(t_q\in \mathrm{substr}(q)\) 矛盾。

結論 3.1:考慮狀態 \(q\),不存在轉移 \(p\to q\) 使得 \(\mathrm{len}(p) + 1 > \mathrm{len}(q)\)

證明:顯然。

結論 3.2:考慮狀態 \(q\),**唯一 **存在狀態 \(p\) 和轉移 \(p\to q\) 使得 \(\mathrm{len}(p) + 1 = \mathrm{len}(q)\)

證明:考慮反證法,若不存在這樣的 \(p\),說明 \(\forall p,\mathrm{len}(p)+1<\mathrm{len}(q)\)。根據結論 2.2,\(\mathrm{substr}(q)\) 中最長的一個串的長度為 \(\max_{\\ t_p\in \mathrm{substr}(p)} |t_p| + 1\)\(\max_{\\ p} \mathrm{len}(p) + 1\)。根據 \(\mathrm{len}\) 的定義與 \(\mathrm{len}(p) + 1 < \mathrm{len}(q)\),推得 \(\mathrm{len}(q) < \mathrm{len}(q)\),矛盾。唯一性不難證明。

簡單地說,若數集 \(T\) 由若干數集 \(S\) 的並加上 \(1\) 后得到,那么 \(\max_{\\ s\in S}s + 1 = \max_{\\ t\in T}t\)

結論 3.3:考慮狀態 \(q\)唯一 存在轉移 \(p\to q\) 使得 \(\mathrm{minlen}(p) + 1 = \mathrm{minlen}(q)\)

證明:同理。

  • 定義 \(\mathrm{maxtrans}(q)\) 表示使得 \(\mathrm{len}(p) + 1 = \mathrm{len}(q)\) 且存在轉移 \(p\to q\) 的唯一的 \(p\)
  • 定義 \(\mathrm{mintrans}(q)\) 表示使得 \(\mathrm{minlen}(p) + 1 = \mathrm{minlen}(q)\) 且存在轉移 \(p\to q\) 的唯一的 \(p\)

結論 4.1:考慮狀態 \(q\),若存在轉移 \(p\to q\),則 \(p\) 在后綴鏈接樹上是 \(\mathrm{maxtrans}(q)\) 或其祖先。

證明:由於所有 \(p\) 轉移到相同狀態 \(q\),故所有 \(p\)\(\mathrm{substr}(p)\) 的並,短串為長串的后綴。根據 \(\mathrm{link}\) 樹的性質即證。

結論 4.2:考慮狀態 \(q\),若存在轉移 \(p\to q\),則 \(p\) 在后綴鏈接樹上是 \(\mathrm{mintrans}(q)\) 或其子節點。

證明:同理。

結論 4.3:考慮狀態 \(q\),若存在轉移 \(p\to q\),則所有這樣的 \(p\)\(\mathrm{link}\) 樹上形成了一條 深度遞減的鏈 \(\mathrm{maxtrans}(q)\to \mathrm{mintrans}(q)\)

證明:結合結論 4.1 與結論 4.2 易證。

可以發現上述性質大都與后綴鏈接有關,因為后綴鏈接是 SAM 所提供的最重要的核心信息。我們甚至可以拋棄 SAM 的 DAWG,僅僅使用后綴鏈接就可以解決大部分字符串相關問題。

  • 擴展定義:\(\mathrm{substr}(p\to q)\) 表示后綴路徑 \(p\to q\) 上所有狀態的 \(\mathrm{substr}\) 的並。

2.3 構建 SAM

鋪墊了這么多,我們終於有足夠的性質來建造 SAM 了。之前的長篇大論可能讓讀者認為它是一個非常復雜的算法:是,但不完全是。至少在代碼實現方面,它比同級的 LCT 簡單到不知道到哪里去了。

SAM 的構建核心思想是 增量法。我們在 \(s[1,i-1]\) 的 SAM \(A_{i - 1}\) 的基礎上進行更新,從而得到 \(s[1,i]\) 的 SAM \(A_i\)。因此,該算法是 在線 算法。它主要分為三個步驟:

  1. 打開 SAM。
  2. 把字符插進去。
  3. 關上 SAM。

\(s[1,i - 1]\)\(A_{i - 1}\) 上的狀態為 \(las\),當前狀態數量為 \(cnt\)\(las\)\(cnt\) 的初始值均為 \(1\),表示初始狀態 \(T = 1\)。不要忘記初始化 \(las\)\(cnt\)

新建初始狀態 \(cur \gets cnt + 1\),並令 \(cnt\) 自增 \(1\) 表示狀態數量增加 \(1\)\(cur\)\(s[1,i]\)\(A_i\) 上對應的狀態。\(\mathrm{endpos}(cur) = \{i\}\)。令變量 \(p\gets las\) 防止接下來的操作改變 \(las\)

接下來我們考慮如何連指向 \(cur\) 的轉移邊:由於 \(las\to T\) 的后綴路徑上的所有狀態表示了所有 \(s[1, i - 1]\) 的后綴,因此若 \(p\) 沒有 \(s_i\) 的轉移邊,就新建 \(p\to cur\) 字符為 \(s_i\) 的轉移,並令 \(p\gets \mathrm{link}(p)\) 表示跳后綴鏈接。直到遇到路徑上第一個有 \(s_i\) 出邊的狀態 \(p\),此時就應該 停止 了,因為再連下去 \(T\to p\to \delta(p, s_i)\)\(T\to p\to cur\) 會表示相同字符串,使相同出邊指向兩個不同節點,與 SAM 的性質相違背。此時需要分三種情況討論:


Case 1:不存在 \(p\)。即后綴路徑 \(las\to T\) 上的所有狀態都沒有字符 \(s_i\) 的轉移邊。

容易發現這種情況僅在 \(s_i\) 未在 \(s[1:i-1]\) 中出現過時發生。我們只需令 \(\mathrm{link}(cur)\gets T\) 即可。


Case 2:存在 \(p\),令 \(q = \delta(p,s_i)\)\(\mathrm{len}(p) + 1 = \mathrm{len}(q)\)

\(\mathrm{link}(cur)\gets q\) 即可,原因如下:設 \(las\to T\) 后綴路徑上 \(p\) 的前一個狀態為 \(p'\)。根據操作,可知 \(p'\to cur\) 有一條轉移邊。則此時 \(\mathrm{minlen}(cur) = \mathrm{minlen}(p') + 1 = (\mathrm{len}(p) + 1) + 1 = \mathrm{len}(q) + 1\),說明 \(q\) 恰好與 \(cur\) 的后綴鏈接的定義相匹配。

可以證明 \(\mathrm{substr}(q\to T)\)\(s[1,i]\) 所有長度 \(\leq \mathrm{len}(q)\) 的后綴:由於 \(\mathrm{substr}(las\to T)\)\(s[1,i - 1]\) 的所有后綴,又因為 \(p\)\(las\to T\) 上,所以 \(\mathrm{longest}(p)\)\(s[1,i-1]\) 長為 \(\mathrm{len}(p)\) 的后綴。而 \(p\to q\) 存在字符為 \(s_i\) 的轉移邊,故 \(\mathrm{longest}(q)\)\(s[1,i]\) 長為 \(\mathrm{len}(p) + 1=\mathrm{len}(q)\) 的后綴。再根據結論 1.2 得證。這同時也證明了 \(\mathrm{link}(cur)\gets q\) 這一操作的正確性。

圖源 hihocoder。上圖中,在插入 \(s_5 = \texttt{a}\) 時,狀態 \(p=las = 4\) 沒有字符 \(\tt a\) 的轉移,因此令 \(\delta(4,\texttt a ) = cur = 6\),然后 \(p\gets \mathrm{link}(p) = 5\)。狀態 \(5\) 也沒有字符 \(\tt a\) 的轉移,因此令 \(\delta(5,\texttt a ) = 6\),然后 \(p\gets \mathrm{link}(p)= T\),也就是圖中的 \(S\)

\(\delta(T,\texttt a )\) 存在,此時 \(p = T, q = \delta(T,\texttt a ) = 1\)。因為 \(\mathrm{len}(T) + 1 = \mathrm{len}(1)\),所以令 \(\mathrm{link}(6)\gets 1\) 即可。

注意狀態 \(4,5,6\) 所表示的子串,可以發現 \((\mathrm{substr}(4)\cup \mathrm{substr}(5)) + \texttt{a} = \mathrm{substr}(6)\)。這很好地驗證了結論 2.1 和結論 2.2。


Case 3:存在 \(p\),令 \(q = \delta(p,s_i)\)\(\mathrm{len}(p) + 1 \neq \mathrm{len}(q)\)

此時 \(\mathrm{len}(p) + 1 < \mathrm{len}(q)\),我們需要將 \(q\) 拆成兩個狀態 \(q_1\)\(q_2\),將 \(\mathrm{substr}(q)\) 分成長度小於等於 \(\mathrm{len}(p) + 1\) 和大於 \(\mathrm{len}(p) + 1\) 兩部分。具體地,先令 \(cnt \gets cnt + 1\),然后新建一個狀態 \(cl \gets cnt\) 表示將 \(\mathrm{substr}(q)\) 長度 \(\leq \mathrm{len}(p) + 1\) 的部分丟給 \(cl\)

  • \(\mathrm{minlen}(cl)\) 等於原來的 \(\mathrm{minlen}(q)\)
  • \(\mathrm{len}(cl)\) 等於 \(\mathrm{len}(p) + 1\)
  • 新的 \(\mathrm{minlen}(q)\) 等於 \(\mathrm{len}(cl) + 1\)

考慮 \(cl\) 如何繼承 \(q\) 這一狀態:首先,\(q\) 的所有轉移要原封不動地存下來,故對於每個字符 \(c\) 都要 \(\delta(cl, c) \gets \delta(q, c)\)。此外,由於 \(\mathrm{minlen}(cl)\) 等於原來的 \(\mathrm{minlen}(q)\),因此 \(\mathrm{link}(cl) \gets\) 原來的 \(\mathrm{link}(q)\)。同時,新的 \(\mathrm{minlen}(q)\) 等於 \(\mathrm{len}(cl) + 1\) 也即 \(\mathrm{len}(p) + 1\),所以 \(\mathrm{link}(q),\mathrm{link}(cur)\gets cl\)

此外,根據結論 4.3,我們知道后綴路徑 \(p\to T\) 上轉移到 \(q\) 的狀態一定是路徑的一段前綴,對於前綴上的所有節點 \(p’\),我們需要把 \(\delta(p', s_i)\) 從本來的 \(q\) 改成 \(cl\),因為我們把 \(\mathrm{substr}(q)\) 長度 \(\leq \mathrm{len}(p) + 1\) 的串丟給了狀態 \(cl\),所以對於原本能轉移到 \(q\) 的所有 \(\mathrm{len}\)\(\leq \mathrm{len}(p)\) 的狀態(顯然也是 \(p\to T\) 路徑的前綴),都需要將字符 \(s_i\) 的轉移 重定向\(cl\)

上圖中,我們把 \(q = 3\) 的不大於 \(\mathrm{len}(p = T) + 1 = 1\) 的所有子串提出來,丟給一個新建的狀態 \(cl=5\),然后 \(\mathrm{link}(cur = 4)\gets cl = 5\)。內部 \(\mathrm{link}(q = 3)\gets cl = 5\),同時 \(\mathrm{link}(cl = 5) \gets p = T\),即原來的 \(\mathrm{link}(q)\)

然后,從 \(p = T\) 往上跳后綴連接直到不存在連向 \(q = 3\) 的路徑或到達根節點 \(T\),表示對於 \(p\to T\) 的一段前綴,滿足前綴上所有狀態添加字符 \(s_i\) 能夠轉移到 \(q = 3\),將它們字符為 \(s_i\) 的轉移重定向至 \(cl = 5\)(當然,上例只有 \(T\) 一個點,不過並不一定會跳到 \(T\),因為可能跳到中間的某個狀態 \(p'\) 時就沒有轉移 \((p',q = 3)\) 了),即 \((T,3)\) 變為了 \((T,5)\)


上述分類討論結束后,令 \(las\gets cur\) 表示添加字符 \(s_{i+1}\)\(s[1,i]\)\(A_i\) 對應狀態 \(cur\)。在實現中,我們通常在連接轉移邊之前執行該操作。構建 SAM 的代碼如下:

const int N = 1e5 + 5;
const int S = 26;
int cnt = 1, las = 1, son[N][S], fa[N], len[N];
void ins(char s) {
	int it = s - 'a', p = las, cur = ++cnt;
	len[cur] = len[p] + 1, las = cur; // 計算 len[cur],更新 las
	while(!son[p][it]) son[p][it] = cur, p = fa[p]; // 添加轉移邊
	if(!p) return fa[cur] = 1, void(); // case 1 
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, void(); // case 2
	int cl = ++cnt; cpy(son[cl], son[q], S); // 新建節點,cl 繼承 q 的所有轉移
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl; // 計算 len[cl] 以及 cl, q, cur 的后綴鏈接,注意 fa[cl] = fa[q] 要在 fa[q] = cl 之前
	while(son[p][it] == q) son[p][it] = cl, p = fa[p]; // 修改后綴路徑 p -> T 的一段前綴
}

當字符集 \(\Sigma\) 非常大的時候,時空復雜度均無法接受,因此需要使用平衡樹維護每個狀態的所有轉移邊,可以用 map 代替。

2.4 時間復雜度證明

下設字符串 \(s\) 長度為 \(n\),證明大部分摘自 OI wiki。

2.4.1 狀態數上界

構建后綴自動機的算法本身就已經證明了其 SAM 狀態數不超過 \(2n-1\):插入 \(s_1,s_2\) 時分別產生一個狀態,后續插入每個 \(s_i\) 時最多產生兩個狀態,因此當 \(n>1\) 時狀態數不超過 \(2n-2\),形如 \(\tt abb\cdots bb\) 的字符串達到上界。當 \(n=1\) 時狀態數為 \(2n-1\)

2.4.2 轉移數上界

\(\mathrm{len}(p) + 1 = \mathrm{len}(q)\) 的轉移 \((p, q)\) 為連續的,顯然,從一個非終止狀態 \(p\) 出發 有且僅有 一條連續轉移 \((p,q)\),對於 \(q\) 也有且僅有一個對應的 \(p\)。因此,連續轉移總數不超過 \(2n-2\)。對於不連續的轉移,找到從根節點 \(T\to p\) 的一條連續路徑,設其所表示字符串為 \(u\);找到從 \(q\) 到任意一個終止節點 \(f\in F\) 的一條連續路徑,設其所表示字符串為 \(v\)。對於不同的 \(p,q\)\(s_{p,q} = u + c_{p,q} + v\) 互不相同:若兩個轉移 \((p,q)\)\((p', q')\) 出現 \(s_{p, q} = s_{p', q'}\) 的情況,由於不同路徑所表示字符串不同,因此 \((p, q)\)\((p', q')\) 在同一條路徑,這與 \(T\to p\)\(q\to F\) 連續矛盾。又因為 \(s_{p, q}\)\(s\) 的真后綴(\(s\) 對應的路徑轉移顯然連續),因此不連續的轉移數量不超過 \(n-1\)。這樣,我們得到了轉移數上界 \(3n-3\)

由於最大的狀態數量僅在形如 \(\tt abb \cdots bb\) 的字符串中達到,此時轉移數量小於 \(3n - 3\)。形如 \(\tt abb\cdots bbc\) 的字符串達到了 \(3n - 4\) 的上界。

2.4.3 操作次數上界

該部分 OI Wiki 上講得較為簡略,因此筆者自行證明了這一結論。在構建 SAM 的過程中,有且僅有將 \(p\to q\) 的轉移邊改為 \(p\to cl\) 的操作 不新建 轉移邊。因此,基於 轉移數線性 這一結論,其它操作的時間復雜度均為線性。

定義 \(\mathrm{depth}(p)\) 表示 \(p\)\(\rm link\) 樹上的 深度。引理:若 \(p\to q\) 存在轉移邊,則 \(\mathrm{depth}(p)\geq \mathrm{depth}(q)\)。證明:

  • 考慮后綴路徑 \(q\to T\) 上的任意兩個不同狀態 \(q_1, q_2\ (q_1 \neq q_2)\)。設 \(p_1\) 為任意能轉移到 \(q_1\) 的狀態,\(p_2\) 為任意能轉移到 \(q_2\) 的狀態。因為 \(\mathrm{substr}(q_1), \mathrm{substr}(q_2)\) 均為 \(\mathrm{longest}(q)\) 的后綴,因此 \(\mathrm{substr}(p_1), \mathrm{substr}(p_2)\) 均為 \(\mathrm{longest}(p)\) 的后綴。所以 \(p_1, p_2\) 均在后綴路徑 \(p\to T\) 上。
  • \(p_1 = p_2\),則 \(p_1\) 通過同一字符能轉移到不同狀態,矛盾。因此 \(p_1\neq p_2\)。故能轉移到 \(q\to T\)任意 狀態 \(q’\) 的所有狀態 \(p'\) 均在 \(p\to T\) 上且 互不相同。由於對於每個 \(q'\) 至少存在一個與之對應的 \(p'\)(可能存在多個),因此 \(|q\to T|\leq |p\to T|\),即 \(\mathrm{depth}(p)\geq \mathrm{depth}(q)\)。證畢。
  • 可結合下圖以更好理解,其中 \(i \to i - 1\) 的邊表示一條后綴鏈接,其余邊表示轉移邊。
    H7pPnU.png

假設我們從 \(p\) 一直跳到 \(p'\),並將 \(p\to p'\) 路徑上所有狀態指向 \(q\) 的轉移邊改為指向 \(cl\)。設 \(q' = \delta(\mathrm{link}(p'), s_i)\),容易證明 \(\mathrm{link}(q)\) \(\mathrm{link}(cl) = q'\)。設 \(d = \mathrm{depth}(p) - \mathrm{depth}(p')\),即從 \(p\) 開始跳 \(\mathrm{link}\) 的次數。根據上述引理,我們有 \(\mathrm{depth}(q') \leq \mathrm{depth}(p') = \mathrm{depth}(p) - d \leq \mathrm{depth}(las) - 1 - d\)

同時,根據 \(\mathrm{link}(cur) = cl\)\(\mathrm{link}(cl) = q'\) 可知 \(\mathrm{depth}(cur) - 2 \leq \mathrm{depth}(las) - 1 - d\),即 \(d\leq \mathrm{depth}(las) - \mathrm{depth}(cur) + 1\),這一不等式通過精確分析還可以更緊。因此,該部分操作的總時間復雜度可用 \(cur\) 相對於 \(las\)深度減少量之和 來估計。同時,若進入 Case 1 或 Case 2,則因為 \(las\to cur\) 存在轉移邊,由引理得 \(\mathrm{depth}(cur)\leq \mathrm{depth}(las)\),若進入 Case 3,則根據上述不等式有 \(\mathrm{depth}(cur) \leq \mathrm{depth}(las) + 1\)。因此,勢能分析得到 \(\sum d\) 的級別為線性。

2.5 應用

2.5.1 求本質不同子串個數

根據 SAM 的性質,每個子串唯一對應一個狀態,因此答案即 \(\sum \mathrm{len}(i) - \mathrm{len}(\mathrm{link}(i))\)

2.5.2 字符串匹配

用文本串 \(t\)\(s\) 的 SAM 上跑匹配時,我們可以得到對於 \(t\) 的每個 前綴 \(t[1, i]\),其作為 \(s\) 的子串出現的 最長后綴 \(L_i\):若當前狀態 \(p\)(即 \(t[i - L_{i - 1}, i - 1]\) 所表示的狀態)不能匹配 \(t_i\)(即 \(\delta(p, t_i)\) 不存在),就跳后綴鏈接令 \(p\gets \mathrm{link}(p)\) 並實時更新 \(L_i = \mathrm{len}(p)\) 直到 \(p = T\)\(\delta(p, t_i)\) 存在,對於后者令 \(p\gets \delta(p, t_i)\)\(L_i\) 還需再加上 \(1\)。若能匹配,則直接令 \(p\gets \delta(p, t_i)\) 並令 \(L_i\gets L_{i - 1} + 1\)。綜合一下,我們得到如下代碼:

for(int i = 1, p = 1, L = 0; i <= n; i++) {
	while(p > 1 && !son[p][t[i] - 'a']) L = len[p = fa[p]];
	if(son[p][t[i] - 'a']) L = min(L + 1, len[p = son[p][t[i] - 'a']]);
}

2.6 廣義 SAM

廣義 SAM,GSAM,全稱 General Suffix Automaton,相對於普通 SAM 它支持對多個字符串進行處理。它可以看做對 trie 建后綴自動機。

一般的寫法是每插入一個字符串前將 \(las\) 指針置為 \(T\),非常方便。一個細節:構建單串 SAM 時,\(\delta(las, s_i)\) 一定不存在,但對於多串 SAM 可能存在。這說明當前字符串 \(s\)\(i\) 前綴是某個已經添加過的字符串的子串。我們需要進行以下特判,否則會出現這種情況:https://www.luogu.com.cn/discuss/322224

  1. \(q = \delta(las, s_i)\) 存在,且 \(\mathrm{len}(las) + 1 = \mathrm{len}(q)\) 時,令 \(las\gets q\) 並直接返回。
  2. \(q = \delta(las, s_i)\) 存在,且 \(\mathrm{len}(las) + 1 \neq \mathrm{len}(q)\) 時,我們會新建節點 \(cl\),並進行復制。此時,令 \(las\gets cl\) 而非 \(cur\)。這是因為 \(\mathrm{len}(cur) = \mathrm{len}(las) + 1\)\(\mathrm{len}(cl) = \mathrm{len}(las) + 1\),又因為 \(\mathrm{link}(cur) = cl\),所以這說明 \(\mathrm{substr}(cur) = \varnothing\),即 節點 \(cur\) 是空殼,真正的信息在 \(cl\) 上面。為此,我們舍棄掉這個 \(cur\),並用 \(cl\) 代替它。
int ins(int p, int it) {
	if(son[p][it] && len[son[p][it]] == len[p] + 1) return son[p][it]; // 如果節點已經存在,且 len 值相對應,即 (p, son[p][it]) 是連續轉移,則直接轉移。
	int cur = ++cnt, chk = son[p][it]; len[cur] = len[p] + 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, cur;
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, cur;
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cl, p = fa[p];
	return chk ? cl : cur; // 如果 len[las][it] 存在,則 cur 是空殼,返回 cl 即可
}

上述方法本質相當於對匹配串建出 trie 后進行 dfs 構建 SAM。部分特殊題目會直接給出 trie 而非模板串,此時模板串長度之和的級別為 \(\mathcal{O}(|S| ^ 2)\),因此只能 bfs 構建 SAM:設 \(P_p\) 表示 trie 樹上狀態 \(p\) 在 SAM 上對應的位置,若 trie 樹 \(T\) 上的轉移 \(q = \delta_T(p, c)\) 存在,其中 \(c\)\(p\to q\) 所表示字符,那么以 \(P_p\) 作為 \(las\),插入字符 \(c\) 后新的 \(las\)\(P_q\)。此時 不需要 像上面一樣特判,因為 \(\delta(P_p, c)\) 必然不存在,這是由於 bfs 使得 \(\mathrm{len}(P_p)\) 單調不降。模板題 P6139 代碼:

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

#define ll long long
#define cpy(x, y, s) memcpy(x, y, sizeof(x[0]) * (s))

const int N = 2e6 + 5;
const int S = 26;

ll n, ans, cnt = 1;
string s;
int len[N], fa[N], son[N][S];
int ins(int p, int it) {
	int cur = ++cnt; len[cur] = len[p] + 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, cur;
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, cur;
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cl, p = fa[p];
	return cur;
}

int node = 1, pos[N], tr[N][S];
void ins(string s) {
	int p = 1;
	for(char it : s) {
		if(!tr[p][it - 'a']) tr[p][it - 'a'] = ++node;
		p = tr[p][it - 'a'];
	}
}
void build() {
	queue <int> q; q.push(pos[1] = 1);
	while(!q.empty()) {
		int t = q.front(); q.pop();
		for(int i = 0, p; i < S; i++) if(p = tr[t][i])
			pos[p] = ins(pos[t], i), q.push(p);
	}
}
int main() {
	cin >> n;
	for(int i = 1; i <= n; i++) cin >> s, ins(s);
	build();
	for(int i = 2; i <= cnt; i++) ans += len[i] - len[fa[i]];
	cout << ans << endl;
	return 0;
}

2.7 常用技巧與結論

2.7.1 線段樹合並維護 \(\mathrm{endpos}\) 集合

對於部分題目,我們需要維護每個狀態的 \(\mathrm{endpos}\) 集合,以刻畫每個子串在字符串中所有出現位置的信息。

為此,我們在 \(s[1, i]\) 對應狀態的 \(\mathrm{endpos}\) 集合里插入位置 \(i\),再根據 \(\mathrm{endpos}\) 集合構造出來的樹本質上就是后綴鏈接樹這一事實,在 \(\mathrm{link}\) 樹上進行 線段樹合並 即可得到每個狀態的 \(\mathrm{endpos}\) 集合。這是一個非常有用且常見的技巧。

注意,線段樹合並時會破壞原有線段樹的結構,因此若需要在線段樹合並后保留每個狀態的 \(\rm endpos\) 集合對應的線段樹的結構,需要在線段樹合並時 新建節點。即 可持久化線段樹合並。SAM 相關問題的線段樹合並通常均需要可持久化。

特別的,如果僅為了得到 \(\mathrm{endpos}\) 集合大小,那么只需求出每個狀態在 \(\mathrm{link}\) 樹上的子樹有多少個表示 \(s\) 的前綴的狀態。前綴狀態即所有曾作為 \(cur\) 的節點。對此,有兩種解決方法:直接建圖 dfs,以及 ——

2.7.2 桶排確定 dfs 順序

顯然后綴鏈接樹上父親的 \(\mathrm{len}\) 值一定小於兒子,但千萬不能認為編號小的節點 \(\mathrm{len}\) 值也小。因此,對所有節點按照 \(\mathrm{len}\) 值從大到小進行桶排序,然后按順序合並每個狀態及其父親是正確的,並且常數比建圖 + dfs 小不少,代碼見例題 I.

2.7.3 快速定位子串

給定區間 \([l, r]\),求 \(s_{l, r}\) 在 SAM 上的對應狀態:在構建 SAM 時容易預處理 \(s_{1, i}\) 所表示的狀態 \(pos_i\)。從 \(pos_r\) 開始在 \(\mathrm{link}\) 樹上倍增找到最淺的,\(\rm len\)\(\geq r - l + 1\) 的狀態 \(p\)​ 即為所求。

2.7.4 其它結論

  1. \(\rm link\) 樹上,若 \(p\)\(q\) 的祖先,則 \(\mathrm{substr}(p)\) 中所有字符串在 \(\mathrm{longest}(q)\)(下記為 \(s\))中出現次數與出現位置相同。具體證明見 CF700E 題解區

2.8 注意點總結

  • 做題時不要忘記初始化 \(las\)\(cnt\)
  • 第二個 while 不要寫成 son[p][it] = cur,應為 son[p][it] = cl
  • SAM 開兩倍空間
  • 對於多串 SAM,如果每插入一個新字符串時令 \(las\gets T\),且插入字符時不特判 \(\delta(las, s_i)\) 是否存在,會導致出現空狀態,從而父節點的 \(\mathrm{len}\)不一定嚴格小於 子節點,使得桶排失效。對此要格外注意。

2.9 例題

I. P3804 【模板】后綴自動機 (SAM)

\(s\) 建出 SAM,對於每個狀態 \(p\) 求出其 \(\mathrm{endpos}\) 集合大小。根據題目限制,答案即 \(\sum_{\\ \mathrm{|endpos}(p)|\geq 2}\mathrm{len}(p)\times |\mathrm{endpos}(p)|\)。視字符集大小為常數,時間復雜度線性。

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

#define ll long long
#define cpy(x, y, s) memcpy(x, y, sizeof(x[0]) * (s))

const int N = 2e6 + 5; // 不要忘記開兩倍空間
const int S = 26;

char s[N];
int cnt = 1, las = 1;
int son[N][S], len[N], fa[N];
int ed[N], buc[N], id[N];
ll n, ans;
void ins(char s) {
	int it = s - 'a', cur = ++cnt, p = las;
	las = cur, len[cur] = len[p] + 1, ed[cur] = 1;
	while(!son[p][it]) son[p][it] = cur, p = fa[p];
	if(!p) return fa[cur] = 1, void();
	int q = son[p][it];
	if(len[p] + 1 == len[q]) return fa[cur] = q, void();
	int cl = ++cnt; cpy(son[cl], son[q], S);
	len[cl] = len[p] + 1, fa[cl] = fa[q], fa[q] = fa[cur] = cl;
	while(son[p][it] == q) son[p][it] = cur, p = fa[p];
}
int main()  {
	scanf("%s", s + 1), n = strlen(s + 1);
	for(int i = 1; i <= n; i++) ins(s[i]);
	for(int i = 1; i <= cnt; i++) buc[len[i]]++;
	for(int i = 1; i <= n; i++) buc[i] += buc[i - 1];
	for(int i = cnt; i; i--) id[buc[len[i]]--] = i;
	for(int i = cnt; i; i--) ed[fa[id[i]]] += ed[id[i]];
	for(int i = 1; i <= cnt; i++) if(ed[i] > 1) ans = max(ans, 1ll * ed[i] * len[i]);
	cout << ans << endl;
	return 0;
}

II. P4070 [SDOI2016]生成魔咒

非常裸的 SAM,插入每個字符后新增的子串個數為 \(\mathrm{len}(cur) - \mathrm{len}(\mathrm{link}(cur))\),求和即可。由於字符集太大,需要使用 map 存轉移數組。時間復雜度線性對數。

*III. P4022 [CTSC2012]熟悉的文章

首先二分答案 \(m\),考慮設 \(f_i\) 表示文章的 \(i\) 前綴最長的符合限制的匹配長度。根據應用 2.5.2 我們可以求出文章的每個前綴作為字典子串出現的最長后綴長度 \(L_i\),則 \(f_i = \max\limits_{j \in [i - L_i, i - m]} f_j + (i - j)\)。顯然,\(L_i \leq L_{i - 1} + 1\),因此 \(i - L_i\) 單調不降,故可以使用單調隊列優化。時間復雜度線性對數。

IV. P5546 [POI2000]公共串

建出 GSAM 后,設 \(msk_i\) 表示 \(\mathrm{substr}(i)\) 在哪些串中出現過,以狀壓形式存儲,直接在 \(\mathrm{link}\) 樹上合並即可。

V. P3346 [ZJOI2015]諸神眷顧的幻想鄉

由於葉子節點僅有 \(20\) 個,因此從每個葉子節點開始,整棵樹都會形成一個字典樹。將這 \(20\) 棵 Trie 樹拼在一起求 GSAM 就做完了。

VI. P3181 [HAOI2016]找相同字符

建出兩個串的 GSAM,設 \(ed_{1, i}\) 表示狀態 \(i\) 關於 \(s_1\)\(\mathrm{endpos}\) 集合大小,\(ed_{2,i}\) 同理。答案顯然為 \(\sum ed_{1, i}\times ed_{2, i}\times (\mathrm{len}(i) - \mathrm{len}(\mathrm{link}(i)))\)

VII. P5341 [TJOI2019]甲苯先生和大中鋒的字符串

建出 \(s\) 的 SAM 后容易得到所有出現 \(k\) 次的子串狀態。每個符合題意的狀態的子串長度是一段區間,差分即可。時間復雜度線性。

VIII. P4341 [BJWC2010]外星聯絡

SAM 的轉移函數刻畫了一個字符串 \(s\) 的所有子串,因此直接在該 DAG 上貪心遍歷即可。貪心指優先走字符小的出邊。

*IX. P3975 [TJOI2015]弦論

根據一條路徑表示一個子串的性質,考慮求出從每個節點開始的路徑條數 \(d_i = 1 + \sum_\limits{\delta(i, c)} d_{\delta(i, c)}\) 幫助跳過不可能的分支,然后在 SAM 的 DAG 上模擬跑一遍即可。對於 \(t = 1\) 只需將上式中的 \(1\) 改為 \(ed_i\)

*X. H1079 退群杯 3rd E.

給定字符串 \(s\),多次詢問求 \(s_{c\sim d}\) 有多少個子串包含 \(s_{a\sim b}\)\(|s|, q \leq 2 \times 10 ^ 5\)

\(L = b - a + 1\)。我們對每個位置 \(p \in [c + L - 1, d]\),求出有多少個左端點 \(l \geq c\) 使得 \(s_{l \sim p}\) 包含 \(s_{a\sim b}\)。考慮找到 \(p\) 前面 \(s_{a\sim b}\) 的最后一次出現位置 \(q\),則貢獻顯然為 \(\max(0, (q - L + 1) - c + 1)\)

轉化貢獻形式,考慮每個落在 \([c + L - 1, d]\)\(s_{a\sim b}\) 的出現位置 \(q\) 對答案的貢獻。為方便說明,我們不妨假設 \(s_{a\sim b}\)\(d + 1\) 處出現。考慮 \(s_{a\sim b}\)\(q\) 之后的下一次出現 \(q'\),則對於 \(p\in [q, q' - 1]\),貢獻均為 \((q - L + 1) - c + 1\)。注意到 \(2 - c - L\) 均與詢問有關,與 \(q\) 無關,因此提出。則貢獻可寫為 \(q \times (q' - q)\)。即每個位置的下標值乘以和下一次出現之間的距離。線段樹維護區間出現位置最小值,最大值即可維護該信息。

\(2 - c - L\) 的貢獻次數為 \(d - (\min q) + 1\),因為所有 \([q, q' - 1]\) 的區間並起來形成了區間 \([\min q, d]\)。對 \(\rm endpos\) 集合 可持久化 線段樹合並,再使用 2.7.3 的技巧,即可做到 \(\log\) 時間內回答每個詢問。時間復雜度線性對數。代碼

XI. CF316G3 Good Substrings

對所有串建出 GSAM,求出每個狀態所表示的串在 \(s\) 和每個模式串中出現了多少次,若合法則統計答案即可。時間復雜度線性。

如果用先建出字典樹再建 GSAM 的方法,空間開銷會比較大,需要用 unsigned short 卡空間。

XII. SP8222 NSUBSTR - Substrings

這就屬於 SAM 超級無敵大水題了吧。

XIII. 某模擬賽 一切的開始

給定字符串 \(s\),求其兩個 不相交 子串的長度乘積最大值,滿足其中一個子串為另一個子串的子串。\(|s| \leq 10 ^ 5\)

\(s\) 建出 SAM,對於每個狀態 \(i\),我們只關心其第一次出現 \(a\) 和最后一次出現的位置 \(b\),因為這樣最優,反證法可證。若前者是后者的子串,那么后者顯然取滿 \([a + 1, n]\),前者長度即 \(L = \min(\mathrm{len}(i), b - a)\)。若后者是前者的子串,則后者一定盡量長,長度為 \(L\),那么前者取滿 \([1, b - L]\) 最優,長度即 \(b - L\)

綜上,答案即 \(\max\limits_i L \times \max(n - a, b - L)\)。時間復雜度線性。

*XIV. CF1037H Security

考慮直接在后綴自動機的 DAWG 上貪心。使用線段樹合並判斷當前字符串是否作為 \([l, r]\) 的子串出現過,時間復雜度 \(\mathcal{O}(|\Sigma|n\log n)\)代碼

*XV. CF700E Cool Slogans

容易發現 \(s_{i - 1}\)\(s_i\) 中一定同時以前綴和后綴的形式出現,否則調整法證明可以做到更優。我們使用 \(s_{i - 1}\)\(s_i\) 中作為后綴的性質,考慮直接在 \(\rm link\) 樹上 DP。

再根據 2.7.4 的結論一(實際上這個結論是筆者做本題時才遇到的),我們可以設 \(f_p\) 表示 \(\mathrm{longest}(p)\) 的答案,以及 \(g_p\) 表示 \(p\) 的祖先中答案取到 \(f_p\) 的深度最小的狀態,因為我們要讓串長盡可能小,這樣出現次數更多。轉移即檢查 \(\mathrm{longest}(g_{\mathrm{link}(p)})\)\(\mathrm{longest}(p)\) 中是否出現了至少兩次,這相當於檢查 \(\mathrm{longest}(g_{\mathrm{link}(p)})\) 是否在 \(\mathrm{longest}(p)\) 的某個出現位置 \(pos\) 之前的一段區間 \([pos - \mathrm{len}(p) + \mathrm{len}(g_{\mathrm{link}(p)}), pos - 1]\) 處出現,容易用線段樹合並維護 \(\rm endpos\) 集合做到。若是,則令 \(f_p = f_{\mathrm{link}(p)} + 1\)\(g_p = p\)。否則 \(f_p = f_{\mathrm{link}(p)}\)\(g_p = g_{\mathrm{link}(p)}\)

\(\max f_p\) 即為答案,時空復雜度線性對數。代碼

*XVI. CF666E Forensic Examination

SAM 各種常用技巧結合版。首先對 \(s\)\(t_i\) 一並建出 GSAM,線段樹維護每個節點對應的子串在每個 \(t_i\) 中出現的次數,即線段樹 \(T_p\) 的位置 \(i\) 上記錄着 \(p\) 所表示的所有串在 \(t_i\) 中的出現次數。由於題目還需求最小編號,所以線段樹維護區間最大出現次數以及對應最小編號。

使用線段樹合並,預處理 \(\rm link\) 的倍增數組以快速定位子串,單次詢問只需倍增到 \(s[pl, pr]\) 的對應狀態 \(p\),查詢 \(T_p\)\([l, r]\) 的信息即可。時空復雜度均為線性對數。代碼

2.10 相關鏈接與資料

3. 回文自動機 PAM

省選前兩周填坑。之所以不是省選之后是因為擔心省選考這玩意。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM