SAM化咸
同样是本着认真负责的态度。
麻了今天的多项式式子也推不动。
我们来到了SAM咸化第二弹!
本文章同样只是浅层讲解,大致理解精神即可。
主要是分析例题,理解一下 \(parent\) 树。
巨佬们可以跳过。
根据上一篇文章,我们已经可以建出来一个正常的SAM了。
但是建SAM只是第一步,还有更重要的第0步。
那就是:想做法。
众所周知,SAM做一道题可能会需要一天。
其中有23小时是用来看题解的。
本文就从几道简单的例题入手吧~
【模板】后缀自动机
做题肯定是先要看板子题。
这次问的是,子串的出现次数 \(\times\) 长度的最大值。
首先毫无疑问,建出来一个SAM。
接下来我们发现,一个节点会存 一些子串 的信息。
这些子串有着相同的 \(endpos\) 集合,也就意味着这些子串的出现次数都是相同的。
那么也就只有长度最大的会有贡献。也就是这个节点的 \(len\) 值。
然后考虑怎么统计出现次数。
每加入一个字符之后,我们会新建一个节点 \(now\) 。
\(now\) 这个节点本身会存储现在的整个串。
(也就是说 \(len(now)\) 等于现在的串长)
根据 \(link\) 的定义,我们发现,
从 \(now\) 开始一直往前跳 \(link\) ,访问到的节点会存储现在的串的后缀的信息,并且所有的后缀都能被表示到。
而加入这个新字符之后,这些后缀都相当于出现了一次。
这样一看做法就很显然了。
我们建SAM的时候,标记一下那些点做过 \(now\) 。
(一共有 \(n\) 个点)。
然后对于标记过的点的 \(link\) 链,出现次数全都 \(+1\) 。
这个过程可以用拓扑排序或者DFS来完成。
(上一篇文章说过,拓扑序可以用桶排序来求出来)
然后,就遍历所有的点就好了。
用 \(len\ \times\) 出现次数作为贡献。
所有贡献取一个 \(max\) 。
怎么样,这题不难吧。
想了想,还是不贴代码了~
毕竟只是个板子,贴代码没啥用。
弦论
这题突然就开始上难度了。
由于我太菜了,为了做这个题,我翻遍了洛谷题解区(悲)。
建议好好读一读题,思考一下再来看下面的哦~
这次问的也很简洁,求第 \(K\) 小子串。
我们将 \(T = 0\) 和 \(T = 1\) 分开来做。
\(T = 0\)
关于字典序,我们其实很容易想到trie树。
而SAM的 \(edge\) 的作用其实就类似于trie树的边。
然后,再回忆一下SAM某个没啥用的性质。
那么做法就很简单了。
我们从根节点开始跑DFS。
遍历边的时候按着从 \(a\) 到 \(z\) 的顺序遍历。
每走一条边其实都意味着遍历到了一种新的子串。
直到遍历到了第 \(k\) 个,就可以结束了。
然后我们就开心的TLE啦。。。
主要问题在于,子串的数量太大了。
因此,我们可以提前预处理一下。
考虑求解的这个问题的本质是DAG的第 \(k\) 大路径。
记 \(dp[i]\) 表示从 \(i\) 节点开始的路径数。
然后我们按着字典序倒着跑DP就可以处理出来所有的 \(dp\) 值了。
那么当我们去DFS的时候,遇到了一条边通向点 \(v\) 。
判一下如果 \(dp[v] \ge k\) ,则答案一定要经过点 \(v\) ,那就往 \(v\) 点走就行。
否则 \(k -= dp[v]\) ,然后就不用管这条边了。
接下来跑DFS就可以做到线性啦(心情简单)
\(T = 1\)
这次重复出现的子串要算多次了。
由于我们啥也不会,
显然不愿意放弃之前的思路。
最好是,可以在 \(T = 0\) 的算法的基础上稍加修改完成这道题。
然后,我们想起来了上面的板子题。
对于一个点 \(v\) ,从原点到 \(v\) 的路径表示了一个子串。
那么这个子串的 \(endpos\) 集合的大小也就是这个子串的出现次数了。
于是,直接用前面那个模板题的套路处理一下出现次数就好。
然后就按照 \(T = 0\) 的算法继续就行了。
融合
其实可以两个算法放在一起跑。
对于一个节点 \(v\) ,我们要处理出来其出现次数 \(apl[v]\) 。
那么当 \(T = 0\) 的时候其实也就是有 \(apl[v] = 1\) 。
然后跑DP的时候每个点的 \(dp\) 初始值就是 \(apl\) 值。
剩下的照常。
如果没有看懂的话可以自己思考一下,再看一看上面的内容。
我已经尽可能的详细了。
这道题解决完了,但是其实我们可能会有一些新的疑问。
在思考 \(T = 1\) 的时候,我们发现每个点其实代表了 一个 子串,也就是从根节点到这个点的路径连成的串。
但是再回忆之前说过的,每个点 \(v\) 其实代表了 \(len(v) - len(link(v))\) 个串。
( \(len(v)\) 是 \(v\) 点表示的最大串长,而从 \(len(link(v))\) 开始更短的后缀就由 \(link(v)\) 以及 \(link(link(v))....\) 来表示了,所以 \(v\) 点表示的有 \(len(v) - len(link(v))\) 个。
那么这里就与我们的想法有些不符了,因为这两种说法出现了冲突。
具体是怎么会逝呢,我们可以看一看下一题~
Sandy 的卡片
这明明是个SA的题。
我为什么要用SAM来做啊啊啊啊啊啊啊。
首先,这题的套路有一点有意思。
找的是多个串的LCS。
.....
.....
........
我实在想不到用什么方式来引入了。。。
反正我当时也是直接贺的题解,那就直接说做法吧~
首先,我们对其中一个串建一个SAM出来。
然后,让剩下的串在这个上面跑匹配。
既然SAM是个自动机,那跑个子串匹配不过分吧(心情简单)。
然后我们发现,其实每次匹配到的是最长的子串。
也就是说,假如现在指针 \(p\) 在节点 \(v\) 。
那么 \(v\) 的 \(link\) 链上的节点其实都相当于是匹配上了。
这个嘛,就跑完一个串之后按照之前的套路按拓扑序跑一跑即可。
记录一下一个 \(bool\) 数组 \(apl\) ,表示是否匹配上。
然后再来一个计数数组 \(cnt\) ,表示这个节点被匹配上了多少次。
我们可以先初步认为,每次都能被匹配上的节点是LCS。
当然,这里涉及到了一些关于时间复杂度的问题。
我们应该挑选长度最短的串来建SAM。
具体原因就是,每次需要按着拓扑序遍历每一个点。
只有选用最短的串复杂度才有保证。
而实际效果是数据点太水了,没区别。
之后再考虑一些细节问题。
前面提到的那个冲突在这里我们又遇到了,并且这次无法避免必须要弄明白了。
(如果忘了前文的话就回到这道题的前面看一看吧)
我们知道,SAM是一个DAG。
也就是说,一个点的入度可以有很多。
而事实上,从原点到达点 \(v\) 的路径种数等于 \(len(v) - len(link(v))\) 。
具体的,这些路径分别组成这个节点表示的 \(len(v) - len(link(v))\) 种子串。
至于证明嘛。。。
我们感性理解就好了。
从原点到 \(v\) 的路径表示的一定是原串的一个子串。
并且这个子串的信息由点 \(v\) 来表示,也仅会被 \(v\) 这一个点表示。
而我们已经知道,\(v\) 这个点表示了 \(len(v) - len(link(v))\) 种子串。
那么从原点到 \(v\) 有这么多种路径其实也就合理了。
我们再次回到这个题,思考上述过程。
当我们从一个节点 \(v\) 走到下一个节点 \(u\) 的时候,其实我们匹配上的长度不一定是 \(len(u)\) 。
( \(len(u)\) 只是 \(u\) 这个节点表示的最大长度,和我们匹配到了多少没关系)
我们需要实时维护一个量 \(tlen\) ,表示现在匹配的长度。
而每次 \(tlen\) 最多会 \(+1\)(如果得配的话)
再考虑失配的时候。如果添加的字符是 \(c\) ,而 \(v.edge[c] == 0\) ,那么我们就认为这个是失配。
失配了怎么办?沿着 \(link\) 走呗。
接下来一直跳 \(link\) (让 \(v = link(v)\) ) ,同时我们使 \(tlen = min(tlen, len(v))\) 。
(这里还需要解释吗)
(算了解释解释吧)
如果我们在 \(v\) 节点有着长度为 \(tlen\) 的匹配,那么去遍历 \(link(v)\) 的时候其实也会有 \(tlen\) 的大保底。
但是 \(len(link(v))\) 本身可能没有那么长,所以需要取一个min。
当然,事实上,如果失配的话我们直接让 \(tlen = len(link(v))\) 就好了。
原因也很简单,既然匹配到了 \(v\) 点那么证明我们匹配上的子串长度一定在 \([len(link(v) + 1), len(v)]\) 之间。
那也就是说一定有 \(tlen > len(link(v))\) 。
取min也就没什么必要了。
那么说,我们现在可以把前文说的“某节点是否被匹配”细化到“某节点的某长度是否被匹配”。
具体的,对于每个节点我们不再维护 \(bool\) 变量了,我们维护一个 \(int\) 变量,表示这次匹配到的最大长度,仍然记为 \(apl\) 好了。每次匹配前的初始值为0。
每当到达一个点 \(v\) 的时候就让 \(apl[v] = max(apl[v], tlen)\) 。
对于点 \(v\) ,同样的,如果这个点出现了,那么它的整个 \(link\) 链上的节点都出现了,并且还都是最大长度。
这里的处理就仍然是套路的按 \(link\) 的拓扑序遍历。
然后处理完 \(n - 1\) 个字符串之后,每个点的 \(n - 1\) 个 \(apl\) 值取个min,作为这个点最终的值。
最后遍历每个点找个最长的就行了。
这道题,讲的抽象吗?
抽象也没办法,就这样了。。。
这里可能需要自己思考理解一下吧。。。
前面我们说了三道题了,但是。。。
还记得开头的时候我说,这篇文章要讲 \(parent\) 树吗。
其实,\(parent\) 树就是由 \(link\) 组成的一个树形结构。
好了本文完结。我睡觉去了。
在前面的第三题里面多少涉及了一点。
接下来我们看一个需要建出来 \(parent\) 树的题吧~
品酒大会
这不也是SA的水题嘛,为什么要用SAM。。。
简单做一下题意转化,其实问的是所有后缀之间的LCP(最长公共前缀)
首先就是说,我们只需要关心两杯酒之间最大的相似度是多少,那么更少的就一定可以取到。
那么根据题意,两个位置 \(p\) 和 \(q\) 的最大相似度就是后缀 \(S[p, n]\) 和后缀 \(S[q, n]\) 的LCP。
(希望你还记得 \(S[p, n]\) 代表的是啥)
接下来,我们来引入一个新的结论~
首先,我们对字符串 \(S\) 建一个SAM出来。
还是,标记出 \(n\) 个当过 \(now\) 的节点。我们称之为结束位置。一个结束位置可以表示一个前缀。
然后对于所有的点 \(v\) ,在 \(v\) 和 \(link(v)\) 之间连边。
(也就是把 \(link\) 组成的那棵树建出来)
对于两个结束位置 \(p,q\) ,我们有结论,\(len(LCA(p, q))\) 是 \(p,q\) 这两个前缀的最长公共后缀。
证明的话就略过了。
还是老样子,我们感性理解一下吧。
从 \(p\) 节点开始往祖先上跳,也就是跳 \(link\) 。
这是之前说烂了的话, 跳 \(link\) 相当于去取后缀。
那么,\(p,q\) 的LCA其实就相当于是 \(p,q\) 两个前缀不停地去取后缀,直到最后取到了同一个点。
那么这个点表示的最长子串就是最长公共后缀了。
好了,有了这个结论又能干什么呢?
我们要求的是后缀的最长公共前缀啊。。。
这里不用多说了吧,我们直接把字符串 \(reverse\) 一下,建出来SAM。
这个时候的最长公共后缀就是原字符串的最长公共前缀了~
然后我们发现,枚举 \(p,q\) 是 \(O(n^2)\) 的,于是我们就去枚举 \(LCA(p, q)\) 。
对于 \(parent\) 树上的一个节点 \(v\) ,如果以这个节点为LCA,则会产生长度为 \(len(v)\) 的贡献。
至于有多少对 \(p,q\) 满足 \(LCA(p, q)\) ,这个其实类似于树形DP了。
(可能已经简单到算不上DP了)
包括美味度最大值,只需要维护一下子树最大权值和最小权值即可。
方法很简单就不多说了。
怎么感觉这个题反而更水呢?
当然,还有一道题 差异,也是用的LCA这个结论。
但是这道题更水,不说了。
好了,简单的SAM / \(parent\) 树应用就写到这里吧。
这次的文章可能没有上一篇详细,多了一些思考的空间。
毕竟是讲题我又不能一步一步教。
不过,应该是比自己看题解要容易一些吧(心情简单)
完结撒花~
莫名其妙的又写了 \(13K\) 的 \(Latex\) 。。。
希望可以对其他人有用吧。
本蒟蒻会的也就只有这么多了,还有一些SAM的用法/套路本蒟蒻也不会。
这两篇文章就当是早教吧(确信)
当时看SAM的时候,各种不理解与各种看不懂。
于是才决定来写这两篇文章。
但是写的时候再来看,其实也挺好理解的。
希望读者理解之后再来看这篇文章,可以认为这篇文章讲了一堆废话吧。。。
\(Write\ on\ 12.30\)