[学习笔记]后缀自动鸡/SAM
〇、关于后缀自动鸡的一些牢骚废话和引入
它取名叫后缀自动鸡,但是实际上在一个自动鸡出炉之后好像和后缀基本上没什么关系,按照 \(\text{JZM}\) 大佬的话,叫 “子串自动鸡” 可能更恰当.
只不过,它取名叫后缀自动鸡,原因是之一可能是在原本的串 \(S\) 后面插入新字符 \(c\) 时,将 \(S\) 的许多后缀遍历了一遍,因而得名 “后缀自动鸡”.
但是实际上,它在建成之后,自动鸡中似乎并没有彰显 “后缀” 的东西,而更像是把串 \(S\) 的所有子串放到一个时空复杂度都很优秀的 \(DAG\) 中.
哦,还有句话,后缀自动鸡的简称 \(\text{SAM/Suffix Automaton}\),而不是 \(\text{SAC/Suffix Autochiken}\).
壹、一些新概念英语
在进行正式说明前,我们有一些新概念需要理清.
一、终止节点等价类(\(\text{endpos}/\text{right}\) 等价类)
首先,对于串 \(S\) 中的每个子串 \(s_i\),我们将其出现的地方全部找出来,然后将这些出现的地方的尾端放到同一个类里面,然后给这个类取一个名字,叫终止节点集合,或者 \(\text{endpos}\) 集合(在某些文章中称为 \(\text{right}\) 集合),举个栗子,有这样的串:
我们有一个子串 \(s_i=\tt aba\),发现有:
那么,\(\text{endpos}_i\) 集合就是 \(\{3,6\}\).
继续,现在我们每个不同的子串都有一个 \(\text{endpos}\) 集合(显然相同的子串没啥用),对于这些不同的子串,我们将 \(\text{endpos}\) 集合相同的子串再放到一个集合中,叫做 \(\text{endpos}\) 等价类,将这个等价类用 \(\text{endpos}\) 集合中的元素进行代替.
那么,在这个例子中,有这些等价类(无特定顺序):
可以看到,有一些 \(\text{endpos}\) 集合相同的串被归为了同一个类.
下文中,我们将用 类 代替 \(\text{endpos}\) 等价类(能少一点就少一点).
二、自动鸡
有一个叫做 \(AC\) 自动鸡的东西 但是不是拿来自动过题的,它也叫 “自动鸡”,但是到底什么是 "自动鸡" 呢?
我们认为一个东西是 "自动鸡",有一些基本概念:
- 这里的自动鸡其实是有限状态自动鸡(\(\text{FSM}\));
- 自动鸡不是 算法/数据结构,只是一种数学模型,对于同一个问题可能有很多只鸡,但是不是所有的鸡都满足我们解决问题时的资源消耗(比如时间和空间);
同时,一只鸡由这几个部分组成(虽然下文不会用到):
- 字符集 \(\Sigma\),表示这只鸡只能输入这些字符;
- 状态集合 \(Q\),即在自动鸡建好之后形成的的 \(DAG\) 上的顶点;
- 起始状态 \(start/s\),不解释;
- 接受状态集合 \(F\),有 \(F\subseteq Q\);
- 转移函数 \(\delta\),即点之间的转移方法;
明白概念即可,下文大概率用不到.
贰、终止节点集合与类的亿些特性及证明
对于上文的类有一些特性 并给它们取了名字,接下来给出并证明:
一、同类即后缀
如果有两个串 \(S,T(|S|\le |T|)\)(其实取不到等号) 同属一个类,那么一定有 \(S\) 是 \(T\) 的后缀.
证明:\(\mathcal Obviously\).
二、从属或无关
\(\forall S,T(|S|\le |T|)\),他们的 \(\text{endpos}\) 集合只有两个情况:
证明:\(\mathcal Obviously\).
三、同类长连续
属于同一个类中的所有串,他们的长度一定是连续的,同时,如果按照长度从小到大排序,那么一定有前一个串是后一个串去掉第一个字符的后缀,或者说,后一个串是前一个串在前面加上了一个字符.
证明:\(\mathcal Obviously\).
所以,如果我们想要储存一个类,只需要存在最短串长度与最长串的长相就可以了.
四、类数为线性
类的个数为 \(\mathcal O(n)\) 级别的.
证明:对于两个类 \(\mathcal{X,Y}\),由 "从属或无关" 我们可以知道 \(\mathcal{X,Y}\) 要么包含要么不相交,同时,我们也可以找到同一个集合 \(\mathcal Z\) 满足 \(\mathcal{X,Y}\subseteq \mathcal Z\),那么,我们可以得到 \(\mathcal{X,Y}\) 其实是 \(\mathcal Z\) 进行了分裂得到的两个类,其实,\(\mathcal Z\) 可以一次性分裂成两个或更多个类,但由于我们最开始的集合 \(\mathcal Z=\{1,2,3,...n\}\),即 \(|\mathcal Z|=n\),由线段树对于集合最多的不交划分结论可知,总类的数量不会超过 \(\mathcal O(2n)\),实际上最多 \(2n-1\) 个类.
终于不是显然了.
五,邻类邻长度
对于一个类 \(\mathcal X\),如果有一个 \(\mathcal Y\) 满足 \(\mathcal X\subseteq \mathcal Y\),且不存在 \(\mathcal Z\) 满足 \(\mathcal X\subseteq \mathcal Z\subseteq Y\),即 \(\mathcal X\) 与 \(\mathcal Y\) 是直接父子关系,那么有 \(\mathcal X\) 中的最短的串 \(S\) 与 \(\mathcal Y\) 中最长的串 \(T\) 满足 \(|S|=|T|+1\).
证明:对于一个集合的分裂,其实就是在最长的串 \(A\) 前面加上一个字符得到新的字符串 \(B\),那么,\(B\) 的出现的限制条件一定比 \(A\) 更强,即 \(B\) 所属的类就变成了 \(A\) 所属的类 \(\mathcal P\) 的子集了,为甚说 \(A,B\) 一定不在同一个类中呢?如果在同一类中,那么 \(A\) 就不是最长的串而是 \(B\) 了,而得到的 \(B\) 就是 \(\mathcal P\) 的某个子集中最短的一个了,那么就有 \(|A|+1=|B|\).
所以,我们只要知道详细父子关系,那么如果我们想要表示一个类只需要存下最长的串的样子就可以了.
叁、如何建自动鸡
其实在证明中已经使用了一些类之间存在父子关系的特性了.
定义直接父子关系:对于一个类 \(\mathcal X\),如果有一个 \(\mathcal Y\) 满足 \(\mathcal X\subseteq \mathcal Y\),且不存在 \(\mathcal Z\) 满足 \(\mathcal X\subseteq \mathcal Z\subseteq Y\),那么 \(\mathcal X\) 与 \(\mathcal Y\) 是直接父子关系.
我们按照类的直接父子关系,建出一棵树,这棵树叫做 \(\text{parent tree}\),这个 \(\text{parent tree}\) 就是 \(SAM\) 的骨架,但是自动鸡只有这棵树似乎什么用都没有,就像 \(\tt fail\) 指针基于 \(\tt trie\) 树得到 \(AC\) 自动鸡一样,我们得在 \(\text{parent tree}\) 上加些东西.
首先,根据一些证明的说明,沿 \(\text{parent tree}\) 边就是在串的前面动手脚,但是我们还需要在串的后面做手脚,这个时候就需要加入新的边了,表示在串的后面加些字符,这就是自动鸡上面的边.
给出代码,做出进一步说明:
/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];
/** @brief the number of nodes*/
int ncnt = 1;
/** @brief this variable is necessary, it records the id of the node of the origin string*/
int lst = 1;
/**
* pay attention : node 1 is an empty node, it represent root
* and the length of this node is 0
*/
inline void add(const int c){
// the id of the original string
int p = lst;
// the id of new node or class
int u = lst = ++ ncnt;
// just means the length increases by 1
lenth[u] = lenth[p] + 1; sz[u] = 1;
/** @brief
* if the suffix of the original string
* add a new character @p c hasn't appeared yet,
* then we connect the represent node with the new node,
* it means that we can get a new class if we add @p c to the end
*/
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
/** @brief
* if all suffixes haven't appeared before after adding @p c
* we'll reach the situation where @p p equals to 0,
* which means the father of the new class is 1(root)
*/
if(!p) fa[u] = 1;
/** @brief if the situation is not like this
* just means some suffixes have appeared before after adding @p c
* which means the new class is a split version of a certain class(actually class @p tre[p][c] )
*/
else{
/** @brief
* of course @p q is the original version of the new class,
* that is, @p q is apparently the father of @p u,
* but there are some other questions that
* some suffixes of @p q will appear at n,
* in other words, some part of @p q will add END POINT @p n (present length),
* which will make a different to class @p q and
* cause the split of class @p q.
* We're going to judge whether @p q should be split or not,
* if the answer is true, we're supposed to handle the split
*/
int q = tre[p][c];
/** @brief
* if all suffix have appeared before after adding @p c ,
* then there's no need to split class @p q ,
* because add END POINT will add n(present length)
* we just need to connect the new class @p u with it father
*/
if(lenth[q] == lenth[p] + 1) fa[u] = q;
/** @brief
* if not, then we're going to find out
* which part of class @p q is going to add END POINT @p n (present length),
* and we split it from here
*/
else{
/** @brief part to add new END POINT
* now node @p q represent a class which won't add new END POINT
* obviously that class @p q and class @p u are the subset of class @p split_part
*/
// pay attention : the new node has no real meaning, so the size shouldn't be added.
int split_part = ++ ncnt;
rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;
fa[q] = fa[u] = split_part;
/** @brief
* because the original node @p q is split to two part,
* @p split_part and @p q (another meaning)
* then if an edge which connect a certain node with origin @p q ,
* it is supposed to be changed
*/
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
}
}
}
首先看变量定义:
/** @brief the size should be two times of the origin*/
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];
\(\tt tre[][]\) 表示的即为自动鸡上面的边;
\(\tt fa[]\) 表示自动鸡上的父子关系,即我们通过保存父亲节点来维护整个 \(\text{parent tree}\);
\(\tt lenth[]\) 表示的每个点代表的类中,最长的串有多长;
接下来进入 \(\tt add()\) 函数:
inline void add(const int c){
这个参数 \(\tt c\) 表示我们要在串的末尾插入的字符;
// the id of the original string
int p = lst;
// the id of new node or class
int u = lst = ++ ncnt;
// just means the length increases by 1
lenth[u] = lenth[p] + 1;
\(\tt p\) 表示我们在插入 \(\tt c\) 之前,代表整个串的点的编号是多少;
\(\tt u\) 代表我们要将插入 \(\tt c\) 之后,代表新的整个的串的点的编号;
\(\tt lenth[u]\) 显然就是在未插入之前的长度基础上增加一;
/** @brief
* if the suffix of the original string
* add a new character @p c hasn't appeared yet,
* then we connect the represent node with the new node,
* it means that we can get a new class if we add @p c to the end
*/
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
接下来,我们考虑对于原串 \(S\),在增加 \(\tt c\) 之后,原来的 \(|S|-1\) 个后缀全都有了新的样子(在原来的基础上在末尾加上 \(\tt c\)),同时,还多出来一个新的后缀——单独一个 \(\tt c\),我们称这些东西为新后缀,这个循环要做的事情就是,这些新后缀如果在之前没有出现过,那么我们要给他们归为一个新的类——\(\text{endpos}=\{|S|+1\}\),同时,显然,后缀越长,它越不容易出现,所以我们从代表 \(S\) 的点 \(\tt p\) 开始向上走,表示从长到短枚举原来的后缀,对于没出现过的,即 \(\tt tre[p][c]=0\),我们将他们连接到新的类——代表 \(\text{endpos}=\{|S|+1\}\) 的点 \(\tt u\) 上,表示在这些后缀后加上 \(\tt c\) 可以到达新的类;
if(!p) fa[u] = 1;
/** @brief if the situation is not like this
* just means some suffixes have appeared before after adding @p c
* which means the new class is a split version of a certain class(actually class @p tre[p][c] )
*/
如果上一个循坏直接走到空点,也就是说对于原串 \(S\) 的所有后缀,在加上 \(\tt c\) 之后,在之前的串中都没见过,那么它的父节点就是 \(\text{endpos=}[1,n]\cap \mathcal Z\) 的根节点了.
else{
/** @brief
* of course @p q is the original version of the new class,
* that is, @p q is apparently the father of @p u,
* but there are some other questions that
* some suffixes of @p q will appear at n,
* in other words, some part of @p q will add END POINT @p n (present length),
* which will make a difference to class @p q and
* cause the split of class @p q.
* We're going to judge whether @p q should be split or not,
* if the answer is true, we're supposed to handle the split
*/
int q = tre[p][c];
否则,则说明有些新后缀在之前出现过,即有些后缀的 \(\text{endpos}\) 集合不只是 \(\{|S|+1\}\),还有些其他的东西,同时,由于之前全部的后缀的点的类中都加上 \(|S|+1\) 这个元素,那么此时,仅仅只代表 \(|S|+1\) 这个元素的类的点 \(\tt u\) 肯定就是其他点的儿子了,那 \(\tt u\) 的父亲究竟是谁?显然就是之前循环中第一个不满足条件的 \(\tt p\) 的 \(\tt tre[p][c]\),我们称这个点为 \(\tt q\).
同时,对于 \(\tt q\),由于它和 \(\tt p\) 并非有实际父子关系,而是由自动鸡连接的 你居然不是我亲生的,也就是说 \(\tt q\) 代表的串并非全是原串 \(S\) 的后缀(但一定会有一个 \(S\) 的后缀,即在 \(\tt p\) 所代表的后缀之后加上一个 \(\tt c\) 的后缀 ),但是我们找到它的原因是什么——它其中的某些串在 \(S\) 加上 \(\tt c\) 之后,会在 \(|S|+1\) 再出现一次,也就是有些串的 \(\text{endpos}\) 就会改动,同样的,\(\tt q\) 中有些串又不会改动,这下出了个问题——\(\tt q\) 所代表的类中的串的 \(\text{endpos}\) 集合不一样了,根据定义已经不能再将他们归为同一类,所以接下来我们要考虑将 \(\tt q\) 分裂——加上 \(|S|+1\) 的部分,和没有加上 \(|S|+1\) 的部分;
/** @brief
* if all suffix have appeared before after adding @p c ,
* then there's no need to split class @p q ,
* because add END POINT will add n(present length)
* we just need to connect the new class @p u with it father
*/
if(lenth[q] == lenth[p] + 1) fa[u] = q;
上文提及,\(\tt q\) 中一定会包含一个 \(S\) 的后缀,即在 \(\tt p\) 所代表的后缀之后加上一个 \(\tt c\) 的后缀,但是,如果我们发现 \(\tt q\) 类中最长的就是这个后缀(下文称其为特征串),由 “同类即后缀” 意味着 \(\tt q\) 类中所有串的 \(\text{endpos}\) 集合都同时加入 \(|S|+1\) 这个元素,那么这个 \(\tt q\) 类就没有分裂的必要了;
/** @brief
* if not, then we're going to find out
* which part of class @p q is going to add END POINT @p n (present length),
* and we split it from here
*/
else{
/** @brief part to add new END POINT
* now node @p q represent a class which won't add new END POINT
* obviously that class @p q and class @p u are the subset of class @p split_part
*/
int split_part = ++ ncnt;
否则,则意味着 \(\tt q\) 逃不掉分裂的命运,接下来我们要考虑的是从哪里裂开,先申请一个新的点 \(\tt split\_part\) 当作分裂出去的部分(此处认定分裂部分为加上 \(|S|+1\) 元素的部分)的编号.
rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;
将 \(\tt p\) 的信息继承到 \(\tt split\_part\) 上,同时,显然我们分裂的点就是特征串(由 “同类即后缀” 同理可得),显然,原 \(\tt q\)(没加上元素 \(|S|+1\))就是多出 \(|S|+1\) 点的 \(\tt split\_part\) 类的一个子集,那么可以确定他们有直接父子关系了 (仅仅只多出一个元素,显然不可能找到 \(\mathcal Z\) 满足 \(\tt q\subseteq\mathcal Z\subseteq \tt \tt split\_part\)),那么我们将 \(\tt q\) 的父亲设置为 \(\tt split\_part\),同样,只有一个元素 \(|S|+1\) 的 \(\tt u\) 类的父亲肯定也是 \(\tt split\_part\) 类了.
/** @brief
* because the original node @p q is split to two part,
* @p split_part and @p q (another meaning)
* then if an edge which connect a certain node with origin @p q ,
* it is supposed to be changed
*/
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
但是,我们确实是将 \(\tt split\_part\) 分裂出去了,但是,这个 \(\tt split\_part\) 是代表原来的 \(\tt q\) 的,但是还有一些点是沿着自动鸡连接在旧的 \(\tt q\) 上,我们要将他们改过来,将他们和 \(\tt split\_part\) 连接.
然后,插入新点结束,构建自动鸡时只需:
rep(i, 1, n) add(s[i] - 'a');
贴一发整合代码:
const int maxn = 1e6;
int tre[maxn * 2 + 5][30];
int lenth[maxn * 2 + 5];
int fa[maxn * 2 + 5];
int sz[maxn * 2 + 5];
int ncnt = 1;
int lst = 1;
char s[maxn + 5];
int n; // the length of string s
inline void input(){
scanf("%s", s + 1);
n = strlen(s + 1);
}
inline void add(const int c){
int p = lst;
int u = lst = ++ ncnt;
lenth[u] = lenth[p] + 1;
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
if(!p) fa[u] = 1;
else{
int q = tre[p][c];
if(lenth[q] == lenth[p] + 1) fa[u] = q;
else{
int split_part = ++ ncnt;
rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;
fa[q] = fa[u] = split_part;
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
}
}
}
signed main(){
input();
rep(i, 1, n) add(s[i] - 'a');
}
注意:如果需要统计某个点的子节点大小,在将 \(\tt q\) 裂开之后产生的新点不能计算 \(\tt sz\),因为它只是一个裂开的点为了表示一个新类,并不会对总点数产生贡献.
肆、复杂度分析
一、点数线性
点数即类数,上文已证明,最多 \(2n-1\) 个 类/点.
二、边数线性
考虑自动鸡有以下性质:
- 从 \(s\) 开始,沿不同的路径,最终一定会走到不同的子串;
- 由 “同类即后缀”,以及 “同类长连续”,可以说明,只要这个类中有一个串是整个串的后缀,那么整个类都是串的后缀(其实从另一个角度说,同一个类中的串,区别只在于他们在前面加字符,但是在后面的字符是不动的)
然后,我们进行一个定义:定义广义环为忽略边的方向,将所有边视为无向边之后形成的环成为广义环.
现在,我们可以开始证明边数不超过 \(3n-4\) 了.
首先,假设我们有了一个自动鸡,但是他们还没有连边,我们从 \(s\) 开始走后缀,现在规定走后缀的规则:
- 每次走后缀只有一次花费;
- 如果走的边和原来的边没有形成广义环,那么我们走这条边并将这条边添加进自动鸡,并且这条边不会使用花费;
- 如果走的边和原来的边形成了广义环,那么我们走这条边并将这条边添加进自动鸡,并且使用花费;
如果我们沿这些规律走后缀,最后最多只会有 \(3n-4\) 条边,
三、\(\tt add()\) 函数复杂度证明
我再贴一个代码:
inline void add(const int c){
int p = lst;
int u = lst = ++ ncnt;
lenth[u] = lenth[p] + 1;
for(; p && !tre[p][c]; p = fa[p]) tre[p][c] = u;
if(!p) fa[u] = 1;
else{
int q = tre[p][c];
if(lenth[q] == lenth[p] + 1) fa[u] = q;
else{
int split_part = ++ ncnt;
rep(i, 0, 25) tre[split_part][i] = tre[q][i];
fa[split_part] = fa[q];
lenth[split_part] = lenth[p] + 1;
fa[q] = fa[u] = split_part;
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
}
}
}
其实这里就几个循环,而对于前两个,我们都是在加边,但是由于我们边数已经是 \(3n-4\) 的上界了,那么这俩循环加起来总共也不超过 \(3n-4\),但是我们考虑我们的第三个循环:
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
这个不好分析,我们考虑整个 \(\tt add()\) 在干什么:
- 从 \(lst\) 向上爬,这个过程会减小可能成为 \(u\) 的 \(\tt fa[u]\) 的 \(\text{minlen}\)(就是类里面最小的字符串长度);
- 爬到头了,从 \(p\) 走到 \(q\),这个过程会让可能成为 \(u\) 的 \(\tt fa[u]\) 的 \(\text{minlen}\) 不多不少 \(+1\);
- 给 \(u\) 认父亲,然后让 \(u\) 变成 \(lst\)(也就是下一次的 \(u\));
我们发现,每次 \(fa[u]\) 的 \(\text{minlen}\) 最多只会增加一,但是减少却可能一次性减少很多,这类似于 \(\tt SA\) 求 \(\text{heit}\) 数组的过程,那么总复杂度大概在 \(\mathcal O(n)\) 级别.
实际上,如果我们将 \(\text{minlen}(fa[u])\) 当成势能函数,可能会更好理解.
伍、应用
部分引自 \(\tt oi-wiki\),对于部分 我知道的 可以使用 \(SA\) 解决的也会大致描述,同样,如果有其它如使用 \(\tt kmp\) 的巧妙方法亦会大致描述.
一、检查子串
- 使用 \(SAM\):
检查 \(T\) 是否是 \(S\) 的某个子串.
后缀自动鸡中实际上保存了 \(S\) 的所有子串信息,所以你只需要从开始节点沿自动鸡边走就可以了.
- 使用 \(SA\):
将 \(S\) 和 \(T\) 拼在一起,中间用分隔符隔开,判断 \(\text{heit}\) 即可.
二、本质不同串个数
- 使用 \(SAM\):
\(SAM\) 沿自动鸡边走形成的串本来就是本质不同的子串,所以,我们只需要统计从开始节点走的不同路径数,由于是 \(DAG\),可以直接跑拓扑 \(DP\).
- 使用 \(SA\):
每个后缀长度减去 \(\text{heit}\) 之和.
三、第 k 大子串
- 使用 \(SAM\):
如果是去重,那么每个点的 \(sz\) 定为 \(1\),即表示每个字串都只出现一次而不因 \(\text{parent tree}\) 树的出现而改变,反之,\(sz\) 即为其 \(\text{parent tree}\) 子树的大小.
然后,我们处理出一个 \(\tt sum\) 数组表示经过这个点的子串有多少,那么就有
处理出来之后用类似 \(\tt trie\) 树的做法即可.
- 使用 \(SA\):
如果是去重,处理出 \(\tt SA\) 之后,由每个后缀都有 \(n-sa[i]+1-heit[i]\) 个本质不同的子串,然后就可以解决.
如果是非去重,这里直接引用 大佬 的解决方案.
因为我们前面的字母是确定的,那么当前面的字母一样的时候,后面一个字母一定是单调不下降的。
那我们就能二分求出这个字母最后一个的位置。
我们建一个后缀长度的前缀和,那我们就能求出以这个字母开头的子串有多少个。
当个数大于 \(k\) 时,就确定了这个字母,否则 \(k\) 减去,继续枚举下一个字母。
枚举完一位继续下一位,那我们就能把范围缩小,知道求出答案。
实际上本质和 \(SAM\) 的做法差不多.
四、二元最长公共子串(LCS)
- 使用 \(SAM\):
对于一个串建立 \(SAM\),拿另一个串在 \(SAM\) 上能跑多远跑多远,跑不了了就往父亲跳.
- 使用 \(SA\):
两个串用 \(SA\) 经典流氓做法接在一起(注意中间用分隔符隔开),处理出 \(\tt sa\) 之后将 \(\tt heit\) 排序从大到小枚举长度,直到一个属于两个串的 \(\tt heit\) 满足标准后就找到长度(如果要找长什么样子也可以,这里不做赘述).
五、最小循环位移
- 使用 \(\tt kmp\):
先找到 \(n\) 位置对应的 \(nxt\),那么最小循环位移就可能是 \(n-nxt[n]\),检查一下,如果不满足就 \(GG\).
- 使用 \(SAM\):
复制一个 \(S\) 并对其建立 \(SAM\),然后在 \(SAM\) 上寻找最小的长度为 \(|S|\) 的路径即可.找最小的,我们只需要贪心往最小字符走即可.
- 使用 \(SA\):
理论上可以当 \(\tt kmp\) 用,我们对于反串排出 \(\tt sa\),然后看 \(S'[1...n]\) 的位置,那么长度要么就是它和它前一名、要么就是它和它后一名的 \(LCP\),然后我们暴力检验就可以了.
六、子串出现次数
- 使用 \(SAM\):
找到子串对应的点,然后算出这个点的 \(sz\) 就可以了.
- 使用 \(SA\):
用流氓做法,把子串接在后面然后处理出 \(\tt sa\),然后看属于两个串的 \(heit\) 刚刚好是子串长度的 \(heit\) 个数即可.
七、多元最长公共子串(exLCS)
- 使用 \(SAM\):
两种思路:
- 对于第一个建立 \(SAM\),然后其他的在这个上跑,对于每个点,记录 \(f[i]\) 表示走到 \(i\) 能够匹配上的最长公共子串的长度,注意 \(f[i]\le \text{longest}(i)\),最后跑拓扑,用 \(f[i]\) 更新 \(f[fa[i]]\),得到匹配完某个串之后的 \(f[]\),然后记录在 \(ans[i]\) 中,其中 \(ans[i]\) 表示第 \(i\) 个点的历史中能匹配的最小长度(因为我们要求的是满足所有的串),最后在 \(ans[i]\) 中找最大就可以了.
- 设 \(T=S_1+D_1+S_2+D_2+...+D_{n-1}+S_n+D_n\),其中 \(S_i\) 是我们想要找的,\(D_i\) 是对应的分隔符,我们对于 \(T\) 建立 \(SAM\),然后,如果 \(S_i\) 中有一个子串,那么存在一条路径从 \(D_i\) 不经过其他的 \(D_j\) 的路径. 然后我们处理连通性,使用 \(\tt BFS\) 等搜索算法之族以及动规计算,然后,答案就是所有的 \(D_i\) 都能到达的点中 \(\text{longest}\) 的最大值.
- 使用 \(SA\):
流氓将所有串暴力接起来,从大到小枚举 \(heit\) 长度,并用并查集枚举每一个合并的块中有多少不同的集合,当某个连通块中存在了所有的串,此时枚举的长度就是答案串的长度,寻找就随意了.