浅析后缀自动机

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

我们知道一个长度为 \(n\) 的字符串中所有的子串数目为 \(O(n^2)\) 个,这很大程度上限制了我们对某些子串相关问题的研究。所以有没有解决方案,使得我们可以在可承受的复杂度内表示出所有的子串?

于是,一种被称作 \(\text{DAWG}\) 的自动机(字符串 \(s\) 的 DAWG 简称 \(\text D_{s}\))横空出世。它可以做到仅用 \(O(n)\) 的状态数或转移数,表示出 \(s\) 的所有子串,并且 DAWG 可以在线性时间内增量构造。

后缀自动机(\(\text{SAM}\))是 DAWG 的一种,其接受状态为 \(s\) 的所有后缀。然而在很多地方所谓的 SAM 其实就是 DAWG。既然现在都叫 SAM,那笔者也将用 SAM 代替一些 DAWG,应该不会影响阅读。

在介绍 SAM 的构造算法之前,我们将了解一些前置知识点作为铺垫。

\(\text{endpos}\) 集合

定义一个字符串 \(t\)\(s\) 中出现的 所有终止位置集合\(endpos(t)\)。如 \(s=\texttt{abbab},t=\texttt{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)=\text{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)\ne \{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\) 转移而停下的。那咱就先拿来看看,\(q\gets \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\notin 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\)。由于 \(n\in 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\notin endpos(q)\),很明显违背的 Parent 树的性质。正好我们刚刚拆出一个 \(nq\),而 \(n\in endpos(nq)\),那么直接将转移重定向到 \(nq\) 就行了。

以上就是 SAM 构造算法的全部内容啦!emm确实非常难理解,需要多琢磨几遍。

一些常用的技巧

这里阐述一些做题中可能遇到的一些问题以及解决方案

字符集过大

如果说,一个题中的 \(\Sigma\) 并不是二十六个字母,而是 \([1,10^9]\) 中所有整数,甚至更大,那么显然不能直接 ch[T][int(1e9)] 这么保存转移。

最简单的应对策略是使用 std::map 代替数组的功能,复杂度为 \(O(n\log|\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;

计算 \(\text{endpos}\) 集合大小

这是很多题目需要的信息,但其实也非常好求。

上面提到 \(endpos\) 的分割关系构成一颗 Parent 树,并且分割中最多只会丢失一个元素。我们记 \(siz(i)=|endpos(i)|\),那么我们先考虑没有丢失的,有:\(siz(i)\gets\sum_{link(j)=i}siz(j)\)

接下来考虑丢失的那个。很显然(前面也提到过)向前扩展导致长度到顶的只有一个位置,而这个必然是一个前缀,也就是说只有在一个可以表示主串的一个前缀的状态的 \(endpos\) 才会拥有这样的元素。代码中只要在 extend() 中加上一句 siz[np] = 1 即可。

所有的 extend() 进行完之后,我们再对 Parent 树做一次求子树和操作,暴力建树+Dfs 或拓扑都可。

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

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

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

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

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

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

动态维护 Parent Tree

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

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

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

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

简单应用示例

判断子串

给定两个字符串 \(s\)\(t\),判断 \(s\) 是否为 \(t\) 的子串。

\(t\) 建立后缀自动机 \(\text D_t\),从根状态开始跑一遍 \(s\)。由于 \(\text D_t\) 中包含了 \(t\) 的所有子串,那么如果 \(s\) 在跑的过程中走到了空状态,那么说明不是 \(t\) 的子串。

子串出现次数

很显然 \(endpos\) 集合大小搞出来就完事了。

本质不同的子串数

离线方法

首先有个结论:\(s\) 本质不同的子串数即为 \(\text 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_{x\in \text 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) \]

在线算法

动态维护 \(\large\sum_{x\in \text D_s}\left(\tfrac{len(x)\times (len(x)+1)}{2}-\tfrac{len(link(x))\times (len(link(x))+1)}{2}\right)\) 即可。

两个串的最长公共子串

给定两个字符串 \(s\)\(t\),求 \(s\)\(t\) 的最长公共子串。

首先对 \(s\) 建 SAM \(\text 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\) 的套路跑一遍,按字典序从小到大遍历转移即可。

习题

posted @ 2020-12-07 21:46  -Wallace-  阅读(871)  评论(4编辑  收藏  举报