少女祈祷中...

写写 SAM 这抽象玩意。

SAM(Suffix Automaton),后缀自动机,是用来解决有关字符串子串问题的一种数据结构。

我们知道,一个字符串的所有子串,其实也就是所有前缀的后缀。因此我们就诞生了一个想法:把所有后缀都扔到 trie 树上,这样我们就可以直接沿着字符走,并且能够获得所有子串的信息。

但是这样的空间复杂度是能够达到 O(n2)O(n^2) 级别的。因此我们需要优化一下这个东西,我们可以发现大部分的转移边和点是重复的。因此我们需要把自动机的状态压缩。

在我们考虑这个问题之前,我们先来看另外一种东西,这个东西就是我们压缩自动机状态的关键。

Parent Tree

为了方便叙述,我们定义整个字符串为 tt

我们定义 Endpos(s)\mathrm{Endpos}(s)ss 这个字符串在 tt 中出现的位置的结尾。比如,我们设 t=abcaabcct=\texttt{abcaabcc},那么 Endpos(abc)={3,7}\mathrm{Endpos}(\texttt{abc})=\{3, 7 \}。根据 Endpos\mathrm{Endpos},我们就可以把所有该字符串的子串划分成一堆等价类。

而关于 Endpos\mathrm{Endpos},有很多性质,这里不再一一证明,仅罗列在下面:

  1. Endpos(a)=Endpos(b),a<b\mathrm{Endpos}(a)=\mathrm{Endpos}(b), |a|<|b| 时,在 tt 中每次出现时,aa 都作为 bb 的后缀出现。
  2. 两个字符串 a,ba, b(假设 a<b|a|<|b|),要么 Endpos(a)Endpos(b)=\mathrm{Endpos}(a) \cup \mathrm{Endpos}(b) = \varnothing,要么 Endpos(b)Endpos(a)\mathrm{Endpos}(b) \subseteq \mathrm{Endpos}(a)
  3. 对于同一 Endpos\mathrm{Endpos} 等价类的两个子串,其中长度较短者为长度较长者的后缀,且该等价类中字符串的长度完全覆盖 [x,y][x, y] 这段区间且不相交。

我们根据这几条性质,我们可以想到树形结构。具体地说,一个等价类可以看作一个点,而等价类的包含关系则对应着树上的父子关系。这样我们就能建立一棵树,而每个点对应的父亲也就是我们在 SAM 中见到的 link\mathrm{link}

更具体的定义 link(u)\mathrm{link(u)},即为大小最小的,且包含 uu 这个等价类并且不与 uu 这个等价类相等的等价类。(OIwiki 上没看懂,因此就自己照着理解写了写)


这里是 OIwiki 的定义

考虑 SAM 中某个不是 t0t_0 的状态 vv。我们已经知道,状态 vv 对应于具有相同 endpos\mathrm{endpos} 的等价类。我们如果定义 ww 为这些字符串中最长的一个,则所有其它的字符串都是 ww 的后缀。

我们还知道字符串 ww 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀(至少有一个——空后缀)在其它的等价类中。我们记 tt 为最长的这样的后缀,然后将 vv 的后缀链接连到 tt 上。

换句话说,一个 后缀链接 link(v)\mathrm{link(v)} 连接到对应于 ww 的最长后缀的另一个 endpos\mathrm{endpos} 等价类的状态。


我们再设一个初始状态 t0t_0,它的 Endpos={1,0,t1}\mathrm{Endpos}=\{-1, 0, \cdots |t|-1 \}。这样我们就把根据 link\mathrm{link} 构建出的一个森林转化为了一棵树。

同时,我们定义 len(u)\mathrm{len}(u)uu 这个等价类中,最长的字符串的长度,minlen(u)\mathrm{minlen}(u) 为最短的字符串长度。根据上面的性质 3,我们可以得到下面的结论:

len(link(u))+1=minlen(u)\mathrm{len}({link}(u))+1=\mathrm{minlen}(u)

同时我们也可以得到一个等价类中包含的字符串的个数为 len(u)minlen(u)\mathrm{len}(u)-\mathrm{minlen}(u)

根据我们上面说的这些,我们构建出来的一棵树就叫做 Parent Tree。

下图是根据字符串 abcbc\texttt{abcbc} 构建出的 Parent Tree:

图片来源:OIWIKI

我们可以证明,Parent Tree 中点的个数的数量级为 O(n)O(n)。而对于 SAM,我们就可以利用 Parent Tree 来作为我们 SAM 的节点。

SAM

上面我们介绍了 Parent Tree,我们这里就要利用 Parent Tree 来构建 SAM。

SAM 的构建是在线算法,也就是说,我们可以每加入一个字符然后就进行扩展自动机。

这里先把构建的全过程放在这里:

首先,SAM 初始状态为只有一个节点 00,表示空节点(也就是 t0t_0),为了方便,我们设 len(0)=0,link(0)=1\mathrm{len}(0)=0, \mathrm{link}(0)=-1

  • lastlast 为添加字符 cc 前对应的状态(初始为 00,最后一步更新 lastlast
  • 创建新状态 curcur,令 len(cur)=len(last)+1\mathrm{len}(cur)=\mathrm{len}(last)+1
  • 接下来遍历 lastlast 的后缀链接,如果所到的节点没有 cc 的转移,那么我们就加入 cc 的转移。
  • 如果到达了虚拟状态 1-1,我们就令 link(cur)=0\mathrm{link}(cur)=0 并直接跳转到最后。
  • 否则设第一个有 cc 的转移的点为 pppp 通过 cc 转移到的点为 qq
  • 如果 len(q)=len(p)+1\mathrm{len}(q)=\mathrm{len}(p)+1,那么我们直接令 link(cur)=q\mathrm{link}(cur)=q 并跳转到最后。
  • 否则则进入最麻烦的情况:
    • 我们对 qq 创建一个副本 copycopy,把 qq 的后缀链接和转移全部复制给 copycopy,同时令 len(copy)=len(p)+1\mathrm{len}(copy)=\mathrm{len}(p)+1
    • 然后我们把 link(q)\mathrm{link}(q)link(cur)\mathrm{link}(cur) 赋值为 copycopy
    • 最后我们再从 pp 开始遍历后缀链接,如果遇到向 qq 的转移,我们就把它重定向到 copycopy,直到转移到的点不为 qq
  • 最后,令 lastlastcurcur

算法的正确性证明我不会/kk,所以我再把 OIWIKI 的讲解赫到这里/kel。


正确性证明
  • 若一个转移 (p,q)(p,q) 满足 len(p)+1=len(q)\operatorname{len}(p)+1=\operatorname{len}(q),则我们称这个转移是 连续的。否则,即当 len(p)+1<len(q)\operatorname{len}(p)+1<\operatorname{len}(q) 时,这个转移被称为 不连续的。从算法描述中可以看出,连续的、不连续的转移是算法的不同情况。连续的转移是固定的,我们不会再改变了。与此相反,当向字符串中插入一个新的字符时,不连续的转移可能会改变(转移边的端点可能会改变)。
  • 为了避免引起歧义,我们记向 SAM 中插入当前字符 cc 之前的字符串为 ss
  • 算法从创建一个新状态 cur\textit{cur} 开始,对应于整个字符串 s+cs+c。我们创建一个新的节点的原因很清楚。与此同时我们也创建了一个新的字符和一个新的等价类。
  • 在创建一个新的状态之后,我们会从对应整个字符串 ss 的状态通过后缀链接进行遍历。对于每一个状态,我们尝试添加一个通过字符 cc 到新状态 cur\textit{cur} 的转移。然而我们只能添加与原有转移不冲突的转移。因此我们只要找到已存在的 cc 的转移,我们就必须停止。
  • 最简单的情况是我们到达了虚拟状态 1-1,这意味着我们为所有 ss 的后缀添加了 cc 的转移。这也意味着,字符 cc 从未在字符串 ss 中出现过。因此 cur\textit{cur} 的后缀链接为状态 00
  • 第二种情况下,我们找到了现有的转移 (p,q)(p,q)。这意味着我们尝试向自动机内添加一个 已经存在的 字符串 x+cx+c(其中 xxss 的一个后缀,且字符串 x+cx+c 已经作为 ss 的一个子串出现过了)。因为我们假设字符串 ss 的自动机的构造是正确的,我们不应该在这里添加一个新的转移。然而,难点在于,从状态 cur\textit{cur} 出发的后缀链接应该连接到哪个状态呢?我们要把后缀链接连到一个状态上,且其中最长的一个字符串恰好是 x+cx+c,即这个状态的 len\operatorname{len} 应该是 len(p)+1\operatorname{len}(p)+1。然而还不存在这样的状态,即 len(q)>len(p)+1\operatorname{len}(q)>\operatorname{len}(p)+1。这种情况下,我们必须通过拆开状态 qq 来创建一个这样的状态。
  • 如果转移 (p,q)(p,\,q) 是连续的,那么 len(q)=len(p)+1\operatorname{len}(q)=\operatorname{len}(p)+1。在这种情况下一切都很简单。我们只需要将 cur\textit{cur} 的后缀链接指向状态 qq
  • 否则转移是不连续的,即 len(q)>len(p)+1\operatorname{len}(q)>\operatorname{len}(p)+1,这意味着状态 qq 不只对应于长度为 len(p)+1\operatorname{len}(p)+1 的后缀 s+cs+c,还对应于 ss 的更长的子串。除了将状态 qq 拆成两个子状态以外我们别无他法,所以第一个子状态的长度就是 len(p)+1\operatorname{len}(p)+1 了。
    我们如何拆开一个状态呢?我们 复制 状态 qq,产生一个状态 clone\textit{clone},我们将 len(clone)\operatorname{len}(\textit{clone}) 赋值为 len(p)+1\operatorname{len}(p)+1。由于我们不想改变遍历到 qq 的路径,我们将 qq 的所有转移复制到 clone\textit{clone}。我们也将从 clone\textit{clone} 出发的后缀链接设置为 qq 的后缀链接的目标,并设置 qq 的后缀链接为 clone\textit{clone}
    在拆开状态后,我们将从 cur\textit{cur} 出发的后缀链接设置为 clone\textit{clone}
    最后一步我们将一些到 qq 转移重定向到 clone\textit{clone}。我们需要修改哪些转移呢?只重定向相当于所有字符串 w+cw+c(其中 wwpp 的最长字符串)的后缀就够了。即,我们需要继续沿着后缀链接遍历,从结点 pp 直到虚拟状态 1-1 或者是转移到不是状态 qq 的一个转移。

根据上面的构造方法,我们还能证明,自动机中的节点数的量级为 O(n)O(n) 级别的。

构建方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
inline void extend(char *s)
{
int n = strlen(s + 1);
for(int i = 1; i <= n; i ++ )
{
int p = last, cur = ++ idx, c = s[i] - 'a';
f[cur] = siz[cur] = 1;
len[cur] = len[p] + 1;
while(p != -1 && !tr[p][c])
{
tr[p][c] = cur;
p = link[p];
}
if(p == -1) link[cur] = 0;
else
{
int q = tr[p][c];
if(len[q] == len[p] + 1) link[cur] = q;
else
{
int copy = ++ idx;
len[copy] = len[p] + 1;
link[copy] = link[q];
for(int i = 0; i < 26; i ++ ) tr[copy][i] = tr[q][i];
while(p != -1 && tr[p][c] == q)
{
tr[p][c] = copy;
p = link[p];
}
link[cur] = link[q] = copy;
}
}
last = cur;
}

for(int i = 1; i <= idx; i ++ ) G[link[i]].emplace_back(i);
}

注意:SAM 中节点数最多能够达到 2n12n-1,因此需要将数组开两倍。

一些题目

【模板】后缀自动机(SAM)

给定一个只包含小写字母的字符串 SS,求出 SS 的所有出现次数不为 11 的子串的出现次数乘上该子串长度的最大值。

数据范围:S106|S| \le 10^6


模板题。我们先建出 SAM,然后在 Parent Tree 上进行 DP。uu 节点代表的字符串出现的次数就是它在 Parent Tree 上子树的大小。因此直接统计即可。

时间复杂度 O(n)O(n)

[JSOI2012] 玄武密码

给定 nn 个字符串 tt,对于每一段字符串 tt,求出其最长的前缀 pp,满足 ppss 的子串。

数据范围:s107,t107|s|\le 10^7, \sum |t|\le 10^7,字符集大小为 44


SAM 裸题。由于 SAM 上存储了所有后缀的前缀,因此我们直接把 tt 放到 SAM 上跑即可,跑了多长不能跑了就为答案。

时间复杂度 O(n)O(n)

[TJOI2015] 弦论

对于一个给定的长度为 nn 的字符串,求出它的第 kk 小子串。

这里有两种询问:不同位置的相同子串算作一个和不同位置的相同子串算作多个。

数据范围:n105n\le 10^5


大杂烩题目,感觉像两道题拼起来的。

我们先考虑不同位置相同子串算一个的情况。这时我们可以先 dp,由于 SAM 为一张 DAG,因此我们可以记录从某个点开始走一共会走出多少种字符串。然后输出方案时,我们从小到大遍历出边,同时减去走这条边的方案数。直到 kk 小于走这条边的方案数就走这条边。

而对于不同位置算多个的,我们可以先按照上面模板题的思路,先统计出每个点出现的次数,然后按照上面的重新 dp 即可。

Longest Common Substring

输入两个字符串,输出它们的最长公共子串长度。

数据范围:s,t2.5×105|s|, |t|\le 2.5\times 10^5


我们先对一个串建 SAM,然后把另一个串放到 SAM 上跑。由于后缀链接记录的是前缀的后缀,因此跑不了的时候我们就一直跳后缀链接,同时记录一个最大长度即可。

时间复杂度 O(n)O(n)

[AHOI2013] 差异

给定一个长度为 nn 的字符串 SS,令 TiT_i 表示它从第 ii 个字符开始的后缀。求

1i<jnlen(Ti)+len(Tj)2×lcp(Ti,Tj)\sum_{1\leqslant i<j\leqslant n}\mathrm{len}(T_i)+\mathrm{len}(T_j)-2\times\mathrm{lcp}(T_i,T_j)

数据范围:n5×105n\le 5\times 10^5


前面两项好搞,我们先提出来。这样我们的目标就变为了求 1i<jnlcp(Ti,Tj)\sum_{1\leqslant i<j\leqslant n}\mathrm{lcp}(T_i,T_j)

lcp 不好求,但是 SAM 适合求 lcs,我们就把字符串翻转过来,就变成了求 lcs。一个结论:ii 前缀和 jj 前缀的 lcs 即为在 Parent Tree 上的 lcalcalen\mathrm{len}。因此我们可以直接树形 dp,对于每个点算一下有多少对点的 lcalca 是它即可。

时间复杂度 O(n)O(n)

Yet Another LCP Problem

给定一个字符串,每次询问的时候给出两个正整数集合 AABB,求 iA,jBlcp(i,j)\sum_{i \in A,j \in B}lcp(i,j)

数据范围:s2×105,A,B2×105|s|\le 2\times 10^5, \sum |A|, \sum |B|\le 2\times 10^5


我们先把字符串翻转,将 lcplcp 转化为 lcslcs。根据上一道题的套路,我们建出来 SAM,这样两个点在 parent tree 上对应的 lca 的 lenlen 即为最长公共后缀。

但是这里有多组询问,我们就可以每组询问建虚树,然后在虚树上进行 dp 计算答案。具体的说,我们设 sumu,0sum_{u, 0}uu 子树中 AA 集合点的数量,sumu,1sum_{u, 1}uu 子树中 BB 集合点的数量。通过容斥我们就能算出有多少 aA,bBa\in A, b\in B 的 lca 是 uu。最终再乘上 lenulen_u 即可。

时间复杂度 O(nlogn)O(n\log n)

[NOI2018] 你的名字

给定一个字符串 SS,多次询问。询问给定一个字符串 TT,有多少 TT 的子串满足其不为 S[lr]S[l\sim r] 构成的字符串的子串。

数据范围:S5×105,T106|S|\le 5\times 10^5, \sum |T|\le 10^6


我们先考虑 L=1,R=SL=1, R = |S| 的情况。这时我们对 SSTT 分别建 SAM,对 TT 的第 ii 个前缀,我们要找出它最长的后缀且这个后缀是 SS 的子串。我们加入 TT 的第 ii 个字符后,我们看当前节点是否有这一个转移,如果没有就一直跳 linklink

最后我们将每一个前缀的满足上面要求的最长后缀记录为 limilim_i。我们再设 SAM 上第 ii 个节点对应字符串中的位置是 tagitag_i。那么最后的答案就是:

p=1idxmax(0,len(p)max(len(link(p)),limtagp))\sum \limits_{p=1}^{idx}\max(0, \mathrm{len}(p)-\max(\mathrm{len}(\mathrm{link}(p)), lim_{tag_p}))

也就是对于每个节点,我们用它的长度减去它匹配的长度就是它不为 SS 子串的长度。

而对于有了区间限制这个东西,我们可以思考一下我们上面用 SAM 都做了什么:跳 linklink 和看是否有转移。我们可以直接用线段树维护每个点的 endpos\mathrm{endpos} 集合。当这个集合中有我们限制的区间之内的位置,我们才可以进行转移,否则就跳出了这个区间。

而对于 endpos\mathrm{endpos},在 parent tree 上是父亲包含儿子的关系,因此我们直接线段树合并维护一下就好了。

时间复杂度 O(nlogn)O(n\log n)

区间本质不同子串个数

给定一个长度为 nn 的字符串 SSmm 次询问由 SS 的第 LL 到第 RR 个字符组成的字符串包含多少个本质不同的子串。

数据范围:n105,m2×105n\le 10^5, m\le 2\times 10^5


对于区间数不同颜色这种问题,一个常见的套路为讲询问离线,然后右端点扫描线,同时只维护每个不同元素在最右端出现的位置。

而对于这题我们也可以这么做。我们把询问离线后做扫描线,发现每次新加入的元素的 endpos\mathrm{endpos} 都包含新加入的右端点,也就是都是以右端点为结尾的子串。可以发现这些字符串在 parent tree 上对应着到根的一条链,于是我们的任务就变成了把这条链上的原来的影响消除,并且把它们在新位置的贡献加上。

我们就可以沿用[SDOI2017] 树点涂色的套路,用 LCT 来维护这个过程。具体地说,我们在 LCT 的每个点维护一个 valval,表示它目前 endpos\mathrm{endpos} 的最大值。每次 access 时我们把一整条链的影响全部消除,同时把最终这条实链的 valval 全部覆盖为当前的右端点。然后我们再把当前新增加的字符串的开始位置加上即可。

时间复杂度 O(nlog2n)O(n\log^2n)

说着很麻烦但是写起来都是板子反而相当好写/yiw

The End

其实 SAM 还有很多东西没讲完,但是限于作者本人能力问题,有很多应用和证明都没有讲。以后可能会再更新一点。

参考资料:OIWIKI -https://oi-wiki.org/string/sam/