解决子串相关问题的强大工具
我们知道一个长度为 (n) 的字符串中所有的子串数目为 (O(n^2)) 个,这很大程度上限制了我们对某些子串相关问题的研究。所以有没有解决方案,使得我们可以在可承受的复杂度内表示出所有的子串?
于是,一种被称作 ( ext{DAWG}) 的自动机(字符串 (s) 的 DAWG 简称 ( ext D_{s}))横空出世。它可以做到仅用 (O(n)) 的状态数或转移数,表示出 (s) 的所有子串,并且 DAWG 可以在线性时间内增量构造。
后缀自动机(( ext{SAM}))是 DAWG 的一种,其接受状态为 (s) 的所有后缀。然而在很多地方所谓的 SAM 其实就是 DAWG。既然现在都叫 SAM,那笔者也将用 SAM 代替一些 DAWG,应该不会影响阅读。
在介绍 SAM 的构造算法之前,我们将了解一些前置知识点作为铺垫。
( ext{endpos}) 集合
定义一个字符串 (t) 在 (s) 中出现的 所有终止位置 的 集合 为 (endpos(t))。如 (s= exttt{abbab},t= exttt{ab}) 那么 (endpos(t) = {2,5})。对于两个子串 (t_1,t_2),若 (endpos(t_1) = endpos(t_2)),那么我们称 (t_1,t_2) 在同一个 (endpos) 等价类(简称“类”)中。
然而这玩意有什么用呢?我们先来看一些有意思的结论:
- 对于处于同一 (endpos) 等价类中的两个子串 (t_1,t_2(|t_1|le|t_2|)),(t_1) 是 (t_2) 的后缀。
- 比较显然吧?不过 (t_1) 不是 (t_2) 的后缀,那么 (t_2) 必然匹配不上 (endpos(t_1)) 中的任何一个位置。
- 对于任意两个子串 (t_1,t_2(|t_1|le|t_2|)),有 (endpos(t_1)supseteq endpos(t_2)) 或 (endpos(t_1)cap endpos(t_2)=varnothing) 其一成立。
- 前者代表 (t_1) 为 (t_2) 的一个后缀,凡是 (t_2) 出现过的位置 (t_1) 必然出现;而后者则表示不是后缀,那么 (t_1) 出现的位置 (t_2) 必然做不到也在这个位置出现。
- 对于一个类,其中的所有子串的 长度连续。
- 证明略。
- (endpos) 等价类的个数为 (O(n))。
- 对于一个类中的一个子串,我们将它在前方拓展一个字符,其所属的类可能会发生改变。一旦改变,那么这一个子串为变为若干个不同的子串。对于其中两个不同的,必然是因为在开头加上的字符不同。根据前面的结论,可知它们的 (endpos) 集合无交集。那么整个过程可以视作 (endpos) 集合的分割。当然,有一部分信息会丢失,这是因为这个位置的长度到顶了,无法往前扩展(这样暗示了最多只能丢失一个)。
- 注意到分割出来的 子集间互不相交,那么所有的分割过程有一个树形结构。考虑到最大的 (endpos) 集合也只有 (n) 个元素,最后只会有 (n) 次分割,那么显然树的结点数也只有 (O(n))。
观察其中第 4 个结论,若我们将所有 (O(n^2)) 个子串按 (endpos) 等价类归类,那么只会有 (O(n)) 个类。这是一个很好的性质,接下来的研究将会围绕这个等价类进行。
为了方便,我们先引入一些记号:
- (longest(i)):等价类 (i) 中最长的子串;
- (len(i)):(longest(i)) 的长度;
- (shortest(i)):等价类 (i) 中最短的子串;
- (minlen(i)):(shortest(i)) 的长度。
上面提到 (endpos) 的分割关系是一颗树,我们称这棵树为 Parent Tree,一个结点 (i) 代表一个类,结点 (i) 的父亲记为 (link(i))(也被称为“后缀链接”,它的实际意义是:将 (i) 中一个子串的前面缩减,直到存在其他位置也出现了这个串,新的 (endpos) 对应的类记为 (link(i)))。
那么接下来,第五个结论:(minlen(i)=len(link(i))+1)。
- (link(i)) 是 (i) 在 Parent 树上的父亲,那么考虑 (i) 这个儿子是怎么来的:(longest(link(i))) 向前拓展一个字符后不再属于 (link(i)),其中一个被归入 (i)。很显然这个扩展得到的子串即为 (shortest(i)),因为它无法再缩减得到 (i) 中的其他子串,它就是最短的那个。
有了这些理论基础,是时候开始学习 SAM 了!
SAM 后缀自动机
SAM 的状态 & 转移
作为一个自动机,其状态的含义是必不可少的。在 SAM 中我们直接 把一个等价类作为一个状态。
这是有一定道理的,至少一个字符串的 (endpos) 等价类的个数就在 (O(n)) 级别。
当我们从一个状态 (x) 走一个 (delta(x,c)) 转移时,意味着在当前的字符串 后追加一个字符 (c)。
SAM 的构造
算是最难理解的部分了吧!反之笔者来来回回看了不下四五遍。先贴 Code!
const int N = 1e6 + 5;
const int T = N << 1;
const int S = 26;
int ch[T][S], link[T], len[T];
int total(1), last(1);
void extend(int c) {
int p = last, np = last = ++total;
len[np] = len[p] + 1;
for (; p && !ch[p][c]; p = link[p]) ch[p][c] = np;
if (!p) { link[np] = 1; return; }
int q = ch[p][c];
if (len[q] == len[p] + 1) { link[np] = q; return; }
int nq = ++total;
memcpy(ch[nq], ch[q], S << 2), link[nq] = link[q];
len[nq] = len[p] + 1, link[np] = link[q] = nq;
for (; p && ch[p][c] == q; p = link[p]) ch[p][c] = nq;
}
看不懂就对了 其中 ch[x][c]
表示 (delta(x,c)),last
表示上一次添加的位置,其他的,现在叫什么就对应上面的什么。
我们总共需要维护:转移、(link) 以及 (len)。不维护额外信息是因为它们要么会影响复杂度((endpos)),要么可以直接计算 ((minlen))。
首先还是要提醒一下我们是 增量法 构造的,也就是说 main()
函数中有一句 for (int i = 0; i < n; i++) extend(s[i] - 'a');
。
所以说这个函数到底在搞什么呢?我们一句句剖析:
-
int p = last, np = last = ++total; len[np] = len[p] + 1;
可以发现 (p) 为上一次添加的状态代表旧串, (np) 表示现在的状态代表新串。很显然每一次
extend()
都会新增状态,至少得把含有 (n) 的 (endpos) 表示出来。len[np] = len[p] + 1;
这也比较显然,(p) 肯定是上一次添加后可以代表最长子串的那一个,而现在是 (np),当然是上一次最大长度 (+1) 啦。 -
for (; p && !ch[p][c]; p = link[p]) ch[p][c] = np;
这是在干啥?我们发现这个 (p) 一直跳 (link),于是考虑这么做的实际意义。我们知道 (link(p)) 表示“最长的不在等价类中的后缀”,大概了解了这其实就是在遍历后缀;而朴素的后缀遍历是一位位扫,这里的高明之处就是对同一类的后缀都可以统一处理,并且可以 压缩地遍历后缀。
再看循环的终止条件
p && !ch[p][c]
。前者你可以单纯地认为是为了不跳出根,需要着重理解的是后者。注意到 (longest(p)) 是旧串的后缀,(longest(link(p)))、(longest(link(link(p)))) 等也都是旧串的后缀。如果说对于当前的 (p),有 (delta(p,c)= ext{NULL}),说明 旧串中不存在 (longest(p)+c),于是向 (np) 新建一个转移:(delta(p,c)gets np)。不难发现 (longest(p)+c) 是新串的后缀,而这又是第一次出现,因此 (endpos(longest(p)+c)={n} = endpos(np)),因此正确性是有保证的。然而一旦发现 (delta(p,c)) 这个转移存在了,说明此时 (longest(p)+c) 已经在旧串中出现了,那么有 (endpos(longest(p)+c) e {n}),直接连边就有失妥当,我们需要对此进一步处理。
-
if (!p) { link[np] = 1; return; }
如果上一句话是因为跳出 SAM 而终止,也就是说任意一个旧串的后缀 (+c) 都不曾在旧串中出现过,那么就会走到这一步。由于根状态 (q_0) 是跳 (link) 的必经之地,而这里都不存在 (c) 的转移那只能说明一件事:这个 (c) 在旧串中就没出现过!于是显然有 (link(np)gets q_0)。
-
int q = ch[p][c];
走到这里了,说明前、前一句是因为发现一个存在的 (c) 转移而停下的。那咱就先拿来看看,(qgets delta(p,c))。考虑这个 (q) 到底有什么特别的地方——它满足 (longest(p)+c) 在旧串中出现并且是新串的后缀。那为什么选择 (q) 而不是 (q^prime=delta(link(p),c)),也就是为什么不要继续跳后缀?注意到 (q^prime) 在 Parent 树上必然是 (q) 的祖先,这些祖先在这一次的
extend()
做完之后,其 (endpos) 也都增加了 (n),情况本质相同。既然如此,对于这个 (q) 还需要多考虑什么呢? -
if (len[q] == len[p] + 1) { link[np] = q; return; }
这个等式成立,等价于“(longest(q)=longest(p)+c)”,也就是说 (q) 可以代表的最长子串恰好是新串的后缀。那么,就好办了啊!我们只要简单地让 (endpos(q)) 中多个 (n) 就行啦:(link(np)gets q)。
但如果这个条件不成立,意味着存在一个比 (longest(q)) 更长(显然不会是更短吧,因为 (minlen(i)=len(link(i))+1))的子串。可以发现 这样更长的子串必然不会是新串的后缀,因为如果真的是,那 (longest(q)) 去掉最后一个字符也是旧串后缀。然而要真是这样,就会有“(len(q)-1>len(p))”这种东西出现,这样的 (q) 明明会比现在这个 (p) 先检查到,但是实际上不然。
“不会是新串的后缀”的话,会出现什么问题呢?首先可以肯定的是,(n otin endpos(q))。倘若我们令 (link(np)gets q),那么显然不符合 (endpos) 集合的分割性质,因为 (endpos(np)={n})。
解决方案?我们尝试将 (q) 这个状态 拆分!
-
int nq = ++total; memcpy(ch[nq], ch[q], S << 2), link[nq] = link[q]; len[nq] = len[p] + 1, link[np] = link[q] = nq; for (; p && ch[p][c] == q; p = link[p]) ch[p][c] = nq;
观察到我们新建了一个状态 (nq),作为拆出来的状态。要知道我们是为了适应 (endpos) 的关系才干的这个事情,新建的 (nq) 满足:(endpos(nq)=endpos(q)cup {n})。在以后的
extend()
中,我们将使用这个新的含有 (n) 的 (nq) 而不是 (q)。考虑一下 (nq) 的这些信息((delta,link,len))应该怎么取。首先看转移:我们的代码中 直接沿用的 (q) 的所有转移。这是因为 (q) 和 (nq) 的 (endpos) 不同,但 (nq) 仅仅是多了 (n),除了这个 (n),对于其他的子串后面加什么字符其实和原来的 (q) 实际上并无大异。
再看 (len)。由于 (nin endpos(nq)),那么 (longest(nq)) 必然为新串的后缀,于是 (len(nq)gets len(p)+1),因为 (longest(p)) 是旧串的后缀,而 (delta(p,c)=nq)(转移在最后一句中被重新定向,下面会说)。
最后是 (link)。考虑到 Parent 树上祖先的 (len) 必然小于子孙,而 (nq) 又是 (q) 分拆出的状态,它们在 Parent 树上相邻。又因为 (len(nq)=len(p)+1<len(q)),那么想必 (nq) 是 (q) 的父亲。这里的实现可以被认为是在 (link(q)leftrightarrow q) 这条树枝上执行了一次类似与链表的 插入,将 (nq) 置于 (q) 和 (link(q)) 之间,那么上面的代码就好理解了。
还有一个循环还没有解释。这有点像上面的那个循环,唯一不同的是终止条件:
ch[p][c] == q
。对于一个存在 (c) 转移的一个 (p) 的祖先,可以肯定的是转移的结果的 (endpos) 理应存在 (n)。然而我们发现这个结果是 (q),而 (n otin endpos(q)),很明显违背的 Parent 树的性质。正好我们刚刚拆出一个 (nq),而 (nin endpos(nq)),那么直接将转移重定向到 (nq) 就行了。
以上就是 SAM 构造算法的全部内容啦!emm确实非常难理解,需要多琢磨几遍。
一些常用的技巧
这里阐述一些做题中可能遇到的一些问题以及解决方案
字符集过大
如果说,一个题中的 (Sigma) 并不是二十六个字母,而是 ([1,10^9]) 中所有整数,甚至更大,那么显然不能直接 ch[T][int(1e9)]
这么保存转移。
最简单的应对策略是使用 std::map
代替数组的功能,复杂度为 (O(nlog|Sigma|))。
求拓扑序
一些题会让你做一些类似于在 SAM 上记忆化搜索(SAM 是一个 DAG,毕竟一个状态可以表示的子串也是有限的个数)或在 Parent 树上 Dfs 的操作,我们可以用拓扑序替代。首先放两个显然的结论:
- (len(p)>len(link(p)))
- (len(p)<len(delta(p,c)))
这意味着我们可以对状态按 (len) 排序而不用常规的 DAG 拓扑,因为由上面两个结论可知这样得到的拓扑序不论对 SAM 主题的 DAG 还是 Parent 树来说都是可行的,常规的拓扑排序算法得到的结果可能只适合其中一者。
具体我们可以仿照计数排序线性实现(其中 buc[]
是一个桶数组,结果为 ord[]
):
for (int i = 1; i <= total; i++) ++buc[len[i]];
for (int i = 1; i <= total; i++) buc[i] += buc[i - 1];
for (int i = 1; i <= total; i++) ord[buc[len[i]]--] = i;
计算 ( ext{endpos}) 集合大小
这是很多题目需要的信息,但其实也非常好求。
上面提到 (endpos) 的分割关系构成一颗 Parent 树,并且分割中最多只会丢失一个元素。我们记 (siz(i)=|endpos(i)|),那么我们先考虑没有丢失的,有:(siz(i)getssum_{link(j)=i}siz(j))。
接下来考虑丢失的那个。很显然(前面也提到过)向前扩展导致长度到顶的只有一个位置,而这个必然是一个前缀,也就是说只有在一个可以表示主串的一个前缀的状态的 (endpos) 才会拥有这样的元素。代码中只要在 extend()
中加上一句 siz[np] = 1
即可。
所有的 extend()
进行完之后,我们再对 Parent 树做一次求子树和操作,暴力建树+Dfs 或拓扑都可。
简单应用示例
判断子串
给定两个字符串 (s) 和 (t),判断 (s) 是否为 (t) 的子串。
对 (t) 建立后缀自动机 ( ext D_t),从根状态开始跑一遍 (s)。由于 ( ext D_t) 中包含了 (t) 的所有子串,那么如果 (s) 在跑的过程中走到了空状态,那么说明不是 (t) 的子串。
子串出现次数
很显然 (endpos) 集合大小搞出来就完事了。
本质不同的子串数
离线方法
首先有个结论:(s) 本质不同的子串数即为 ( ext D_s) 中根开始的不同路径数。
其实不难理解,因为 SAM 既能表示出所有子串,又不会出现两条不同路径表示同一个子串。
那么设计一个 dp:(f(x)) 表示从状态 (x) 开始的不同路径数,转移:
那么答案就是 (f(q_0)-1),复杂度线性。
在线方法
考虑到一个状态表示的子串的长度连续,并且短串都是长串的后缀。那么 (x) 这个状态表示了 ([minlen(x),len(x)]) 这么多本质不同的子串。这些子串显然不能在其他状态中,于是所有状态包含的子串数之和即为答案:(sum_{xin ext D_s}(len(x)-len(link(x))))。
在实际维护时我们只要对于那个新建的 (np),更新答案,不管 (nq) 是因为它只是分割了一个 ([minlen,len]) 区间,并没有对答案产生贡献。复杂度显然也是线性。
所有本质不同子串总长
都可以类比上面“本质不同的子串数”的方法。
离线算法
在原来 (f) 的基础上设 (g(x)) 为从状态 (x) 开始的不同路径总长,转移:
在线算法
动态维护 (largesum_{xin ext D_s}left( frac{len(x) imes (len(x)+1)}{2}- frac{len(link(x)) imes (len(link(x))+1)}{2} ight)) 即可。
两个串的最长公共子串
给定两个字符串 (s) 和 (t),求 (s) 和 (t) 的最长公共子串。
首先对 (s) 建 SAM ( ext D_s),然后对于 (t) 的每一个前缀,我们希望这个前缀有尽量长的后缀可以匹配。
那么先把 (t) 在 SAM 上跑,如果能走转移就走转移,否则我们慢慢从前面缩减长度,也就是跳 (link),直到存在一个当前字符的转移为止。
答案我们实时更新,每走一次转移取一次最大值即可。
复杂度仍为线性,因为我们维护 (t) 的起始位置和终止位置都在前移。
多个串的最长公共子串
稍微复杂一些,但只要稍加改动就能从两个串扩展过来。
首先我们对第一个串建 SAM 其他的往上面跑,并记下来原来我们动态维护的答案:(mx(i)) 表示状态 (i) 在跑当前串时匹配到的最大值,而 (mn(i)) 表示 (mx(i)) 的历史最小值。
然后每个串跑完之后,还需要多考虑一点,就是 (i) 的结果对于 (link(i)) 同样有效,也就是我们需要干这个:(mx(link(i))gets max(mx(i),mx(link(i))))。
最后别忘了更新 (mn(i)),以及确保 (mn(i)le len(i))(直接取一波 (min) 即可)。
字典序第 (k) 小的子串
有分为本质不同和位置不同的子串,这里以本质不同为例。
这其实也对应 SAM 中字典序第 (k) 小的路径,那么就先像求本质不同的子串数的离线方法一样 dp 一遍,得到一个点出发的路径数。
然后用平衡树查 (k-th) 的套路跑一遍,按字典序从小到大遍历转移即可。