「学习笔记」后缀自动机

推荐博客 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\)

posted @ 2020-12-21 16:46  yspm  阅读(199)  评论(0编辑  收藏  举报