yang-xi-jie-mi-sam

iorit發表於2024-03-07

詳細揭秘 SAM

這個咕了好久


這個東西很玄乎,大家抽象理解就好。


首先,SAM 是啥:

  • 字尾自動機。

  • 每個節點代表一個狀態,裡面存著一些原串的子串(但是不會在程式碼中存)。

  • 每個節點有轉移邊指向其他節點,一個字元最多有一條它的轉移邊。

  • 整個 SAM 的節點和轉移邊構成 DAG(有向無環圖)。

  • 每個節點有它的 link 指標,指向另外一個節點。

  • 整個 SAM 的節點和 link 指標構成一棵內向樹。

下面是一些你需要知道的定義:

\(\texttt{endpos}\):設字串 \(t\)\(\texttt{endpos}\) 集合表示它在原串 \(s\) 中的出現位置構成的集合,出現位置以最後一個字元定位。

\(s=\texttt{aabaaba}\) 中,串 \(\texttt{ab}\)\(\texttt{endpos}\) 即為 \(\{3,6\}\)

接著,我們將 \(\texttt{endpos}\) 相同的兩個串劃分到同一個 等價類 中。

例如上面的例子,\(\texttt{aab}\)\(\texttt{ab}\) 就在一個等價類中,這個等價類即為 \(\{\texttt{aab},\texttt{ab}\}\)

好了。那麼現在我告訴你,SAM 每個節點就代表一個等價類。

為了方便說明,現在我們下一些定義:

  • 對於一個等價類(節點)\(u\),設 \(longest(u)\) 表示等價類中最長的字串,\(maxlen(u) = |longest(u)|\)。類似地,設 \(shortest(u)\) 表示等價類中最短的字串,\(minlen(u) = |shortest(u)|\)

現在有一些顯而易見的結論:

  • 同一個等價類中的字串,兩兩之間一定是字尾關係。

因為既然它們 \(\texttt{endpos}\) 相同,從 \(\texttt{endpos}\) 往前延伸,形成的串們一定有某一段字尾相同。

如圖,紅色是 \(\texttt{endpos}\),藍色和綠色是等價類中的兩個串。

進一步地,這些串一定都是等價類中最長串的字尾。

  • 兩個串的 \(\texttt{endpos}\) 要麼成包含關係,要麼不相交。

這個顯然。只要有相交說明這兩個串成字尾關係,那麼它們 \(\texttt{endpos}\) 必定是一個包含一個的。(跟上圖一樣)

  • 一個等價類 \(u\) 中的串長度連續,覆蓋了區間 \([minlen(u),maxlen(u)]\)

這個也顯然,\(longest(u)\) 的所有字尾長度在 \([minlen(u),maxlen(u)]\) 之間的,\(\texttt{endpos}\) 至少會包含 \(longest(u)\)\(\texttt{endpos}\),又不會比 \(shortest(u)\)\(\texttt{endpos}\) 多。所以它們全都在一個等價類中。

這意味著一個等價類含有的串數即為 \(maxlen-minlen+1\)


下面引入 link。

我們定義一個節點(等價類)\(u\) 的 link 指標指向另一個節點(等價類)\(v\),滿足

  • \(longest(v)\)\(longest(u)\) 的真字尾。

  • 在所有滿足上述條件的 \(v\) 中,\(maxlen(v)\) 最大。

換句話說,在 \(longest(u)\) 的字尾中,選一個最長的字尾使得它的 \(\texttt{endpos}\)\(u\)\(\texttt{endpos}\) 不同(前者比後者更大),將 \(u\) 的 link 指向這個 \(\texttt{endpos}\) 代表的等價類。

如圖,假設整個字串是 \(longest(u)\),那麼 link 鏈的關係應該如圖所示。

可以看出來一直跳 link 最後會去到空串,對應的也就是初始狀態。

以及把這些等價類的串長區間(也就是圖中黑、紅、藍、綠色的括號)並起來,最後會得到 \([0,maxlen(u)]\)

這個東西也可以這麼理解:把 \(longest(u)\) 每次刪去頭一個字元,隨著串變短,在 SAM 原串中出現位置自然會變多(即 \(\texttt{endpos}\) 的擴充)。

按照 \(\texttt{endpos}\) 變大的位置我們把這些字尾劃分成很多區間(對應不同的等價類),之間用 link 連線。


然後是節點的轉移邊。

這個東西其實很簡單:假設當前節點為 \(u\),它有一條 \(c\) 的轉移邊指向 \(v\)

那麼你可以理解為把 \(u\) 中的所有字串後面加上 \(c\) 得到的字串一定在 \(v\) 中。

因此想要找到一個串在 SAM 中屬於的節點,只需要逐個字元地走轉移邊,走到的節點就是了。


然後是 link 樹。

我們知道,整個 SAM 的節點和 link 指標構成一棵內向樹。這是因為每個點不斷跳 link 最後都會回到初始節點。

同時,這棵樹還是 \(\texttt{endpos}\) “要麼成包含關係,要麼不相交”關係構成的樹。

因為跳 link 的同時 \(\texttt{endpos}\) 也會慢慢增加元素,也就是說越靠近根節點 \(\texttt{endpos}\) 越大。

這棵樹有很廣泛的用途,一會再講。


不講如何構造。這個東西有點複雜,但是感性理解也能懂。建議背程式碼


SAM 有什麼用:

  • 檢查串 \(t\) 是否在串 \(s\) 中出現過。

根據上面所說的,對 \(s\) 建 SAM,把 \(t\) 放上面按照轉移邊走,能一直走下去就是出現過。

  • 求串 \(t\) 在串 \(s\) 中的出現次數。

出現次數就是 \(\texttt{endpos}\) 集合大小。

因此我們對 SAM 的節點預處理 \(siz\) 表示 \(\texttt{endpos}\) 集合大小,找到 \(t\) 對應的節點查詢 \(siz\) 即可。

如何預處理?先給出方法:先將 不由複製得到的 節點的 \(siz\) 設為 \(1\)

對 link 樹 dfs 一遍,對每個點 \(u\) 執行 \(siz_u\gets siz_u+siz_v\) 後得到的就是最終的 \(siz\)

為什麼這樣是對的?我們考慮每個不由複製得到的節點(下稱真節點)。假設它是在加入第 \(k\) 個字元時建立的,

在建立時它的 \(siz=1\),並會使原串 \(2\sim k\) 的字尾 \(\texttt{endpos}\) 多了一個 \(k\),即 \(siz\)\(1\)

因為遍歷字尾相當於跳 link,所以這相當於把這個真節點在 link 樹上到根的路徑全部 \(+1\)

這個和求子樹和的效果是一樣的,與我們用求子樹和的方法最後處理效果一致。

  • 求串 \(s\) 的本質不同子串數量。

SAM 已經幫你把相同的子串壓縮在一起了。

我們知道一個節點含有的串數即為 \(maxlen-minlen+1\),我們對所有節點的這個值求和再 \(-1\)(空串)即可。

  • 找到串 \(s\) 的一個子串 \(s_{l,r}\) 在 SAM 中對應的節點。

這個非常有用,在一些關於區間的題裡會常用到。(如 CF666E

子串 \(s_{l,r}\) 就是字首 \(s_{1,r}\) 的一段字尾。

我們考慮對於每個字首先預處理出它在 SAM 中屬於的節點。

然後從字首 \(s_{1,r}\) 屬於的節點開始,不斷跳 link(相當於遍歷該字首的字尾),直到當前 \(maxlen<=r-l+1\)

由於跳 link 過程中 \(maxlen\) 是不斷減小的,我們可以用倍增代替這個暴力跳的過程。

這樣單次詢問複雜度就做到了 \(\mathcal O(log n)\)