Loading

SAM小寄

SAM 是啥

简单来说,选取字符串 \(S\) 的任意子串 \(S[L:R]\),在 \(S\) 对应的 SAM 上,从初始结点出发,依次走 \(S[L:R]\) 每个字符对应的边(也可以称作转移),可以走到某个点,而不会在中途发现没有这个字符对应的边。
也就是说,从初始结点出发在 SAM 上走,可以走出字符串的每一个子串。
因此,SAM 中的每个结点都对应字符串中某个前缀的一段后缀(这个性质特别重要);从初始结点开始一直走直到没有出边,对应的一定是原串的一个后缀。

后缀链接

如果暴力构造 SAM,对于每个后缀都建一条链,时间和空间复杂度都是平方级别的,这是可(un)接(acc)受(ept)的(able)
但是这样显然非常浪费。比如,一些子串在字符串中所有出现的地方结尾位置都相同,能不能把它们合并到一起?
其实,这就是优化构造 SAM 的核心思想之一。

\(S\) 的子串 \(s\)\(S\) 中子串在字符串中所有出现的地方的结尾位置的集合为 \(endpos(S)\)。对于 \(S\) 的一个前缀 \(S_{pre}\),可以将其后缀按照 \(endpos\) 分为若干个等价类,可以合并 SAM 中一些 \(endpos\) 相同的结点。
为了高效地构造 SAM,需要记录下每个结点对应的子串 \(s\) 的最长的满足 \(endpos\)\(s\) 不相同的后缀对应的结点,称作这个结点的后缀链接;还要记下该结点对应的子串最长的长度 \(Mx\)(该结点对应的子串最短的长度(接下来记为 \(Mn\))即为该结点的后缀链接到达的结点的 \(Mx\) 加上 \(1\))。

接下来说的 SAM,构造过程中时间复杂度为 \(O(|S|)\),空间复杂度都为 \(O(|S||\sum{}|)\)

如何构造

考虑一个一个字符插入。在没有任何字符时,有初始结点 \(0\) 号点。记当前最后一个字符对应的结点为 \(Nw\)

在插入一个新结点 \(N\)(其对应的字符为 \(c_i\))时,将其 \(Mx\) 设置为 \(Mx_{Nw}+1\),从 \(Nw\) 开始,一步一步通过后缀链接往上跳,每到一个结点就建立从这个结点通过 \(c_i\)\(N\) 的转移,直到这个结点已经存在通过 \(c_i\) 的转移;
如果一直跳到初始结点还没有找到这样的转移,那么就将 \(Nw\) 的后缀链接设置为初始结点。
这是因为从 \(Nw\) 跳后缀链接到达的是原串以 \(Nw\) 为结尾的前缀的一些长度连续的后缀;若跳到的一个结点没有通过 \(c_i\) 的转移,那么这个结点对应的子串连接上 \(c_i\) 构成的子串都只在 \(i\) 处出现一次(\(endpos\)\(\{i\}\))。

如果跳到某个结点 \(X\) 时找到了通过 \(c_i\) 的转移,该转移到达 \(Y\),分两种情况考虑:

  • \(X\) 是插入 \(Y\) 前的 \(Nw\)\(Mx_Y=Mx_X+1\),那么 \(Y\) 对应的子串是 \(N\) 对应的子串的后缀,且满足上面所说的后缀链接的性质,将 \(N\) 的后缀链接指向 \(Y\)
  • \(X\) 不是插入 \(Y\) 前的 \(Nw\),那么 \(Y\) 对应的子串可能不是 \(N\) 对应的子串的后缀,因为 \(N\)\(Y\) 是从不同的两条后缀链接路径跳到 \(X\) 的,顺着转移走到 \(N\)\(Y\) 的路径可能出现分叉。
    这时候,新建一个 \(Y\) 的克隆结点 \(Cl\)(这个结点并不对应原串中的某个字符),作为 \(N\)\(Y\) 对应的子串最长的共同后缀。
    根据定义,复制从 \(Y\) 出发的所有转移与 \(Y\) 的后缀链接,随后从 \(X\) 出发跳后缀链接,将所有到达的结点指向 \(Y\) 的转移重定向到 \(Cl\);将 \(N\)\(Y\) 的后缀链接都指向 \(Cl\)
    这里还有一个优化,即指向一个结点的转移来自后缀链接上的一条路径,因此在重定向转移时只要在某个结点找不到指向 \(Y\) 的转移就可以退出了。

最后,将 \(Nw\) 重设为 \(N\)

时间复杂度证明

空间复杂度显然。
在证明算法的时间复杂度之前,先来证明构造的 SAM 的转移数为线性。
首先,选出 SAM 的一棵生成树,树上的转移数是线性的,因为状态数是线性的。
然后,分析不在生成树里的转移,其中的每一条转移都将树上从初始结点出发的一条路径(根据树的性质一定有这样的路径)与到达某个终止状态的任意路径连接在一起(根据一开始所说的 SAM 的性质一定有这样的路径),
可以与至少一个后缀相对应,且不可能有两条不同的路径对应同一个子串,一个后缀只能对应一条不在树上的转移,而后缀只有 \(|S|\) 个,这里的转移数也是线性的。
于是建立新转移的时间复杂度显然为线性。

重定向转移的复杂度分析还不会。咕咕咕。

需要注意的是,需要用邻接表或 vector 存下转移以保证复杂度中不会多出来一个 \(|\sum|\)(虽然大部分情况下这个优化几乎没有用)。

用处

统计不同子串个数

SAM 上的每个结点 \(i\) 对应 \(Mx_i-Mn_i+1\) 个子串(显然),并且 SAM 是一个 DAG(同样显然),给每个结点赋初值,直接在 DAG 上 dp 即可。
如果要求不同子串的总长度,可以将每个结点的初值改为其对应的 \(Mx_i-Mn_i+1\) 个(长度连续的)子串长度总和,即 \(\sum_{x=Mn_i}^{Mx_i}x\)

统计子串出现次数

根据后缀链接的性质,假如 \(i\) 在某些位置出现了,那么其后缀链接指向的结点在这些位置也出现了。
因此给每个非克隆结点(它们与原串中的字符一一对应)赋初值 \(1\),在后缀链接构成的以初始结点为根的树上 dp,父亲的 dp 值等于所有儿子的 dp 值加上自身的初值。
这样一定是不重不漏的,因为每个结点只有一条后缀链接,并不会重复地统计一个状态出现的次数。

最长公共子串

一种简单的方法是将两个字符串的结尾加上互不相同的特殊字符,连接起来,建立 SAM
若一个字符串 \(S\) 为它们的公共子串,那么对于每个特殊字符 \(C_i\)\(S\) 对应的结点可以在不经过其他特殊字符的情况下转移到某个节点并经过一条通过 \(C_i\) 的转移。
通过 dp 可以求出每个结点满足几个特殊字符的要求(如上),找到每个公共子串对应的结点。取 \(Mx\) 最大的即可。
这种方法也适用于有多个字符串的情况。

接下来的方法只能用于两个字符串的情况,但是也有另外的用途。
对一个字符串建立 SAM,类似 AC自动机,用另外一个字符串在 SAM 上匹配。记录下目前所在的结点 \(Nw\)(一开始为初始结点)与目前最长的匹配的长度 \(Len\)。对于每个字符 \(c_i\)

  • 不断跳后缀链接,直到当前结点有通过 \(c_i\) 的转移,或到达初始结点。如果这一步跳了后缀链接,就将 \(Len\) 设置为 \(Mx_{Nw}\)(要找最长的)。
  • 如果当前结点有通过 \(c_i\) 的转移,那么就走这条转移,并且将 \(Len\) 设置为 \(Len+1\)(多匹配了一个字符)。

\(Len\) 的最大值就可以了。这样还可以求出每个前缀能匹配的最长后缀。
这样的时间复杂度是线性的,因为每次跳后缀链接,长度至少减少 \(1\),而每次走转移长度增加 \(1\)

posted @ 2024-06-26 15:39  AsiraeM  阅读(46)  评论(0)    收藏  举报