【学习笔记】后缀自动机(SAM)
后缀自动机
可怜的我,学了两三遍才终于有点感觉了,也是因为这东西是真的复杂。
基本定义
后缀自动机(SAM)是用来解决字符串问题的一种数据结构,可以理解为字符串的压缩形式,是一个有向无环图。其中从源点出发到达每一个点的路径都是原串的一个子串,且原串的每一个子串都一定可以表示为一条路径。
endpos
定义
在字符串 \(s\) 中,我们记 \(endpos(t)\) 为字符串 \(t\) 在 \(s\) 中所有的结束位置。例如在字符串 "\(abcbc\)" 中 \(endpos("bc") = {3,5}\) 。
我们就将 \(endpos\) 值相同的字符串称作一个等价类,即在同一个等价类中必定存在 \(endpos(t_1) = endpos(t_2)\),其中 \(t_1,t_2\) 为两个字符串
SAM 中的每个节点对应一个等价类,即 SAM 节点的数量为等价类的数量加一,因为存在一个初始状态。
性质
性质一:若存在 \(endpos(t_1) = endpos(t_2)\),且满足 \(|t_1| \le |t_2|\) ,即在字符串 \(s\) 中, \(t_1\) 都是以 \(t_2\) 的后缀的形式出现。
性质二:对于每一个 \(endpos\) 等价类,等价类中的子串长度一定恰好完全覆盖 \([l,r]\),\(l\) 为最短子串长度, \(r\) 为最长子串长度。且任取两个子串,较短者一定是较长者的后缀。
后缀链接(link)
定义
对于一个状态 \(t\),我们记 \(w\) 为其的最长子串,那么一个后缀链接就是由 \(t\) 连向 \(w\) 的所有后缀中与 \(w\) 的 \(endpos\) 值不同且最长的子串的状态。(好绕啊)
性质
性质一: 所有后缀链接构成一棵以源点为根的树
边也就是后缀链接,把所有的边反向也就是一颗以源点为根的树
(转自 OI_Wiki)
构造
SAM 的构造看上去很简单,但实际上非常难以理解。
(转自 OI_Wiki)
下面就是详细地解释一下这个东西。
SAM 的构造是在线的,也就是说我们是在原有的字符串末尾一点点插入字符然后构造的。
第一个点:没有什么好说的
第二个点:\(len(t)\) 即 \(t\) 这个节点代表的状态下的最长子串的长度,新加入的这个状态代表的最长子串也就是整个字符串,长度也就是没有这个点的时候的字符串长度加一
第三个点:其实也没啥说的,就是找到一个状态 \(p\),使得 \(p\) 是从 \(last\) 沿后缀链接向前第一个遇到的有字符 \(c\) 的转移的状态,转移可以理解为从这个状态的最长子串后面加一个 \(c\) 能得到的子串所对应的状态。
第四个点:所有的能从 \(last\) 沿后缀链接能到的状态都没有字符 \(c\) 的转移,也就是原串里加入字符 \(c\) 之前,不含有字符 \(c\),那么根据后缀链接的定义,连接任何状态都不对,所以只能与源点项链。所谓从 \(last\) 沿后缀链接能到的状态,可以理解为其的子串一定包含原串的所有后缀,但不仅包含这些而已。
第五个点:没啥说的,其实就是找到一个状态 \(q\) 使得 \(q\) 是 \(p\) 的最长子串后加入一个字符 \(c\) 得到的状态。
第六个点:略过
第七个点:根据后缀链接的定义,那么对于 \(p\) 中的最长子串一定是不加入字符串 \(c\) 的原串的后缀,那么其加入一个字符 \(c\) 之后就一定是新串的后缀,而 \(p\) 又是第一个找到的,也就是满足该条件的状态里最长字串最长的一个,那么在 \(p\) 后面加入一个字符 \(c\),也就可以理解为新串的后缀,也就是 \(q\) 状态,而 \(q\) 一定不等于 \(cur\),而 \(q\) 又是最长的一个满足条件的新串的后缀,也就是 \(cur\) 的后缀,满足后缀链接的定义,所以应该从 \(cur\) 后缀链接到 \(q\)。
第八个点:这里前提条件就是 \(q\) 含有比 \(p + c\) 更长的一个字符串,但是考虑一点 \(p+c\) 是新串的后缀,而 \(p+c\) 与 \(q\) 的 \(endpos\) 相同,也就是说 \(q\) 里面包含的这个最长的字符串也肯定是新串的后缀。下面我们就考虑分出来这样的一个点,也就是 \(clone\) ,使得 \(clone\) 是类似第七个点情况下的 \(q\) ,所以这样的话就应该使得 \(cur\) 连向 \(clone\), \(q\) 连向 \(clone\),根据后缀链接的定义,\(clone\) 肯定是 \(q\) 中的后缀,而且它们的 \(endpos\) 一定不同,所以这样链接。这样的话加上字符 \(c\) 的转移就有了更加好的去处,也就是 \(clone\)
第九个点:略过。
可能是相当的不严谨,但是我们也没必要有那么优秀的证明能力,能有自己理解的逻辑应该就够了。
时间复杂度: \(O(|S|)\)
用处:
(OI_Wiki 上的加上我的理解)
(1)检查一个字符串是否出现过:
因为原串的每一个子串都一定是从源点出发的一条路径,所以就从源点扫就好了
(2) 询问不同子串个数:
1.我们的 SAM 是一个有向无环图,因为每一条路径都对应原串的每一个子串,所以就相当于统计路径条数, DP 即可
2.考虑 SAM 本身的定义: SAM 上的节点定义为不同的 \(endpos\) 等价类,那么我们只要能求出所有等价类的对应的子串的数量然后求和即可。对于同一 \(endpos\) 等价类中的子串,其长度一定恰好完全覆盖 \([len_{link[i]} + 1,len_i]\),因为我们的后缀链接存在一个性质,\(len[link[now]] = minlen[now] - 1\),根据定义也非常好理解
(3)所有不同子串的总长度:
1.利用有向无环图, DP 就好了
(4)字符串第 \(k\) 大子串:
字符串第 \(k\) 大子串即 SAM 上的第 \(k\) 大路径,求出每个状态的路径条数,然后找就可以了
(5)最小循环移位:
\(S + S\) 这个字符串里长度为 \(|S|\) 的一段,一定对应着 \(S\) 的一个循环进位,就对 \(S + S\) 建 SAM 然后贪心选择最小的长度为 \(|S|\) 的一段就好了