字尾自動機(SAM)
OI-wiki:字尾自動機 (SAM)
拓展:廣義字尾自動機(廣義SAM)
基礎、概念部分
Right集合(endpos集合):
Right = 能到達該節點的子串出現的右端點集合 = 想匹配的話,當前可能在的位置集合
-
兩子串的Right集合,要麼包含,要麼不交(樹形),suffix link 指向的是樹中的父親。
-
Right集合相同,則後續轉移相同,因此在SAM中壓成一個點(完全不懂)
-
len:同一Right集合中最長子串
簡單的說, \(Right\) 集合可以看作從根節點走到該節點的所有路徑的子串集合在原串中的結尾位置集合。
len:節點所代表的最長子串
每個節點能表示的子串長度為 \([~len[fa[cur]] + 1 , len[cur]~]\)。結合下圖理解:
其中 1 是 6 的 \(fa\)。由於 6 往後的 DFA 再也無法回到 6 以及 6 以前,因此上一個點(5)的 \(len\) 加 1 就是 6 的 \(len\),即 \(maxlen\)。由於想要到達 6 的最小路徑是從 \(fa\)(2) 直接沿著 \(a\) 邊走下來,字串長就是 \(fa\) 再加上 1.
值得注意的是, \(ins\) 時一旦建立了一個節點,它的 \(len\) 就基本不會再變了,但是 \(endpos\) 集合會經常變動。因為以後再加字元時,還可能會出現當前節點所代表的字串,還會將 \(suffix ~ ~ link\)
注意:確切地說,每個節點的 \(endpos\) 集合,實際上時該節點所代表的子串的 \(endpos\) 集合。 由於某種原因,這些子串的 \(endpos\) 集合相同。
用一張圖來說就是:
一些常識
-
相鄰節點,len長的,endpos短。因此拓撲排序通常以len排序
-
\(minlen(cur) = len(fa[cur]) + 1\)
-
一個點在Parent Tree到根節點的路徑所經過的點的代表子串(指能到達那些點的串)所組成的集合為一個字尾。
-
如果想要多次查詢一個串的子串\([L, R]\)的接受點,可以首先找到[1,R]的接受點,然後往上倍增找到最靠上的 \(len <= R - L + 1\)
主要還是背板子,結合O(n^2)的字尾樹理解。
兩種操作:加一個葉子節點,在邊上加一個點。
重點記憶:
fa[nq] = fa[q]; fa[np] = fa[q] = nq;
while (son[p][c] == q) son[p][c] = nq, p = fa[p];
輔助記憶拆點部分程式碼:
//拆點:為了防止一些串搞怪,
//把代表子串長度為len[p]+1 -- len[q] 的q點拆成
//len[p] + 1 和 len[p]+2 -- len[q]的點
1. 新建節點
2. len[nq] = len[p] + 1 不難理解
3. 因為nq和q都是由一個點拆出,所以nq的son,fa與q相同
4. 由於nq的len較小,所以endpos集合位置較多。
實際上,nq的endpos包含q的endpos。
根據SAM的fa邊都是由endpos少的點指向endpos多的點
(len長的點指向len短的點)
所以需要fa[q] = nq;
又因為nq的endpos包含q的endpos,也包含np的endpos,
所以根據樹形,fa[np] = nq。
5. 沿著p的fa,將(son)指向q的點都指向nq。
其餘基本可以理解記憶。
模板題:P2408 不同子串個數(感覺這道題要比洛谷模板題更板一些)
Code:
inline void ins(int c) {
int p = lst, np = ++tot;
lst = np; len[np] = len[p] + 1;
while (p && !son[p][c]) son[p][c] = np, p = fa[p];
if (!p) return fa[np] = 1, void();
int q = son[p][c];
if (len[p] + 1 == len[q]) return fa[np] = q, void();
int nq = ++tot;
len[nq] = len[p] + 1;
memcpy(son[nq], son[q], sizeof(son[q]));
fa[nq] = fa[q]; fa[np] = fa[q] = nq;
while (son[p][c] == q) son[p][c] = nq, p = fa[p];
}
注意!!!
-
一定要加lst = np。注意,是np,不是p!!!
-
陣列要開二倍!!
-
while(p && !son[p][c])
不要和while(son[p][c] == q)
弄混了,可不是while(p && son[p][c] != np)
!! -
一定要
p = fa[p]
!!!
SAM的應用
識別長串的子串
直接在 DFA 上跑。每個節點都是接收節點。如果跑著跑著突然沒有 \(c\) 出邊了,那就說明識別失敗了;否則順利跑完小串,就說明是長串子串。
最小迴圈移位
暴力方法:倍長字串,在字串的前n個位置開始往後跑 \(n\) 個字元,取字典序最小。
優化方法:每次跑的 \(n\) 個字元都是倍長後字串的子串,因此可以直接建一個倍長後字串的字尾自動機,在 DFA 上挑字典序最小的路徑跑 \(n\) 步。
某節點所代表(本質不同)子串的個數 / 本質不同子串總數
例題:
P2408 不同子串個數(即上面提到的模板題)
由於“代表”這個詞的意思不清,dp 方法中“代表”指的是能從該節點向下轉移的總方案數(通常包含不動的情況),而SAM 的方法中“代表”則指的是有多少個串能夠到達該節點,和 \(Right\)集合(\(endpos\) 集合)有關。因此這裡主要講如何求本質不同的子串總數。
方法一:DP
某節點的後續轉移數 \(=\) 在 DFA 上從該節點往下走的路徑總數 \(+\) 1(不動也算一種路徑,就相當於直接被識別了)
由於 DFA 為有向無環圖,因此可以dp搞:
\[f[u] = 1 + \sum_{son[u][c] = v}{f[v]} \]
可以按 \(len\) 拓撲排序,也可以直接記憶化搜尋。拓撲排序或許更快。
最終答案為根節點的 \(f\) 再減一(除去空串)
方法二:利用SAM的性質
某節點所代表子串的個數 \(=\) 其所代表子串長度範圍 \(= len[x] - (len[fa[x]] + 1) + 1~ ~ ~ ~ = ~ ~ ~ ~len[x] - len[fa[x]]\)
依據:\(minlen[cur] = len[fa[cur]]~ + ~1\)
由於 SAM 的優秀性質:不同節點所代表的子串一定本質不同,因此 本質不同子串總數 \(=~ \sum\) 某節點所代表(本質不同)子串的個數
對於動態求子串總數,要是硬維護 \(\sum len[cur] - len[fa[cur]]\),也不是不可做。但有更簡便的方法:每加一個字元,新增的子串肯定是該串的一個字尾,並且肯定 \(endpos\) 只有 \(len\)。因為如果 \(endpos\) 還有一個前面的,那說明這個子串已經出現過了。因此直接:\(ans += len[lst] - len[fa[lst]]\) 即可。
\(Code:\)
不同子串個數 :略。
生成魔咒:my record
所有不同子串出現總長度
方法一:DP
某節點所代表子串個數 \(f[cur]\) (從 \(cur\) 往後走(含呆在 \(cur\) 不動),能得到的子串數)可以通過上面的方法得出。
設 \(ans[cur]\) 為 在 DFA 上,從 \(cur\) 往後走,能走到的所有子串的長度之和。那麼有:
\[ans[u] = f[u] + \sum_{son[u][c] = v}{ans[v]} \]
依據:每條路徑子串的長度都 \(+1\)(含靜止不動)
方法二:利用 SAM 的性質
容斥:用 \(cur\) 代表的子串總長度 \(- fa[cur]\)所代表的子串長度 即可。
\[sumlen[cur] = \frac{len[cur] * (len[cur] - 1)}{2} \]
\[ans = \sum_{cur}sumlen[cur]-sumlen[fa[cur]] \]
注:這裡的 “\(cur\) 代表的子串總長度” 實際上並不是真正\(cur\) 代表的子串總長度,而是從空串到它代表的最長子串。事實上應該是\(minlen[cur] -> len[cur]\)。反正是等差數列,隨便算算即可。
注意:
以上兩種方法,第一種是根據以後的路徑來考慮的,第二種是根據之前怎麼來的來考慮的,實際上並不太一樣。
第k小子串
或許SA更方便些?
例題:SP7258 SUBLEX - Lexicographical Substring Search
求本質不同的子串中第 \(k\) 小子串。多次詢問。
搞出每個點往後有幾種轉移方式(字串),然後充分運用貪心思想,學著Splay搞第k小即可。
注意站著不動也算一種轉移方式,並且是字典序最小的轉移方式。
注意根節點的特判。
\(Code\):my record
查詢小串在大串中的第一次出現位置
問題可以轉化成 \(endpos\) 集合裡的最小值。
當 \(cur\) 為 \(np\) 時,\(firstpos[cur] = len[cur]\);當 \(cur\) 為 \(nq\) 時, \(firstpos[cur] = firstpos[q]\)。
類似證明在“某子串出現次數”中。
注意,這裡的 \(firstpos[]\) 為小串的結尾位置;一般要求小串的開頭位置,稍作轉化即可。
查詢小串在大串中的所有出現位置
絕妙的解釋:oi-wiki
就是說,每個節點的 \(endpos\) 集合,(假設)一定是由許許多多的子樹節點的 \(firstpos\) 組合在一起的,因此最簡單的方法就是在 \(parent\) 樹上跑子樹的 \(firstpos\),然後去重。
但是要去重。因為可能會有兩個可能有相同 \(endpos\) 值的不同狀態(摘自 oi-wiki,但我覺得這裡的 \(endpos\) 應該改為 \(firstpos\))。如果一個狀態是由另一個複製而來的,則這種情況會發生。所以我們可以直接忽視掉 \(nq\)。
這可能要我們真的建出 \(parent\) 樹,但這並不難。
例題:CF1037H Security
這道題關鍵在於查詢長串的 [l, r] 內是否出現某子串。
直接用權值線段樹維護每個節點的 \(endpos\) 集合。如果存在該子串,那麼一定有 $pos $ ∈ \([l, r]\),且 \(pos - len + 1\) ∈ \([l, r]\)(\(pos\) 為 某一 \(endpos\))
需要適用線段樹合併。這裡有一個小技巧,就是我們只需要知道某節點管轄範圍內有沒有數,因此我們不需要維護\(siz\)之類的東西,直接利用動態開點權值線段樹的特點,如果有這個點,那麼有它一定是有原因的,一定曾在這個點新增過數,因此直接返回true
即可。
int merge(int L, int R, int cur1, int cur2) {
if (!cur1 || !cur2 || L == R) return cur1 | cur2;
int mid = (L + R) >> 1, nwcur = ++ttot;
ls[nwcur] = merge(L, mid, ls[cur1], ls[cur2]);
rs[nwcur] = merge(mid + 1, R, rs[cur1], rs[cur2]);
return nwcur;
}
bool query(int L, int R, int l, int r, int cur) {
if (!cur) return false;
if (l <= L && R <= r) return true;
if (R < l || r < L) return false;
int mid = (L + R) >> 1;
return query(L, mid, l, r, ls[cur]) || query(mid + 1, R, l, r, rs[cur]);
}
inline void Merge() {
for (register int i = tot; i; --i) {
int p = id[i];
if (fa[p]) rt[fa[p]] = merge(1, s_n, rt[fa[p]], rt[p]);
}
}
inline bool che(int p, int ll, int rr, int nwlen) {
if (rr - (ll + nwlen - 1) + 1 <= 0) return false;
return query(1, s_n, ll + nwlen - 1, rr, rt[p]);
}
當然也可以用主席樹做,發現每次我們只要知道一個節點的子樹(\(Parent\) 樹上)的某個區域內有沒有 \(endpos\);如果搞到 \(dfn\) 序上,那麼我們實際上就是在查詢某一區間上的集合中 在某一區域內 有沒有 \(endpos\)。然後可以字首和+差分轉化成 權值線段樹可做的問題。
這個問題還是很常見的(或許?),NOI也考過:P4770 [NOI2018]你的名字
某子串出現次數
某子串出現次數 = 代表該子串的節點的 \(endpos\) 集合的大小
- 求法:
在 \(ins\) 時,將 \(np\) 的 \(siz\) 標為 \(1\),將 \(nq\) 的 \(siz\) 標為 \(0\).然後按 \(len\) 拓撲排序。在 \(parent\) 樹(由 \(fa(suffix links)\) 組成)上跑,\(siz[to] += siz[cur]\);
具體講解見:史上全網最清晰字尾自動機學習(三)字尾自動機裡的樹結構
以及:oi-wiki
感覺裡面講得不錯。
\(Code:\)my record
簡單(假)證明:
(感覺說不清楚的樣子)新建節點時,它的 \(endpos\) 集合大小肯定是 \(1\)。但是隨著 \(ins\) 函式的進行,它的 \(endpos\) 集合在改變。
比如說有個 \(len = 4\) 的點向它連 \(suffix ~ link\),它的 \(endpos\) 成功變成 {\(2, 4\)}。最後我們處理時會讓 \(len = 4\) 的節點給它貢獻一個 \(1\),再加上原本的那個 \(1\),就能算出 {\(2,4\)}的 \(endpos\) 集合大小了。
再有一個 \(len = 6\) 的節點指向它也差不多。
為什麼不能讓 \(siz[nq] = 1\) 呢?因為我們並不能保證 \(nq\) 的最小 \(endpos\) 是自己給自己貢獻的,就像上面的 \(2\) 一樣,它的每個 \(endpos\) 應該都是 \(parent\) 樹上的兒子貢獻的。畢竟 \(nq\) 只是硬生生地根據 \(len\) 把一個節點分成了兩個,然後讓原節點指向它,並且再加一個節點。
實在不行感性理解吧。
第k小子串(加強版)
求所有子串中第 \(k\) 小子串。(本質相同,位置不同的子串算多次)
繼續搞出每個點往後有幾種轉移方式(字串),只不過本質相同,位置不同的子串算多次。
\[f[u] = siz[u] + \sum_{son[u][c] = v}{f[v]} \]
找第 \(k\) 小子串時也要判斷一下:
if (np != 1) k -= siz[np];
(np = 1時,空串不算串,不佔用排名的名額)
其餘和之前基本相同。
\(Code\):my record
最短的 沒有出現的 字串
把該字串放到 DFA 上跑,無法被 DFA 接受。
如果在長串中沒有出現某一字元,即 DFA 上的初始節點沒有 \(c\) 的出邊,那麼答案肯定就是 \(c\),否則要找初始節點所有出邊中最優的出邊,即需要向後加最少字元才能使字串不被 DFA 接受 的出邊。
用 DP 來解決。在 DFA 上 dp:
當存在無某一出邊時:\(d[u] = 1\)
否則:\(d[u] = 1 + min(v)\) (\(son[u][c]=v\))
依據 \(d\) 陣列,我們可以推出字串到底是什麼。
求兩串的最長公共子 串
例題:SP1811 LCS - Longest Common Substring
給出 \(S\) 和 \(T\),求 \(S\) 和 \(T\) 的最長公共子串。
考慮從 \(T\) 的每個字首中找出 其各個字尾中屬於 \(S\) 的子串的最長字尾,答案就是所有最長字尾中最長的那個。(有點繞口)
怎麼找字尾中屬於 \(S\) 的子串的最長字尾呢?既然屬於 \(S\) 的子串,那麼那個字尾一定能被 \(S\) 的 DFA 識別。我們要在此基礎上,刪去儘可能短的字首,留下儘可能長的字尾。
充分運用貪心,只要能走 \(c\) 邊,就走,並且 \(mxlen++\),否則:
先要保證能被識別,於是不斷跳 \(fa\),直到可以走為止;
再要保證儘可能長,因此修改 \(len\) : \(mxlen = len[cur]\)
(因為如果我們不幸跳了 \(fa\),我們將有多個子串可以選擇。這些子串在原 \(S\) 串上是連續的(“連續”啥意思見開頭圖,即末尾相同),並且長度是一段連續的區間。出於貪心的想法,我們自然要選擇最長的那個子串)
記得要往下走!因此實際上是 \(mxlen = len[cur] + 1\),但在程式碼中可以合併,一會兒看程式碼就知道了。
答案為所有 mxlen 中的最大的那個。
關鍵程式碼:
int ans;
inline void sol() {
int p = 1, mxlen = 0;
for (register int i = 1; i <= n; ++i) {
int c = s[i] - 'a';
while (p && !son[p][c]) p = fa[p], mxlen = len[p];
if (!p) {
mxlen = 0, p = 1;
continue;
}
p = son[p][c]; mxlen++;
ans = max(ans, mxlen);
}
}
兩串的公共子串個數
例題 : P4770 [NOI2018]你的名字
題意:給出一個長串 \(S\) ,然後給出一堆短串 \(T\),給 \(T\) 同時給出 \(L, R\),詢問有多少串 不屬於\(S\) 的子串,但屬於 \(T\)的子串。
可以轉化成求兩串的公共子串個數。
轉化成對於 \(T\) 的每個字首,求有多少串是 \(S\) 的子串。然而還不對,最後還要去重。
去重可以在 \(T\) 的字尾自動機上進行。
具體來說,對於 \(T\) 的 \(SAM\) 上的每一個節點,記錄一下其管轄的子串(指的是從根能到達的子串)中有多少子串屬於 \(S\) 的子串。
發現對於每個節點 \(cur\),其管轄範圍的子串是連續的(前面已經提到過);如果其中某個長串屬於 \(S\),那麼其子串一定是 \(S\) 的子串。因此我們轉而求 每個節點中最長的 屬於 \(S\) 的子串的串長度。 我們記此為 \(mx[]\)。
求每個字首在 \(S\) 中的最長子串?已經就有點 求最長公共子串 的意思了。
還是那樣,每拓展一個字元,\(S\) 的 \(DFA\) 上最多往下跑一個節點;如果一個可以跑的節點都沒有,就嘗試往 \(fa\) 上跳(類似\(AC\)自動機),再進行嘗試。往 \(fa\) 上跳,就說明我們的子串變小了,那麼 \(T\) 上自動機的點可能(肯定)就不合法了(比如 \(abbaa\) 變成了 \(baa\))。怎麼辦呢?
幸運的是,我們發現,因為我們跳 \(fa\) 只會砍掉開頭的幾個字母,串的 \(endpos\) 還是那幾個,可能還會多一些。那麼我們就在 \(T\) 的自動機上跳 \(fa\)。最後找到合法的 \(S\) 位置和合法的 \(T\) 位置後,用 \(nwlen\)(當前串長度)來更新 \(mx[]\) 即可。
和下面(求多串的最長公共子串)同理,如果 \(T\) 上某一個節點能匹配 \(nwlen\),其祖先也可以匹配 \(nwlen\),但是不能超過其 \(len\)。
我們離線搞或許也可?題解裡面給出了一種線上的做法:就是說更新沒多久,某個節點的 \(mx[]\) 變成了 \(len[]\),那麼其所有祖先肯定也被更新成了 \(len[]\),所以我們暴力跳鏈,知道 \(mx[]=len[]\)停止。複雜度正確。
總結一下:
-
建立 \(S\) 和 \(T\) 的 \(SAM\)。
-
在 \(S\) 的自動機上跑 \(T\)(以 \(AC\) 自動機的跑法),找到當前字首的合法(公共)字尾(實際上是找到了 \(S\) 的合法位置)。
-
同時在 \(T\) 上跑,並且檢查 \(T\) 上的位置是否合法,並更新所有合法位置的 \(mx[]\)
-
所有的 \(max(mx[cur] - len[fa[cur]], 0)\) 之和即為答案。
另外說明一下,NOI2018這道題還結合了 查詢子串是否在 \([L, R]\) 區間內 的問題,此問題與CF1037H Security 問題類似,故不過多講解。
但是需要注意的是,在完成這道題的時候,不能像while (np_s && (!S.son[np_s][c] || !che(l, r, np_s, nwlen))) np_s = S.fa[np_s]
一樣跳,因為有了 \([L, R]\) 的限制,我們無法匹配可能是由於 \(nwlen\) 太長造成的,但直接跳 \(fa\) 是不合適的。比如 \(aabaa, abaa, baa\) 的節點,如果 \(aabaa\) 不合法,\(abaa\) 和 \(baa\) 是有可能合法的(可能正好卡在 \([L, R]\) 的邊緣),因此我們應該不斷嘗試縮小 \(nwlen\),小到小於該節點的最小串後再跳 \(fa\)。
普通的求 兩串公共子串個數 應該不用這樣做。
\(Code:\)my record
int np_s = 1, np_t = 1, nwlen = 0;
int ct = 0;
for (register int i = 1; i <= t_n; ++i) {
int c = s[i] - 'a';
while (np_s && !S.son[np_s][c]) np_s = S.fa[np_s]
nwlen++;
np_s = S.son[np_s][c]; np_t = T.son[np_t][c];
while (T.len[T.fa[np_t]] + 1 > nwlen) np_t = T.fa[np_t];
int p = np_t;
while (T.mx[p] != T.len[p] && p)
T.mx[p] = max(T.mx[p], min(nwlen, T.len[p])), p = T.fa[p];
}
for (register int i = 2; i <= T.tot; ++i) {
res -= max(T.mx[i] - T.len[T.fa[i]], 0ll);
}
求多串的最長公共子串
SP1812 LCS2 - Longest Common Substring II
SP10570 LONGCS - Longest Common Substring
對其中一個串做 SAM,然後還是上回的思路,只不過這我們要記錄每個節點的匹配的最大值,然後眾串取個最小,最後每個節點取個最大。
眾所周知,每個節點代表一些長度連續的區間,我們的任務是找出每個其他串能匹配的最長長度,這樣,能匹配長度為 4 的子串就一定能匹配長度為 3 的子串,但能匹配長度為 3 的子串不一定能匹配長度為 4 的子串.因此我們需要各串的答案中取個最小值,這個最小值所代表的串一定在所有串中出現。
最終把所有這樣能在所有串中出現的區域性最大值(對於每個節點而言)再取個 \(max\) 就是全域性最大值(對於所有串而言)。
然而會被 \(hack\) 掉。
abcba
ab
ba
答案顯然是1("a"或"b"),但看看我們做的好事:
我們只經過了 \(5,6,2,3\) 節點,並且每個都只經過了一次,答案當然為 0.
哪裡有問題?發現 6 節點答案一定可以作為 2 節點答案,因為 2 節點的串全是 6 節點的串的子串,6節點的 \(abba,bba,ba\) 中能搞到長度為 2 的最長公共子串,那麼 2 節點的 \(a\) 就也應該能搞到長度為 2 的子串(如果有的話);又因為 2 節點最長才為 1,所以需要和 1 取一個 \(min\)。
是不是有點像 AC 自動機的部分操作?
總結一下:
首先對一個串建立 SAM
然後把其他串拿過來再 SAM 的 DFA 上跑,並記錄每個節點能搞到的最長長度。
接著我們把每個節點的最長長度傳遞給 \(fa\)(當然是按照 \(len\) 遞減的順序),並且更新當前節點的公共長度(取 \(min\))。
最後我們找出所有節點公共長度的最大值,作為答案。
\(Code:\)
//for every other string
int p = 1, res = 0;
for (register int i = 1; i <= n; ++i) {
int c = s[i] - 'a';
while (p && !son[p][c]) p = fa[p], res = len[p];
if (!p) {
res = 0, p = 1;
continue;
}
res++; p = son[p][c];
nwmx[p] = max(nwmx[p], res);
}
for (register int i = tot; i; --i) {
int p = id[i];
nwmx[fa[p]] = max(nwmx[fa[p]], min(len[fa[p]], nwmx[p]));
mn[p] = min(mn[p], nwmx[p]);
nwmx[p] = 0;
}
//at last
for (register int i = 1; i <= tot; ++i) {//only one string
ans = max(ans, mn[i]);
}
求多串的公共子串數量
並沒有找到相關題目和題解,只有一個這個
我的大致思路是 結合前三種問題。第二種問題我們拿到 \(T\) 上搞,主要是因為 \(S\) 太長了,一個就 \(5e5\);並且詢問太多了,一共有 \(1e5\) 次詢問,並且每次詢問還有不同的 \(L, R\),不好離線。
如果不含 \([L, R]\) 的限制的話,第二種的本質其實和第一種問題相同,只不過是求每個節點(\(S\)上)的 \(mx[]\)。方法和第三種問題類似。
那麼求多穿的公共子串數量是否也可以搞出每個 \(S\) 節點上的 \(mx\),最後一彙總?
我想是這樣的(未經檢驗)
那麼程式碼應該和第三種類似,只不過最後不是取 \(max\),而是加和。
注意要減掉 \(len[fa[cur]]\) 以防計重。
好吧,只是我瞎想的
一些例題
對子序列這樣的東西也可以建一個自動機:\(son[np][c]\) 指向下一個 \(c\) 的位置。
二分答案,問題轉化為在一個大串裡面查詢一個小串是否出現。倍增快速查子串接受點+線段樹合併查endpos。
錯誤
1
len[nq] = len[q] + 1;
2
len[np] = len[p] = 1;