后缀自动机SAM
后缀自动机
推荐学习博客:
后缀自动机(SAM)学习笔记(极其推荐,有图过程更清晰)
oi-wiki 后缀自动机 (SAM)(十分严谨的语言和证明)
后缀自动机学习笔记(应用篇)(配套习题,各种应用)
本文参考了上述文章。
- 确定有限状态自动机(DFA):由字符集,状态集合,初始状态,接受状态集合,转移函数。
接受状态集合属于状态集合,也就是说状态中有一些是接受状态。
总的来说,在自动机上,从初始状态开始,输入一个字符集里的字符,就能通过转移函数到达另一个状态,其中到达过所有状态都属于状态集合。
如果最终到达一个状态,这个状态是属于接受状态集合,那么输入的所有字符构成的字符串就被自动机接受了。
- 后缀自动机:接受状态集合是一个串所有后缀的 DFA, 并且 sam 的节点数最小。
其中,它的状态集合包含了这个字符串的所有子串,每个状态是一个\(\text{endpos}\)等价类,记录一个或多个子串。
定理(太菜不会证)和概念:
约定:字符下标从1开始。
-
初始节点为空集,下标视为 0,且 \(\text{len}\) 为 0。
-
\(\text{endpos}\) 代表结束位置的集合。对于一个字符串 \(s\), 它的子串 \(t\), \(\text{endpos}(t)\) 就是 \(t\) 在所有 \(s\) 出现过的地方的结尾的位置的集合。
例如:\(s = aabaab,t = aab,\text{endpos}(t) = \{3,6\}\)
-
字符串 \(u, v(|u| < |v|)\) 的 \(\text{endpos}\) 相同,那么就有 \(u\) 是 \(v\) 的后缀。
-
对于字符串 \(u, v(|u| < |v|)\) 属于文本串 \(s\),就有 \(\text{endpos}(v) \subseteq\text{endpos}(u)\) 或 \(\text{endpos}(v) \cap \text{endpos}(u) = \varnothing\)
这说明两个串 \(\text{endpos}\) 的关系是 包含或者不交。
大概是因为出现相同的位置时,必有一个串是另一个串的子串(同时也是另一个串后缀),既然是后缀,\(\text{endpos}\)就是包含关系, 小的串 \(\text{endpos}\)更大。
-
\(\text{endpos}\) 等价类 : 所有 \(\text{endpos}\)相同的子串的集合。
在这个类中,串的长度是连续的,可以用\([\text{minlen},\text{maxlen}]\)表示。
-
后缀连接(\(\text{link}\)):对于任意状态\(u\)(\(\text{endpos}\)的等价类), 有\(v = \text{link}(u)\), 满足\(\text{endpos}(u) \subsetneq \text{endpos}(v)\), 那么就有 \(\text{maxlen}(v) + 1 = \text{minlen}(u)\)。
即 \(\text{link}\) 连接了不同 \(\text{endpos}\)等价类 的关系。
特别的,将初始状态的 \(\text{link}\) 设为 \(-1\),仅仅是方便实现。
-
将所有 \(u\) 和 \(\text{link}(u)\) 连接,就会构成一个树形结构,就称其为\(\text{parent}\) 树。
-
从一个状态 \(u\) 通过后缀连接跳到初始状态,那么这个状态所表示的字符串的所有后缀都会被遍历,也就是长度在\([0, \text{mexlen}(u)]\)的后缀都会被遍历到。
-
sam最多 \(2n - 1\) 个点, 最多 \(3n - 4\) 条边。
开始构造sam:
通过增量法构造。
假设已经造好了关于字符串\(S\)的前 \(i\) 个字符的sam,现在加入第 \(i + 1\) 个字符。
令:
\(last\) 为 \(S_{1 ...i}\) 所在的状态。
\(\text{len}(u)\) 为状态 \(u\) 的 \(\text{endpos}\) 等价类中最长子串的长度(\(\text{maxlen}\))
\(\text{link}(u)\) 为 \(u\) 的后缀连接,满足 \(\text{link}(u)\) 的 \(\text{len}\) 为 \(u\) 的 \(\text{minlen} - 1\)。
-
新建点 \(cur\) 表示 \(S_{1...i+1}\) 在这个状态的等价类里,显然\(\text{len}(cur) \gets \text{len}(last)+ 1\)
-
由于状态集合是所有的子串,需要考虑加入新增那些子串,不难发现,这些串都是由 \(S_{j...i}\) + \(c_{i + 1}\),\((j\leq i)\) 构成的 ,所以需要遍历\(S_{1...i}\) 的所有后缀。
点 \(p\) 从 \(last\) 开始,每次跳\(\text{link}(u)\),如果转移函数\(\text{next}_p(c_{i+1})\) 的值是空,就代表 \(s_{j...(i + 1)}\) 第一次出现,\(p\) 就能通过转移函数转移到 \(cur\) .
\(\begin{array}{ll} 1 & p \gets last \\ 2 & \textbf{while } p \neq -1 \textbf{ and } \text{next}_p(c_{i+1}) \text{ is } null \textbf{ do} \\ 3 & \qquad \text{next}_p(c_{i+1}) \gets cur \\ 4 & \qquad p \gets \text{link}(p) \\ 5 & \textbf{end} \end{array}\)
-
如果最后 \(p\) 的值为 \(-1\) 就代表 \(c_{i + 1}\) 当前第一次出现在字符串里,直接令 \(\text{link}(cur) = 0\).
\(\begin{array}{ll} 1 & \textbf{if } p = {-1} \textbf{ then} \\ 2 & \qquad \text{link}(cur) = start \\ 3 & \textbf{end} \\ \end{array}\)
-
当\(p\) 的值不为 \(-1\)。
先令 \(q\) 为 \(\text{next}_p(c_{i + 1})\).
由于 \(q\) 状态的等价类中的子串一定是 \(S_{1...i}\) 的后缀 + \(c_{i+ 1}\) 构成的串。
这个状态中的串原本已经出现在\(1...i\)位中,又会在第 \(i + 1\) 位出现,那么 \(\text{endpos}(cur) \subsetneq \text{endpos}(q)\).
又因为 \(p\) 是第一个 \(\text{next}(c_{i + 1})\) 不为空的,所以 \(q\) 中的子串是长度最大的能匹配\(i + 1\) 位置的子串。
具体地,只有在 \(q\) 中长度为 \(\text{len}(p) + 1\) 的串的后缀,才会新增第\(i + 1\)位。
-
当满足 \(\text{len}(q) = \text{len}(p) + 1\) 时,刚好 \(q\) 中所有的串都能新增 \(i + 1\) 这个位置, 就将$ \text{link}(cur) \gets q$。
-
当\(\text{len}(q) > \text{len}(p) + 1\), 这时 \(q\) 中长度在 \((\text{len}(p) + 1, \text{len}(q)]\) 就不会更新 \(i + 1\) 这个位置。
所以就将 \(q\) 拆成 两个一样的点,只不过一个是最大串长度是 \(\text{len}(p) + 1\),另一个保持不变,令新增的状态为 clone.
那么原先 \(q\) 中最长串的后缀的转移函数就会改变,通过跳\(\text{parent}\)树遍历所有转移到 \(q\) 的状态并修改, 当不是 \(q\) 是直接退出,这时再上面的状态都不会是 \(q\)。
最后将
\(\text{link}(cur) \gets clone\)
\(\text{link}(q) \gets clone\)
\(\begin{array}{ll} 1 & \textbf{if } p \neq -1 \textbf{ then}\\ 2 & \qquad q \gets \text{next}_p(c_{i + 1})\\ 3 & \qquad \textbf{if } \text{len}(q) = \text{len}(p) + 1 \textbf{ then} \\ 4 & \qquad\qquad \text{link}(cur) \gets q \\ 5 & \qquad \textbf{else} \\ 6 & \qquad\qquad \text{link}(clone) \gets \text{link}(q) \\ 7 & \qquad\qquad \text{len}(clone) \gets \text{len}(p) + 1 \\ 8 & \qquad\qquad \text{next}_{clone} \gets \text{next}_{q} \\ 9 & \qquad\qquad \textbf{while } p \neq -1 \textbf{ and } \text{next}_p(c_{i + 1}) = q \textbf{ do} \\ 10 &\qquad\qquad\qquad \text{next}_p(c_{i + 1}) = clone \\ 11 &\qquad\qquad\qquad p \gets \text{link}(p) \\ 12 &\qquad\qquad\textbf{end} \\ 13 &\qquad\text{link}(cur) \gets clone \\ 14 &\qquad\text{link}(q) \gets clone \\ 15 &\qquad\textbf{end} \\ 16 &\textbf{end} \end{array}\)
-
-
最后 \(last \gets cur\)
这样构造的时间复杂度是 \(O(n)\) 的。
具体代码
struct state {
int len, link;
map<char, int> next;
};
const int MAXLEN = 1000010;
state st[MAXLEN * 2];
int sz, last;
void sam_init() {
st[0].len = 0;
st[0].link = -1;
sz ++;
last = 0;
}
void sam_extend(char c) {
int cur = sz ++;
st[cur].len = st[last].len + 1;
int p = last;
while(p != -1 && !st[p].next.count(c)) {
st[p].next[c] = cur;
p = st[p].link;
}
if (p == -1) {
st[cur].link = 0;
} else {
int q = st[p].next[c];
if (st[p].len + 1 == st[q].len) {
st[cur].link = q;
} else {
int clone = sz ++;
st[clone].len = st[p].len + 1;
st[clone].next = st[q].next;
st[clone].link = st[q].link;
while(p != -1 && st[p].next[c] == q) {
st[p].next[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}
int main() {
...
sam_init();
for(int i = 1; i <= n; i++)
sam_extend(s[i]);
...
}
题目:
所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。
只用在接受状态的点的出现次数设为 \(1\),相当于\(\text{endpos}\)集的大小,构造出\(\text{parent}\)树,对于所有\(u\), \(cnt_{\text{link}(u)} += cnt_{u}\), \(\text{endpos}\)集的大小是\(\text{parent}\)树上子树的\(\text{endpos}\)集大小和。
这样就能求出所有状态\(\text{endpos}\)集大小,也就是这个等价类中所有子串的出现次数,称之为\(\text{cnt}(u)\)。
其中,状态\(u\)的最长的子串长度为 \(\text{len}(u)\)。
所以
void sam_extend(char c) {
int cur = sz ++;
st[cur].len = st[last].len + 1;
cnt[cur] = 1;
...
}
void dfs(int u) {
for(auto v : e[u]) {
dfs(v);
cnt[u] += cnt[v];
}
if (cnt[u] > 1) ans = max(ans, 1ll * cnt[u] * st[u].len);
}
int main() {
...
for(int i = 1; i < sz; i ++)
e[st[i].link].emplace_back(i);
dfs(0);
...
}
本质不同的子串的大小,
就是求所有状态的等价类大小。
由于新增的子串都在 \(cur\) 到 \(start\) 的路径上,而根据构造过程,只有 \(cur\) 这个状态包含新的不同的子串。
只需要每次插入字符时,\(ans += \text{len}(u) - \text{len}(\text{link(u)})\)
同上
本质不同的子串出现次数的平方和。
和模板题很像。
对于一个给定的长度为 \(n\) 的字符串,求出它的第 \(k\) 小子串是什么。
参考平衡树求第 \(k\) 小的思想,如果能算出某一个状态包含有多少个子串,就能通过决定 \(\text{next}\) 走到哪,实际上sam像trie,并且是DAG,可以这么做。
对于本质不同的子串个数,每个状态起始贡献是 \(1\), 也就是空集,在DAG上跑DP就能算出每个状态中,以这个状态的字符串为开头的字符串的个数。
对于可以有相同子串的个数,每个状态有\(\text{endpos}\)集大小\(\text{cnt}\)贡献,同样跑DP。
注意跑过的状态就不要再跑。
然后把比较一下总个数和\(k\) 大小,决定往哪走,每过一个状态,还要减去这个状态单独的贡献的字符串的个数。
void dfs(int u, int p) {
for(auto v : e[u]) {
dfs(v, p);
cnt[u] += cnt[v];
}
if (!p) cnt[u] = 1;
}// 这里cnt表示贡献
ll f[MAXN];
void dfs1(int u) {
if (f[u]) return;
f[u] = cnt[u];
for(int i = 0; i < 26; i ++) {
int v = st[u].next[i];
if (!v) continue;
dfs1(v);
f[u] += f[v];
}
}
void query(int u, int k) {
if (k <= cnt[u]) return;
k -= cnt[u];
for(int i = 0; i < 26; i ++) {
int v = st[u].next[i];
if (!v) continue;
if (k > f[v]) k -= f[v];
else {
cout << char(i + 'a') ;
query(v, k);
break;
}
}
}
int main() {
...
dfs(0);
cnt[0] = 0;//空集不算
dfs1(0);
...
}
SP7258 SUBLEX - Lexicographical Substring Search
同上,求本质不同的子串中第 \(k\) 小的子串。
SP1811 LCS - Longest Common Substring
求最长公共子串。
字符串\(S\), \(T\)。
对\(T\)建sam, 用\(S\)中的字符串依次在sam中转移。
第\(i\)个字符,当前在\(p\)点,匹配\(len\)位。
如果有\(\text{next}_p(S_i) \neq 0\), 就能匹配,长度++。
如果不能匹配,就通过\(\text{link}\)跳\(\text{parent}\)树,找到\(p\)状态的字符串的后缀中长度最大且能匹配\(S_i\)的状态,更新长度。
其实就像kmp跳fail链。
现在求出的是 \(S\)中以\(i\)结尾的字符串中最大能匹配\(T\)的长度,不妨叫它\(\text{slen}(i)\)
答案就是
时间复杂度好像也是\(O(n)\)的。
int main() {
...
int p = 0, len = 0;
for(int i = 1; i <= n; i ++) {
int c = s[i] - 'a';
if (!st[p].next[c]) {
while(p != 0 && !st[p].next[c]) p = st[p].link;
len = st[p].len;//跳link的时候才更新
}
if (st[p].next[c]) {
p = st[p].next[c];
len ++;
}
slen[i] = len;
}
int ans = 0;
for(int i = 1; i <= n; i ++)
ans = max(ans, slen[i]);
cout << ans;
...
}