[字串相關]字尾自動機(SAM)- 一
我們約定,\(S\) 為字串,\(|S|\) 為該字串的長度,文中的所有字串的下標從 \(0\) 開始。
#1.0 何為字尾自動機
#1.1 簡單介紹
字尾自動機(\(\texttt{Suffix automaton, SAM}\)),是一個能解決許多字串相關問題的有力的資料結構。
舉個例子,以下的字串問題都可以線上性時間內通過 SAM 解決。
- 在另一個字串中搜索一個字串的所有出現位置。
- 計算給定的字串中有多少個不同的子串。
直觀上,字串的 SAM 可以理解為給定字串的 所有子串 的壓縮形式。值得注意的事實是,SAM 將所有的這些資訊以高度壓縮的形式儲存。對於一個長度為 \(n\) 的字串,它的空間複雜度僅為 \(O(n)\)
#1.2 字尾自動機的定義
字串 \(s\) 的 \(\tt{SAM}\) 是一個接受 \(s\) 的所有後綴的最小 \(\tt{DFA}\)[1](確定性有限自動機或確定性有限狀態自動機[2])。
換句話說:
- \(\tt{SAM}\) 是一張有向無環圖。結點被稱作 狀態,邊被稱作狀態間的 轉移。
- 圖存在一個源點 \(t_0\),稱作 初始狀態,其它各結點均可從 \(t_0\) 出發到達。
- 每個 轉移 都標有一些字母。從一個結點出發的所有轉移均 不同。
- 存在一個或多個 終止狀態。如果我們從初始狀態 \(t_0\) 出發,最終轉移到了一個終止狀態,則路徑上的所有轉移連線起來一定是字串 \(s\)
- 在所有滿足上述條件的自動機中,\(\tt{SAM}\) 的結點數是最少的。
在後綴自動機中,每一個狀態對應著一個字串的 等價類,而這個 “等價類” 具體是什麼,下面會介紹。
比如字串 \(“bccb”\) 的 \(\tt SAM\) 如下:
#1.3 性質
\(\tt{SAM}\) 包含關於字串 \(s\) 的所有子串的資訊,任意從初始狀態開始的路徑,如果我們將轉移路徑上的標號寫下來都會形成一個 \(s\) 的子串;同樣,每一個 \(s\) 的子串對應從 \(t_0\) 開始的某條路徑。
為了簡化表達,我們稱子串對應一條路徑,反過來,我們說任意一條路徑對應它的標號構成的字串。
到達某個狀態的路徑可能不止一條,因此我們說一個狀態對應一些字串的集合,這個集合的每一個元素對應這些路徑。
#2.0 SAM 的建立
在解釋 \(\tt{SAM}\) 的建立前,我們需要先定義幾個概念。
#2.1 endpos
定義字串 \(s\) 的任意一個非空子串 \(t\) 的 \(\texttt{endpos}(t)\) 為 \(t\) 在 \(s\) 中的所有結束位置組成的集合。
如,\(s=“bccb”\),那麼 \(\texttt{endpos}(“c”)=1,2\)。注意兩個子串 \(t_1,t_2\) 的 \(\tt endpos\) 可能相等,比如 \(\texttt{endpos}(“bcc”)=\texttt{endpos}(“cc”)=2\),我們將這樣的 \(\tt endpos\) 相等的子串劃分為一個等價類。
注意,我們定義 \(\tt SAM\) 的每一個狀態對應著一個 等價類,那麼 \(\tt SAM\) 中的節點總數為所有等價類的數量加一(\(t_0\) 節點)。
由 \(\tt endpos\) 可以得到的一些重要結論:
-
字串 \(s\) 的兩個非空子串 \(u,w\) ,(假設 \(|u|<|w|\))的 \(\tt endpos\) 相同,當且僅當字串 \(u\) 在 \(s\) 中的每次出現,都是以 \(w\) 的字尾形式存在的。
正確性顯然。
-
考慮兩個非空子串 \(u\) 和 \(w\)(假設 \(|u|<|w|\))。那麼要麼 \(\texttt{endpos}(u)\cap\texttt{endpos}(w)=\varnothing\) ,要麼 \(\texttt{endpos}(w)\subseteq\texttt{endpos}(u)\),取決於 \(u\) 是否為 \(w\) 的一個字尾:
\[\begin{cases}\texttt{endpos}(w)\subseteq\texttt{endpos}(u),&\text{if }u\text{ is s suffix of }w\\\texttt{endpos}(u)\cap\texttt{endpos}(w)=\varnothing,&\text{otherwise}\end{cases} \]證明:如果集合 \(\texttt{endpos}(w)\) 與 \(\texttt{endpos}(u)\) 有至少一個公共元素,那麼由於字串 \(u\) 與 \(w\) 在相同位置結束,故 \(u\) 是 \(w\) 的一個字尾。所以在每次 \(w\) 出現的位置,子串 \(u\) 也會出現。所以 \(\texttt{endpos}(w)\subseteq\texttt{endpos}(u)\)
-
考慮一個 \(\tt endpos\) 等價類,將類中的所有子串按長度非遞增的順序排序。每個子串都不會比它前一個子串長,與此同時每個子串也是它前一個子串的字尾。換句話說,設其中最長者長 \(x\),最短者長 \(y\),對於同一等價類的任一兩子串,較短者為較長者的字尾,且該等價類中的子串長度恰好覆蓋整個區間 \([x,y]\)。
證明:結合性質 \(1.\) 考慮,記 \(w\) 為等價類中最長的字串、\(u\) 為等價類中最短的字串。字串 \(u\) 是字串 \(w\) 的真字尾。現在考慮長度在區間 \([|w|,|u|]\) 中的 \(w\) 的任意字尾 \(v\)。容易看出,這個字尾也在同一等價類中,因為這個字尾只能在字串 \(s\) 中以 \(w\) 的一個字尾的形式存在(也因為較短的字尾 \(u\) 在 \(s\) 中只以 \(v\) 的字尾的形式存在)。因此結合性質 \(2.\),有 \(\texttt{endpos}(w)\subseteq\texttt{endpos}(v)\subseteq\texttt{endpos}(u)\),而 \(\texttt{endpos}(w)=\texttt{endpos}(u)\),於是有 \(\texttt{endpos}(w)=\texttt{endpos}(v)=\texttt{endpos}(u)\)
#2.2 字尾連結 link
考慮 \(\tt SAM\) 中某一個不是 \(t_0\) 的狀態 \(v\),我們已經知道,狀態 \(v\) 對應於具有相同 \(\tt endpos\) 的等價類,設 \(w\) 是最長的一個,那麼所有等價類中的字串都是 \(w\) 的字尾。
我們還知道字串 \(w\) 的前幾個字尾全部包含於這個等價類,且所有其它字尾都在其他的等價類中,我們記 \(t\) 為最長的等價類不和 \(w\) 的等價類相同的字尾。然後將 \(v\) 的字尾連結連到 \(t\) 的等價類上。
為了方便,我們規定:\(\text{endpos}(t_0)=\{−1,0,...,|s|−1\}\)。
將所有節點由字尾連結連線起來,會得到一棵樹,這棵樹的相關性質我們會在後面 #3.0 字尾連結樹 一節進行討論。
#2.3 開胃小菜
在開始我們的大餐前,讓我們先來點開胃小菜吧!在這裡會總結一點上面的結論,同時定義一些符號。
- \(s\) 的子串可以被劃分成多個等價類。
- \(\tt SAM\) 由若干狀態構成,其中每一個狀態對應一個等價類。對於每一個狀態 \(v\),一個或多個子串與之匹配,我們記 \(\texttt{longest}(v)\) 為裡面最長的一個,記 \(\texttt{len}(v)\) 為它的長度,記 \(\texttt{shortest}(v)\) 為最短的子串,它的長度為 \(\texttt{minlen}(v)\) ,那麼所有字串的長度恰好覆蓋 \([\texttt{minlen}(v),\texttt{len}(v)]\) 中的每一個整數。
- 字尾連結可以定義為連線到對應字串 \(\texttt{longest}(v)\) 的長度為 \(\texttt{minlen}(v)−1\) 的字尾的一條邊。從根節點 \(t_0\) 出發的字尾連結可以形成一棵樹。這棵樹也表示 \(\texttt{endpos}\) 集合間的包含關係。
- 對於任意非 \(t_0\) 節點 \(v\),有\[\texttt{minlen}(v)=\texttt{len}(\texttt{link}(v))+1 \]
- 如果我們從任意節點 \(v_0\) 開始一直走到 \(t_0\) ,那麼沿途所有字串的長度形成了連續的區間 \([0,\texttt{len}(v_0)]\)。
#2.4 演算法流程
在下面的過程中,有不少地方從 “字尾” 角度思考會比較簡單,同樣也必須謹記 “等價類” 這一概念。二者結合才能更好地理解演算法。
這裡採用的構造 \(\tt SAM\) 的演算法是 線上 演算法。也就是說,我們在構建 \(\tt SAM\) 時,是一個一個字元地插入,逐漸將整個 \(\tt SAM\) 構建起來。
為了保證線性的空間複雜度,我們將只儲存 \(\tt len\) 和 \(\tt link\) 的值和每個狀態的轉移列表,我們不會標記終止狀態(但是我們稍後會展示在構造 \(\tt SAM\) 後如何分配這些標記)。令 \(\tt last\) 表示在添加當前字元之前,整個字串對應的狀態。
這裡我會一步一步的講述步驟,同時講解正確性。假設現在我們要加入字元 \(c.\)
步驟一:新建 cur
節點,同時令 len[cur] = len[last] + 1
。
這一步並不難理解,當前的 cur
節點對應的狀態是 \(c\) 加入後整個字串對應的狀態,顯然 len[cur]
應當是此時字串的長度,而 len[last]
是 \(c\) 加入前整個字串的長度。
步驟二:我們現在需要求出 link[cur]
。我們從狀態 now = last
開始,看當前的 now
狀態是否有字元 \(c\) 的轉移,
- 如果有,那麼進入下一步;
- 如果沒有,那麼就將
now
狀態的 \(c\) 字元轉移指向cur
,然後向前跳now = link[now]
,直到找都一個有的,進入下一步;或now == -1
,此時令link[now] = 0
,進入步驟五;
這裡我們分兩種情況討論正確性:
- 存在字元 \(c\) 的轉移;
...這種情況在這一步並沒有太多涉及,留到下一步再說...
不過跳出迴圈的正確性是很顯然的,因為每一個 link
必然對應著一個當前狀態對應的等價類中最長的子串的一個 真字尾,所以向前跳 link
時的長度只會嚴格遞減,再向前跳就滿足不了 link
要求的長度最大了。
- 不存在 \(c\) 的轉移;
我們先來看第一個操作:將 now
狀態的 \(c\) 字元轉移指向 cur
,再向前跳 link
;
我們知道,我們從 last
開始向前跳 link
,得到的一定是與當前狀態不在同一等價類、在加入 \(c\) 字元前的字串真字尾(在非結尾位置也有出現),這樣的真字尾加上 \(c\) 字元後形成的串必然未曾在之前出現過,那麼這是這個串的第一次出現,在整個字串的末尾,與 cur
屬於同一等價類。
舉個例子,假如當前字串為 \(“bcc”\),我們現在要插入 \(“b”\),那麼先跳到的狀態對應的最長的字串為 \(“c”\),\(“c”\) 是 \(“bcc”\) 的一個真字尾,但不止在末尾處出現,顯然他是應當可以從 \(“c”\) 轉移到 \(“cb”\) 的,這個結論並不失一般性。
然後第二個操作是:當 now == -1
時,令 link[cur] = 0
。這一步正確性顯然。
步驟三:檢查當前 p = now
狀態通過 \(c\) 字元轉移得到的狀態 q
,如果滿足 len[q] == len[p] + 1
,那麼直接令 link[cur] = q
,進入步驟五;否則進入下一步。
步驟三和步驟四是筆者理解時的難點,過了這個坎就一路平坦了!
首先我們先要理解這樣一個問題:為什麼要要求 \(\texttt{len}(q)=\texttt{len}(p)+1\)?
\(\texttt{len}(q)=\texttt{len}(p)+1\) 意味著 \(q\) 所代表的狀態中,最長的那個是從 \(p\) 轉移過來的,而 \(p\) 是什麼?是有 \(c\) 字元轉移的、與在 \(c\) 插入前整個字串不在同一等價類的、最長的、\(c\) 插入前整個字串的真字尾,於是不難推出,\(q\) 是與在 \(c\) 插入後整個字串不在同一等價類的、最長的、\(c\) 插入後整個字串的真字尾,這與 \(\texttt{link}(cur)\) 的定義相吻合,於是應當有 link[cur]=q
;
那麼除了 \(\texttt{len}(q)=\texttt{len}(p)+1\),還會有什麼情況呢?根據 \(\tt len\) 的定義,剩下的只有一種可能了:\(\texttt{len}(q)>\texttt{len}(p)+1\),而這種情況正是我們下一步要討論的情況。
步驟四:將 q
節點分裂,具體步驟如下:
- 建立
q
的副本clone
,除 \(\tt len\) 以外的所有資訊(字尾連結link
,轉移ch
)全部複製過來; - 令
len[clone] = len[p] + 1
, - 令
link[cur] = clone, link[q] = clone
; - 順著
now
及link
向前跳,不斷將字元 \(c\) 的轉移目標為q
的狀態的轉移目標設定為clone
;
這裡的所有步驟整體分為兩個主題:拆分 \(q\) 狀態 和 修改轉移。
來看看我們為什麼要拆分 \(q\) 狀態:因為 \(\texttt{len}(q)>\texttt{len}(p)+1\),所以意味著當前 \(q\) 中最長的串轉移來源 \(v\ne p\),於是有 \(\texttt{len}(v)>\texttt{len}(p)\),顯然 \(v\) 所代表的狀態中的最長串不可能是在加入 \(c\) 字元前的整個字串的的真字尾(如果是,一定會比 \(p\) 先被跳到,否則意味著這個串與整個字串屬於同一個等價類,完全不可能),那麼同樣可以得到狀態 \(q\) 中長度大於 \(\texttt{len}(p)+1\) 的一定不會在插入字元 \(c\) 後的字串末尾出現,而長度小於等於 \(\texttt{len}(p)+1\) 的卻可以,所以說 \(q\) 中的串實際已經不屬於同一個等價類了,我們需要將他們分開防止搓起來,顯然原本的轉移並沒有受到影響,可以複製過來,而原本 \(q\) 的 \(\tt link\) 現在應當變成 \(clone\) 的,仔細看一看剛才分裂的過程和原因不難發現, \(clone\) 成了 \(q\) 的 \(\tt link\) 指向的節點。
再來看修改轉移的部分:
我們這裡是把所有 \(p\) 的真字尾的字元 \(c\) 指向 \(q\) 的轉移指向 \(clone\),這裡結合上面分裂的步驟思考,不難發現所有修改的過程都是正確的,那麼我們來看為什麼遇到第一個轉移目標不是 \(q\) 的就可以停止。
按照我們上面的過程構建自動機,不難發現從 last
開始,沿著 \(\tt link\) 向前跳的過程中,\(c\) 字元轉移的賦值是不可能跳開一段再賦值為同一個值的,所以當一個位置出現目標不為 \(q\) 的轉移,再向前的也就沒必要再看了。
不從 \(\tt SAM\) 的構造過程上來看,直接按照其性質也可以證明:我們知道 v = link[now]
所指向的狀態中的串必然都是 p = now
的狀態中的最長串的真字尾,且不屬於同一等價類,所以應當有 \(\texttt{endpos}(p)\subsetneqq\texttt{endpos}(v)\),同時因為 u = link[v]
所指向的狀態中的串必然都是 v
的狀態中的最長串的真字尾,且不屬於同一等價類,所以也應當有 \(\texttt{endpos}(v)\subsetneqq\texttt{endpos}(u)\),所以如果此時 \(v\) 的字元 \(c\) 的轉移目標與 \(q\) 不是同一個等價類,之前的也一定不會是。這一部分如果考慮字串上的 \(\tt endpos\) 的意義會更好理解。
步驟五:令 last = cur
,演算法結束。不解釋。
#2.5 程式碼實現
inline void insert(int c) {
int cur = ++ tot;
p[cur].len = p[last].len + 1;
int now = last;
while ((~now) && !p[now].ch[c])
p[now].ch[c] = cur, now = p[now].link;
if (!(~now)) p[cur].link = 0;
else {
int q = p[now].ch[c];
if (p[q].len == p[now].len + 1)
p[cur].link = q;
else {
int clone = ++ tot; p[clone] = p[q];
p[clone].len = p[now].len + 1;
p[cur].link = p[q].link = clone;
while ((~now) && p[now].ch[c] == q)
p[now].ch[c] = clone, now = p[now].link;
}
}
last = cur;
}
#3.0 字尾連結樹
這個名字只是筆者瞎起的,因為實在沒有在網上找到較為規範的叫法,下面我們約定 \(\texttt{SLT(Suffix link tree)}\) 指的是字尾連結樹。規定 \((u,v)\) 表示樹上一條從父親 \(u\) 指向兒子 \(v\) 的邊。
#3.1 一些性質
將所有節點由字尾連結連線起來,會得到一棵樹,這棵樹上的葉節點所代表的狀態(等價類)中最長的串一定是原本字串的一個只出現一次的字首。比如 \(S=“bccb”\) 的字尾連結樹便如下圖:
(節點上的字串為對應等價類中最長的串)
這一點經過簡單思考也是不難發現的,因為一個狀態如果不是任何一個狀態的字尾連結所指向的狀態,那麼意味著這個狀態中的最長串必然不是其他任何一個原字串子串的字尾,這樣的串必然是原串的一個字首,且這個字首必然只在原串中出現一次,否則便可以作為其他子串的真字尾出現。
如果放寬一下限制,則有原字串的一個字首本身不會作為其他任意一個子串的真字尾。
因此我們也可以得到一個性質:
在 \(\tt SLT\) 上,如果一個節點所對應的等價類不包括原串的一個字首,那麼其子節點的 \(\tt endpos\) 的並等於該等價類的 \(\tt endpos\),如果包含字首的話,則有
\[|\texttt{endpos}(u)|=\sum\limits_{(u,v)\in E}|\texttt{endpos}(v)|+1. \]上式的加一併不難理解:不會存在將原字串的一個字首作為真字尾的字串。
#3.1 運用
來看一道例題吧。
給定一個只包含小寫字母的字串 \(S\),
請你求出 \(S\) 的所有出現次數不為 \(1\) 的子串的出現次數乘上該子串長度的最大值。
考慮運用上面的性質,我們只需要對於任意一個字首的出現次數設為 \(1\),建出 \(\tt SLT\),在上面做樹形 \(\tt DP\) 即可。
const int N = 2000010;
const int INF = 0x3fffffff;
template <typename T>
inline T Max(const T a, const T b) {
return a > b ? a : b;
}
struct Node {
int link, len;
int ch[30];
};
struct Edge {
int u, v;
int nxt;
};
Edge e[N];
int head[N], cnt = 1, f[N];
struct SAM {
Node p[N]; int tot, last;
inline SAM() {
p[0].len = 0, p[0].link = -1;
tot = 0, last = 0;
}
inline void insert(int c) {
int cur = ++ tot; ++ f[cur]; //注意這裡 f[cur] ++
p[cur].len = p[last].len + 1;
int now = last;
while ((~now) && !p[now].ch[c])
p[now].ch[c] = cur, now = p[now].link;
if (!(~now)) p[cur].link = 0;
else {
int q = p[now].ch[c];
if (p[q].len == p[now].len + 1)
p[cur].link = q;
else {
int clone = ++ tot; p[clone] = p[q];
p[clone].len = p[now].len + 1;
p[cur].link = p[q].link = clone;
while ((~now) && p[now].ch[c] == q)
p[now].ch[c] = clone, now = p[now].link;
}
}
last = cur;
}
};
SAM sam;
char s[N];
int n, ans;
inline void add(const int u, const int v) {
e[cnt].u = u, e[cnt].v = v;
e[cnt].nxt = head[u], head[u] = cnt ++;
}
void dp(int x) {
for (int i = head[x]; i; i = e[i].nxt) {
dp(e[i].v); f[x] += f[e[i].v];
}
if (f[x] > 1) ans = Max(f[x] * sam.p[x].len, ans);
}
int main() {
scanf("%s", s); n = strlen(s);
for (int i = 0; i < n; ++ i)
sam.insert(s[i] - 'a');
for (int i = 1; i <= sam.tot; ++ i)
add(sam.p[i].link, i);
dp(0);
printf("%d", ans);
return 0;
}
參考資料
[2] 自動機 - OI Wiki