【瞎口胡】后缀自动机(SAM)
前言#
后缀自动机(Suffix Automaton, SAM)是一个能解决许多字符串相关问题的数学模型。
需要注意的是,自动机不是算法,也不是数据结构,而是一种数学模型。实现同一种自动机的方法不同可能会造成时空复杂度不同。
以下问题都可以在线性时间内通过 SAM 解决:
-
在另一个字符串中搜索一个字符串的所有出现位置。
-
计算给定的字符串中有多少个不同的子串。
定义与性质#
基础定义#
字符串 的 SAM 是一个接受 所有后缀的最小 DFA(确定性有限状态自动机)。
即:
- SAM 是一张边上标有字符的有向无环图。
- 节点被称作状态,边称为状态间的转移。
- 从每个节点出发的转移均不同。
- 存在一个起始状态 和若干个终止状态。从 开始不断转移到达终止状态时,路径上所有转移连接起来形成 的一个后缀,且 的每一个后缀均对应一条从 到某个终止状态的路径。
- 在所有满足上述条件的自动机中,SAM 的节点数最小。
按照上述定义,我们可以推导出:对于同一个状态,所有以该状态为终点的转移边上字符应该相同。
下图是 时的后缀自动机。我们用绿色节点表示终止状态,用节点 来表示起始状态 。
子串性质#
另外,SAM 具有一个重要的关于子串的性质。
因为 SAM 接受 的所有后缀,而 的子串一定是 某个后缀的前缀,所以对于字符串 ,当 是 的子串时,存在一条从 到某个节点的路径,路径上所有转移连接起来的字符串和 相同。
同时,当 不是 的子串时,我们也可以证明这样的路径不存在。注意到:
- 如果这条路径是 到某个终止状态的路径(设这条路径对应的字符串是 )的子路径,那么这条路径对应的字符串一定是 的后缀 的一个前缀,即 的一个子串。
- 如果这条路径不是任何 到终止状态的路径的子路径,那么必然可以删掉若干个节点,使得 SAM 的节点数更小,这和「SAM 的节点数是所有符合条件的自动机中最小的」这一定义矛盾。
结束位置 endpos#
字符串 的非空子串 在 中所有的结束位置构成的集合记作 。
例如,当 时,。
这样, 的所有非空子串都可以通过它们的 来划分成若干个等价类。
关于 ,有以下几个显然但十分重要的引理。
-
引理
对于 的两个非空子串 (),如果 ,那么 是 的后缀;如果 是 的后缀,那么 。
-
证明
前半句显然。对于后面半句,考虑到对于任意字符串 , 的出现次数一定不超过 的每个真后缀(不与 相等的后缀)的出现次数。因为 每次出现意味着 的每个真后缀的出现,而反之则不一定成立,所以两者的 集合是包含关系。
-
-
引理
对于 的两个非空子串 (),如果 是 的后缀,那么 ,否则 。
-
证明
前半句参见引理 。
对于后面,更长的 肯定不能和一个比它短还不是它后缀的 在同一个结束位置出现。
-
-
引理
同一个 等价类中的字符串的长度互不相同且连续。
-
证明
对于命题的前半部分,考虑反证法。由引理 ,假设等价类中存在两个不同的长度相同的字符串,那么这两个字符串互为后缀,因此这两个字符串是相同的,这和假设矛盾。
对于命题的后半部分,设等价类中最长的字符串为 ,最短的字符串为 。由引理 , 一定是 的后缀。因为 和 在同一个等价类中,因此 所有比 长的后缀也一定在这个等价类中。
-
后缀链接 link#
等价类的定义之后,我们规定,SAM 中的所有状态对应的字符串必须属于同一 等价类。容易发现,这样规定之后,SAM 的任何一个状态对应的所有字符串都是该状态对应的最长字符串的后缀。
对于 SAM 中不是 的状态 ,记 表示状态 对应的最长的字符串,记 。我们称状态 的最长对应串为 。
设 为 最长的和 不在同一个等价类中的后缀,则 的后缀链接 指向最长对应串为 的状态。
-
引理
对于 SAM 中不是 的状态 , 一定存在且唯一。
-
证明
由引理 和后缀链接的定义, 一定是其所属等价类中最长的字符串。又因为 SAM 的子串性质,所以一定存在状态使得该状态对应的字符串集合中包含 ,且该状态的最长对应串就是 。
如果 有多个后缀链接,那么这不满足 SAM 的最小性。
-
-
引理
将 SAM 上除 外所有节点的后缀链接和它连边,会形成一棵以 为根的有根树。
-
证明
只需要注意到, 每经过一次后缀链接,状态的最长对应长度便至少减少 ,这样一定会到达最长对应为空串的节点 。
-
-
引理
如果我们令空串的 为 ,那么通过 构造的树(每个节点父亲的最长对应串的 真包含该节点最长对应串的 )和通过后缀链接构造的树相同。
-
证明
只需要注意到,如果字符串 的某个后缀 和它不在一个等价类中,那么一定有 。
而 是 的后缀且和它不在一个等价类中,因此对于状态 , 对应的状态就是 在 树上的父节点。
-
SAM 的线性构造#
我们可以在线性时间内维护 从而构造 SAM。在构造时,我们先构造出 的 SAM,然后尝试加入 。在加入 时,我们默认之前的所有构造是正确的。
记 表示 对应的状态。首先我们新建一个节点 表示现在的状态。
我们需要计算 ,并且因为 的所有后缀新出现了一次,所以我们还需要维护 所有后缀的后缀链接,这需要找出 后缀的所有等价类。事实上,沿着 的后缀链接向上遍历一直到根,那么有结论:
-
记途中经过所有状态的最长对应串为 ,那么我们会发现, 所有和 在同一个等价类中的后缀 ,满足 在同一个等价类中。
这是显然的。所有的 总是同时出现,而 会出现且仅出现在满足 且 的位置 。
那么我们沿着 的后缀链接向上遍历,经过一个节点 就找一下 这个节点,它对应的就是 的某个后缀的等价类了。根据上面的结论,我们不会漏掉某些等价类。
从 开始不断遍历后缀链接,记当前节点为 ,。接下来,我们需要分类讨论。
Case 1.1#
如果 已经有出边 ,设 经过出边转移到的节点为 。此时我们需要继续分类讨论。
先来讨论 。
此时, 就是 ,我们只需要将 设为 。因为我们从下往上遍历,所以找到的一定是最长的 。
已经出现在了 SAM 中,因此 的所有后缀(它们也是 的后缀)已经出现在了 SAM 中(想一想,为什么?),我们不需要继续遍历。
Case 1.2#
再来讨论 。
此时 不再是 的最长对应串。
设 ,并设 表示通过 转移到 的节点中 最长的节点。显然, 是 的后缀。因为 和 在同一等价类中且 比 长,但通过 通过后缀链接先访问到的节点是 而不是 ,这说明了 不是 的后缀。
此时, 的 中新增了 ,而 不是 的后缀,因此 的所有后缀和 不应属于一个等价类中。我们需要新建一个节点 ,复制 除了 之外的所有信息。我们继续遍历 的后缀链接,将所有连向 的状态重定向到 ,然后将 设置为 。这样,我们保证了 SAM 的正确性。
值得一提的是,在遍历后缀链接的过程中,我们可以找到第一个出边 不转移到 的状态就停止。如果这个状态的出边 不转移到 ,那么之后所有状态的出边 都不可能转移到 。这是因为遍历后缀链接时,状态的最长对应串长度会减少,可能会成为更多后缀的子串。而出边 如果不转移到 ,那么说明找到了一个比 「更强」的状态可供转移。之后的状态要么转移到这个状态,要么转移到比这个状态还「强」的状态,不可能转移到 。
和第一种情况一样,此时我们也不需要继续遍历。但有一个疑问仍然存在:如果 的某个祖先 的出边 指向异于 的一个节点 ,而 ,我们不应该执行同样的操作,将 分成两个部分吗?
事实上,这种情况不会存在。
考虑到,如果 ,那 是 的后缀。
设通过出边 转移到 的点中 最长的是 ,那么通过和之前一样的策略,能够证明 不是 的后缀。
但是 是 的后缀,两边去掉最后一个字符, 是 的后缀。
于是 必须要比 长(注意是 而非 ),因为 自己是 的后缀,但 不是 的后缀,然而这是不可能的。这样的串在 进行复制操作的时候就已经被分离出来了,不可能在向上遍历的过程中再次出现,这不符合 SAM 的最小性。
Case 2#
最后,来考虑 没有出边 的情况。
此时我们无法找到 。因为我们需要在 SAM 中保存所有子串的信息,但此时不存在 对应的状态,因此我们需要将 设置为 。
此时我们需要继续遍历。
小结#
对 依次执行上述操作,便完成了 SAM 的构造。我们发现,上述构造除了最小性之外的正确性是显然的。
感兴趣的读者可以自行查阅最小性的证明。
复杂度证明#
此处的证明不太严谨。事实上 SAM 的节点数和转移数都有不带 记号的准确上界,感兴趣的读者可以自行查阅。
SAM 的复杂度证明依赖于一个假设:字符集的大小 是常数。接下来,我们设 。
空间复杂度#
我们发现,每添加一个字符,最多增加两个状态,因此节点数是 的;每个节点对应字符的转移边最多被添加一次,因此边数也是 的(别忘了字符集大小是常数!)。
时间复杂度#
有两处的时间复杂度还不太清晰:
-
遍历 的所有后缀链接,找到第一个有字符 出边的状态
这取决于有多少个状态没有字符 的出边。因为一个出边被加入就不会被删除,因此总复杂度是均摊 的:节点数是 ,字符集是常数。
-
遍历 的后缀链接,将出边重定向到
我们可以证明, 的后缀链接链是 的后缀链接链上的状态通过一条 出边到达的状态所组成集合的子集。因此,如果我们把多个状态的出边重定向到同一个状态,那么 的后缀链接链的长度上界减小。
设 表示添加第 个字符时,出边被重定向到 的状态个数; 表示 对应状态的后缀链接长。那么我们有 。同时,我们有 和 ,因此, 最多增加 ,所以 ,于是 是 的。这正是我们要证明的。
代码(主体部分)#
inline void sam_insert(int c){
int p=last;
int now=++tot;
sam[now].len=sam[last].len+1;
size[now]=1;
while(p&&(!sam[p].nex[c]))
sam[p].nex[c]=now,p=sam[p].link; // case2: 对应节点没有出边 c
last=now;
if(!p){ // 一个有出边的 c 都没有
sam[now].link=1;
return;
}
if(sam[p].len+1==sam[sam[p].nex[c]].len){ // case1.1
sam[now].link=sam[p].nex[c];
return;
}
int clone=++tot,qnode=sam[p].nex[c]; // case1.2
sam[clone]=sam[qnode];
sam[clone].len=sam[p].len+1; // 全部复制
sam[qnode].link=clone,sam[now].link=clone; // 更新 link
while(p&&sam[p].nex[c]==qnode){ // 重定向到 clone
sam[p].nex[c]=clone,p=sam[p].link;
}
return;
}
作者:Meatherm
出处:https://www.cnblogs.com/Meatherm/p/16390617.html
版权:本作品采用「署名-非商业性使用-相同方式共享 4.0 国际」许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律