[学习笔记]后缀自动鸡/SAM
〇、关于后缀自动鸡的一些牢骚废话和引入
它取名叫后缀自动鸡,但是实际上在一个自动鸡出炉之后好像和后缀基本上没什么关系,按照 大佬的话,叫 “子串自动鸡” 可能更恰当.
只不过,它取名叫后缀自动鸡,原因是之一可能是在原本的串 后面插入新字符 时,将 的许多后缀遍历了一遍,因而得名 “后缀自动鸡”.
但是实际上,它在建成之后,自动鸡中似乎并没有彰显 “后缀” 的东西,而更像是把串 的所有子串放到一个时空复杂度都很优秀的 中.
哦,还有句话,后缀自动鸡的简称 ,而不是 .
壹、一些新概念英语
在进行正式说明前,我们有一些新概念需要理清.
一、终止节点等价类( 等价类)
首先,对于串 中的每个子串 ,我们将其出现的地方全部找出来,然后将这些出现的地方的尾端放到同一个类里面,然后给这个类取一个名字,叫终止节点集合,或者 集合(在某些文章中称为 集合),举个栗子,有这样的串:
我们有一个子串 ,发现有:
那么, 集合就是 .
继续,现在我们每个不同的子串都有一个 集合(显然相同的子串没啥用),对于这些不同的子串,我们将 集合相同的子串再放到一个集合中,叫做 等价类,将这个等价类用 集合中的元素进行代替.
那么,在这个例子中,有这些等价类(无特定顺序):
可以看到,有一些 集合相同的串被归为了同一个类.
下文中,我们将用 类 代替 等价类(能少一点就少一点).
二、自动鸡
有一个叫做 自动鸡的东西 但是不是拿来自动过题的,它也叫 “自动鸡”,但是到底什么是 "自动鸡" 呢?
我们认为一个东西是 "自动鸡",有一些基本概念:
- 这里的自动鸡其实是有限状态自动鸡();
- 自动鸡不是 算法/数据结构,只是一种数学模型,对于同一个问题可能有很多只鸡,但是不是所有的鸡都满足我们解决问题时的资源消耗(比如时间和空间);
同时,一只鸡由这几个部分组成(虽然下文不会用到):
- 字符集 ,表示这只鸡只能输入这些字符;
- 状态集合 ,即在自动鸡建好之后形成的的 上的顶点;
- 起始状态 ,不解释;
- 接受状态集合 ,有 ;
- 转移函数 ,即点之间的转移方法;
明白概念即可,下文大概率用不到.
贰、终止节点集合与类的亿些特性及证明
对于上文的类有一些特性 并给它们取了名字,接下来给出并证明:
一、同类即后缀
如果有两个串 (其实取不到等号) 同属一个类,那么一定有 是 的后缀.
证明:.
二、从属或无关
,他们的 集合只有两个情况:
证明:.
三、同类长连续
属于同一个类中的所有串,他们的长度一定是连续的,同时,如果按照长度从小到大排序,那么一定有前一个串是后一个串去掉第一个字符的后缀,或者说,后一个串是前一个串在前面加上了一个字符.
证明:.
所以,如果我们想要储存一个类,只需要存在最短串长度与最长串的长相就可以了.
四、类数为线性
类的个数为 级别的.
证明:对于两个类 ,由 "从属或无关" 我们可以知道 要么包含要么不相交,同时,我们也可以找到同一个集合 满足 ,那么,我们可以得到 其实是 进行了分裂得到的两个类,其实, 可以一次性分裂成两个或更多个类,但由于我们最开始的集合 ,即 ,由线段树对于集合最多的不交划分结论可知,总类的数量不会超过 ,实际上最多 个类.
终于不是显然了.
五,邻类邻长度
对于一个类 ,如果有一个 满足 ,且不存在 满足 ,即 与 是直接父子关系,那么有 中的最短的串 与 中最长的串 满足 .
证明:对于一个集合的分裂,其实就是在最长的串 前面加上一个字符得到新的字符串 ,那么, 的出现的限制条件一定比 更强,即 所属的类就变成了 所属的类 的子集了,为甚说 一定不在同一个类中呢?如果在同一类中,那么 就不是最长的串而是 了,而得到的 就是 的某个子集中最短的一个了,那么就有 .
所以,我们只要知道详细父子关系,那么如果我们想要表示一个类只需要存下最长的串的样子就可以了.
叁、如何建自动鸡
其实在证明中已经使用了一些类之间存在父子关系的特性了.
定义直接父子关系:对于一个类 ,如果有一个 满足 ,且不存在 满足 ,那么 与 是直接父子关系.
我们按照类的直接父子关系,建出一棵树,这棵树叫做 ,这个 就是 的骨架,但是自动鸡只有这棵树似乎什么用都没有,就像 指针基于 树得到 自动鸡一样,我们得在 上加些东西.
首先,根据一些证明的说明,沿 边就是在串的前面动手脚,但是我们还需要在串的后面做手脚,这个时候就需要加入新的边了,表示在串的后面加些字符,这就是自动鸡上面的边.
给出代码,做出进一步说明:
/** @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];
表示的即为自动鸡上面的边;
表示自动鸡上的父子关系,即我们通过保存父亲节点来维护整个 ;
表示的每个点代表的类中,最长的串有多长;
接下来进入 函数:
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;
表示我们在插入 之前,代表整个串的点的编号是多少;
代表我们要将插入 之后,代表新的整个的串的点的编号;
显然就是在未插入之前的长度基础上增加一;
/** @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;
接下来,我们考虑对于原串 ,在增加 之后,原来的 个后缀全都有了新的样子(在原来的基础上在末尾加上 ),同时,还多出来一个新的后缀——单独一个 ,我们称这些东西为新后缀,这个循环要做的事情就是,这些新后缀如果在之前没有出现过,那么我们要给他们归为一个新的类——,同时,显然,后缀越长,它越不容易出现,所以我们从代表 的点 开始向上走,表示从长到短枚举原来的后缀,对于没出现过的,即 ,我们将他们连接到新的类——代表 的点 上,表示在这些后缀后加上 可以到达新的类;
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 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];
否则,则说明有些新后缀在之前出现过,即有些后缀的 集合不只是 ,还有些其他的东西,同时,由于之前全部的后缀的点的类中都加上 这个元素,那么此时,仅仅只代表 这个元素的类的点 肯定就是其他点的儿子了,那 的父亲究竟是谁?显然就是之前循环中第一个不满足条件的 的 ,我们称这个点为 .
同时,对于 ,由于它和 并非有实际父子关系,而是由自动鸡连接的 你居然不是我亲生的,也就是说 代表的串并非全是原串 的后缀(但一定会有一个 的后缀,即在 所代表的后缀之后加上一个 的后缀 ),但是我们找到它的原因是什么——它其中的某些串在 加上 之后,会在 再出现一次,也就是有些串的 就会改动,同样的, 中有些串又不会改动,这下出了个问题—— 所代表的类中的串的 集合不一样了,根据定义已经不能再将他们归为同一类,所以接下来我们要考虑将 分裂——加上 的部分,和没有加上 的部分;
/** @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
*/
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;
将 的信息继承到 上,同时,显然我们分裂的点就是特征串(由 “同类即后缀” 同理可得),显然,原 (没加上元素 )就是多出 点的 类的一个子集,那么可以确定他们有直接父子关系了 (仅仅只多出一个元素,显然不可能找到 满足 ),那么我们将 的父亲设置为 ,同样,只有一个元素 的 类的父亲肯定也是 类了.
/** @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;
但是,我们确实是将 分裂出去了,但是,这个 是代表原来的 的,但是还有一些点是沿着自动鸡连接在旧的 上,我们要将他们改过来,将他们和 连接.
然后,插入新点结束,构建自动鸡时只需:
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');
}
注意:如果需要统计某个点的子节点大小,在将 裂开之后产生的新点不能计算 ,因为它只是一个裂开的点为了表示一个新类,并不会对总点数产生贡献.
肆、复杂度分析
一、点数线性
点数即类数,上文已证明,最多 个 类/点.
二、边数线性
考虑自动鸡有以下性质:
- 从 开始,沿不同的路径,最终一定会走到不同的子串;
- 由 “同类即后缀”,以及 “同类长连续”,可以说明,只要这个类中有一个串是整个串的后缀,那么整个类都是串的后缀(其实从另一个角度说,同一个类中的串,区别只在于他们在前面加字符,但是在后面的字符是不动的)
然后,我们进行一个定义:定义广义环为忽略边的方向,将所有边视为无向边之后形成的环成为广义环.
现在,我们可以开始证明边数不超过 了.
首先,假设我们有了一个自动鸡,但是他们还没有连边,我们从 开始走后缀,现在规定走后缀的规则:
- 每次走后缀只有一次花费;
- 如果走的边和原来的边没有形成广义环,那么我们走这条边并将这条边添加进自动鸡,并且这条边不会使用花费;
- 如果走的边和原来的边形成了广义环,那么我们走这条边并将这条边添加进自动鸡,并且使用花费;
如果我们沿这些规律走后缀,最后最多只会有 条边,
三、 函数复杂度证明
我再贴一个代码:
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;
}
}
}
其实这里就几个循环,而对于前两个,我们都是在加边,但是由于我们边数已经是 的上界了,那么这俩循环加起来总共也不超过 ,但是我们考虑我们的第三个循环:
for(; p && tre[p][c] == q; p = fa[p])
tre[p][c] = split_part;
这个不好分析,我们考虑整个 在干什么:
- 从 向上爬,这个过程会减小可能成为 的 的 (就是类里面最小的字符串长度);
- 爬到头了,从 走到 ,这个过程会让可能成为 的 的 不多不少 ;
- 给 认父亲,然后让 变成 (也就是下一次的 );
我们发现,每次 的 最多只会增加一,但是减少却可能一次性减少很多,这类似于 求 数组的过程,那么总复杂度大概在 级别.
实际上,如果我们将 当成势能函数,可能会更好理解.
伍、应用
部分引自 ,对于部分 我知道的 可以使用 解决的也会大致描述,同样,如果有其它如使用 的巧妙方法亦会大致描述.
一、检查子串
- 使用 :
检查 是否是 的某个子串.
后缀自动鸡中实际上保存了 的所有子串信息,所以你只需要从开始节点沿自动鸡边走就可以了.
- 使用 :
将 和 拼在一起,中间用分隔符隔开,判断 即可.
二、本质不同串个数
- 使用 :
沿自动鸡边走形成的串本来就是本质不同的子串,所以,我们只需要统计从开始节点走的不同路径数,由于是 ,可以直接跑拓扑 .
- 使用 :
每个后缀长度减去 之和.
三、第 k 大子串
- 使用 :
如果是去重,那么每个点的 定为 ,即表示每个字串都只出现一次而不因 树的出现而改变,反之, 即为其 子树的大小.
然后,我们处理出一个 数组表示经过这个点的子串有多少,那么就有
处理出来之后用类似 树的做法即可.
- 使用 :
如果是去重,处理出 之后,由每个后缀都有 个本质不同的子串,然后就可以解决.
如果是非去重,这里直接引用 大佬 的解决方案.
因为我们前面的字母是确定的,那么当前面的字母一样的时候,后面一个字母一定是单调不下降的。
那我们就能二分求出这个字母最后一个的位置。
我们建一个后缀长度的前缀和,那我们就能求出以这个字母开头的子串有多少个。
当个数大于 时,就确定了这个字母,否则 减去,继续枚举下一个字母。
枚举完一位继续下一位,那我们就能把范围缩小,知道求出答案。
实际上本质和 的做法差不多.
四、二元最长公共子串(LCS)
- 使用 :
对于一个串建立 ,拿另一个串在 上能跑多远跑多远,跑不了了就往父亲跳.
- 使用 :
两个串用 经典流氓做法接在一起(注意中间用分隔符隔开),处理出 之后将 排序从大到小枚举长度,直到一个属于两个串的 满足标准后就找到长度(如果要找长什么样子也可以,这里不做赘述).
五、最小循环位移
- 使用 :
先找到 位置对应的 ,那么最小循环位移就可能是 ,检查一下,如果不满足就 .
- 使用 :
复制一个 并对其建立 ,然后在 上寻找最小的长度为 的路径即可.找最小的,我们只需要贪心往最小字符走即可.
- 使用 :
理论上可以当 用,我们对于反串排出 ,然后看 的位置,那么长度要么就是它和它前一名、要么就是它和它后一名的 ,然后我们暴力检验就可以了.
六、子串出现次数
- 使用 :
找到子串对应的点,然后算出这个点的 就可以了.
- 使用 :
用流氓做法,把子串接在后面然后处理出 ,然后看属于两个串的 刚刚好是子串长度的 个数即可.
七、多元最长公共子串(exLCS)
- 使用 :
两种思路:
- 对于第一个建立 ,然后其他的在这个上跑,对于每个点,记录 表示走到 能够匹配上的最长公共子串的长度,注意 ,最后跑拓扑,用 更新 ,得到匹配完某个串之后的 ,然后记录在 中,其中 表示第 个点的历史中能匹配的最小长度(因为我们要求的是满足所有的串),最后在 中找最大就可以了.
- 设 ,其中 是我们想要找的, 是对应的分隔符,我们对于 建立 ,然后,如果 中有一个子串,那么存在一条路径从 不经过其他的 的路径. 然后我们处理连通性,使用 等搜索算法之族以及动规计算,然后,答案就是所有的 都能到达的点中 的最大值.
- 使用 :
流氓将所有串暴力接起来,从大到小枚举 长度,并用并查集枚举每一个合并的块中有多少不同的集合,当某个连通块中存在了所有的串,此时枚举的长度就是答案串的长度,寻找就随意了.
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现