解決子串相關問題的強大工具
我們知道一個長度為 \(n\) 的字符串中所有的子串數目為 \(O(n^2)\) 個,這很大程度上限制了我們對某些子串相關問題的研究。所以有沒有解決方案,使得我們可以在可承受的復雜度內表示出所有的子串?
於是,一種被稱作 \(\text{DAWG}\) 的自動機(字符串 \(s\) 的 DAWG 簡稱 \(\text D_{s}\))橫空出世。它可以做到僅用 \(O(n)\) 的狀態數或轉移數,表示出 \(s\) 的所有子串,並且 DAWG 可以在線性時間內增量構造。
后綴自動機(\(\text{SAM}\))是 DAWG 的一種,其接受狀態為 \(s\) 的所有后綴。然而在很多地方所謂的 SAM 其實就是 DAWG。既然現在都叫 SAM,那筆者也將用 SAM 代替一些 DAWG,應該不會影響閱讀。
在介紹 SAM 的構造算法之前,我們將了解一些前置知識點作為鋪墊。
\(\text{endpos}\) 集合
定義一個字符串 \(t\) 在 \(s\) 中出現的 所有終止位置 的 集合 為 \(endpos(t)\)。如 \(s=\texttt{abbab},t=\texttt{ab}\) 那么 \(endpos(t) = \{2,5\}\)。對於兩個子串 \(t_1,t_2\),若 \(endpos(t_1) = endpos(t_2)\),那么我們稱 \(t_1,t_2\) 在同一個 \(endpos\) 等價類(簡稱“類”)中。
然而這玩意有什么用呢?我們先來看一些有意思的結論:
- 對於處於同一 \(endpos\) 等價類中的兩個子串 \(t_1,t_2(|t_1|\le|t_2|)\),\(t_1\) 是 \(t_2\) 的后綴。
- 比較顯然吧?不過 \(t_1\) 不是 \(t_2\) 的后綴,那么 \(t_2\) 必然匹配不上 \(endpos(t_1)\) 中的任何一個位置。
- 對於任意兩個子串 \(t_1,t_2(|t_1|\le|t_2|)\),有 \(endpos(t_1)\supseteq endpos(t_2)\) 或 \(endpos(t_1)\cap endpos(t_2)=\varnothing\) 其一成立。
- 前者代表 \(t_1\) 為 \(t_2\) 的一個后綴,凡是 \(t_2\) 出現過的位置 \(t_1\) 必然出現;而后者則表示不是后綴,那么 \(t_1\) 出現的位置 \(t_2\) 必然做不到也在這個位置出現。
- 對於一個類,其中的所有子串的 長度連續。
- 證明略。
- \(endpos\) 等價類的個數為 \(O(n)\)。
- 對於一個類中的一個子串,我們將它在前方拓展一個字符,其所屬的類可能會發生改變。一旦改變,那么這一個子串為變為若干個不同的子串。對於其中兩個不同的,必然是因為在開頭加上的字符不同。根據前面的結論,可知它們的 \(endpos\) 集合無交集。那么整個過程可以視作 \(endpos\) 集合的分割。當然,有一部分信息會丟失,這是因為這個位置的長度到頂了,無法往前擴展(這樣暗示了最多只能丟失一個)。
- 注意到分割出來的 子集間互不相交,那么所有的分割過程有一個樹形結構。考慮到最大的 \(endpos\) 集合也只有 \(n\) 個元素,最后只會有 \(n\) 次分割,那么顯然樹的結點數也只有 \(O(n)\)。
觀察其中第 4 個結論,若我們將所有 \(O(n^2)\) 個子串按 \(endpos\) 等價類歸類,那么只會有 \(O(n)\) 個類。這是一個很好的性質,接下來的研究將會圍繞這個等價類進行。
為了方便,我們先引入一些記號:
- \(longest(i)\):等價類 \(i\) 中最長的子串;
- \(len(i)\):\(longest(i)\) 的長度;
- \(shortest(i)\):等價類 \(i\) 中最短的子串;
- \(minlen(i)\):\(shortest(i)\) 的長度。
上面提到 \(endpos\) 的分割關系是一顆樹,我們稱這棵樹為 Parent Tree,一個結點 \(i\) 代表一個類,結點 \(i\) 的父親記為 \(link(i)\)(也被稱為“后綴鏈接”,它的實際意義是:將 \(i\) 中一個子串的前面縮減,直到存在其他位置也出現了這個串,新的 \(endpos\) 對應的類記為 \(link(i)\))。
那么接下來,第五個結論:\(minlen(i)=len(link(i))+1\)。
- \(link(i)\) 是 \(i\) 在 Parent 樹上的父親,那么考慮 \(i\) 這個兒子是怎么來的:\(longest(link(i))\) 向前拓展一個字符后不再屬於 \(link(i)\),其中一個被歸入 \(i\)。很顯然這個擴展得到的子串即為 \(shortest(i)\),因為它無法再縮減得到 \(i\) 中的其他子串,它就是最短的那個。
有了這些理論基礎,是時候開始學習 SAM 了!
SAM 后綴自動機
SAM 的狀態 & 轉移
作為一個自動機,其狀態的含義是必不可少的。在 SAM 中我們直接 把一個等價類作為一個狀態。
這是有一定道理的,至少一個字符串的 \(endpos\) 等價類的個數就在 \(O(n)\) 級別。
當我們從一個狀態 \(x\) 走一個 \(\delta(x,c)\) 轉移時,意味着在當前的字符串 后追加一個字符 \(c\)。
SAM 的構造
算是最難理解的部分了吧!反之筆者來來回回看了不下四五遍。先貼 Code!
const int N = 1e6 + 5;
const int T = N << 1;
const int S = 26;
int ch[T][S], link[T], len[T];
int total(1), last(1);
void extend(int c) {
int p = last, np = last = ++total;
len[np] = len[p] + 1;
for (; p && !ch[p][c]; p = link[p]) ch[p][c] = np;
if (!p) { link[np] = 1; return; }
int q = ch[p][c];
if (len[q] == len[p] + 1) { link[np] = q; return; }
int nq = ++total;
memcpy(ch[nq], ch[q], S << 2), link[nq] = link[q];
len[nq] = len[p] + 1, link[np] = link[q] = nq;
for (; p && ch[p][c] == q; p = link[p]) ch[p][c] = nq;
}
看不懂就對了 其中 ch[x][c]
表示 \(\delta(x,c)\),last
表示上一次添加的位置,其他的,現在叫什么就對應上面的什么。
我們總共需要維護:轉移、\(link\) 以及 \(len\)。不維護額外信息是因為它們要么會影響復雜度(\(endpos\)),要么可以直接計算 (\(minlen\))。
首先還是要提醒一下我們是 增量法 構造的,也就是說 main()
函數中有一句 for (int i = 0; i < n; i++) extend(s[i] - 'a');
。
所以說這個函數到底在搞什么呢?我們一句句剖析:
-
int p = last, np = last = ++total; len[np] = len[p] + 1;
可以發現 \(p\) 為上一次添加的狀態代表舊串, \(np\) 表示現在的狀態代表新串。很顯然每一次
extend()
都會新增狀態,至少得把含有 \(n\) 的 \(endpos\) 表示出來。len[np] = len[p] + 1;
這也比較顯然,\(p\) 肯定是上一次添加后可以代表最長子串的那一個,而現在是 \(np\),當然是上一次最大長度 \(+1\) 啦。 -
for (; p && !ch[p][c]; p = link[p]) ch[p][c] = np;
這是在干啥?我們發現這個 \(p\) 一直跳 \(link\),於是考慮這么做的實際意義。我們知道 \(link(p)\) 表示“最長的不在等價類中的后綴”,大概了解了這其實就是在遍歷后綴;而朴素的后綴遍歷是一位位掃,這里的高明之處就是對同一類的后綴都可以統一處理,並且可以 壓縮地遍歷后綴。
再看循環的終止條件
p && !ch[p][c]
。前者你可以單純地認為是為了不跳出根,需要着重理解的是后者。注意到 \(longest(p)\) 是舊串的后綴,\(longest(link(p))\)、\(longest(link(link(p)))\) 等也都是舊串的后綴。如果說對於當前的 \(p\),有 \(\delta(p,c)=\text{NULL}\),說明 舊串中不存在 \(longest(p)+c\),於是向 \(np\) 新建一個轉移:\(\delta(p,c)\gets np\)。不難發現 \(longest(p)+c\) 是新串的后綴,而這又是第一次出現,因此 \(endpos(longest(p)+c)=\{n\} = endpos(np)\),因此正確性是有保證的。然而一旦發現 \(\delta(p,c)\) 這個轉移存在了,說明此時 \(longest(p)+c\) 已經在舊串中出現了,那么有 \(endpos(longest(p)+c)\ne \{n\}\),直接連邊就有失妥當,我們需要對此進一步處理。
-
if (!p) { link[np] = 1; return; }
如果上一句話是因為跳出 SAM 而終止,也就是說任意一個舊串的后綴 \(+c\) 都不曾在舊串中出現過,那么就會走到這一步。由於根狀態 \(q_0\) 是跳 \(link\) 的必經之地,而這里都不存在 \(c\) 的轉移那只能說明一件事:這個 \(c\) 在舊串中就沒出現過!於是顯然有 \(link(np)\gets q_0\)。
-
int q = ch[p][c];
走到這里了,說明前、前一句是因為發現一個存在的 \(c\) 轉移而停下的。那咱就先拿來看看,\(q\gets \delta(p,c)\)。考慮這個 \(q\) 到底有什么特別的地方——它滿足 \(longest(p)+c\) 在舊串中出現並且是新串的后綴。那為什么選擇 \(q\) 而不是 \(q^\prime=\delta(link(p),c)\),也就是為什么不要繼續跳后綴?注意到 \(q^\prime\) 在 Parent 樹上必然是 \(q\) 的祖先,這些祖先在這一次的
extend()
做完之后,其 \(endpos\) 也都增加了 \(n\),情況本質相同。既然如此,對於這個 \(q\) 還需要多考慮什么呢? -
if (len[q] == len[p] + 1) { link[np] = q; return; }
這個等式成立,等價於“\(longest(q)=longest(p)+c\)”,也就是說 \(q\) 可以代表的最長子串恰好是新串的后綴。那么,就好辦了啊!我們只要簡單地讓 \(endpos(q)\) 中多個 \(n\) 就行啦:\(link(np)\gets q\)。
但如果這個條件不成立,意味着存在一個比 \(longest(q)\) 更長(顯然不會是更短吧,因為 \(minlen(i)=len(link(i))+1\))的子串。可以發現 這樣更長的子串必然不會是新串的后綴,因為如果真的是,那 \(longest(q)\) 去掉最后一個字符也是舊串后綴。然而要真是這樣,就會有“\(len(q)-1>len(p)\)”這種東西出現,這樣的 \(q\) 明明會比現在這個 \(p\) 先檢查到,但是實際上不然。
“不會是新串的后綴”的話,會出現什么問題呢?首先可以肯定的是,\(n\notin endpos(q)\)。倘若我們令 \(link(np)\gets q\),那么顯然不符合 \(endpos\) 集合的分割性質,因為 \(endpos(np)=\{n\}\)。
解決方案?我們嘗試將 \(q\) 這個狀態 拆分!
-
int nq = ++total; memcpy(ch[nq], ch[q], S << 2), link[nq] = link[q]; len[nq] = len[p] + 1, link[np] = link[q] = nq; for (; p && ch[p][c] == q; p = link[p]) ch[p][c] = nq;
觀察到我們新建了一個狀態 \(nq\),作為拆出來的狀態。要知道我們是為了適應 \(endpos\) 的關系才干的這個事情,新建的 \(nq\) 滿足:\(endpos(nq)=endpos(q)\cup \{n\}\)。在以后的
extend()
中,我們將使用這個新的含有 \(n\) 的 \(nq\) 而不是 \(q\)。考慮一下 \(nq\) 的這些信息(\(\delta,link,len\))應該怎么取。首先看轉移:我們的代碼中 直接沿用的 \(q\) 的所有轉移。這是因為 \(q\) 和 \(nq\) 的 \(endpos\) 不同,但 \(nq\) 僅僅是多了 \(n\),除了這個 \(n\),對於其他的子串后面加什么字符其實和原來的 \(q\) 實際上並無大異。
再看 \(len\)。由於 \(n\in endpos(nq)\),那么 \(longest(nq)\) 必然為新串的后綴,於是 \(len(nq)\gets len(p)+1\),因為 \(longest(p)\) 是舊串的后綴,而 \(\delta(p,c)=nq\)(轉移在最后一句中被重新定向,下面會說)。
最后是 \(link\)。考慮到 Parent 樹上祖先的 \(len\) 必然小於子孫,而 \(nq\) 又是 \(q\) 分拆出的狀態,它們在 Parent 樹上相鄰。又因為 \(len(nq)=len(p)+1<len(q)\),那么想必 \(nq\) 是 \(q\) 的父親。這里的實現可以被認為是在 \(link(q)\leftrightarrow q\) 這條樹枝上執行了一次類似與鏈表的 插入,將 \(nq\) 置於 \(q\) 和 \(link(q)\) 之間,那么上面的代碼就好理解了。
還有一個循環還沒有解釋。這有點像上面的那個循環,唯一不同的是終止條件:
ch[p][c] == q
。對於一個存在 \(c\) 轉移的一個 \(p\) 的祖先,可以肯定的是轉移的結果的 \(endpos\) 理應存在 \(n\)。然而我們發現這個結果是 \(q\),而 \(n\notin endpos(q)\),很明顯違背的 Parent 樹的性質。正好我們剛剛拆出一個 \(nq\),而 \(n\in endpos(nq)\),那么直接將轉移重定向到 \(nq\) 就行了。
以上就是 SAM 構造算法的全部內容啦!emm確實非常難理解,需要多琢磨幾遍。
一些常用的技巧
這里闡述一些做題中可能遇到的一些問題以及解決方案
字符集過大
如果說,一個題中的 \(\Sigma\) 並不是二十六個字母,而是 \([1,10^9]\) 中所有整數,甚至更大,那么顯然不能直接 ch[T][int(1e9)]
這么保存轉移。
最簡單的應對策略是使用 std::map
代替數組的功能,復雜度為 \(O(n\log|\Sigma|)\)。
求拓撲序
一些題會讓你做一些類似於在 SAM 上記憶化搜索(SAM 是一個 DAG,畢竟一個狀態可以表示的子串也是有限的個數)或在 Parent 樹上 Dfs 的操作,我們可以用拓撲序替代。首先放兩個顯然的結論:
- \(len(p)>len(link(p))\)
- \(len(p)<len(\delta(p,c))\)
這意味着我們可以對狀態按 \(len\) 排序而不用常規的 DAG 拓撲,因為由上面兩個結論可知這樣得到的拓撲序不論對 SAM 主題的 DAG 還是 Parent 樹來說都是可行的,常規的拓撲排序算法得到的結果可能只適合其中一者。
具體我們可以仿照計數排序線性實現(其中 buc[]
是一個桶數組,結果為 ord[]
):
for (int i = 1; i <= total; i++) ++buc[len[i]];
for (int i = 1; i <= total; i++) buc[i] += buc[i - 1];
for (int i = 1; i <= total; i++) ord[buc[len[i]]--] = i;
計算 \(\text{endpos}\) 集合大小
這是很多題目需要的信息,但其實也非常好求。
上面提到 \(endpos\) 的分割關系構成一顆 Parent 樹,並且分割中最多只會丟失一個元素。我們記 \(siz(i)=|endpos(i)|\),那么我們先考慮沒有丟失的,有:\(siz(i)\gets\sum_{link(j)=i}siz(j)\)。
接下來考慮丟失的那個。很顯然(前面也提到過)向前擴展導致長度到頂的只有一個位置,而這個必然是一個前綴,也就是說只有在一個可以表示主串的一個前綴的狀態的 \(endpos\) 才會擁有這樣的元素。代碼中只要在 extend()
中加上一句 siz[np] = 1
即可。
所有的 extend()
進行完之后,我們再對 Parent 樹做一次求子樹和操作,暴力建樹+Dfs 或拓撲都可。
求出具體的 \(\text{endpos}\) 集合
有些題不僅僅需要 \(endpos\) 集合的大小,還要更具體的信息,比如出現位置必須在一個區間內等等。
那么我們考慮用一個什么數據結構維護它。觀察到要求出 \(endpos\) 集,必然會涉及到許多集合合並的操作。
那么動態開點線段樹看似挺好,它支持在 \(O(n\log n)\) 總時間內合並求出所有狀態的 \(endpos\) 集,事實上主流的方法就是線段樹合並。
不僅僅因為合並方便,線段樹還能維護多種額外信息。
當然少數情況下也推薦使用平衡樹啟發式合並或者 這個,當然線段樹支持可持久化方法的合並,要想保留 \(endpos\) 集而不是一次性的話平衡樹可能會難操作一點。
動態維護 Parent Tree
可以使用 LCT,支持各種樹上操作非常方便,復雜度一只 \(\log\)。
用於應付某些強制在線的接地府出題人。
由於這里的 LCT 是有根的,跑的巨快無比,一般都隨手過百萬。
LCT 可以看這里:https://www.cnblogs.com/-Wallace-/p/lct.html
簡單應用示例
判斷子串
給定兩個字符串 \(s\) 和 \(t\),判斷 \(s\) 是否為 \(t\) 的子串。
對 \(t\) 建立后綴自動機 \(\text D_t\),從根狀態開始跑一遍 \(s\)。由於 \(\text D_t\) 中包含了 \(t\) 的所有子串,那么如果 \(s\) 在跑的過程中走到了空狀態,那么說明不是 \(t\) 的子串。
子串出現次數
很顯然 \(endpos\) 集合大小搞出來就完事了。
本質不同的子串數
離線方法
首先有個結論:\(s\) 本質不同的子串數即為 \(\text D_s\) 中根開始的不同路徑數。
其實不難理解,因為 SAM 既能表示出所有子串,又不會出現兩條不同路徑表示同一個子串。
那么設計一個 dp:\(f(x)\) 表示從狀態 \(x\) 開始的不同路徑數,轉移:
那么答案就是 \(f(q_0)-1\),復雜度線性。
在線方法
考慮到一個狀態表示的子串的長度連續,並且短串都是長串的后綴。那么 \(x\) 這個狀態表示了 \([minlen(x),len(x)]\) 這么多本質不同的子串。這些子串顯然不能在其他狀態中,於是所有狀態包含的子串數之和即為答案:\(\sum_{x\in \text D_s}(len(x)-len(link(x)))\)。
在實際維護時我們只要對於那個新建的 \(np\),更新答案,不管 \(nq\) 是因為它只是分割了一個 \([minlen,len]\) 區間,並沒有對答案產生貢獻。復雜度顯然也是線性。
所有本質不同子串總長
都可以類比上面“本質不同的子串數”的方法。
離線算法
在原來 \(f\) 的基礎上設 \(g(x)\) 為從狀態 \(x\) 開始的不同路徑總長,轉移:
在線算法
動態維護 \(\large\sum_{x\in \text D_s}\left(\tfrac{len(x)\times (len(x)+1)}{2}-\tfrac{len(link(x))\times (len(link(x))+1)}{2}\right)\) 即可。
兩個串的最長公共子串
給定兩個字符串 \(s\) 和 \(t\),求 \(s\) 和 \(t\) 的最長公共子串。
首先對 \(s\) 建 SAM \(\text D_s\),然后對於 \(t\) 的每一個前綴,我們希望這個前綴有盡量長的后綴可以匹配。
那么先把 \(t\) 在 SAM 上跑,如果能走轉移就走轉移,否則我們慢慢從前面縮減長度,也就是跳 \(link\),直到存在一個當前字符的轉移為止。
答案我們實時更新,每走一次轉移取一次最大值即可。
復雜度仍為線性,因為我們維護 \(t\) 的起始位置和終止位置都在前移。
多個串的最長公共子串
稍微復雜一些,但只要稍加改動就能從兩個串擴展過來。
首先我們對第一個串建 SAM 其他的往上面跑,並記下來原來我們動態維護的答案:\(mx(i)\) 表示狀態 \(i\) 在跑當前串時匹配到的最大值,而 \(mn(i)\) 表示 \(mx(i)\) 的歷史最小值。
然后每個串跑完之后,還需要多考慮一點,就是 \(i\) 的結果對於 \(link(i)\) 同樣有效,也就是我們需要干這個:\(mx(link(i))\gets \max(mx(i),mx(link(i)))\)。
最后別忘了更新 \(mn(i)\),以及確保 \(mn(i)\le len(i)\)(直接取一波 \(\min\) 即可,根據寫法差異這種問題不一定每份代碼都要注意)。
字典序第 \(k\) 小的子串
有分為本質不同和位置不同的子串,這里以本質不同為例。
這其實也對應 SAM 中字典序第 \(k\) 小的路徑,那么就先像求本質不同的子串數的離線方法一樣 dp 一遍,得到一個點出發的路徑數。
然后用平衡樹查 \(k-th\) 的套路跑一遍,按字典序從小到大遍歷轉移即可。