「学习笔记」后缀自动机
推荐博客 link
子串问题可以考虑 \(trie\)
但是如果要维护不同子串个数类似的问题的话,\(trie\) 的 \(DAG\) 的量到了 \(O(n^2)\)
那么后缀自动机作为压缩的 \(trie\) 就应运而生了,主要是合并了大量没用的节点
定义 & 性质
首先是一个子串的 \(\{endpos\}\) 表示当前子串的在大串里面结尾下标的集合
每个 \(endpos\) 会对应若干个子串,这些子串长度必然连续
如果在这些子串里面最长的前面加入两个不同的字符,可以把当前集合分成两个没有交集的集合(可能有元素没有被分到这两个集合里面)
那么可以得到 \(\{endpos\}\) 的数量级是 \(O(n)\) 的,像线段树式的分割方法可以得到最大的数目
如果考虑把母集作为分割出来小集合的父亲,就可以得到一个树,这里是 \(Parent \ Tree\)
这里有一个性质,设 \(mx(x)\) 为当前 \(\{x\}\) 的最长的字符串的长度,\(mn(x)\) 为最短的字符串的长度,那么有 \(mx(fa_x)+1=mn(x)\)
证明就是子集合是父亲加字符,所以就好理解了
针对 \(endpos\) 的定义和性质可以得到以下的性质:
\((1)\ SAM\) 的每个点代表的是一个 \(endpos\),从源点通过加边到达这个点的字符串要保证它的 \(endpos\) 是这个集合
但是可能并不会把这个 \(endpos\) 到底是几显式地表示出来
\((2)\) 到达 \(fa_x\) 的任意字符串是到达 \(x\) 的后缀
因为 ${x\to endpos}\subsetneq { fa_x\to endpos} $,这里和平常的字符串维护不太一样
颠覆了一点认知,但是仔细思考上面说的 \(endpos\) 的性质还是成立的
那么后缀自动机的构建就是使它满足这个条件
建立
首先每次新加入一个字符得到的字符串所有的后缀的 \(endpos\) 都会改变
那么遍历所有原串的后缀,如果没有 \(x\) 的出边,就连上(这样保证了 \(sam\) 有维护子串个数的功能)
如果出现有且满足 \(len_p=len_{tmp}+1\) ,那么按照 \(endpos\) 来理解,就直接把 \(fa[np]\) 设为 \(q\) 就行了
如果出现了 \(q\) 但是不满足 \(len_p=len_{tmp}+1\) ,那么需要新建一个节点 \(clone\)
也就是分割 \(endpos\)
然后把 \(q\) 的信息给到 \(clone\) 接着把所有的前面满足相应的 \(endpos\) 指向 \(clone\)
inline void extend(int x){
int tmp=las,np=las=++tot; len[np]=len[tmp]+1;
while(tmp&&!ch[tmp][x]) ch[tmp][x]=np,tmp=fa[tmp];
if(!tmp) return fa[np]=1,void();
int q=ch[tmp][x];
if(len[q]==len[tmp]+1) return fa[np]=q,void();
int clone=++tot; fa[clone]=fa[q]; fa[q]=fa[np]=clone; copy(clone,q);
len[clone]=len[tmp]+1;
while(tmp&&ch[tmp][x]==q) ch[tmp][x]=clone,tmp=fa[tmp];
return ;
}
应用
判断子串:和 \(AC\) 自动机类似
求在原串所有子串中(相同的算或者不算一个)字典序第 \(i\) 大
每个串出现的次数等价于建出来后缀树之后对应的 \(endpos\) 对应的点的子树的点全和
\(dp\) 得到后缀树上面的子树点权值,然后考虑从这个字母出去的子串有几个的时候要考虑在 \(DAG\) 上面处理
输出考虑树上二分即可
求本质不同的子串个数
考虑到 \(sam\) 上根节点到任意一个点都是一个子串,且其本身是个 \(Dag\)
所以拓扑 \(dp\) 一下就好了
如果在线的话,那就考虑每个点的贡献吧:\(max_i-min_i+1\) 即 \(max_i-max[fa[i]]\)
两串 \(LCP\) :
其实就是后缀树上点的 \(lca\) 的 \(len\)(然后“差异”那个题就可以考虑每条边的长度是 \(len_i-len[fa[i]]\) 然后贡献法一下了)
关于后缀树:
这个比较就是每个点的父亲向点建边,建成了一棵树,就是后缀树
最小表示法:
这个比较简单了,把原串复制一遍,然后直接在后缀自动机上面跑,能往小的点上跑,就跑,没有后继节点就跳 \(fail\)
求最长公共子串的长度:
用其中一个字符串建后缀自动机,然后把其它的子串放在上面匹配
记录下来每个点匹配的最长长度,然后在每个点上面取记录下来 \(max\) 的 \(min\) 吧
最后的答案就是所有点 \(min\) 的 \(max\)
(很绕,但是很好理解)
维护子串排名
每个后缀树上的点维护的是一个后缀,那么我们翻转之后就能得到前缀
先算 \(endpos\) 大小表示这个点一共多少个串
然后把所有的出边都标号,标号就是 \(s[pos[x]+len[fa[x]]]\)
这样每个点在后缀树上排序之后再 \(dfs\) 得到每个点的 \(rk\)
对于每个 \([l,r]\) 倍增出来对应的点之后直接 \(rk[now]+len[now]-(r-l+1)+1\) 即可
相关复杂度:
\((1)\) 建后缀自动机的复杂度和后缀自动机的点数是 \(O(n)\) 的
\((2)\) 后缀树和 \(parent\) 树的节点数量级是 \(O(n)\) 的
\((3)\) 遍历后缀自动机对应的 \(DAG\) 是 \(O(n^2)\) 的
广义 SAM
支持了多串匹配的功能
一个例题是 [USACO17DEC]Standing Out from the Herd P
考虑按照存 \(sz[id][x]\) ,但是空间会炸,应该用大量stl也是能写的
其实发现只用维护每个点是不是被经过了两次
所以标记即可
或者是 \(SNOI2020\) 字符串
考虑修改两个字符串的代价 \(k-dep[lca]\)
那就搞出来广义 \(SAM\),把所有的子串在对应节点上打上标记
然后建后缀树,现在问题转化成了:树上有一些点,求让这些点两两匹配后每对 \(lca\) 的深度之和最大
贪心发现每两个在它最先能匹配的 \(lca\) 匹配会优,那么甚至都可以不用 \(dp\)
记得判断 \(len[x]>k\) 的时候直接赋成 \(k\) 就完事了
例题
这篇博客主要放一些线段树维护 \(\rm endpos\) 的题目
NOI2018 你的名字
首先考虑一个 \(n^2\) 的做法是对于每个 \(T\) 建 \(SAM\) 然后跑可达性统计,然后两个串一起跑,如果这个点在 \(T\) 里面能跑到 \(S\) 里面没有就加上可达的点
貌似也没啥优化的空间,那么换一个思路,往 后缀树 上面靠
直接统计还是不太行,那么正难则反,用总的 \(T\) 上的减掉公共子串
具体就是求一下 \(l[i]\) 表示 \(T\) 到 \(i\) 的前缀有多长可以在 \(S\) 上面作为一段后缀出现
如果如果不考虑存在重复的情况,就把 \(T\) 放到 \(S\) 上面跑,每次不行了就跳 \(fa\)
(这里深刻一个观点:后缀树上面点的深度基本上是没啥用的,含义就可以轻松得证)
因为 \(fa\) 里面的都是 \(x\) 的后缀,又要选最大值,所以每跳一下,长度减成现在点的 \(len\)
那么这就得到了一个 \(68pts\) 的做法
接着考虑怎么做不是全串的情况,先求所有的 \(endpos\) 集合
(如果对后缀树的相关熟练的话,其实这步是很好说的)
然后考虑对于 \([l\dots r]\) 的限制怎么写
然而并不能每次暴力重建,但是每次只是找儿子,跳父亲,找当前点在 \([l,r]\) 内的最大 \(len\)
第一个其实对应的是 \(endpos\) 有没有在 \([l,r]\) 之间有点
第二个是跳父亲,因为父亲的 \(endpos\) 是母集,随便跳
最后一个比较恶心,想清楚了是 \(min(len[i],now-max(now,pos)+1)\)
\(pos\) 是指在 \([l,r]\) 内的最大的 \(endpos\) 下标,这个可以用线段树求出来
还是一样的跑就行了
HAOI2018 字串覆盖
显然的贪心就是尽可能取位置靠前的点
那么问题转化成了维护另一个子串的 \(endpos\) 在这个子串的出现位置
按照古老的套路就是把一个建出来自动机另一个跑匹配
如果 \(l_{ed[r]}<r-l+1\) 那么答案就是 \(0\)
反之考虑当前的点的在 \([tl,tr]\) 中所有的 \(endpos\)
查询的话复杂度是 \(\frac{len_1}{len_2}\times \log_n\)
数据大的时候显然能过,但是数据小的时候会完蛋
那么考虑重新维护一个 \(f_{len,i,j}\) 表示当前字符串是 \(S[i,i+len-1]\) ,后面的 \(2^j\) 的字符串的位置,同时记录 \(sum_{len,i,j}\) 为所有的 \(pos\) 之和
每个询问倍增跳出来所有的位置和求和即可
CF700E
\(O(n^4)\) 的暴力就是枚举两个子串是不是能满足关系
然后 \(dp\) 最长链,显然这东西是铁废物
在 \(SAM\) 上那么转移必然是从父亲向儿子进行的
那么问题就变成了如何维护出现两次的限制
本质上出现了两次就是说这个子串的 \(endpos\) 有至少两个点在上一个子串的所在区间 \([st,ed]\) 中出现了
线段树合并求 \(endpos\) 是 \(trivial\)
直接做是 \(O((2n)^2\log n)\) 的
如何进行快速完整的转移
其实本质上可以在后缀树上倍增,每次暴力跳来找到最大的满足条件的祖先,然后转移即可
貌似可以证明深度更深的点的答案不会劣于深度浅的点
不过倍增复杂度不行,那么考虑记录转移点即可
十二省联考2019 字符串问题
因为不会 \(SA\) 所以想想这题目怎么用 \(SAM\) 写
预处理显然,倍增跳后缀树维护出来每个点代表的 \(a_i \ or\ b_i\),复杂度 \(O((na+nb)\log |S|)\)
如果能连接那么下一个串得是上一个串的限制的前缀
前缀就是反串的后缀
然后建反串的自动机,考虑在这上面得到的 \(a\to b\) 的限制就可以变成 \(a\) 向 \(b\) 的子树里面建边了
那么因为是个树,所以有 \(dfn\) 序,所以线段树优化建图
具体而言就是每个 \(a\) 点往区间连边,点带权就完事了
写的时候要特别注意 \(i \ or\ dfn[i]\)
但是这样的话会发现如果 \(a_i\) 和某个 \(b_j\) 倍增到同一个点上了
如果 $a\ and \ b $ 共点的时候两个 \(len_a\ge len_b\) 就是完全可以转移的
那么我原来直接把 \(a\) 放到 \(fa_{id}\) 的方法就不奏效了
所以排序然后暴力扫,做前缀优化建图
具体就是按照长度把当前的点的 \(a_{len},b_{len}\) 排序,把树上的每个点拆成 \(num_1\) 和 \(num_2\)
父亲 \(num_1\) 连给 \(b\) ,\(b\) 连给满足条件的这点的 \(a\) 或者这个点自己的 \(num_2\),\(a\) 只能往外连
然后拓扑即可
Border的四种求法
因为一定有一个 \(endpos\) 是 \(r\) ,所以这题转出来就变得清晰了许多
对应出来这个点的链,那么问题就是找到树上的 \(\max\{endpos\in [l,r]\},len[p]\ge endpos-l+1\) 的这个 \(p\),同时需要的是最大的 \(endpos\)
因为上面的点的 \(endpos\) 肯定比下面的大,但是 \(len\) 要比下面的小,所以貌似没有单调性
然后只会暴力跳父亲,所以写了 \(50pts\)(为啥长度不满足就不跳了不给分)
拓展上面的做法,按照上面的思路可以先倍增到想对应的位置,省去无效的查询
不过还是完全不会,貌似在线是没法支持的,所以试试离线的思路
关键结论:\(Border\) 可以理解成一个最大的 \(i\) 满足 \(lcs(i,r)\ge i-l+1\)
然后扔到 \(parent\) 树上面就是两个点的 \(len[lca]\) 最大
那么每个询问的 \(lca\) 必然在 \(ed[r]\to root\) 的链上面
所以变成了一个很好的数据结构题,学到了很多:
先对 \(parent\) 树链剖分,每个询问的挂到所有的重链的底部:
把所有的询问放到 \(r\) 向上的重链底下,最后统一遍历所有重链来对所有的答案进行更新
可能的 \(lca\) 只会在这条链上面,但是构成 \(lca\) 的另外一个前缀点就可能在链顶的子树里面
遍历重链的时候考虑维护当前点作为 \(lca\) ,把所有其轻子树里面的前缀点放到一个线段树里面
这个线段树下表为 \(i\) 的点存 \(i-len(lca(ed[i],now))\)
那么对于挂在这个点上的询问进行线段树上的二分即可
归总,其实这是我真正意义上第一道做的树剖或者 \(DS\) 题吧
倒和字符串没有太大关系了
最后:数据水得过分了,过不了小数据点的代码能过大数据(所以我就数据点分治过掉了这题)
\(upd:\) 其实答案统计当然是要枚举两个点,一个是在维护的 \(SGT\) 上的,另一个是在原来的线段数合并得到的那个上面查非完全子段的答案
所以一开始写挂了
Codeforces1063F
观察一下发现是个最长下降的模型,所以考虑 \(DP\)
直接硬上 \(SAM\) 很不优美,所以翻转一下,变成最长上升
设 \(dp[i]\) 为到反转后的第 \(i\) 个点的最长上升序列的长度
显然存在最优的方式使得 \(|t_i|\le |t_{i+1}|\) ,那么 \(dp[i]\) 也就表示了最后一个串的长度
那么求出来当前的 \([i-dp_i+1,i-1],[i-dp_i+2,i]\) 的串的表示,看看是不是有可转移的点即可
现在是 \(O(n\log^2n)\) 的复杂度(我没写不知道能不能过掉)
不过由 \(SAM\) 建立的时候的写法就能观察出来 \(i-len[pos]\) 是单调不降的
那么加入 \(dp[i]\) 的时候维护一个指针
这样还是 \(\Theta(n\log^2n)\) 的复杂度
考虑这样一个结论, \(dp[i]\le dp[i-1]+1\),貌似是 \(trivial\) 的
那么就做完了
Luogu5576
一开始的思路是建出来广义 \(SAM\) 然后每次询问跑所有点,判断是不是 \([l,r]\) 中的点都在这个点上
然后想到离线,把询问挂到什么东西上,对于每个点线段树合并的时候维护一下
考虑 \(set\) 和启发式合并来规避空间的限制,然后就写了两天的 区间合并
接着得到了 \(40pts\) 的好成绩
最后还是学到了分治的想法:(真的想不太到)
考虑正经 \(SAM\) 维护 \(lcp\) 的题目的做法,那么考虑设当前有一个 \(x\)
长度 \(\le\ x\) 的是短串,反之是长串
如果一个区间里面全是短(或长)串就 \(++/--x\) 即可
要短串中位置中间的一个作为区间的标准串建 \(SAM\),\([mid,r]\) 的求后缀,$[l,mid] $ 维护前缀的最大值
那么考虑所有跨过 \(mid\) 的询问直接在 \([l,r]\) 中查询最大值即可
这个思路确实是开阔了视野,其实也不太难写出来233
2021-04-05 最小表示
考虑 \(SAM\) 来维护这样的子串,最后还是len[i]-len[fa[i]]
来统计答案
对于题目中的最小表示的定义,显然在其当前第一次出现的时候会不一样
下述视角均为从后往前,也就是下一次为通常意义上的左边一次
那么我们考虑其上一次出现之后到其的位置,维护每个位置最后在 \(SAM\) 上出现的位置 \(pos[i]\) 和其下一次在原串中出现的位置 \(dst[i]\)
每次扩展 \(pos[i],dst[i]\) ,注意只对当前点所对应的后缀自动机上的点的 \(size\) 加一
(要写那种广义自动机形式的东西)
最后遍历后缀树,对于有 \(size\) 大小的节点进行答案统计
后缀自动机上的节点不超过 \(\Theta(nm)\),证明考虑每种字符向前统计不超过 \(n\) 次