淺析后綴自動機


解決子串相關問題的強大工具

我們知道一個長度為 \(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\) 等價類(簡稱“類”)中。

然而這玩意有什么用呢?我們先來看一些有意思的結論:

  1. 對於處於同一 \(endpos\) 等價類中的兩個子串 \(t_1,t_2(|t_1|\le|t_2|)\)\(t_1\)\(t_2\) 的后綴。
    • 比較顯然吧?不過 \(t_1\) 不是 \(t_2\) 的后綴,那么 \(t_2\) 必然匹配不上 \(endpos(t_1)\) 中的任何一個位置。
  2. 對於任意兩個子串 \(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\) 必然做不到也在這個位置出現。
  3. 對於一個類,其中的所有子串的 長度連續
    • 證明略。
  4. \(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(x)=1+\sum_{\delta(x,c)=y} f(y) \]

那么答案就是 \(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\) 開始的不同路徑總長,轉移:

\[g(x)=f(x) + \sum_{\delta(x,c)=y} g(y) \]

在線算法

動態維護 \(\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\) 的套路跑一遍,按字典序從小到大遍歷轉移即可。

習題


免責聲明!

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



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