SAM学习笔记

0. 什么是SAM/为什么要学SAM

先给出一些题目:

  1. 给定一个字符串 \(S\) 和一个模式串 \(T\),求 \(T\) 是否在 \(S\) 中出现过。\(|T|<|S|\leq 10^6\)

显然,这个随便用KMP搞一下就好了。这里不再阐述。

看下一道:

  1. 给定一个字符串 \(S\)\(n\) 个模式串 \(T_i\),求 \(T_i\) 是否在 \(S\) 中作为子串出现过。\(|S|,\sum|T_i|\leq 10^6\)

这个显然可以对于 \(T_i\) 建立一个AC自动机,然后让 \(S\) 在上面跑匹配。

再下一道:

  1. 给定一个字符串 \(S\) ,动态给出模式串 \(T_i\),求 \(T_i\) 是否在 \(S\) 中作为子串出现过,强制在线。\(|S|,\sum|T_i|\leq 10^6\)

显然这道题无法应用上述两种做法。所以我们就需要一个新的算法来处理这样一类问题。这就是SAM。

当然,如果你完全可以当场口胡出SAM这种神仙算法,那么你完全可以把我D一顿然后跑路。

SAM(后缀自动机)是解决字符串子串以及后缀问题的一种数据结构,也是近些年里的各种字符串题目的万恶之源重点。

由名可知,它是一种基于有限状态自动机的算法,所以其每个节点都代表着一类状态。

由于其本质是将一个字符串的所有子串压缩,所以其状态数就是本质不同子串数量,这在后文会提及。

1. 如何建一个SAM

这个其实网上都有,我个人觉得 OI WIKI 上SAM的定义与性质部分讲的挺好,里面也包含了很多模板题目。

这里我以 OI WIKI 为基础写了一个简化版本:如何建一个SAM

2. 后缀自动机进阶

2.1 SAM与线段树

SAM里有一个很重要的东西叫做endpos。但是好像在上述题目中我们并不需要真的求出这个集合。因为我们真正需要的其实是通过集合获得SAM的一些性质。

但是有些题目中就不一样了。比如询问的模式串是这个字符串的某个子串。这种情况下我们就不得不求出endpos集合。

显然我们并不能直接暴力求出,因为总集合大小是 \(O(n^2)\)。但是我们注意到,一个点的endpos集合完全包含它的儿子。这就可以直接套上线段树合并了。

例1:CF1037H Security

题目大意

给定一个字符串 \(S\)\(q\) 次询问,每次询问一个串 \(t_i\) 和一段区间 \([l_i,r_i]\),问 \([l_i,r_i]\) 的子串里字典序比 \(t_i\) 大中最小的是多少。
\(|S|\leq 10^5,\ q,\sum|t|\leq 2\times 10^5\)

题解

首先看到子串,联想到SAM。再看到严格大于,可以发现SAM本质是一个字符转移,每一条路径对应一个字符串,所以符合贪心的原则。

假如 \(l=1,r=n\),我们可以直接建出SAM,然后每次跑转移时我们跑比 \(s_i\) 大的尽可能小的转移边。

特别的,由于题目要求严格大于,所以我们跑转移边同时记录当前严格大于的转移边。

再考虑一般情况。按照套路,我们把每个节点的endpos集合用线段树合并处理出来。

显然,假如一个节点的endpos集合中存在一个位置使该串完全位于 \([l,r]\) 中,那么该字符串在 \([l,r]\) 中出现过。显然又有其祖先的endpos包含该点的endpos。所以该转移在子串 \([l,r]\) 中存在。

显然,我们只需要对每次转移判断是否存在于 \([l,r]\) 即可。复杂度 \(O(n\log n)\)

例2:CF700E Cool Slogans

题目大意

给定一个字符串 \(S\),构造一个字符串序列 \(s_i\),满足 \(s_i\)\(S\) 的子串,且 \(\forall \ i>1\ ,\ s_i\)\(s_{i-1}\) 中出现至少两次。

求序列最长长度。

题解

按照套路,这道题和子串出现次数有关,先对 \(S\) 建一个SAM。

我们假设把所有子串拉了出来,然后若 \(s_u\)\(s_v\) 中出现两次以上则连边 \(s_u\rightarrow s_v\)。显然这样就是求最长链。

首先贪心可以发现,如果 \(s_u\rightarrow s_v\) 符合条件,\(s_u\) 一定是 \(s_v\) 的前缀和后缀。\(s_v\) 否则缩短到符合条件一定不会更差。

可以发现,SAM上如果 \(s_u\rightarrow s_v\) 符合条件,显然有 \(s_u\)\(s_v\) 的祖先,而且有对于 \(s_u\) 的祖先 \(s_{u'}\) 也有 \(s_{u'}\rightarrow s_v\) 符合条件。

所以假如我们算出 \(s_u\rightarrow s_v\) 的符合条件,那么直接parent树上dp一下求最长链就好了。

考虑一个性质:\(u\) 祖先 \(f\) 的pos集合一定是 \(u\) 的pos集合的超集。根据pos集合的定义,如果 \(u\) 的某个祖先 \(v\) 在当前字符串内存在pos,那么一定可行。

所以我们直接在 \(f\) 查询 \(u\) 的子串的长度区间,查询是否存在pos即可。

然后对于endpos集合,按照套路直接线段树合并即可。

复杂度 \(O(n\log n)\)

例3:NOI2018 你的名字

题目大意

给定一个串 \(S\),多次询问一个串 \(t_i\) 没有在 \(S\)\([l,r]\) 子串中出现过的本质不同子串个数。

题解

我们知道 SAM 是一种自动机,每条路径都代表着一个子串,可以用跑AC自动机的方法去跑 SAM 的匹配。

按照套路,我们先考虑 \(l=1,r=|S|\) 的情况。按照套路,我们先建出 \(S\)\(s_i\) 的后缀自动机。

考虑一个很显然的结论:如果 \(s_i\) 的某个子串 \(t\)\(S\) 中出现过,那么 \(t\) 的后缀也一定在 \(S\) 中出现过。

所以我们只需要对 \(s_i\) 的每一个前缀,找到最短的后缀使其没有在 \(S\) 中出现过。但是这样没办法处理“本质不同”这个条件。

我们知道后缀自动机上一个点 \(u\) 代表的状态其实是某个前缀的长度大于 \(len_{fa}\) 的后缀。也就是说,对于 \(s_i\) 后缀自动机的一个节点 \(u\),我们找到最短的长度 \(Len_u\) 使该长度的后缀没有在 \(S\) 中出现过,那么答案就是 \(\sum_{u}\left(len_u-\max{(len_{fa},Len_u)}\right)\)

考虑如何得到 \(Len_u\)。我们知道一个后缀自动机上的节点对应的是一个后缀(包括分裂出来的节点)。显然我们对每一个后缀,类似于AC自动机一样,如果当前字符在 \(S\) 的后缀自动机的当前节点上有转移,我们就转移并让匹配长度+1,否则一直让匹配长度-1并一直跳parent树直到存在转移。

至此为之你已经可以在这道题上拿到68分的好成绩,见好就收

接下来考虑一般情况。可以发现,对于 \([l,r]\) 子串,唯一的区别就是原SAM的某些转移边不一定可行。

具体来说,对于某个点 \(u\),如果其endpos集合中存在一个点使其完全在 \([l,r]\) 中出现,那么转移到该点的转移边一定合法。

所以我们只需要对 \(S\) 的SAM的endpos集合进行线段树合并,然后对于 \(u\) 查询 \([l+len,r]\) 区间是否存在endpos。其他同 \(l=1,r=|S|\)

复杂度 \(O(n\log n)\)

2.2 SAM与LCT

既然后缀自动机的parent树是一棵需要动态加点的动态树,那LCT必然是少不了了。

更好的是,新加入一个点对原树的修改是 \(O(1)\) 的。这完全符合了LCT的时间复杂度。

例:区间本质不同子串个数

题目大意

如标题所说。

题解

按照套路,我们把询问离线。可以发现,对于某个串,如果我们统计出对于 \([1,r]\) \(T\)第一次出现的位置 \(a\) 和最后一次出现的位置 \(las\),那么字符串 \(T\) 对于 \(l\in[a,las+|T|]\) 会产生1的贡献。

考虑如何统计。我们考虑模拟SAM插入字符的过程,可以发现它本质是将它到其父亲的 \(las\) 修改为 \(r\)。而“将当前点到根的路径上所有边修改”本质就是LCT的access操作。

所以我们不妨先预处理出parent树。可以发现,对于LCT的每次splay操作,只需要修改虚实子树信息即可。这段修改可以用线段树维护,时间复杂度 \(O(n\log^2 n)\)

2.3 广义SAM

我们知道,SAM是对单串建立的,那么如果是多串呢?

例1:【模板】广义后缀自动机

题目大意

给定 \(n\) 个串,求总的本质不同子串数量。

题解

这道题有3种写法:

  1. 多串转单串,串与串之间插入一个分割符,直接dp没有包含分割符的串即可。
  2. 建立trie树,bfs建SAM。
  3. 对每个串从根建立SAM。

这里主要介绍后两种。

对于2,具体来说就是先建一颗trie,然后对于trie的某个节点,我们以它在trie上的父亲为las插入字符。bfs建立广义SAM。

代码很好想也很好写,但是我们发现它是离线的,这丧失了SAM最重要的性质之一(比SA优秀的性质之一)。

所以就出现了第三种。但是很快我们就会发现:它会把相同的字符插入两遍。

所以我们在insert开头加入这样一段代码。

    if(ch[las][c])
    {
        int p=las,np=ch[p][c];
        if(len[p]+1==len[np])return np;
        else
        {
            int nq=++cnt;
            len[nq]=len[p]+1;
            memcpy(ch[nq],ch[np],sizeof(ch[np]));
            fa[nq]=fa[np],fa[np]=nq;
            for(;p && ch[p][c]==np;p=fa[p]) ch[p][c]=nq;
            return nq;
        }
    }

我们考虑这段代码特判了什么:因为在普通SAM种las是新插入的最后一个节点,它是没有转移的。但是在广义SAM中可能就会有。

那如果有了转移边,说明当前插入的字符串已经出现过了,直接返回已经建立的节点即可。

还是和普通SAM一样的问题:假如这个节点并没有建立怎么办?我们直接仿照普通SAM的做法,将这个节点建立出来并更新结果即可。

可以证明,上述两种方式建出来的SAM是本质相同的。

例2:ZJOI2015 诸神眷顾的幻想乡

建议以这道题作为广义SAM的模板题

题目大意

给定一棵树,入度为一的点不超过20个。每个点表示一个字符。定义合法字符串是两点之间的简单路径经过的点依次拼接而成的字符串。求本质不同的合法字符串数量。

题解

这里使用第三种建法比较方便。

考虑以每个叶子为根,dfs将对应字符插入,每个字符以其父亲为las插入即可。

例3:CF547E Mike and Friends

题目大意

给定 \(n\) 个字符串 \(s_{1 \dots n}s\)\(q\) 次询问 \(s_k\)\(s_{l\dots r}\) 中出现了多少次。

题解

很经典的问题。

按照套路,考虑如果有 \(l=r\) 怎么做。很显然,将 \(l\) 这个字符串建一个 \(SAM\),预先统计每个节点的 \(endpos\) 集合大小,将 \(s_k\) 跑匹配就好了。

那么假如 \(l=1,r=n\) 怎么做。很显然,直接建立广义 \(SAM\) ,将放上去 \(s_k\) 跑匹配。

再考虑原题。每个节点的 \(endpos\) 的贡献是有一个位置的。只需要查询位置在 \([l,r]\)\(endpos\) 集合大小。

所以建出广义 \(SAM\),然后线段树合并,注意这里线段树需要可持久化,不能直接在原版本上合并。

同时在建广义 \(SAM\) 的时候记录一下每个字符串最终的位置,查询时直接在这个位置上的线段树上查询即可。

复杂度 \(O(n\log n)\)

例4:lucky string

题目大意

给定 \(n\) 个字符串 \(s_i\),每个字符串有一个权值 \(v_i\)\(m\) 次询问,每次询问一个长度 \(l\),定义一个字符串 \(t\) 的价值为

\[\prod_{t\text{是}s_i\text{的子串}}v_i \]

问所有长度 \(\leq l\) 的随机字符串的期望价值。

题解

看到“\(n\) 个字符串”,“子串”就可以推测本题用的是广义SAM。

考虑广义SAM中的一个节点对应的状态就是该模式串 \(t\) 在一些串中出现过,而且广义SAM可以保证一个子串被且仅被节点所表示。

首先对于一个给定的字符串 \(t\) 需要判断是否为 \(s\) 的子串,只需要沿着 \(s\) SAM中的转移边按 \(t\) 依次转移,当且仅当所有转移都顺利进行下 \(t\)\(s\) 的子串。同样,这一方式也可以拓展到广义SAM上。

根据节点的意义可以发现,每个广义SAM中的节点 \(u\) 可以表示一段字符串中一段长度为 \((len_{fa},len_u]\) 的后缀。反过来说,对于任意长度为 \((len_{fa},len_u]\) 的字符串,有且仅有一个字符串的转移终止在节点 \(u\) 上。

所以,如果我们算出了所有广义SAM的节点的价值 \(val_i\)(即终止于该节点的字符串的价值),那么对于询问 \(l\) ,期望价值就是

\[\frac{\sum_{i\in SAM}{\sum_{j=1}^{l} [\ j\in(len_{fa_i},len_i]\ ]\ val_i}}{\sum_{i=1}^{l}26^i} \]

很显然,上述公式可以通过差分解决。即对于节点 \(i\),在 \(sum[len[fa[i]]+1]\)\(+val[i]\),在 \(sum[len[i]+1]\)\(-val[i]\),最后前缀和两次即可。

接下来考虑如何计算价值 \(val_i\)。可以发现这等同于求

\[\prod_{i\text{能到达}s_j\text{的节点}}v_j \]

可以发现,这相当于 \(i\) 是某个 \(s_i\) 的节点在parent树上的祖先。

所以,对于每次插入 \(s_i\) 的一个节点,直接跳祖先 \(\times v_i\) 即可。为了防止记重,可以开一个数组表示当前节点是否被更新过。如果被更新了就不必往上跳了。

看似复杂度很假。但是由于这些节点本来就应该属于 \(s_i\) 的SAM当中的,根据广义 \(SAM\) 的复杂度证明其实是可行的。

所以只需按上述方式统计答案即可,复杂度 \(O(26n)\)

2.4 一些奇技淫巧

例1:十二省联考2019 字符串问题

题目大意

给一个字符串,有一个二分图 \((A,B)\),每个点代表字符串的一个子串。定义 \(B\rightarrow A\) 有边当且仅当 \(B\)\(A\) 的前缀。

再额外给定一些 \(A\rightarrow B\) 的边。定义一条链的价值是经过的 \(A\) 点的字符串长度之和,求该有向图的最长链。有环输出 \(-1\)

题解

显然有一个 \(O(n^2)\) 的做法:暴力枚举二分图每个点对 \((a_i,b_j)\) 有无 \(b_j\rightarrow a_i\) ,Hash/SA判断是否为前缀,然后跑topo即可。

考虑到这种情况下总边数是 \(O(n^2)\) 级别。考虑SAM优化建图。由于这里是前缀,我们不妨把后缀自动机倒过来建,那么这样一个节点代表的就是一段后缀。

我们对于子串 \([l,r]\) 找到 \(l\) 对应的SAM上的点,然后倍增找到该子串对应的点的位置,可以发现 \([l,r]\) 的前缀一定在该结点和其祖先上。

那么我们不妨在所有对应在SAM某点上的子串按长度排序,然后我们向前找到第一个 \(B\) 类点。显然,由于 \(B\) 类点不计点权,所以 \(A\rightarrow B'\rightarrow B\)\(A\rightarrow B\) 没有本质区别。

然后我们同样把后缀树上的每个点向它父亲表示的最后一个 \(B\) 类点连边。可以发现,所有 \(A\rightarrow B\) 的边都可以通过走若干点权为0的边得到。

所以再连上额外边,跑一遍topo即可。

复杂度 \(O(n\log n)\)

posted @ 2020-08-01 08:24  Flying2018  阅读(650)  评论(1编辑  收藏  举报