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\)

posted @ 2023-03-17 19:09  Houraisan_Kaguya  阅读(63)  评论(1编辑  收藏  举报