SAM
\(\mathcal{SAM}\)
这次的标题是不是就正常了很多
为了 THUWC 紧急插播的知识点,执笔于2024/1/25 09:11。本人的题单进度其实还在动态规划。
波特好闪,拜谢波特。
理论上来说这玩意其实不在省选考纲里,学这个的主要目的有三个:
- 准备THUWC
- 提升码力
虽然这玩意不在省选考纲里但是后缀树在而这玩意是建后缀树的最方便的写法
对SAM的理性认识
字符串 \(s\) 的 SAM 是接受 \(s\) 的所有后缀的最小 DFA。
SAM 最基本的性质是:它包含关于串 \(s\) 的所有字串的信息。任意从初始状态 \(t_0\) 开始的路径,都对应 \(s\) 的一个子串。
但是注意:由于 SAM 是高度压缩的,所以每个点会对应不止一个字符串。
结束位置endpos
SAM中最重要的概念。记 \(endpos(t)\) 为 \(t\) 在 \(s\) 中出现的结束位置集合。比如在 \(s = ababa\) 中,串 \(ab\) 的 endpos 就是 \(\{2, 4\}\)。 SAM 把所有 endpos 相同的字符串压缩到一起保存。
引理1:两个字符串的 endpos 要么无交,要么互相包含。
证明:显然。如果 endpos 有交,说明两个字符串存在一个是另一个的后缀。此时两个字符串的 endpos 就是互相包含的关系。
推论1:一个 endpos 集合对应的字符串集合里前一个是后一个的后缀,且长度依次增加 1 。
这个也比较显然。
后缀链接link
SAM 上的所有非起始状态结点都具有一个后缀链接 link。
如果定义 \(w\) 为一个结点所压缩的所有字符串中最长的一个,那么一个结点的后缀链接 \(link_v\) 指向所有 \(w_t\) 为 \(w_v\) 后缀的结点中长度最长的一个。特别的,定义 \(link_{t_0} = -1\)。
对SAM的感性认识
这部分好像才是最重要的?
直观上,字符串的 SAM 可以理解为给定字符串的 所有字串 的压缩形式。值得注意的事实是:SAM将所有的这些信息以高度压缩的形式储存。——OI wiki
理论上:SAM 可以完全替代 KMP 自动机,广义 SAM 可以完全替代 AC 自动机。
结束位置endpos
可以发现,endpos相同的子串在大多数字符串问题中没有本质区别,可以合并在一起处理。
如何知道一个字符串的 endpos 集合具体是哪些呢?每一个终止位置在它的子树里的前缀都属于它的 endpos。毕竟,后缀的前缀是子串嘛。
通过endpos,SAM给了我们一个“对字符串暴力”的机会。
毕竟,你已经知道一个字符串的所有子串和它的出现位置之间的树形关系了。剩下的事就是在树或者转移DAG上操作了。
后缀链接link
万恶之源
容易发现所有的 link 形成一棵树的结构,这个方便我们用数据结构维护字符串。也是出题人最喜欢干奇怪操作的地方
具体来说,link 承担了“前缀的后缀是子串” 的功能。我们通过在 SAM 上走,能走到所有的前缀。这个时候再不断的跳它的后缀链接,就能遍历在这个位置结束的所有子串。
SAM的构建
这是字符串 \(s = abbb\) 的SAM。
也就是说:在建完SAM之后,一个字符串分成了两个 相对独立 的部分:
-
一棵由 link 构成的树。对于字符串的每一个前缀,link 树上的祖先关系能提供给我们它的后缀的信息。反过来:对于一个子串,link树上的子树能提供给我们它出现的位置的信息。
-
一个转移DAG。用来匹配字符串。每一个字符串都能通过在DAG上游走到达对应的 endpos 集合。
初始化
建立空节点 \(0\) ,规定 \(link_0 = -1\) ,设置转移函数 \(tr\) ,当前位置的指针 \(cur\) ,总非空结点数 \(tot\) ,节点代表的最长串长度 \(len\)。
每次在 SAM 上加一个字符 \(c\) ,构建新的SAM。
每次添加字符分为四步:
- 建立新状态
建立新点 \(tot + 1\) ,令 \(lst\) 指向 \(cur\) ,\(cur\) 指向 \(tot + 1\) ,\(len_{cur}\) 赋值为 \(len_{lst} + 1\)。
- 更新link
\(cur\) 的 \(link\) 应该是它的某个后缀。某个后缀删掉最后一个字符之后是 \(lst\) 的一个后缀。那么跳 \(lst\) 的 \(link\) ,在路上把所有的 \(tr_{p, c}\) 赋值为 \(cur\) ,直到找到第一个存在转移 \(tr_{p, c}\) 的点 \(p\) 。特别的,如果跳到了 \(-1\) ,那么不存在它的后缀,直接设置 \(link_{cur} = -1\) 并退出。
- 拆点
记 \(tr_{p, c} = q\) 。如果满足 \(len_p + 1 = len_q\) ,那么万事大吉,\(link_{cur} = q\) ,也退出。
否则,这意味着 \(q\) 所代表的集合分成了两个子集,短的部分在 \(cur\) 的后缀中出现过,长的部分没有,我们把 \(q\) 分裂成两个点,新建一个点 \(q\) 仍然保留长的部分,\(clone\) 保存短的那部分。赋值 \(len_{clone} = len_p + 1\) ,并把 \(q\) 的 \(link\) 和 \(tr\) 都拷贝给 \(clone\) 。将 \(q\) 和 \(cur\) 的 \(link\) 都指向 \(clone\) 。
- 更新链接
然后,我们跳 \(p\) 的所有祖先,如果它们有向 \(q\) 的转移(这些点一定是p向上连续的一段),那么就更改转移的终点为 \(clone\) 。结束构建过程。
复杂度
咕了。
这东西有什么用?
随手记录,以备不时之需。我能确定的是这个东西的能力上限远比我想象的强大。
找出一个子串的出现次数
对于 SAM 上的所有点,它的子树里所有的前缀对应的结点的数量就是它的出现次数。
对SAM的节点按照深度计数排序
算是一个小技巧?
提取一个字符串的子串
这个多少有点抽象,但是经常有 吃多了没事干
的出题人关注一个字符串的给定子串之间的关系。这个时候要借助SAM提取子串,而且——在必要的时候——分裂SAM的节点。
省流:动态加串,支持离线,找出所有是至少 \(K + 1\) 个给定子串的后缀的串串并输出 个数 和 最长的那个的长度。
“是后缀” 让我们联想到 SAM 的 link 链接。按理来说,所有被一个串的 link 链接指向的串都有一个出现次数。
但是问题是某个子串和前缀不一样,前缀一定是对应一个 endpos 集合里最长的一个,但子串可能和其它串压缩到一起。没关系,我们拆点。先把询问离线下来。要访问的子串就把它拆出来。
这样原问题就转化为:给定一颗树,每次将一个点染色,定义子树大小是子树中染色点的数量,要求每次操作后输出满足子树大小 \(>k\) 的所有点的点权和 与 \(len_u\) 的最大值。(这个是题解原话)
注意到如果一个点合法,那么它的祖先一定合法。树剖之后可以暴力移动每条链合法位置的指针更新答案,那么 \(\Theta(n \log ^2 (n))\) 是容易的。扔掉一个 \(\log\) 也不难,只需差分即可。如果修改的位置高于当前指针就不修改。
还有,这题卡空间,差评
应用2:P5284 [十二省联考 2019] 字符串问题 (我还不会,待填坑。)
把字符串匹配问题转化为DAG上问题
转移DAG承载着匹配字符串的信息,可以灵活处理“本质不同” 和 “本质相同” 的关系。
我们想对于所有串 \(t\) ,记录它和在它后面添加字符得到的所有串 在给定串 \(s\) 中的出现次数 \(w_t\)。(本质相同可能算多个/一个)这样,我们容易每次加一个字符转移。
容易发现,所有 endpos 相同的串的答案都相同,符合 SAM 的使用条件。我们就把所有 endpos 相同的 \(t\) 的答案压缩到一起保存。
如果本质相同的串算多次,我们就先处理出每个串出现的次数 \(cnt_t\)。否则就记所有在SAM上的结点(空结点除外)出现次数为 \(1\) 。
对于所有串 \(t\),\(w_t = cnt_t + \sum_{x \in \Sigma} w_{t + x}\) 。直接在DAG上dp即可。
\(\mathcal{GSAM}\)
待填坑。