后缀自动机 (SAM) 的构造及应用
cnblogs 怎么又炸了。
为什么又可爱又强的 xxn 去年 9 月就会的科技樱雪喵现在还不会呢 /kel。
感觉 SAM 的教程已经被前人写烂了啊。那就写点个人学习过程中对 SAM 的理解。
参考资料:KesdiaelKen-史上最通俗的后缀自动机详解、OI wiki-后缀自动机 (SAM)。
upd on 2023.10.25:补充了时空复杂度证明。
概述
SAM 是一个能够在线性时间内解决很多字符串问题的算法。
比如对于 ,构造出的 SAM 形如下图:
看得出来,它是一张有向无环图,其中每条边上标有一个字母表示转移。在图上的任意一条路径,把它经过的边上的字符接起来,都是 的一个子串。相应地, 中的任意一个子串都能在 SAM 中找到至少一条对应的路径。
设 SAM 的起点为 ,则字符串 的任意一个后缀都存在唯一一条从 出发的路径与之对应。
SAM 就是用来构造满足上面条件,且点数与边数均最小的图的算法。
SAM 的点数(状态数)上界为 ,边数(转移数)上界为 ,时空复杂度均为 。这些将在后文给出解释与证明。
在构建 SAM 之前,先要了解一些相关的概念和结论。
endpos
假设 为 的一个非空子串,我们定义 在 中的所有结束位置构成的集合为 。例如对于 ,。
那么 的所有子串 ,可以根据它们 的不同,将子串划分为若干个等价类。
为方便后文对 性质的总结,这里定义空串 。
而这也就是 SAM 中节点的定义。SAM 中的每个节点恰好对应地表示一个 等价类,所有从 到这个点的路径表示的子串,它们的 都相同,并与以其他点为终点的均不同。
因此,SAM 的节点个数即为 所有子串不同的 等价类个数 (起点)。
根据 的定义,它有如下性质:
性质 1:若子串 和 属于同一等价类,且 ,则 每次出现在 中,都作为子串 的后缀。
反证,若存在 在某个结束位置 出现,且它不是 的后缀,那 没法也在 位置出现,与它们属于同一 等价类矛盾。
性质 2:对于两个非空子串 和 (),必然满足 和 其中之一。
- 若 为 的后缀:所有出现 的位置一定会同时存在子串 ,而出现子串 的位置不是一定有 。故此时 成立。
- 不是 的后缀:依旧反证,如果有一个位置同时是它们两个的 , 一定得是 的后缀,与条件矛盾。此时 成立。
性质 3:将一个 等价类所包含的所有互不相同子串按长度升序排序,任意两串长度不相等,每个串都是下一个串的后缀,且长度差为 。
- 若存在两个串长度相等, 相同,那这两个串一定相同,与子串互不相同矛盾。
- 设存在两个串 和 的 相同,且 。设 为 的一个后缀,且 。那么由性质 1 知, 是 的后缀。那么由性质 2,得 。又因为 ,则 。证得 也必定属于该等价类。
link
对于 SAM 上除 外的点 ,设它包含的子串中最长的一个是 。定义这样的 的长度为 。
根据上文的性质 3,我们知道:一段连续的,长度在 之间的 的后缀属于该等价类。在这里,我们定义点 的后缀链接 表示 最长且 不等于 的后缀所属的等价类节点。
对于 ,有如下性质:
性质 4:把所有 和 连边,则后缀链接构成一棵以 为根的树。
- 根据定义, 包含的子串均为 子串的后缀。那么跳后缀链接过程中,子串长度单调递减,最后必然能跳到空串,即 。
性质 5:对任意节点 ,都有 。
- 由性质 2 和后缀链接的定义, 包含的子串是 包含的后缀,故 ;
- 它们一定不相等,因为如果相等应该合并成一个点。
同时,我们得到关于 的表达式:。
正片开始
SAM 的构造
这是一个在线算法,也就是说我们逐一加入字符,并对应地构造出当前字符串的 SAM。现假设已经构造完了串 ,要在 后面添加一个字符 。
这里先粘个板子,再逐一解释每句代码的含义。
void add(int c)
{
int p=lst,np=lst=++tot;
d[np].len=d[p].len+1;
for(;p&&!d[p].ch[c];p=d[p].fa) d[p].ch[c]=np;
if(!p) {d[np].fa=1;return;}
int q=d[p].ch[c];
if(d[q].len==d[p].len+1) d[np].fa=q;
else
{
int nq=++tot; d[nq]=d[q];
d[nq].len=d[p].len+1,d[q].fa=d[np].fa=nq;
for(;p&&d[p].ch[c]==q;p=d[p].fa) d[p].ch[c]=nq;
}
}
背下来背下来。
int p=lst,np=lst=++tot;
d[np].len=d[p].len+1;
在已有的字符串后面接了一个 ,考虑多了哪些子串:原来 的每个后缀(包含空串)后面接一个 。
设 为原来表示整个 串的点,即 的点;那么 这个字符串用现在的 SAM 肯定表示不出来,我们要建一个新点 ,并连一条 的边来表示新串。
那么这个新点包含的最长子串长度显然为 ,即 。
for(;p&&!d[p].ch[c];p=d[p].fa) d[p].ch[c]=np;
if(!p) {d[np].fa=1;return;}
我们现在的 SAM 表示出了 这一整个子串,接下来我们要让 的每个后缀后面都接上 这个转移。
对于第一个循环,p=d[p].fa
就是枚举 的所有后缀,并让它向 连边。如果循环过程中没有发现一个 连过 这条边,代表新串的所有后缀都没有在原串中出现过,它们的 都是 ,那么根据 的定义,第一个不属于该等价类的后缀就是空串,即 。
否则如果跳的过程中发现有一个 连过 这条边了,那么 的后缀链接自然也都连过 边,无需继续循环;则接下来的后缀都是以前在 里就出现过的子串,要把这种情况拿出来继续讨论。
int q=d[p].ch[c];
if(d[q].len==d[p].len+1) d[np].fa=q;
这里需要讨论 和 的关系。
考虑 的实际含义。感觉这里很神秘啊。
对于所有连向点 的点 ,里面显然只有一个点满足 。那如果这条边正好是 ,就代表 。
根据 的性质 3,这就保证了 包含的所有子串都是新字符串的一个后缀。它们的 集合都增加了一个 ,依旧属于同一个等价类,不用改动。
而 自然也就是 。
else
{
int nq=++tot; d[nq]=d[q];
d[nq].len=d[p].len+1,d[q].fa=d[np].fa=nq;
for(;p&&d[p].ch[c]==q;p=d[p].fa) d[p].ch[c]=nq;
}
到了最抽象的部分!
的话, 是 的一个后缀。也就是说, 包含的所有子串中,只有长度不大于 的这部分后缀出现次数又增加了 。我们被迫把原来全部属于 的子串分进两个不同的等价类里面。
新建一个节点 ,表示从 转移过来,出现次数增加了 的这部分子串。虽然这部分被单独分出来了,但它的后续转移跟新加的 并没有关系,可以从 直接复制过来。
考虑被分出来的子串是 中比较短的那一部分后缀,而且它们也是新串的后缀。故有 。
循环的作用是把原来连在 的边都改到 上去。
至此,我们成功地构建了一个 SAM 。
正确性证明
upd: 来补个证明。
状态数
SAM 的状态数上界为 。
这可以从 SAM 的构建过程中直接看出:插入前两个字符时一定只会新建一个状态,而后面假设每次都需要新建状态,至多会建出 个。算上初始节点,总数为 。
该上界可以在字符串形如 时取到,因为它从第三个字符开始每加一个 都会产生新的 等价类。画出来大概长这样:
转移数
SAM 的转移数上界为 。
我们把满足 的转移称作连续的,考虑这样的转移个数。观察代码,可以发现除初始状态以外的每个状态 有且仅有一个 满足 到 的转移连续。
那么连续的转移数上界为状态数上界减 ,即 。
接下来证明不连续的转移个数。设 到 的转移是不连续的,这条转移边上的字符是 。
我们考虑找出经过这条转移边的最长字符串。设 为从初始状态到 的最长字符串, 为从 到某个终止状态的最长字符串。根据最长的性质, 和 上的转移都是连续的。这也就是说,经过每条不连续的转移边的最长字符串均不相同。
再考虑 这个串的性质,根据 SAM 的定义,一条从 出发走到某终止状态的路径唯一对应原串的一个后缀。那么 是原串 的后缀,且不能是完整的 , 因为这样所有转移都应该连续。
故我们证明了不连续的转移至多有 个。
但是 等于 ,为什么多了一个呢?
这是因为两种转移数不可能同时取到上界。我们令连续的转移取上界,即状态数取上界时,字符串形如 。而此时根据上图不连续的转移数量显然取不到 。因此我们得到更精确的上界为 ,这可以在 时取到。它长成这样:
构建的时间复杂度
证明了状态与转移的数量,我们来证明 SAM 的构建过程也是线性。
代码里只有两句带循环,对它们依次进行分析。
for(;p&&!d[p].s[c];p=d[p].fa) d[p].s[c]=np;
这句的作用是新连一条转移边,根据转移数线性的结论,我们执行这个循环的次数是线性。
for(;p&&d[p].s[c]==q;p=d[p].fa) d[p].s[c]=nq;
首先我们知道:设 ,根据代码可以看出一定不会存在 的不连续转移边。
考虑每次进行完该循环后的 fail 树。此时有 ,同时由于 是原来的 ,而且原来的 还是不连续转移, 不会是某个遍历到过的 。那么下次再插入时,从 开始跳 树,必然不会把这些重定向过的边再重定向一遍。那么每个边最多被重定向了一次,为线性。
综上,构建 SAM 的总时间复杂度为线性。
基础应用
貌似 SAM 能做的事 SA 也差不多都能,所以合一起口胡一下做法。
判断字符串是否出现
-
SAM
把文本串的 SAM 建出来。根据任意一个子串都能被 SAM 上一条以 为起点的路径表示的性质,从起点开始顺着模式串字符的转移边跑,能跑完整个模式串就是出现了。 -
SA
对文本串 跑 SA,模式串如果出现就一定是 的一个后缀的前缀,在排序后 的所有后缀中逐位二分即可确定它所在的位置。
不同子串个数
-
SAM
两种做法。
从 SAM 上转移边的角度考虑,不同的子串个数即为从 出发的不同路径数。设从点 出发的路径数为 ,则有转移:,答案为 。
从后缀树的角度考虑,我们知道 ,则属于点 的 等价类的子串个数为 。总个数为每个节点的答案之和。 -
SA
不同子串个数本质上求的是 。使用单调栈处理出 左右首个比自己小的数的位置,即可统计贡献。似乎也可以从大到小填数,用并查集维护两边的 ?
字典序第 大子串
-
SAM
预处理从每个点出发的路径数,那么可以快速地判断是否要走当前转移边。按字典序从小到大枚举转移边,直到确定第 个子串包含在里面即可。
如果本质不同的算成多个,就在处理 时把同一个点包含的子串个数计入贡献。 -
SA
排序后从前往后枚举每个后缀,它对答案的贡献就是总长度去掉 。
对于本质不同算多个好像挺麻烦的,具体可以看 这篇博客。
子串出现次数
-
SAM
找到该子串在 SAM 上对应的点,从这个点出发能走到的终止状态数就是子串出现的次数。这个东西可以在后缀树上 DP,注意求的不是从这个点出发的路径数。 -
SA
与判断字符串是否出现方法一致,使用二分找到以该子串作为前缀的 区间。
最长公共子串
-
SAM
要求 和 的最长公共子串,我们先对 构造 SAM,然后再对 在 SAM 上进行匹配。具体地,对于 的每个前缀,我们在 上找最长的( 的这个前缀)的后缀。
听起来很绕。换句话来讲:逐一往 后面添加字符,维护当前 的后缀在 上最多匹配到哪里。我们维护两个变量 和 ,表示当前 SAM 上匹配到的节点 & 匹配的字符串长度。
考虑在 末尾新加入字符 对答案的影响。若 有字符 的转移边,则沿边转移,;否则不断跳 直到存在该转移边,并令 。在这个过程中记录 的最大值即可。
关于这里为什么令 是对的:如果实际匹配的长度大于 ,那在上一次跳 的时候就应该能转移。如果实际匹配的长度小于 ,那加入 之前,原来的 和 根本对不上,与上一轮已经匹配正确的前提矛盾。 -
SA
相比之下 SA 处理这个就很简单,把两个字符串接在一起,最长公共子串所属的后缀,排完序肯定挨在一起,所以满足 和 不属于同一个串的 的最大值就是答案。
多个串的最长公共子串
-
SAM
看到两种主流做法。
一种是 OI-wiki 说的把所有串并在一起跑 SAM,然后根据特殊字符判断。
另外一种是对最短的子串建 SAM,并依次考虑其他串,在 SAM 上记录每个点的最大匹配长度的最小值。然后答案是(这些最小值)的最大值。(禁止套娃。 -
SA
把所有串并在一起跑 SA。二分最长公共子串的长度 ,对于每个 的连续段判断是不是在每个串中都出现过即可。
于是学个 SAM 板子学了一天,并且可以预见到未来的几天内我又会把它忘得一干二净。什么时候能变得有效率呢!
那就完结撒花吧 >w<
本文来自博客园,作者:樱雪喵,转载请注明原文链接:https://www.cnblogs.com/ying-xue/p/sam.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)