后缀自动机
(本文不适合初学者)
SAM
-
个人认为 SAM yyds
-
希望有一天 SAM 能统治字符串界
前置概念
-
\(\operatorname{endpos}\) 集合表示一个子串在原串中出现的位置集合
-
所有的子串通过 \(\operatorname{endpos}\) 分成一个个等价类
构造
-
每个节点代表一个子串集合(或者看成是一种状态),状态之间有一些字符转移边
-
每个节点维护集合最长的字符串的长度
-
每个节点维护的子串集合实际上就是集合中的最长子串的一些前缀
-
每个节点维护每个字符转移边的节点
-
每个节点维护后缀链接指向集合中的最长子串的最长的和这个子串的 \(\operatorname{endpos}\) 不一样的后缀所在的节点,并且满足 \(\operatorname{minlen(x)}=\operatorname{maxlen(link(x))}+1\)
namespace SAM{
struct node{
int len,link,nxt[26];
}a[N<<1];
int las=0,tot=0;
inline void init() {a[0].link=-1;}
inline void insert(int x) {
int cur=++tot;cnt[cur]=1;
a[tot].len=a[las].len+1;
int p=las;
while(p!=-1 && !a[p].nxt[x]) {a[p].nxt[x]=cur;p=a[p].link;}
if(p==-1) a[cur].link=0;
else {
int q=a[p].nxt[x];
if(a[p].len+1==a[q].len) a[cur].link=q;
else {
int clone=++tot;
a[clone]=a[q];
a[clone].len=a[p].len+1;
a[q].link=a[cur].link=clone;
while(p!=-1 && a[p].nxt[x]==q) {a[p].nxt[x]=clone;p=a[p].link;}
}
}
las=cur;
}
}
(注意空间要开两倍)
性质
-
每个前缀的终止节点不同
-
每个节点所代表的子串集合一定是一段 “连续” 的字符串,形式化的说集合中的每个子串都是最长子串的一个后缀,并且长度连续
Trie 图相关
-
在一个 Trie 图中建立 SAM ,其实就是将全局 las 改成转移过来的状态
-
下面以一棵 Trie 树建立 SAM 为例
void dfs(int u,int f,int las) {
int t=insert(las,val[u]);
for(int v:V[u]) {
if(v==f) continue ;
dfs(v,u,t);
}
}
基础题
-
拿 SAM 随便做
-
不过最后对于每个节点不需要对于每个长度去取 \(\max\) ,直接对最长的长度取 \(\max\) ,因为长的出现了,短的也一定会出现,所以最终输出答案的时候对于所有比自己长的取 \(\max\) 就可以了
-
先把 SAM 建出来,然后直接在 SAM 上搜索即可,复杂度显然就是 \(O(n)\),这不吊打 FFT ......
-
FFT 的做法大概就是快速的求出对于模式串的每个子串和文本串之间的差异
-
考虑这个差异分别是 \(ACTG\) 四种字母的情况
-
对于一种字符求差异的时候,将模式串中的这个字符看成 1 ,其他的为 0 ,对于文本串的这个字符看成 0 ,其他的看成 1
-
那么将这两个东西卷起来,可以发现当两者不一样的时候贡献为 1 ,那么就是四次 FFT ,最后对于每个位置的数量判断是否相差在 3 以内就可以了
- 在线询问非空子串的种类数,SAM 随便做
SP1811 LCS - Longest Common Substring
- 首先对于一个串建立 SAM ,然后第二个字符串一个一个匹配过去就行了
SP1812 LCS2 - Longest Common Substring II
- 首先对于一个串建立 SAM ,但是每次需要遍历整个 SAM 记录一下哪些节点可以被所有模式串便利到
SP7258 SUBLEX - Lexicographical Substring Search
- 用 dp 求一下每个节点往后的字符串的个数,SAM 裸题
- \(t=1\) 的话无非就是要多求每个节点出现的次数,然后 dp ,仍然是 SAM 裸题
-
其实就是要求 \(\sum lcp(T_i,T_j)\)
-
考虑把整个串翻转,那么对于两个前缀的终止节点的最长公共后缀就是 在 parent 树上 \(a[lca].len\)
-
那么直接考虑每个点作为 \(lca\) 的贡献就可以了
-
感觉还是比较简单的 SAM 板题,这里直接考虑第二问
-
首先对于反串建 SAM ,然后 dfs 一遍每个节点
-
因为有负数,所以要维护最大值和次大值和最小值和次小值
-
每个节点先让最长的长度取 \(\max\) ,最后再每个长度向后取 \(\max\) 就可以了 (不知道为什么题解一大堆人要用线段树,虽然我不知道这个套路之前也曾经想过线段树)
-
总的复杂度就是 \(O(n)\)
- 大概就是要支持动态加边,维护子树和,用 LCT 维护一下就可以了
-
对于这种区间元素贡献要判重的问题,一律考虑离线后扫描线,维护每个元素最后出现的位置的贡献,用数据结构维护
-
对于这个题目,每个子串对应的就是一个元素,所以对于每个子串维护最后出现的起始点,用线段树维护这个贡献
-
提前建好 SAM ,但是扫描线的时候每次要从一个节点一支跳祖先,这可能就会导致复杂度不对
-
我们考虑对于最后一次出现的位置相同的子串一定是连续的,并且是 parent 树上的一条链,我们考虑对于这种最后一次出现的字串一起用线段树区间修改
-
而每次从一个节点跳到祖先节点,并且将链上的点的最后出现的标记修改成最新的位置,这可以想到 access 操作
-
那么考虑 LCT 维护,每一个 splay 都维护的是一些最后一次出现的位置相同的子串,对于这种字串就可以一起修改了
-
根据 LCT 的性质,splay 的个数不会超过 \(\log n\) ,加上线段树的复杂度,总的复杂度就是 \(O(m\log^2n)\)
-
总的来说就是线段树+LCT+SAM,都是板子
进阶题
-
首先一个很明确的方向就是对于每一个拆分的位置算贡献
-
也就是对于每个位置求出前面有多少个形如 AA 的子串,求出后面有多少形如 BB 的子串,答案就是所有位置两者相乘的和
-
而实际上前缀和后缀是一样的,无非是将子串翻转一下再做,所以考虑一种就可以了
-
一个暴力的做法就是 \(O(n^2)\) 用 Hash 判断,然而这样就有了 95 分了(多少有点随意了......),考虑最后的 5 分怎么拿
-
讲实话正解不是很好想,考虑枚举每一个 A 可能的长度,然后以 \(len,2*len,3*len,...\) 为关键点
-
那么 \(AA\) 一定会经过恰好两个相邻关键点
-
那么我们考虑对于两个关键点求贡献,求出 \(lcp(i,j),lcs(i,j)\) ,根据这个可以算出通过这两个关键点的 \(AA\) 的范围,对于这个范围整体 + 1,这个可以差分做
-
对于 \(lcp,lcs\) 考虑 SAM 的做法就是求 parent 树上的 lca 的深度,那么考虑用 ST 表求正串和反串的 parent 树的 lca
-
关键点对的数量是一个调和级数,所以总的复杂度是 \(O(n\log n)\)
-
因为有 AAAA... 这种样子的东西,唯一能想到的套路就是上面的关键点的套路,所以还是考虑怎么用关键点做
-
枚举长度,然后设关键点
-
对于一个出现次数为 \(k\) 次的字串一定恰好会经过 \(k\) 个关键点,也就是对于两个关键点 \(i,j\) ,当 \(LCS_{i\leq p\leq j}(p)+LCS_{i\leq p\leq j}(p)\geq L\) ,那么存在字符串出现 \(j-i+1\) 次
-
注意到 \(LCS_{i\leq p\leq j}=min_{i\leq p <j}(LCS(i),LCS(i+1))\)
-
所以我们还是预处理出两个相邻的关键点的 \(LCS\) ,复杂度是 \(O(n\log n)\)
-
我们考虑对于每一个点求出最右边的关键点满足上述条件,同时更新答案
-
注意到左指针移动的时候,右指针只会向右移动,也就是满足单调性,但是左指针的最小值是没办法撤回的,所以考虑维护一个区间的最小值,考虑用线段树维护
-
那么整体的复杂度就是 \(O(n\log^2n)\) ,常数似乎巨大
- 首先讨论关于 68 分的做法
想法一
-
不知道对不对,恳请指出错误
-
大概就是对于所有 T 离线下来建立广义 SAM ,然后用 S 在这个广义 SAM 上面先跑一遍,预处理出哪些子串在 S 中出现过,复杂度为 \(O(n)\)
-
然后对于每个 T ,就是询问在 S 中出现过多少本质不同的子串,那么我们找到每个 T 的每个前缀所在的节点,那么实际上就是求在 parent 树上 \(n\) 个点到根的链合并算贡献
-
那么就考虑经典套路按照 dfs 序排序,然后算每个点到根的贡献和,减去相邻点的 LCA 到根的贡献和,这里复杂度因为有 LCA ,所以是 \(O(n\log n)\)
-
所以总的复杂度就是 \(O(n\log n)\)
-
这是我最初的想法,然后你会发现没办法优化了
想法二
- 考虑还是对于每个 T 单独处理
- 对于 T 的每个前缀先求出在 S 中出现的最长后缀的长度
- 这个可以先对 S 建立 SAM ,然后让 T 在 S 上面跑,如果对于 las 有相应的匹配,那么就直接匹配,否则就一直跳 parent 树直到可以匹配
- 注意到这里的复杂度为什么是正确的,考虑只算向前匹配的复杂度是 \(O(|T|)\) ,而向后撤的复杂度不会超过 \(O(|T|)\) ,所以总的复杂度仍然是 \(O(|T|)\)
- 然后对于 T 建立 SAM,对于 SAM 上的每个节点求出从 \(\operatorname{endpos}\) 往前最长出现在 S 中的长度,然后就可以算贡献了
- 总的复杂度为 \(O(|T|+|S|)\)
正解做法
-
如果知道了 S 在区间的后缀自动机,那么就可以直接做上面的东西了
-
对于 S ,我们实际上做的是:
- 找到一个存在出现范围在 \((l,r)\) 的节点转移
- 对于当前节点求出范围在 \((l,r)\) 的最长长度
-
那么我们考虑对于每个 S 上的节点用线段树维护每个位置在范围 \((1,pos)\) 中 \(\operatorname{endpos}\) 最大的位置
-
那么对于每个终止值一开始就会有 \(O(|S|)\) 个元素,总的复杂度可以证明就是 \(O(|S|\log |S|)\)
-
注意到之前的线段树合并一般是以 dfs 的形式,但是这里的线段树合并需要记录每个线段树大小,所以每次不能直接用指针,而是需要新建一个节点
-
对于 T 做匹配的时候每次都要做询问,所以复杂度带一支 \(\log\)
-
总的复杂度就是 \(O((|T|+|S|)\log |S|)\)
总结
- SAM + 线段树合并
- 首先要想出部分分的做法,然后再在部分分的做法上思考范围的限制
- 但是你如果像我一样想的部分分的做法和正解没什么关系,那就......
- 代码是真的难写,主要是细节难调,真是佩服考场能写出来的人
- 感觉 68 分第一种想法细节没那么多,考场上的最佳策略应该是迅速拿 68 分走人
广义 SAM
-
大概是用来解决多个模式串
-
我们可以认为每个字符串的每个位置都是独一无二的,也就是在算等价类的时候是不同的
-
但是为了尽可能的压缩我们的信息,每新插入一个新的字符串的时候,就从头插入
-
其余的构造和普通的 SAM 是一样的
namespace SAM{
struct node{
int link,len,nxt[26];
}a[N<<1];
int tot,las=0;
inline void init() {a[0].link=-1;}
inline void insert(int x) {
if(a[las].nxt[x]) {
int cur=a[las].nxt[x];
if(a[las].len+1==a[cur].len) return las=cur,void();
int clone=++tot;
a[clone]=a[cur];a[clone].len=a[las].len+1;
a[cur].link=clone;
int p=las;
while(p!=-1 && a[p].nxt[x]==cur) a[p].nxt[x]=clone,p=a[p].link;
return las=clone,void();
}
int cur=++tot;
a[cur].len=a[las].len+1;
int p=las;
while(p!=-1 && !a[p].nxt[x]) a[p].nxt[x]=cur,p=a[p].link;
if(p==-1) a[cur].link=0;
else {
int q=a[p].nxt[x];
if(a[p].len+1==a[q].len) a[cur].link=q;
else {
int clone=++tot;
a[clone]=a[q];
a[clone].len=a[p].len+1;
a[q].link=a[cur].link=clone;
while(p!=-1 && a[p].nxt[x]==q) a[p].nxt[x]=clone,p=a[p].link;
}
}
las=cur;
}
}using namespace SAM;
性质
-
如果将所有模式串去重后,每个串的终止节点一定不一样,并且一定代表着所在节点集合的最长子串
-
一个比较感性的说法(尽量理解因为很重要):对于一个节点所代表的集合的 “范围” 是任意一个它能转移到的状态所代表的集合的 “范围” 的子集,这里的 “范围” 抽象理解,主要是为了证明下面所说的
-
目前我的观点是 AC 自动机可以做的,广义 SAM /SAM 也可以做
-
观察 AC 自动机每个节点维护的是 \(fail\) 指针,也就是失配指针,而 SAM 的每个节点的 \(link\) 其实是和失配指针有着差不多的一个意思
-
但是不能直接按照这么做,这是因为 SAM 的结构还是和 AC 自动机有所不同的
-
我们转移的时候,对于一个节点是否有贡献一定要注意判断,因为假设当前 \(p\) 转移到 \(a[p].nxt[x]\) ,但是 \(a[p].len!=a[a[p].nxt[x]].len\) ,这说明还有其他的子串可以转移到这个点,但是这个贡献显然是不能算上去的,所以这里要根据每个题目去判断一下
-
还是需要根据代码理解
-
考虑用广义 SAM 去做 AC 自动机的板子题
- 重点在于理解以下部分
int p=0,fl=1;
FOR(i,1,len) {
int x=S[i]-'a';
while(p!=-1 && !a[p].nxt[x]) p=a[p].link,fl=1;
if(p==-1) p=0;
else {
if(a[p].len+1!=a[a[p].nxt[x]].len) fl=0;
p=a[p].nxt[x];
if(!fl) ++num[a[p].link];
else ++num[p];
}
}
-
当然这样其实是很蠢的,毕竟光是普通的 SAM 的做法都可以秒杀这个了
-
但是想要体现的是当有多个串的时候,并且 SAM 没办法做的时候,可以考虑广义 SAM 来做
-
毕竟在这种情况下广义 SAM 和 SAM 的切入点是完全相反
-
一个是拿模式串构造自动机,一个是拿文本串构造自动机
例题
SP1811 LCS - Longest Common Substring
-
对于一个节点如果被两个字符串都能标记,那么说明这个节点算是一个公共子串
-
那么直接对于两个节点建立广义 SAM ,最后 dfs 扫一遍就可以了
SP1812 LCS2 - Longest Common Substring II
- 直接对于所有字符串建立广义 SAM,然后最后遍历一遍 parent 树求解哪些节点可以被所有的字符串包含到
-
这个题想来想去还是用广义 SAM 最合适
-
对于每个节点记录在两个串中出现的次数,然后直接算贡献就可以了
P4081 [USACO17DEC]Standing Out from the Herd P
-
还是考虑先建广义 SAM ,然后对于每个节点打上标记
-
如果这个节点的被不同的模式串包含,那么标记为 -1 ,否则标记为包含自己的那个模式串的编号
-
那么就不需要记录每个节点对于每个编号具体出现的次数或者标记
-
整个复杂度就是 \(o(n)\)
-
一个结论就是树上的每条路径都可以由以一个叶节点为根遍历整颗树得到
-
以每个叶节点为根,把此时的整个树当作一个 Trie 图,然后用广义 SAM 将所有 Trie 图建立一个自动机
-
答案就是这个自动机中不相同的子串个数
SP8093 JZPGYZ - Sevenk Love Oimaster
-
对于模板串建立 SAM ,那么查询就是找到对应的节点,询问这个节点所在 parent 树中出现的模板串的个数
-
将一个子树转换成连续的一段序列,那么就是区间数颜色的问题了
-
对于 T 建立广义 SAM ,然后对于每个节点开权值线段树维护每个串分别出现的次数,这里拿线段树合并做
-
然后对于 S 的一个子串,只要我们能找到这个子串在广义 SAM 中的节点就可以直接询问了
-
我是将所有询问按照右端点离线下来,然后每次先找到对于一个 S 的前缀最长匹配的节点,然后用树上倍增找到自己对应的节点,然后询问
-
在线的就是预处理出每个前缀对于的最长匹配的节点,然后每次直接树上倍增就可以了
-
纯属于无思维大码量题,在写这种题我是狗,调了一个下午最后对拍了发现 SAM 写寄了