后綴自動機(SAM)學習筆記


此篇博客大部分內容來自於 hihoCoder , 借此學習 !! (侵刪) 主要是上面講的通俗易懂qwq

本文只是將其用更好的格式進行展現,希望對讀者有幫助。

而且以后博客的 markdown 風格會進行改變qwq 主要是找到了新的 typora 用法 2333

不想看那么長的講解,可以直接先跳到后面的代碼再回頭看。

定義

對於一個字符串 \(S\) ,它對應的后綴自動機是一個最小的確定有限狀態自動機( \(DFA\) ),接受且只接受 \(S\) 的后綴。

比如對於字符串 \(S=\underline{aabbabd}\),它的后綴自動機是

其中 紅色狀態 是終結狀態。你可以發現對於S的后綴,我們都可以從S出發沿着字符標示的路徑( 藍色實線 )轉移,最終到達終結狀態。

特別的,對於S的子串,最終會到達一個合法狀態。而對於其他不是S子串的字符串,最終會“無路可走”。

我們知道 \(SAM\) 本質上是一個 \(DFA\)\(DFA\) 可以用一個五元組 <字符集、狀態集、轉移函數、起始狀態、終結狀態集> 來表示。至於那些 綠色虛線 雖然不是DFA的一部分,卻是SAM的重要部分,有了這些鏈接 \(SAM\) 是如虎添翼,我們后面再細講。

其中比較重要的是 狀態集 和 轉移函數 .

SAM 的狀態集

首先我們先介紹一個概念 子串的結束位置 集合 \(endpos\)

對於 \(S\) 的一個子串 \(s\)\(endpos(s) = s\)\(S\) 中所有出現的結束位置集合。

還是以 \(S=\underline{aabbabd}\) 為例,\(endpos(\underline{ab}) = \{3, 6\}\) ,因為 \(\underline{ab}\) 一共出現了 \(2\) 次,結束位置分別是 \(3\)\(6\) 。同理 \(endpos(\underline{a}) = \{1, 2, 5\}\) , \(endpos(\underline{abba}) = \{5\}\)

我們把 \(S\) 的所有子串的 \(endpos\) 都求出來。如果兩個子串的 \(endpos\) 相等,就把這兩個子串歸為一類。最終這些 \(endpos\) 的等價類就構成的 \(SAM\) 的狀態集合。

一些性質

  1. \(s_1,s_2\)\(S\) 的兩個子串 ,不妨設 \(|s_1|\le|s_2|\) (我們用 \(|s|\) 表示 \(s\) 的長度 ,此處等價於 \(s_1\) 不長於 \(s_2\) )。則 \(s_1\)\(s_2\) 的后綴當且僅當 \(endpos(s_1) \supseteq endpos(s_2)\)\(s_1\) 不是 \(s_2\) 的后綴當且僅當 \(endpos(s_1) \cap endpos(s_2) = \emptyset\) 。

    這個證明是很顯然的 :

    首先證明 \(s_1\)\(s_2\) 的后綴 \(\Rightarrow\) \(endpos(s_1) \supseteq endpos(s_2)\)

    因為每次出現 \(s_2\) 時候,\(s_1\) 一定會伴隨出現。然后證明 \(endpos(s_1) \supseteq endpos(s_2)\) \(\Rightarrow\) \(s_1\)\(s_2\) 的后綴 。顯然 \(endpos(s_2) \not = \emptyset\) ,那么意味着每次 \(s_2\) 結束的時候 \(s_1\) 也會結束,且 \(|s_1| \le |s_2|\) ,那么顯然成立。

    所以這兩個互為充要條件。那么 \(s_1\) 不是 \(s_2\) 的后綴當且僅當 \(endpos(s_1) \cap endpos(s_2) = \emptyset\) 就是其中的推論了,后者是前者的必要條件。

  2. \(SAM\) 中的一個狀態包含的子串都具有相同的 \(endpos\),它們都互為后綴。

    其中一個狀態指的是從起點開始到這個點的所有路徑組成的子串的集合。

    例如上圖中狀態 \(4\)\(\{\underline{bb},\underline{abb},\underline{aabb}\}\)

  3. 我們用 \(substrings(st)\) 表示狀態 \(st\) 中包含的所有子串的集合,\(longest(st)\) 表示 \(st\) 包含的最長的子串,\(shortest(st)\)表示\(st\)包含的最短的子串。

    例如對於狀態 \(7\)\(substring(7)=\{\underline{aabbab},\underline{abbab},\underline{bbab},\underline{bab}\}\)\(longest(7)=\underline{aabbab}\)\(shortest(7)=\underline{bab}\)

    那么有 對於一個狀態 \(st\) ,以及任意 \(s\in substrings(st)\) ,都有 \(s\)\(longest(st)\) 的后綴。

    證明比較容易,因為 \(endpos(s)=endpos(longest(st)) ~|s| \le |st|\) ,所以 \(endpos(s) ⊇ endpos(longest(st))\) ,根據我們剛才證明的結論有 \(s\)\(longest(st)\) 的后綴。

  4. 對於一個狀態 \(st\) ,以及任意的 \(longest(st)\) 的后綴 \(s\) ,如果 \(s\) 的長度滿足:\(|shortest(st)| \le |s| \le |longsest(st)|\) ,那么 \(s \in substrings(st)\)

    其實是定義然后很顯然?證明有:\(|shortest(st)| \le|s|\le|longsest(st)|\)> ,所以\(endpos(shortest(st)) \supseteq endpos(s) \supseteq endpos(longest(st))\) ,又 \(endpos(shortest(st))=endpos(longest(st))\) 所以 \(endpos(shortest(st)) = endpos(s) = endpos(longest(st))\) ,所以 \(s\in substrings(st)\)

    也就是說 \(substrings(st)\) 包含的是 \(longest(st)\) 的一系列 連續 后綴。

    例如 狀態 \(7\) 中包含的就是 \(\underline{aabbab}\) 的長度分別是 \(6,5,4,3\) 的后綴;狀態 \(6\) 包含的是 \(\underline{aabba}\) 的長度分別是 \(5,4,3,2\) 的后綴。

SAM 的后綴鏈接

前面我們講到 \(substrings(st)\) 包含的是 \(longest(st)\) 的一系列 連續 后綴。這連續的后綴在某個地方會“斷掉”。

比如狀態 \(7\) ,包含的子串依次是 \(\underline{aabbab},\underline{abbab},\underline{bbab},\underline{bab}\) 。按照連續的規律下一個子串應該是 \(\underline{ab}\) ,但是 \(\underline{ab}\) 沒在狀態 \(7\) 里。

這是為什么呢?

\(\underline{aabbab},\underline{abbab},\underline{bbab},\underline{bab}\)\(endpos\) 都是 \(\{6\}\) ,下一個 \(\underline{ab}\) 當然也在結束位置 \(6\) 出現過,但是 \(\underline{ab}\) 還在結束位置 \(3\) 出現過,所以 \(\underline{ab}\)\(\underline{aabbab},\underline{abbab},\underline{bbab},\underline{bab}\) 出現次數更多,於是就被分配到一個新的狀態中了。

\(longest(st)\) 的某個后綴 \(s\) 在新的位置出現時,就會“斷掉”,\(s\) 會屬於新的狀態。

比如上例中 \(\underline{ab}\) 就屬於狀態 \(8\)\(endpos(\underline{ab})=\{3,6\}\) 。當我們進一步考慮 \(\underline{ab}\) 的下一個后綴 \(\underline{b}\) 時,也會遇到相同的情況:\(\underline{b}\) 還在新的位置 \(4\) 出現過,所以 \(endpos(\underline{b})=\{3,4,6\}\)\(\underline{b}\) 屬於狀態 \(5\) 。在接下去處理 \(\underline{b}\) 的后綴我們會遇到空串, \(endpos(\underline{})= \{0,1,2,3,4,5,6\}\) ,狀態是起始狀態 \(S\)

於是我們可以發現一條狀態序列: \(7 \to 8 \to 5 \to S\) 。這個序列的意義是 \(longest(7)\)\(\underline{aabbab}\) 的后綴依次在狀態 \(7,8,5,S\) 中。我們用后綴鏈接 \(Suffix Link\) 這一串狀態鏈接起來,這條 \(link\) 就是上圖中的綠色虛線。

后面這個會有妙用qwq

SAM 的轉移函數

最后我們來介紹 \(SAM\) 的轉移函數。對於一個狀態 \(st\) ,我們首先找到從它開始下一個遇到的字符可能是哪些。我們將 \(st\) 遇到的下一個字符集合記作 \(next(st)\) ,有 \(next(st) = \{S[i+1] | i \in endpos(st)\}\) 。例如 \(next(S)=\{S[1], S[2], S[3], S[4], S[5], S[6], S[7]\}=\{a, b, d\}\)\(next(8)=\{S[4], S[7]\}=\{b, d\}\)

一些性質

  1. 對於一個狀態 \(st\) 來說和一個 \(next(st)\) 中的字符 \(c\) ,你會發現 \(substrings(st)\) 中的所有子串后面接上一個字符 \(c\) 之后,新的子串仍然都屬於同一個狀態。

    比如對於狀態 \(4\)\(next(4)=\{a\}\)\(\underline{aabb},\underline{abb},\underline{bb}\) 后面接上字符 \(\underline{a}\) 得到 \(\underline{aabba},\underline{abba},\underline{bba}\) ,這些子串都屬於狀態 \(6\)

    所以我們對於一個狀態 \(st\) 和一個字符 \(c\in next(st)\) ,可以定義轉移函數 \(trans(st, c) = \{x | longest(st) + c \in substrings(x) \}\) 。換句話說,我們在 \(longest(st)\)(隨便哪個子串都會得到相同的結果)后面接上一個字符 \(c\) 得到一個新的子串 \(s\) ,找到包含 \(s\) 的狀態 \(x\) ,那么 \(trans(st, c)\) 就等於\(x\)

算法構造

構造方法

\(SAM\)\(O(|S|)\) 的構造方法,接下來我們講講如何構造。

首先為了實現 \(O(|S|)\) 的構造,對於每個狀態肯定不能保存太多數據。例如 \(substrings(st)\) 肯定無法保存下來了。

對於狀態 \(st\) 我們只保存如下數據:

數據 含義
\(maxlen[st]\) \(st\) 包含的最長子串的長度
\(minlen[st]\) \(st\) 包含的最短子串的長度
\(trans[st][1..c]\) \(st\) 的轉移函數, \(c\) 為字符集大小
\(link[st]\) \(st\) 的后綴鏈接

其次,我們使用增量法構造 \(SAM\) 。我們從初始狀態開始,每次考慮添加一個字符 \(S[1],S[2],...,S[N]\) ,依次構造可以識別 \(S[1], S[1..2], S[1..3], \cdots ,S[1..N]=S\)\(SAM\)

假設我們已經構造好了 \(S[1..i]\)\(SAM\) 。這時我們要添加字符 \(S[i+1]\) ,於是我們新增了 \(i+1\)\(S[i+1]\) 的后綴要識別:\(S[1..i+1], S[2..i+1], ... S[i..i+1], S[i+1]\) 。 考慮到這些新增狀態分別是從 \(S[1..i], S[2..i], S[3..i], \cdots , S[i], \underline{}\) (空串)通過字符 \(S[i+1]\) 轉移過來的,所以我們還要對 \(S[1..i], S[2..i], S[3..i], \cdots , S[i], \underline{}\) (空串) 對應的狀態們增加相應的轉移。

我們假設 \(S[1..i]\) 對應的狀態是 \(u\) ,等價於 \(S[1..i]\in substrings(u)\) 。根據上面的討論我們知道 \(S[1..i], S[2..i], S[3..i], ... , S[i], \underline{}\) (空串)對應的狀態們恰好就是從 \(u\) 到初始狀態 \(S\) 的由 \(Suffix Link\) 連接起來路徑上的所有狀態,不妨稱這條路徑(上所有狀態集合)是 \(suffix-path(u\to S)\)

這個也就是說,對於 \(S[1..i] = longest(u) \in substrings(u)\) 對於其他 \(s'\)\(longest(u)\) 的后綴 要么存在於 \(u\) 這個狀態中,要么存在於前面的 \(SuffixLink\) 連接的狀態中。

顯然至少 \(S[1..i+1]\) 這個子串不能被以前的 \(SAM\) 識別,所以我們至少需要添加一個狀態 \(z\)\(z\) 至少包含\(S[1..i+1]\) 這個子串。

  1. 首先考慮一種最簡單的情況:對於 \(suffix-path(u \to S)\) 的任意狀態 \(v\) ,都有 \(trans[v][S[i+1]]=NULL\) 。這時我們只要令 \(trans[v][S[i+1]]=z\) ,並且令 \(link[st]=S\) 即可。

    例如我們已經得到了 \(\underline{aa}\) 的 \(SAM\) ,現在希望構造 \(\underline{aab}\)\(SAM\) 。就如下圖所示:

    img

    此時 \(u=2,z=3\)\(suffix-path(u\to S)\) 桔色狀態 組成的路徑 \(2-1-S\) 。並且這 \(3\) 個狀態都沒有對應字符 \(b\) 的轉移。所以我們只要添加紅色轉移 \(trans[2][b]=trans[1][b]=trans[S][b]=z\) 即可。當然也不要忘了 \(link[3]=S\)

  2. 還有一種難一點的情況為:\(suffix-path(u\to S)\) 上有一個節點 \(v\) ,使得 \(trans[v][S[i+1]]\not =NULL\)

    我們以下圖為例,假設我們已經構造 \(\underline{aabb}\)\(SAM\) 如圖,現在我們要增加一個字符 \(a\) 構造 \(\underline{aabba}\)\(SAM\)

    這時 \(u=4,z=6,suffix-path(u\to S)\) 桔色狀態 組成的路徑 \(4-5-S\) 。對於狀態 \(4\) 和狀態 \(5\) ,由於它們都沒有對應字符 \(a\) 的轉移,所以我們只要添加紅色轉移\(trans[4][a]=trans[5][a]=z=6\) 即可。但此時 \(trans[S][a]=1\) 已經存在了。

    不失一般性,我們可以認為在 \(suffix-path(u\to S)\) 遇到的第一個狀態 \(v\) 滿足 \(trans[v][S[i+1]]=x\) 。這時我們需要討論 \(x\) 包含的子串的情況。

    1. 如果 \(x\) 中包含的最長子串就是 \(v\) 中包含的最長子串接上字符\(S[i+1]\) ,等價於 \(maxlen(v)+1=maxlen(x)\) 。這種情況比較簡單,我們只要增加 \(link[z]=x\) 即可。

      比如在上面的例子里,\(v=S, x=1\)\(longest(v)\) 是空串,\(longest(1)=\underline{a}\) 就是 \(longest(v)+\underline{a}\)

      我們將狀態 \(6\) \(link\) 到狀態 \(1\) 就行了。因為此時 \(z\) 只缺少了這個 \(suffix-path(x \to S)\) 的狀態。

    2. 如果 **\(x\) 中包含的最長子串 不是 \(v\) 中包含的最長子串接上字符 \(S[i+1]\) ,等價於 \(maxlen(v)+1 < maxlen(x)\) ** ,這種情況最為復雜。

      不失一般性,我們用下圖表示這種情況,這時增加的字符是 \(c\) ,狀態是 \(z\)

      \(suffix-path(u\to S)\) 這條路徑上,從 \(u\) 開始有一部分連續的狀態滿足 \(trans[u..][c]=NULL\) ,對於這部分狀態我們只需增加 \(trans[u..][c]=z\) 。緊接着有一部分連續的狀態 \(v..w\) 滿足\(trans[v..w][c]=x\) ,並且 \(longest(v)+c\) 不等於 \(longest(x)\)

      這時我們需要從 \(x\) 拆分出新的狀態 \(y\) ,並且把原來 \(x\) 中長度小於等於 \(longest(v)+c\) 的子串分給 \(y\) ,其余子串留給 \(x\) 。同時令 \(trans[v..w][c]=y\)\(link[y]=link[x], ~link[x]=link[z]=y\)

      也就是 \(y\) 先繼承 \(x\)\(link\) ,並且 \(x,z\) 前面斷開的 \(substrings\) 就存在於 \(y\) 中了。

      好像比較復雜。我們來舉個例子。假設我們已經構造 \(\underline{aab}\)\(SAM\) 如圖,現在我們要增加一個字符\(b\) 構造 \(\underline{aabb}\)\(SAM\)

      img

      當我們處理在 \(suffix-path(u\to S)\) 上的狀態 \(S\) 時,遇到 \(trans[S][b]=3\) 。並且 \(longest(3)=\underline{aab}\)\(longest(S)+ \underline{b}= \underline{b}\) ,兩者不相等。其實不相等意味增加了新字符后 \(endpos(\underline{aab})\) 已經不等於 \(endpos(\underline{b})\) ,勢必這兩個子串不能同屬一個狀態 \(3\) 。這時我們就要從 \(3\) 中新拆分出一個狀態 \(5\) ,把 \(\underline{b}\) 及其后綴分給 \(5\) ,其余的子串留給 \(3\) 。同時令 \(trans[S][c]=5, link[5]=link[3]=S, link[3]=link[6]=5\)

      到此整個構造算法全部結束。

時間復雜度證明

不難發現這個的時間復雜度只與 狀態以及轉移的數量 有關。

我們考慮分析這兩個部分。這部分證明來自大佬 DZYO的博客

狀態的數量

由長度為 \(n\) 的字符串 \(s\) 建立的后綴自動機的狀態個數不超過 \(2n-1\)(對於 \(n\ge 3\) )。

證明:上面描述的算法證明了這一性質(最初自動機包含一個初始節點,第一步和第二步都會添加一個狀態,余下的 \(n-2\) 步每步由於需要分割,至多增加兩個狀態)。

所以就是 \(1+2+(n-2) \times 2 = 2n-1\) 了。

有趣的是,這一上限無法被改善,即存在達到這一上限的例子: \(\underline{abbb...}\) 。每次添加都需要分割。

轉移的數量

由長度為 \(n\) 的字符串 \(s\) 建立的后綴自動機中,轉移的數量不超過 \(3n-4\) (對於 \(n\ge 3\) )。

證明: 我們計算 連續的 轉移個數。考慮以 \(S\) 為初始節點的自動機的最長路徑樹。這棵樹將包含所有連續的轉移,樹的邊數比結點個數小 \(1\) ,這意味着連續的轉移個數不超過 \(2n-2\)

我們再來計算 不連續 的轉移個數。考慮每個不連續轉移;假設該轉移——轉移 \((p,q)\) ,標記為 \(c\) 。對自動機運行一個合適的字符串 \(u+c+w\) ,其中字符串 \(u\) 表示從初始狀態到 \(p\) 經過的最長路徑,\(w\) 表示從 \(q\) 到任意終止節點經過的最長路徑。

一方面,對所有不連續轉移,字符串 \(u+c+w\) 都是不同的(因為字符串 \(u\)\(w\) 僅包含連續轉移)。另一方面,每個這樣的字符串 \(u+c+w\) ,由於在終止狀態結束,它必然是完整串 \(s\) 的一個后綴。由於 \(s\) 的非空后綴僅有 \(n\) 個,並且完整串 \(s\) 不能是某個 \(u+c+w\) (因為完整串 \(s\) 匹配一條包含 \(n\) 個連續轉移的路徑),那么不連續轉移的總共個數不超過 \(n-1\)

有趣的是,仍然存在達到轉移個數上限的數據:\(\underline{abbb...bbbc}\)

這個證明其實我是沒太懂的。。記下結論吧。

代碼實現

我們令 \(id\) 為這次插入字符的編號,\(trans,maxlen,link\) 意義同上。\(Last\) 為上次最后插入的狀態的編號,\(Size\) 為當前的狀態總數,\(clone\) 為復制節點即上文的 \(y\) 。 具體來說如下代碼所示:

\(minlen\) 可以最后計算 ,因為我們是從 \(link\) 處斷開的,所以顯然有 \(minlen[i] = maxlen[link[i]]+1\)

struct Suffix_Automata {
	int maxlen[Maxn], trans[Maxn][26], link[Maxn], Size, Last;
	Suffix_Automata() { Size = Last = 1; }

	inline void Extend(int id) {
		int cur = (++ Size), p;
		maxlen[cur] = maxlen[Last] + 1;
		for (p = Last; p && !trans[p][id]; p = link[p]) trans[p][id] = cur;
		if (!p) link[cur] = 1;
		else {
			int q = trans[p][id];
			if (maxlen[q] == maxlen[p] + 1) link[cur] = q;
			else {
				int clone = (++ Size);
				maxlen[clone] = maxlen[p] + 1;
				Cpy(trans[clone], trans[q]);
				link[clone] = link[q];
				for (; p && trans[p][id] == q; p = link[p]) trans[p][id] = clone;
				link[cur] = link[q] = clone;
			}
		} 
		Last = cur;
	}
} T;

實際應用

統計本質不同的子串個數

HihoCoder 1445

其實這個可以用后綴數組做,具體來說,答案就是 \(\displaystyle \sum_{i=1} ^ n (n - sa[i] + 1) - height[i]\) 。我們考慮 \(sa\) 相鄰兩個后綴。首先多出了 \((n - sa[i] + 1)\) 個后綴,然后 \(LCP\) 長度為 \(height[i]\) 的子串重復計算過,減去就行了。

\(SAM\) 的話,其實就是統計所有狀態包含的子串總數,也就是 \(\displaystyle \sum_{i=1}^{Size} maxlen[i] - minlen[i]+1\) ,建完直接算就行了。注意前面講過的 \(minlen[i] = maxlen[link[i]]+1\) 。

計算任意子串出現次數

HihoCoder 1449

我們首先考慮一個子串出現的次數,不難發現就是它 \(endpos\) 集合的大小。所以我們當前需要計算的就是 \(\forall st, |endpos(st)|\) 的大小。如果我們每次構建時候維護這個的話,每次需要跳完整個 \(suffix-path(u\to S)\) ,對於這上面的所有節點加一(這是因為后綴路徑上的所有點都具有新加狀態的 \(endpos\) ,總時間復雜度能達到 \(O(|S|^2)\) ,但是對於隨機數據表現優秀)。我們先構造完 \(SAM\) 最后再算答案。我們單獨把它所有的后綴路徑拿出來看一下是什么情況。

我們以最開始的 \(SAM\) 為例,它后綴鏈接構成的圖如下:

不難他的后綴鏈接組成了一個 \(DAG\) 圖。並且它反向建那么就是一顆以 \(S\) 為根的樹(因為除了 \(S\) 每個點有且僅有一個出邊,並且不可能存在環,因為 \(maxlen[link[i]] < maxlen[i]\) ),我們稱之為后綴樹。

前面講過了我們每次是暴力把路徑上的所有點權值 \(+1\) 。我們就能轉化成 \(DAG\) 每一個點對於它能走的路徑上的所有點 \(+1\) ,這個直接考慮在 \(DAG\) 圖上進行拓撲 \(dp\) 就行了。

但注意 \(clone\) 的節點是不能對它到 \(S\) 的路徑上有單獨貢獻的,因為它的貢獻會在它的本體上計算一遍。

然后這題是要計算對於所有 \(i\) 長度為 \(i\) 子串個數,那么不難發現一個狀態 \(st\) 包含的是長度為 \([minlen(st), maxlen(st)]\) 的子串,那么它對於 \(minlen(st) \le k \le maxlen(st)\) 的長度的答案具有貢獻。這個我們打個區間取 \(max\) 就行了。這樣要寫一個線段樹比較麻煩,但我們發現對於長度更大 \(ans\) 我當前肯定也是可以使用的,一開始把標記打在 \(maxlen\) 上,直接最后倒着取 \(max\) 就行了。

至此這道題就做完啦。復雜度為 \(O(n)\) 比排序 \(len\) 的復雜度 \(O(n \log n)\) 要優秀。

vector<int> G[Maxn]; int indeg[Maxn];
void Build() {
	For (i, 1, Size)
		G[i].push_back(link[i]), ++ indeg[link[i]];
}

void Topo() {
	queue<int> Q; Build();
	For (i, 0, Size) if (!indeg[i]) Q.push(i);
	while (!Q.empty()) {
		int u = Q.front(); Q.pop();
		for (int v : G[u]) {
			val[v] += val[u];
			if (!(-- indeg[v])) Q.push(v);
		}
	}
	For (i, 1, Size) chkmax(Tag[maxlen[i]], val[i]);
    Fordown (i, n, 1) chkmax(Tag[i], Tag[i + 1]);
}

統計所有本質不同子串的權值和

HihoCoder 1457

此題就是要統計所有本質不同的子串權值和,對於每個子串權值定義就是它在十進制下的值。因為每個數都是從前往后構成的,並且 \(SAM\) 上每個狀態的 \(substrings\) 是從起點開始的路徑構成的單詞集合。

正向的轉移函數 \(trans[u][1..c]\) 是一個 \(DAG\) 圖。

因為狀態有限,所以不可能存在環使得狀態無限。

不難考慮用正向拓撲 \(dp\) 求解這個值。令 \(dp_{i}\) 為狀態 \(i\) 所有 \(substrings(i)\) 的權值和,那么顯然有 \(\displaystyle dp_{v}=\sum_{trans[u][id]} dp[u] \times 10 + id\) . 但這樣顯然會錯... 因為一個狀態可能有很多子串加上了 \(id\) 這個值,但我們只加上了一個,所以我們記下每個狀態具有的子串個數 \(tot_i\) 。那么有 \(\displaystyle tot_v = \sum_{trans[u][id]}tot_u\) 。又有 \(\displaystyle dp_v = \sum_{trans[u][id]}dp[u] \times 10 + id \times tot_u\)

但是這個是有許多串一起詢問答案,可以用 廣義后綴自動機 來解決。

但其實這題我們可以用當初做后綴數組題的一些思想,我們對於許多子串在中間加入一些字符例如 \(\underline{:}\) (字符集大小 \(+1\) )將其隔開,然后每次統計的時候不能統計中間具有 \(\underline{:}\) 的字符,對於這些枚舉的邊為這些轉移的,我們就不轉移 \(dp,tot\) 就可以了。

int val[Maxn], indeg[Maxn], tot[Maxn], n;
void Get_Val() {
	queue<int> Q; Q.push(1); tot[1] = 1;
	For (i, 1, Size) For (j, 0, spc) ++ indeg[trans[i][j]];

	while (!Q.empty()) {
		int u = Q.front(); Q.pop();
		For (i, 0, spc) {
			int v = trans[u][i]; if (!v) continue ;
			if (i != 10) {
				(tot[v] += tot[u]) %= Mod;
				(val[v] += val[u] * 10ll % Mod + 1ll * i * tot[u] % Mod) %= Mod;
			}
			if (!(-- indeg[v])) Q.push(v);
		}
	}
}

求循環串在原串中出現次數

HihoCoder 1465

這個比較巧妙qwq ,首先先講如何求 **兩個串的最長公共子串 \((LCS)\) ** 注意此處不是最長公共前綴 \((LCP)\)

假設我們當前有兩個串 \(S\)\(T\) ,求它們的 \(LCS\) 我們考慮先把 \(S\)\(SAM\) 建出來。

然后對於 \(T\) 的每一個位置 \(T[i]\) 計算出以 \(T[i]\) 為結尾的子串與 \(S\)\(LCS\)

比如對於 \(S=\underline{aabbabd}, T=\underline{abbabb}\) 。得到的情況如下:

S: aabbabd
T: abbabb               
1: a
2: ab
3: abb
4: abba
5: abbab
6:    abb

這個如何求呢?

首先,對於每一個 \(T[i]\) 我們記兩個數據 \(u, l\) 分別代表當前 \(LCS\) 所在的 \(SAM\) 狀態以及它在原串的長度。

我們假設我們已經得到了 \(T[i-1]\)\(u,l\) ,現在我們要求 \(T[i]\)\(u',l'\) 。討論幾種情況就行了。

  1. \(trans[u][T[i]] =v,v \not = NULL\) 。這種就很顯然了,直接向后匹配一位。\(u' = v, l' = l +1\)

  2. \(trans[u][T[i]]=NULL\) 。這種我們可以用類似 \(KMP\)\(AC\) 自動機的方法跳 \(fail\) ,此處我們的 \(Suffix~Link\) 相當於 \(fail\) ,因為每次失配后我們只需要找它的一個前綴使得剛好匹配。我們有之前的結論 \(longest(st)\) 的前綴必在 \(Suffix-Path(u \to S)\) 的狀態上。

    所以我們每次向前跳 \(Suffix-Path(u \to S)\) 上的點 \(q\) ,直到找到第一個 \(trans[q][T[i]] = v, v \not = NULL\) ,此時 \(u'=v, l' = maxlen[q]+1\) 。因為此時 \(maxlen[q]\) 是剛好能滿足的前綴的長度。

    如果整條鏈不存在那就令 \(u'=s, l'=0\)

這樣就是 \(O(|S| + |T|)\) 的復雜度了,輕松愉悅。

有了這個后就很好做了。循環的串,不難想到拆壞為鏈,也就是說我們將要查詢的串倍長去里面匹配。

假設對於 \(\underline{aab}\) 我們將其變成 \(\underline{aabaab}\) 然后對於其中每一個位置,如果與原串得到的 \(LCS\) 的長度不小於這個串的長度 \((l \ge |T|)\) ,那么以這個點結尾的循環串就會在原串中出現。還是剛剛那個例子,假設原串是 \(\underline{abaaa}\) ,對於查詢串位置為 \(5\) 的地方與原串 \(LCS\) 長度為 \(4\) 那么對於 \(\underline{baa}\) 必在原串中出現過,它出現的次數也就是求 \(LCS\) 時候狀態 \(u\) 出現的次數。

狀態出現次數可以用前面講過的計數方法來求,求 \(LCS\) 的狀態也可以按前面來求。但這樣有兩個問題……

  1. 有些串會計算多次。例如 \(\underline{a}\) ,將其倍長后為 \(\underline{aa}\) 。我們會計算兩次 \(a\) ,此時只要對 \(SAM\) 中被統計的狀態打個標記就行了(也就是記一下現在被哪個版本統計過)。
  2. 有些串不該被算卻被計算了。同上 \(\underline{a}\) 倍長后為 \(\underline{aa}\) ,我們會把 \(\underline{aa}\) 也計算進來,這樣顯然是不行的。所以我們每次得到了一個 \(LCS\) 后如果長度 \(l \ge |T|\) 那么我們不斷嘗試跳 \(link\) 直到第一個 \(u\) 剛好滿足 \(l \ge |T|\) 就可以了。
int version[Maxn];
ll Calc(char *str, int num) {
	ll res = 0; int u = 1, lcs = 0, len = strlen(str + 1), bas = len >> 1;
	For (i, 1, len) {
		int id = str[i] - 'a';
		if (trans[u][id]) u = trans[u][id], ++ lcs;
		else {
			for (; u && !trans[u][id]; u = link[u]) ;
			if (!u) { u = 1; lcs = 0; }
			else lcs = maxlen[u] + 1, u = trans[u][id];
		}
		if (lcs >= bas) {
			while (maxlen[link[u]] >= bas) lcs = maxlen[u = link[u]];
			if (version[u] != num) version[u] = num, res += times[u];
		}
	}
	return res;
}

SAM 上博弈與 trans 上查詢

HihoCoder 1466

題意

首先認真讀題。

給你兩個串 \(A,B\) 。然后每天你要和別人博弈,博弈規則如下:

  1. 一開始你挑選兩個串使得它們分別為 \(A,B\) 的一個子串,分別寫在兩張紙上。
  2. 你先手。每次輪流在兩張紙上其中一張的串尾添加一個字符,使得其仍為這張紙所指的原串 \((A~ or~ B)\) 的一個子串。
  3. 操作到不能添加就算輸。

然后每天你可以制定這兩個子串,但任意兩天不能重復,字典序從小到大制定(先比 \(A\) 再比 \(B\) )。且你需要一直贏 \(k\) 天,問第 \(k\) 天你給出的字符串是什么。如果無解輸出 \(NO\)

\((|A|,|B| \le 10^5, k \le 10^{18})\)

題解

我們每次末尾添加一個字符並仍是原串的一個子串的操作就相當於在 \(SAM\) 按照 \(trans\) 移動到后一個節點。然后沒有轉移了就為敗態。由於 \(trans\) 是個 \(DAG\) 圖,我們這個相當於在 \(DAG\) 上進行移動,我們可以直接用組合游戲 \((nim)\) 的結論,也就是 \(SG\) 函數。

對於 \(DAG\) 上任意一個點的 \(SG\) 值為 \(mex_{v\in G[u]} \{SG[v]\}\)\(mex \{S\}\) 定義為 \(S\) 集合中第一個未出現的自然數。然后必敗態的 \(SG\) 值為 \(0\) 。如果初始狀態的 \(SG\) 值不為 \(0\) 先手必勝,否則必敗。

然后這是兩個獨立的游戲,把它們合並的話就是它們所有的 \(SG\) 異或和不為 \(0\) 先手必勝,否則必敗。

但此處是要求第 \(k\) 個可行的答案。那么我們只要首先在 \(A\)\(trans\) 上按 \(a \to z\) 的順序走,每次走的時候只要保證接下來走的對應方案數足夠就行了。

那么我們需要統計一個這個東西 \(tot[u][i][0/1]\) 表示 \(u\) 這個狀態包含的子串為前綴 \(SG\) 值 是/否 為 \(i\) 的子串個數。

例如對於 \(\underline{ab}\) 來說,狀態 \(1\) 為起點,它包含的子串為 \(\underline{}\) (空串)。所以它為前綴所包含的子串集合為 \(\{\underline{}, \underline{a}, \underline{b}, \underline{ab}\}\)\(SG\) 值分別為 \(\{2,1,0,0\}\) 所以它的 \(tot[1][2][1]=1,tot[1][2][0]=3\)

這個 \(tot\)\(SG\) 可以直接先求出 \(trans\) 的拓撲序,然后倒推就行了,這個比較容易推。然后我們有了這個就很好做了。

不難發現 \(SG\) 值最多只有 \(26\) 因為每個點最多只會有 \(26\) 個出邊,所以這些最多只能從 \([0,25]\) 取值,也就是說這個點 \(SG\) 值最大為 \(26\)

我們首先確定 \(A\) 的串應該是什么,我們從高到低依次枚舉每一位,判斷是否在需要走入其中。具體來說我們假設當前到了 \(SAM\) 的第 \(u\) 個點需要取字典序第 \(k\) 小的字符串,在當前這個點的結束條件是 \(B.tot[A.SG[u]][S][0] \le k\) 也就是意味着對於這個點能取勝的總方案數是 \(B\)\(SG\) 不和 \(A.SG[u]\) 相等的子串數。然后如果在當前節點結束不了,那么我們先減去這一部分的貢獻。然后枚舉接下來那一位,選擇這個節點的貢獻就是 \(\displaystyle \sum_{i=0}^{c+1} A.tot[v][i][1] \times B.tot[1][i][0]\) 也是就走完這一步后手必敗的方案數之和,然后判一下大小就行了。

接下來只需要確定 \(B\) 串了,我們只需要用之前最后確定 \(A\) 串的 \(SG\) 函數去算就行了,具體見代碼。(似乎寫的有點長。。。湊合看吧。。。)

時間復雜度 \(O((|A|+|B|)c)\)\(c\) 為字符集大小。

#include <bits/stdc++.h>
#define For(i, l, r) for(register int i = (l), i##end = (int)(r); i <= i##end; ++i)
#define Fordown(i, r, l) for(register int i = (r), i##end = (int)(l); i >= i##end; --i)
#define Set(a, v) memset(a, v, sizeof(a))
#define Cpy(a, b) memcpy(a, b, sizeof(a))
#define debug(x) cout << #x << ": " << x << endl
using namespace std;

inline bool chkmin(int &a, int b) {return b < a ? a = b, 1 : 0;}
inline bool chkmax(int &a, int b) {return b > a ? a = b, 1 : 0;}

typedef long long ll;

inline ll read() {
    ll x = 0, fh = 1; char ch = getchar();
    for (; !isdigit(ch); ch = getchar()) if (ch == '-') fh = -1;
    for (; isdigit(ch); ch = getchar()) x = (x << 1) + (x << 3) + (ch ^ 48);
    return x * fh;
}

void File() {
#ifdef zjp_shadow
	freopen ("1466.in", "r", stdin);
	freopen ("1466.out", "w", stdout);
#endif
}

const int N = 1e5 + 1e3, Maxn = N << 1, spc = 25;

struct Suffix_Automata {

	int trans[Maxn][spc + 1], maxlen[Maxn], minlen[Maxn], link[Maxn], Size, Last;

	Suffix_Automata() { Last = Size = 1; }

	inline void Extend(int id) {
		int cur = (++ Size), p;
		maxlen[cur] = maxlen[Last] + 1;
		for (p = Last; p && !trans[p][id]; p = link[p]) trans[p][id] = cur;
		if (!p) link[cur] = 1;
		else {
			int q = trans[p][id];
			if (maxlen[q] == maxlen[p] + 1) link[cur] = q;
			else {
				int clone = (++ Size);
				maxlen[clone] = maxlen[p] + 1;
				Cpy(trans[clone], trans[q]);
				link[clone] = link[q];
				for (; p && trans[p][id] == q; p = link[p]) trans[p][id] = clone;
				link[cur] = link[q] = clone;
			}
		}
		Last = cur;
	}

	int SG[Maxn], lis[Maxn], indeg[Maxn], cnt; ll tot[Maxn][spc + 2][2];
	void Get_SG_Tot() {
		queue<int> Q;
		cnt = 0; Q.push(1);
		For (i, 1, Size) For (j, 0, spc) if (trans[i][j]) ++ indeg[trans[i][j]];
		while (!Q.empty()) {
			int u; u = lis[++ cnt] = Q.front(); Q.pop();
			For (i, 0, spc) {
				int v = trans[u][i];
				if (!v) continue ;
				if (!(--indeg[v])) Q.push(v);
			}
		}
		bitset<spc + 2> App;
		Fordown (i, cnt, 1) {
			int u = lis[i]; App.reset();
			For (j, 0, spc) {
				register int v = trans[u][j];
				if (v) {
					App[SG[v]] = true;
					For (k, 0, spc + 1)
						tot[u][k][1] += tot[v][k][1];
				}
			}
			for (int j = 0; ; ++ j)
				if (!App[j]) { SG[u] = j; break; }

			ll sum = 0;
			++ tot[u][SG[u]][1];
			For (i, 0, spc + 1) sum += tot[u][i][1];
			For (i, 0, spc + 1) tot[u][i][0] = sum - tot[u][i][1];
		}
	}

	void Out() {
		For (i, 1, Size) {
			debug(i);
			For (j, 0, 5) printf ("%lld%c", tot[i][j][1], j == jend ? '\n' : ' ');
			debug(SG[i]);
		}
	}

} A, B;

char ansa[N], ansb[N];

ll k;
int Get_A(int u, int cur) {
	ll cnt = B.tot[1][A.SG[u]][0];
	if (k <= cnt) return u; k -= cnt;
	For (i, 0, spc) {
		int v = A.trans[u][i]; if (!v) continue ;
		ll now = 0;
		For (i, 0, spc + 1)
			now += 1ll * A.tot[v][i][1] * B.tot[1][i][0];
		if (now < k) k -= now;
		else { ansa[cur] = i + 'a'; return Get_A(v, cur + 1); }
	}
	return 0;
}

void Get_B(int u, int cur, int val) {
	k -= (val != B.SG[u]);
	if (!k) return ;
	For (i, 0, spc) {
		int v = B.trans[u][i]; if (!v) continue ;
		ll now = B.tot[v][val][0];
		if (now < k) k -= now;
		else { ansb[cur] = i + 'a'; Get_B(v, cur + 1, val); return ; }
	}
}

char str[N];

int main () {
	File();

	k = read();
	scanf ("%s", str + 1);
	For (i, 1, strlen(str + 1)) A.Extend(str[i] - 'a');
	A.Get_SG_Tot();

	A.Out();

	scanf ("%s", str + 1); 

	For (i, 1, strlen(str + 1)) B.Extend(str[i] - 'a');
	B.Get_SG_Tot();

	int pos = Get_A(1, 1); if (!pos) return puts("NO"), 0; Get_B(1, 1, A.SG[pos]);

	printf ("%s\n", ansa + 1);
	printf ("%s\n", ansb + 1);

    return 0;
}


免責聲明!

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



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