浅析后缀自动机

解决子串相关问题的强大工具

我们知道一个长度为 (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) 等价类(简称“类”)中。

然而这玩意有什么用呢?我们先来看一些有意思的结论:

  1. 对于处于同一 (endpos) 等价类中的两个子串 (t_1,t_2(|t_1|le|t_2|))(t_1)(t_2) 的后缀。
    • 比较显然吧?不过 (t_1) 不是 (t_2) 的后缀,那么 (t_2) 必然匹配不上 (endpos(t_1)) 中的任何一个位置。
  2. 对于任意两个子串 (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) 必然做不到也在这个位置出现。
  3. 对于一个类,其中的所有子串的 长度连续
    • 证明略。
  4. (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 或拓扑都可。

求出具体的 ( ext{endpos}) 集合

有些题不仅仅需要 (endpos) 集合的大小,还要更具体的信息,比如出现位置必须在一个区间内等等。

那么我们考虑用一个什么数据结构维护它。观察到要求出 (endpos) 集,必然会涉及到许多集合合并的操作。

那么动态开点线段树看似挺好,它支持在 (O(nlog n)) 总时间内合并求出所有状态的 (endpos) 集,事实上主流的方法就是线段树合并。

不仅仅因为合并方便,线段树还能维护多种额外信息。

当然少数情况下也推荐使用平衡树启发式合并或者 这个,当然线段树支持可持久化方法的合并,要想保留 (endpos) 集而不是一次性的话平衡树可能会难操作一点。

动态维护 Parent Tree

可以使用 LCT,支持各种树上操作非常方便,复杂度一只 (log)

用于应付某些强制在线的接地府出题人。

由于这里的 LCT 是有根的,跑的巨快无比,一般都随手过百万。

LCT 可以看这里:https://www.cnblogs.com/-Wallace-/p/lct.html

简单应用示例

判断子串

给定两个字符串 (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(x)=1+sum_{delta(x,c)=y} f(y) ]

那么答案就是 (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) 开始的不同路径总长,转移:

[g(x)=f(x) + sum_{delta(x,c)=y} g(y) ]

在线算法

动态维护 (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) 的套路跑一遍,按字典序从小到大遍历转移即可。

习题

后记

原文地址:https://www.cnblogs.com/-Wallace-/p/sam.html