后缀自动机(SAM)囫囵乱讲

(注:本篇博客虽然很详细,但是讲的非常垃圾,而且废话还一大片,所以仅供参考,如果我讲不懂的话把谭老师讲的 \({\rm SA}\) 搞懂就行了,我们相信谭老师讲的一定非常好!

后缀自动机(\({\rm SAM}\)

前置约定

字符串从 \(0\) 开始计数。字符串或集合 s 的符号 \(|s|\) 表示 \(s\) 的大小。

定义

一个字符串 \(s\)\({\rm SAM}\) 是一个能接受 \(s\) 的所有后缀的最小的 \({\rm DFA}\)(确定性有限自动机或确定性有限状态自动机)。

不需要知道什么是 \({\rm DFA}\) ,你只需要知道:

  • \({\rm SAM}\) 是一个 \({\rm DAG}\) ,其中每个节点是一个状态,状态内是一个字符子串,边是状态间的转移,转移都是一个字符,注意每个状态所有出边没有重复。

  • 整张图存在一个源点 \(\tau\) ,称为初始状态,其他所有状态都能经由初始状态到达。

  • 存在一个或多个终止状态,如果从初始状态出发,到一个终止状态为止,路径上所有转移连接起来是原字符串 \(s\) 的一个后缀,可能会有多条路径,但是同样适用。

  • 在所有满足上述条件中,\({\rm SAM}\) 是节点数最少的(这个可以选择性看不到)

性质夹概念带证明

概念-结束位置 \(endpos\)

考虑一个 \(s\) 的一个非空字串 \(t\) ,我们计 \(endpos(t)\) 表示字符串 \(t\)\(s\) 中出现的所有结束位置


image

概念-等价类

因为两个非空字串 \(t_1\)\(t_2\) 有可能存在 \(endpos\) 相等的情况,对于每种不同的 \(endpos\) 称所有 \(endpos\) 相等的字串组成的集合为若干个等价类


image

引理 1

如果两个非空子串 \(t_1\)\(t_2\)\(endpos\) 完全相等,且规定前者大小更小,那么前者是后者的后缀,且只在后者中出现一次。


分析:如果要求 \(endpos\) 完全相同的话,结尾的几个字符一定是相等的,并且必定包含整个 \(t_1\)

引理 2

考虑两个非空子串 \(t_1\)\(t_2\) ,且规定前者大小更小,那么当前者是后者的后缀,有: \(endpos(t_1) \supseteq endpos(t_2)\) ,否则,有: \(endpos(t_1) \cap endpos(t_2) = \emptyset\)


分析:前半段是在引理 1 的基础上,存在在 \(t_2\) 没有出现的时候 \(t_1\) 出现了;后半段是因为有不相同的地方,所以不能在每个位置都匹配完。

引理 3

考虑一个 \(endpos\) 的等价类,将集合中所有字符串按长度降序排列,其中没有长度相同的字符串,且后面的是前面的后缀,而且其长度的集合覆盖了一段区间的所有整数


分析:“没有长度相同的字符串”可以配合引理 1 解决,因为只有自己是所有长度相等中唯一一个是自己的后缀的字符串;“长度的集合覆盖了一段区间的所有整数”可以配合引理 2 解决,因为倘若 \(t_1\)\(t_2\) 的后缀,那么对于每一个长度在两者之间的字符串,都能用同样的道理说明是 \(t_2\) 的后缀。

概念-后缀连接 \(link\)

考虑 \({\rm SAM}\) 中一个非 \(\tau\) 的状态 \(v\) ,设 \(u\)\(v\) 对应的 \(endpos\) 中大小最大的那个。

然后考虑 \(u\) 中所有后缀,取长度最长的一个和 \(u\) 不在同一个等价类的字符串 \(w\) ,将 \(v\)\(link\) 连向 \(w\)

注意,不可能不存在这样的 \(w\) ,因为还有空集,也就对应了初始状态 \(\tau\)


image

所以状态 \(abb\)\(bb\) 的后缀链接都要连向 \(b\) ,而 \(b\) 就要连向空集 \(\tau\)

性质 4

没啥技术但是是我加的哈哈哈(

对于一个非 \(\tau\) 的状态 \(v\) ,其 \(link\) 对应的等价类中所有字符串都是 \(v\) 中字符串的后缀。


分析:引理 3 + \(link\) 概念

引理 5

所有后缀连接反向之后构成一颗以 \(\tau\) 为根的


分析:考虑一个不是 \(\tau\) 的状态 \(v\) ,后缀链接连到的状态因为根本不和自己属于一个等价类,并且长度是严格减小的,所以最后必然会连到 \(\tau\) ,还有由于每个状态只有一个入边(反向后),所以整体上就是一棵树。

概念-parent树

没毛用。。不管他

(我是说概念没毛用,因为他说的就是引理 5 ,但没说 parent树 没用哈。。

性质 6

哈哈又是我加的(

parent树 中任意一个状态 v 的儿子们的 endpos 相互交为空。


分析:如果有交,那么必然会被分进一个状态,并且两个集合肯定有包含关系,详见性质 4 和引理 2 。

一些必要的符号

我们设 \(longest(v)\) 表示一个等价类中最长的那个字符串, \(len(v)\) 为其长度;记 \(shortest(v)\) 为等价类中最短的那个字符串, \(minlen(v)\) 为其长度。

引理 3:这个等价类所有字符串的长度覆盖了区间 \([minlen(v), len(v)]\) 中的每个整数。

后缀连接 \(link\)\(minlen(v) = len(link(v)) + 1\)

后缀连接树

这个指的就是那个 parent树 哈。

  1. 如果 \(u\)\(v\) 的祖先,那么 \(u\) 的等价类中所有字符串都是 \(v\) 的等价类中所有字符串的后缀。
  • 分析:性质 4 从父亲到祖先。

  1. \(s_{1-i}\)\(s_{1-j}\) 的最长公共后缀为 \(longest(lca(i, j))\)
  • 分析:根据 \(link\) 的概念,所有状态的父亲内的所有字符串都是后缀,所以 \(lca\) 就满足一定是后缀,因为 \(lca\) 是所有满足条件中的最近祖先,所以 \(lca\) 满足最长,然后取状态里面最长的就行了。

  1. 一个非 \(\tau\) 的状态 \(v\) 的本质不同子串的个数为 \(len(v) - len(fa)\)
  • 分析:引理 3 然后稍微想想,其实整个状态里面本质不同子串就是从 \(shortest\)\(longest\) 长度依次增大的这些子串呀,又因为有 \(minlen(v) = len(fa) + 1\) ,相当于 \(len(v) - len(fa)\) 表示的就是本质不同子串数量。

  1. 一个非 \(\tau\) 的状态 \(v\) 中每个子串在原串中的出现次数相等,且为以 \(v\) 为根的子树中非 \(clone\) 状态的个数之和(这里要知道 \(clone\) 是什么,具体在后面)
  • 分析:前半段没啥问题,那就来看后半段。同时,我们注意到 \(clone\) 只是从一个现有状态里面“抢”了一点在新位置多出现的字符串,但是这个位置实际上对应的是状态 \(cur\) ,所以 \(clone\) 不能算数。

  • 因为每次转移之后最靠前出现的位置一定不会再有贡献了。由性质 6 ,我们能知道这些儿子的出现位置交集为空,于是所有结束位置不会出现两次。所以最终每个位置会贡献一个状态,总和也就是出现的总次数。

构造 \({\rm SAM}\)

首先,这是一个在线的算法,即每次逐个添加一个字符并维护当前 \({\rm SAM}\)

\(las\) 为添加新字符 \(c\) 前整个字符串对应的终止状态,然后创建一个新状态 \(cur\) ,易知有 \(len(cur) = len(las) + 1\) ,然后剩下的任务就是要解决 \(link\) 的问题。

我们从状态 \(las\) 开始往上用 \(link\) 跳,如果没有字符 \(c\) 的转移就添加,直到存在就停下,记为状态 \(p\)

situation1

如果 \(p\)\(-1\) ,那么说明这个字符 \(c\) 是首次出现,将 \(link\) 赋为初始状态 \(0\) ,完成。

situation2

如果存在 \(p\) ,那么通过转移 \(c\) 到一个新状态 \(q\) 。如果有 \(len(q) = len(p) + 1\) ,将 \(link\) 赋为 \(q\) ,完成。

situation3

如果不是,那么我们复制一遍 \(q\) ,创建为新状态 \(clone\) ,把 \(q\) 除了 \(len\) 的所有信息复制到 \(clone\) 上,并将 \(len(clone)\) 赋为 \(len(p) + 1\) 。然后将 \(cur\)\(q\)\(link\) 都指向 \(clone\)

最后我们要让 \(p\) 继续往上跳,假如有一个向 \(q\) 的字符 \(c\) 的转移,就重新定向到 \(clone\) ,没有就结束跳。


(补:因为如果有 \(len(q) \neq len(p) + 1\) 的情况,只有可能是大于,因为如果还是小于,加之有排除了相等的情况, \(p\)\(q\) 的关系就不会是 \(p\) 转移到 \(q\) ,而是反过来。)

最后的最后,三种情况完了之后就可以更新 \(las\)\(cur\) 就行了。


给个 \({\rm SAM}\) 的样子,扒个图(OI-wiki 上的):

image

正确性(实际上还是分析)

整个 \({\rm SAM}\) 会大概由两种情况组成,一种是对于转移上的两个状态 \(p,\ q\) ,有 \(len(q) = len(p) + 1\) ,这种我们算连续性的转移,而其他的就叫非连续性转移。

很容易发现这两种转移是完全分离的,前者属于构造中的前两种情况,后者属于第三种情况。

并且前两种情况建立完就不会变了,后者能感觉到是有可能发生变化的,因为 \(p\) 在往上面跳的话, \(len\) 只会变得更小,必然还会满足条件,也就会继续改变一些非连续性转移。

开端

加入新字符 \(c\) ,同时新建一个状态 \(cur\) ,因为前面的半成品 \({\rm SAM}\) 以及已经加入的半个字符串 \(m\) 中,是无论如何找不出一个字符串 \(m + c\) 的,没有问题。

对于 situation1

因为最后是直接跳到了 \(\tau\) ,所以只意味着整个字符串上都没有出现新字符,自然就会有以新字符串开头的子串,连向 \(\tau\) ,没有问题。

对于 situation2

相当于是我们找到了一个字符串 \(k\)\(m\) 的后缀,其中 \(k + c\) 已经在 \(m\) 中出现过了。正好,再加入一个新字符 \(c\) 之后,\(k + c\) 的出现次数是 \(2\) 次,而 \(m + c\) 的出现次数仅为 \(1\) 次,这意味着此时 \(link\) 已经满足了一个条件。

又因为 \(k + c\) 是刚刚出现了 \(2\) 次,由引理 3 可以知道,原先在加入 \(c\) 之前,\(|k| - |m|\) 的长度区间内都有字符串,当 \(k + c\)\(m + c\) 不属于一个等价类后,由于连续性, \(k + c\) 也是当前的等价类中最长的那一个,也就符合了 \(link\) 的另一个条件。

所以最后要把 \(cur\)\(link\) 接上 \(q\) ,没有问题。

对于 situation3

其实我们可以拆成 situation2 和剩余的情况来看,对于前半部分,我们本想和上面一样的处理方法,但是我们发现并没有这样的状态 \(q\) ,所以迫不得已我们只能选择新建一个点 \(clone\) ,然后把原来 \(len(q)\) 中存在的比 \(len(p) + 1\) 长的字符串刨掉,具体体现就是令 \(len(clone) = len(p) + 1\)

然后因为更长的部分其实还是属于 \(q\) 这个等价类,但是 \(clone\) 抢走了一部分,所以 \(q\)\(link\) 肯定是要更新的,我们能发现类似 situation2 的情况, \(q\) 能够正好重新怼上 \(clone\) ,所以除了把 \(cur\)\(link\) 接上 \(clone\) ,还要再加上更新 \(q\)\(link\) ,然后把 \(p\)\(q\) 的转移重新定向到 \(clone\)

这样就够了吗,恐怕还不够吧,因为 \(p\) 前面还有很多状态能跳吧,而根据性质 4, \(p\) 往上跳的状态里面有 \(p\) 中字符串的后缀,同样是有会连到 \(q\) 的可能,同样会由变更,所以同样要修改一遍,重新定向到 \(clone\) 。那这样的话 \(c\) 加入后的影响就处理完了,没有问题!

(PS:下文中 \(n\) 表示字符串长度。)

总状态数(点数)

\([n + 1,\ 2n - 1]\)

初始状态一个,前两个字符都只会有一个新状态,后面每个字符加进来要么新建一个状态,要么新建再新建一个 \(clone\)

总转移数(边数)

\([n,\ 3n - 4]\)

首先是连续转移,最少就是整个字符串只有一种字符,转移数只有 \(n\) 个,并且不存在非连续性转移。

假如要上限的话,就是 \(2n - 2\) 个转移数。

(下面都是胡乱口胡的,感性理解一下就行)

考虑非连续性转移的个数,每次对于 situation3 的情况,新建一个状态,意味着检测到一个非连续性转移,但是有可能更新之后没有干掉这条边,同时有可能还会再加一条非连续性转移,最终就有可能会有 \(n - 1\) 条出来。

但是这种情况对应出来的字符串是 \(ab...bb\) ,似乎忽略了前面连续转移,并不满足边数 \(3n - 3\) 的数量,所以我们有了个更紧的上限 \(3n - 4\) ,对应的字符串是 \(abb...bbc\)

时间复杂度证明

我们先假设字符集大小为常数,比如 \(26\) 个字母。

放眼整个构造过程,有这么几个时间复杂度的瓶颈:

  1. 最一开始不断跳 \(las\) 的后缀链接 \(link\) 看是否能添加字符 \(c\)

  2. situation3 中复制之后继续跳 \(las\)\(link\) ,并重新为 \(clone\) 相关的点重新定向。

我们能明显感觉到的,时间复杂度肯定是跟状态数和转移数有关系,然而这两者都是线性的(前面有准确上下界分析)。

所以这样来看,第一个肯定是线性的,因为每次操作平均只新增 \(1\)\(2\) 个状态,在加入这个字符 \(c\) 之后,之后在加入这个字符的话就会在这个位置停下,所以对于每种字符,均摊下来都是线性的。

第二个瓶颈的话,先这样来想:

我们设 \(linklth(v)\) 表示加入第 \(v\) 个字符后对应状态到初始状态的距离(指跳 \(link\) )。然后看构造的整个过程,发现这样的一个小式子: \(linklth(v) \leq linklth(v - 1) + 1\)

然后回到第二个瓶颈,我们再假设往上有 \(k_v\) 个状态需要重新定向,那么 \(linklth\) 又要变小,所以有新式子 \(linklth(v) \leq linklth(v - 1) + 1 - (k_v - 1)\)

于是我们对 \(k\) 求个和,发下有这么一个式子:

\[linklen(n) \leq linklen(0) + 2n - \sum_{i} k_i \]

因为 \(linklen(n)\) 的最大长度为 \(n\) ,所以显然 \(k\) 的总和是线性级别的。

啊,不用担心了。

板子代码

/*

*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 2e6 + 10;
int n, cnt, las, len[N], link[N], ch[N][26];
int tong[N], rk[N]; ll ans;
char s[N];
inline int read() {
	char ch = getchar();
	int s = 0, w = 1;
	while (!isdigit(ch)) {if (ch == '-') w = -1; ch = getchar();}
	while (isdigit(ch)) {s = (s << 3) + (s << 1) + (ch ^ 48); ch = getchar();}
	return s * w;
}
inline void SAM(int c) {
	int cur = ++cnt, p = las;
	las = cur;
	len[cur] = len[p] + 1;
	while (p && !ch[p][c]) ch[p][c] = cur, p = link[p];
	if (!p) {link[cur] = 1; return ;}
	int q = ch[p][c];
	if (len[p] + 1 == len[q]) {link[cur] = q; return ;}
	int clo = ++cnt;
	link[clo] = link[q]; len[clo] = len[p] + 1;
	link[q] = link[cur] = clo;
	memcpy(ch[clo], ch[q], sizeof(ch[clo]));
	while (p && ch[p][c] == q) ch[p][c] = clo, p = link[p];
}
inline void Tong_sort() {
	for (int i = 1; i <= cnt; ++i) ++tong[len[i]];
	for (int i = 1; i <= cnt; ++i) tong[i] += tong[i - 1];
	for (int i = 1; i <= cnt; ++i) rk[tong[len[i]]--] = i;
}
int main() {
	cnt = las = 1;
	n = read(); scanf("%s", s);
	for (int i = 0; i < n; ++i) SAM(s[i] - 'a');
	Tong_sort();
	for (int i = 1, v; i <= cnt; ++i) {
		v = rk[i]; ans += len[v] - len[link[v]];
	}
	printf("%lld\n", ans);
	return 0;
}

应用

1.求本质不同子串个数

给出文本串 \(s\) ,求本质不同子串个数

\(s\) 建立 \({\rm SAM}\) ,一种方法见后缀连接树性质 3 ,因为每个状态内没有相同的子串,对所有非 \(\tau\)\(len(v) - len(fa)\) 求和就行。

还有一种就是直接 DP ,因为本质上我们要求的是 \({\rm SAM}\) 的路径条数(定义里面有写),所以在 \({\rm DAG}\) 上跑一遍 DP 也行。

\(d_v\) 表示状态 \(v\) 为根的答案,式子大概是

\[d_v = 1 + \sum_{(v, u) \in E} d_u \]

最终结果就是 \(d_{\tau} - 1\) ,因为还要减去一个空集。

2.求本质不同子串总长度和

给出文本串 \(s\) ,求本质不同子串总长度和

一个意思吧,同样是两种方法,前一种改成什么
“首项加末项乘以项数除以 \(2\) ”就行。

DP 的话除了个数,在另设一个数组 \(g\) 表示总长度,式子就大概是:

\[d_v = 1 + \sum_{(v,\ u) \in E} d_u \]

\[g_v = 1 + \sum_{(v,\ u) \in E} (g_u + d_u) \]

意思就是原先 \(u\) 的答案的长度再加上转移中的新字符。

3.求字典序第 \(k\) 大子串

给出文本串 \(s\) ,和多个 \(k_i\) ,求 \(s\) 字典序第 \(k_i\) 大子串

\(s\) 建立 \({\rm SAM}\) ,能发现因为转移实际上是 \(26\) 个字母,所以我们可以预处理每个转移下的字串数量(1 的做法),然后找到对应的的转移,依次往下跳就能找到了。

PS:但其实这道题更适合 SA ,OIwiki说的,为什么我不知道,去问谭老师就好啦

4.最小循环移位

给出文本串 \(s\) ,问 \(s\) 在循环移位若干次后的最小字典序

回到字串本身,什么字符串能够包含所有 \(s\) 的循环移位??

\(s + s\) 对吧,那对它做 \({\rm SAM}\) ,贪心的往最小转移上跑,长度跑到 \(|s|\) 就行,并且肯定跑的完,因为如果跑不完那肯定是从后半段开始跑的,那前面还有完全一样的一段,所以不用担心跑不完。

5.检查模式串是否出现

给出文本串 \(s\) ,和多个模式串 \(t\) ,检查这些模式串 \(t\) 是否在 \(s\) 中出现过

\(s\) 建立 \({\rm SAM}\) ,因为 \({\rm SAM}\) 存在所有 \(s\) 的子串,所以就把 \(t\) 直接从 \(\tau\) 上跑对应的字符,跑不动就说明不存在,跑完了就说明存在。

6.求模式串出现次数

给出文本串 \(s\) ,和多个模式串 \(t\) ,求这些模式串 \(t\)\(s\) 中出现次数

出现次数无非就是求一个字串 \(endpos\) 集合大小是多少嘛,又因为这些位置对应的每一个非 \(clone\) 状态,而且每个状态内所有字符串是一个等价类,所以出现次数相同,所以我们设 \(f_v\) 表示状态 \(v\) 的出现次数,有式子:

\[f_{link(v)} += f_v \]

又因为状态之间的转移不会出现状态重复,所以不会存在一个位置是同时在两种状态上转移而来的,所以不会算重什么的。

7.求模式串第一次出现的位置

给出文本串 \(s\) ,和多个模式串 \(t\) ,求这些模式串 \(t\)\(s\) 中第一次出现的位置

直接在构造 \({\rm SAM}\) 时解决,如果不是 \(clone\) 状态,在新建的时候设成 \(len - 1\) 就行了,因为它出现了,否则的话同样复制成对应的状态 \(q\) 的信息,因为 \(clone\) 本质上还是 \(q\) 的一小部分。

但是我们记录的是第一次结束的位置,所以最后搜完 \(t\) 之后剪掉长度就行了。

8.求模式串所有出现的位置

给出文本串 \(s\) ,和多个模式串 \(t\) ,求这些模式串 \(t\)\(s\) 中所有出现的位置

发现其实不需要每个节点都需要记录所有出现次数,因为发现在 parent树 上,一个状态 \(v\) 的儿子们 \(u\) 第一次出现的位置必然 \(v\) 也再次出现了,并且不会重复。

所以我们只需要同上的做法,然后在 \({\rm SAM}\) 上找到模式串对应的状态,继续遍历(注意要刨掉 clone 状态)后代们就得到了所有出现位置。

9.求最短的没有出现的字符串

给出文本串 \(s\) ,求长度最小的没有出现过的字符串

怎么一步到位呢,好像不太行,我们尝试通过一次遍历来确定定位,设 \(f_v\) 表示状态 \(v\) 时答案的长度。注意到要求长度最小,所以答案中第一个不存在的字符一定是唯一并且还在末尾。

如果一个状态的转移存在空缺,标记 \(f_v\)\(1\) ,因为不存在,所以可以作为答案的结尾。否则的话有这样一个式子:

\[f_v = 1 + \sum_{(v,\ u)\in E} \min(f_u) \]

意思就是找所有转移里面不合法长度最短的那一个。最后全部算完之后,倒着推回去,如果有长度相同的随便挑一个就行了。

10.两个字符串最长公共子串

给出两个文本串 \(s\)\(t\) ,求两个文本串的最长公共子串

还是对 \(s\) 建立 \({\rm SAM}\) ,最自然的想法就是对于每一个 \(t\) 的前缀,在 \({\rm SAM}\)\(s\) 的最长后缀,答案显然就是这些所有答案中的最大值。

复杂度接受不了呀,咋办呢??

我们发现其实每次操作的差异仅仅是 \(t\) 的一个字符,所以我们考虑怎么把状态接着上一个继续跑。设两个变量:当前状态 \(v\) 和当前长度 \(lth\)

每次加入一个字符 \(c\) 后判断:

  • 当前状态是否存在 \(c\) 的转移,如果没有,我们选择让 \(v = link(v)\) ,也就是继续往上找一个状态,同时让 \(lth\) 变更为 \(len(v)\) ,因为想要的是最长的字符串。

  • 直到存在转移 \(c\) 或跳到了虚拟状态(即 \(c\) 都没有在 \(s\) 中出现过,这样的话让 \(v = lth = 0\) 就行),让 \(v\) 转移然后让 \(lth++\)

11.多个字符串最长公共子串

给出多个文本串 \(s_i\) ,求所有文本串的最长公共子串

受上个做法的启发,我们单独拎一个字符串出来建 \({\rm SAM}\) ,然后把其他字符串往上面跑。

但怎么求公共的??

我们可以每次加入字符处理完之后在对应 \({\rm SAM}\) 上的状态记录一下目前的最大匹配长度。因为要求多个串同时满足,注意要取 \(\min\) ,因为状态内所有字符串互为后缀,所以还是有正确性的。

这样对吗??

样例是这样的: \(abba,\ ab,\ ba\) 。假如我们就用第一个字符串建立 \({\rm SAM}\) ,上个图:

image

很显然第二个字符串会跑 \(2,\ 3\) ,第三个字符串会跑 \(5,\ 6\) ,但是都只经过了一次,取 \(\min\) 的话,啪唧,全部变回 \(0\) 了,肯定不是答案了。

那错哪里了呢??

我们发现 \(6\) 其实能给 \(2\) 做贡献,因为 \(2\) 中包含的字符串全是 \(6\) 的后缀。所以有这样的改进措施:一个状态能被匹配到的话,他的所有祖先( \(link\) )同样能被匹配到,所以还要从下往上取一遍 \(\max\)

莫得问题啦!

12.两个字符串公共子串个数

给出两个文本串 \(s\)\(t\) ,求两个文本串的公共子串个数

受上上一个做法的启发,我们每次新加入一个字符之后找到了一个对应的状态 \(v\) 和长度 \(len\) ,然后我们能发现整个状态内所有长度不大于 \(len\) 的似乎都是子串呀。拿求和不就完了吗??

要去重吧,万一两个地方都能匹配到 \({\rm SAM}\) 上的一个地方呢??

咋去重呢??

\(t\)\({\rm SAM}\) 上面跑能做到吧。那咋办呢。我们发现 \(t\)\(s\) 上匹配,和 \(s\)\(t\) 上匹配其实是一个道理吧。所以我们可以反过来设 \(mx_v\) 表示 \(t\)\({\rm SAM}\) 上状态 \(v\) 最长的属于 \(s\) 的子串串长。

那这下又咋算呢??

原先算法在加入字符之后,可能会有几率在 \(s\)\({\rm SAM}\) 上跳 \(link\) 以获取新字符 \(c\) 的转移。这个时候实际上在 \(t\) 上的匹配也会寄掉一点,所以我们尝试在 \(s\)\({\rm SAM}\) 跳完之后,在 \(t\)\({\rm SAM}\) 上再跳,直到两者的状态都合法为止,然后重新更新目前的长度。

所以总的来说,当 \(s\)\({\rm SAM}\) 状态发生变化了, \(t\)\({\rm SAM}\) 就跟着一起变,然后沿路更新 \(mx_v\) 就行了。

这样就对了吗??

受上一个做法的启发,可能会有类似的错误:

\[s = abbaaaa,\ t = abaaaa \]

(实际上有没有错俺也不清楚,总之是相似的例子,感性理解下就行)

所以更新之后还要全部往祖先上重新取 \(\max\) 。这样的话直接暴力跳链肯定时间复杂度会假,离线下来统一做或许可以。

但是实际上是可以在线的,因为如果一个点的 \(mx\) 已经是 \(len\) ,说明他的所有祖先也会全部这样,就没必要再更新了,所以就可以跳链到 \(mx = len\) 时停止,时间复杂度不会假。

(以下是感性证明,有错的话直接大吼大叫就行:

因为我们要更新是因为 \({\rm SAM}\) 是个 \({\rm DAG}\) ,有多条路径能到达一个状态,造成了一部分点没能更新到答案。所以我们更新就只针对这部分点,停止条件就是到达了一个之前更新过的点。

就像这样:

image

所以相当于这部分暴力跳链的时间和我们本身在 \(t\) 上转移状态是同级别的,所以是时间真的。

莫得问题啦!!

upd:有更简单的写法:

我们发现其实去重的话全部是建立在 \(t\)\({\rm SAM}\) 已经建立好之后搞的,我们想想能不能在建立的过程中就记录一些必要的信息呢??

能!

我们想要的仅是在第 \(i\) 个字符加进来后 \(endpos\) 第一个元素是 \(i\) 的那些子串。因为 \(link\) 上面所有子串在之前出现过,也必定在 \(i\) 处出现,所以在更靠前的点上已经能记录贡献了,要刨掉这种。

所以在加完第 \(i\) 个字符之后,紧接着记录父亲的最大子串长度,即 \(len(link(cur))\) 的值为数组 \(ha(i)\) ,表示这个状态前要刨掉的长度。正好此时 \(endpos\) 第一个元素为 \(i\) 的子串必然已全部出现,防止后面有 \(clone\) 状态捣乱。

所以我们只需要正常的向求最大公共子串那样求就行了,求完以此把贡献加上 \(lth - ha(i)\)

例题

【模板】最小表示法(应用 4 )

不同子串个数(应用 1 )

[SDOI2016]生成魔咒(应用 1 )

【模板】后缀自动机 (\({\rm SAM}\))(应用 2 )

[TJOI2015]弦论(应用 3 )

SP1811 LCS - Longest Common Substring(应用 10 )

SP1811 LCS - Longest Common Substring II(应用 11 )

[AHOI2013]差异(后缀连接树 性质 2 )sol

[TJOI2019]甲苯先生和大中锋的字符串(萌萌题)sol

扩展

CF914F Substrings in a String(分块)sol

CF1037H Security(线段树合并维护 endpos )sol

[NOI2018] 你的名字(应用 12 + 线段树合并)sol

区间本质不同子串个数(应用 1 + LCT )sol

[CmdOI2019]口头禅(应用 11 + 猫树)sol

[FZOI 4449] 斩尽牛杂(区间本质不同子串个数 Plus)sol

参考资料

(算是个鸣谢把)

posted @ 2022-02-06 17:10  Illusory_dimes  阅读(151)  评论(0编辑  收藏  举报