yang-xi-jie-mi-sam

详细揭秘 SAM

这个咕了好久


这个东西很玄乎,大家抽象理解就好。


首先,SAM 是啥:

  • 后缀自动机。

  • 每个节点代表一个状态,里面存着一些原串的子串(但是不会在代码中存)。

  • 每个节点有转移边指向其他节点,一个字符最多有一条它的转移边。

  • 整个 SAM 的节点和转移边构成 DAG(有向无环图)。

  • 每个节点有它的 link 指针,指向另外一个节点。

  • 整个 SAM 的节点和 link 指针构成一棵内向树。

下面是一些你需要知道的定义:

\(\texttt{endpos}\):设字符串 \(t\)\(\texttt{endpos}\) 集合表示它在原串 \(s\) 中的出现位置构成的集合,出现位置以最后一个字符定位。

\(s=\texttt{aabaaba}\) 中,串 \(\texttt{ab}\)\(\texttt{endpos}\) 即为 \(\{3,6\}\)

接着,我们将 \(\texttt{endpos}\) 相同的两个串划分到同一个 等价类 中。

例如上面的例子,\(\texttt{aab}\)\(\texttt{ab}\) 就在一个等价类中,这个等价类即为 \(\{\texttt{aab},\texttt{ab}\}\)

好了。那么现在我告诉你,SAM 每个节点就代表一个等价类。

为了方便说明,现在我们下一些定义:

  • 对于一个等价类(节点)\(u\),设 \(longest(u)\) 表示等价类中最长的字符串,\(maxlen(u) = |longest(u)|\)。类似地,设 \(shortest(u)\) 表示等价类中最短的字符串,\(minlen(u) = |shortest(u)|\)

现在有一些显而易见的结论:

  • 同一个等价类中的字符串,两两之间一定是后缀关系。

因为既然它们 \(\texttt{endpos}\) 相同,从 \(\texttt{endpos}\) 往前延伸,形成的串们一定有某一段后缀相同。

如图,红色是 \(\texttt{endpos}\),蓝色和绿色是等价类中的两个串。

进一步地,这些串一定都是等价类中最长串的后缀。

  • 两个串的 \(\texttt{endpos}\) 要么成包含关系,要么不相交。

这个显然。只要有相交说明这两个串成后缀关系,那么它们 \(\texttt{endpos}\) 必定是一个包含一个的。(跟上图一样)

  • 一个等价类 \(u\) 中的串长度连续,覆盖了区间 \([minlen(u),maxlen(u)]\)

这个也显然,\(longest(u)\) 的所有后缀长度在 \([minlen(u),maxlen(u)]\) 之间的,\(\texttt{endpos}\) 至少会包含 \(longest(u)\)\(\texttt{endpos}\),又不会比 \(shortest(u)\)\(\texttt{endpos}\) 多。所以它们全都在一个等价类中。

这意味着一个等价类含有的串数即为 \(maxlen-minlen+1\)


下面引入 link。

我们定义一个节点(等价类)\(u\) 的 link 指针指向另一个节点(等价类)\(v\),满足

  • \(longest(v)\)\(longest(u)\) 的真后缀。

  • 在所有满足上述条件的 \(v\) 中,\(maxlen(v)\) 最大。

换句话说,在 \(longest(u)\) 的后缀中,选一个最长的后缀使得它的 \(\texttt{endpos}\)\(u\)\(\texttt{endpos}\) 不同(前者比后者更大),将 \(u\) 的 link 指向这个 \(\texttt{endpos}\) 代表的等价类。

如图,假设整个字符串是 \(longest(u)\),那么 link 链的关系应该如图所示。

可以看出来一直跳 link 最后会去到空串,对应的也就是初始状态。

以及把这些等价类的串长区间(也就是图中黑、红、蓝、绿色的括号)并起来,最后会得到 \([0,maxlen(u)]\)

这个东西也可以这么理解:把 \(longest(u)\) 每次删去头一个字符,随着串变短,在 SAM 原串中出现位置自然会变多(即 \(\texttt{endpos}\) 的扩充)。

按照 \(\texttt{endpos}\) 变大的位置我们把这些后缀划分成很多区间(对应不同的等价类),之间用 link 连接。


然后是节点的转移边。

这个东西其实很简单:假设当前节点为 \(u\),它有一条 \(c\) 的转移边指向 \(v\)

那么你可以理解为把 \(u\) 中的所有字符串后面加上 \(c\) 得到的字符串一定在 \(v\) 中。

因此想要找到一个串在 SAM 中属于的节点,只需要逐个字符地走转移边,走到的节点就是了。


然后是 link 树。

我们知道,整个 SAM 的节点和 link 指针构成一棵内向树。这是因为每个点不断跳 link 最后都会回到初始节点。

同时,这棵树还是 \(\texttt{endpos}\) “要么成包含关系,要么不相交”关系构成的树。

因为跳 link 的同时 \(\texttt{endpos}\) 也会慢慢增加元素,也就是说越靠近根节点 \(\texttt{endpos}\) 越大。

这棵树有很广泛的用途,一会再讲。


不讲如何构造。这个东西有点复杂,但是感性理解也能懂。建议背代码


SAM 有什么用:

  • 检查串 \(t\) 是否在串 \(s\) 中出现过。

根据上面所说的,对 \(s\) 建 SAM,把 \(t\) 放上面按照转移边走,能一直走下去就是出现过。

  • 求串 \(t\) 在串 \(s\) 中的出现次数。

出现次数就是 \(\texttt{endpos}\) 集合大小。

因此我们对 SAM 的节点预处理 \(siz\) 表示 \(\texttt{endpos}\) 集合大小,找到 \(t\) 对应的节点查询 \(siz\) 即可。

如何预处理?先给出方法:先将 不由复制得到的 节点的 \(siz\) 设为 \(1\)

对 link 树 dfs 一遍,对每个点 \(u\) 执行 \(siz_u\gets siz_u+siz_v\) 后得到的就是最终的 \(siz\)

为什么这样是对的?我们考虑每个不由复制得到的节点(下称真节点)。假设它是在加入第 \(k\) 个字符时创建的,

在创建时它的 \(siz=1\),并会使原串 \(2\sim k\) 的后缀 \(\texttt{endpos}\) 多了一个 \(k\),即 \(siz\)\(1\)

因为遍历后缀相当于跳 link,所以这相当于把这个真节点在 link 树上到根的路径全部 \(+1\)

这个和求子树和的效果是一样的,与我们用求子树和的方法最后处理效果一致。

  • 求串 \(s\) 的本质不同子串数量。

SAM 已经帮你把相同的子串压缩在一起了。

我们知道一个节点含有的串数即为 \(maxlen-minlen+1\),我们对所有节点的这个值求和再 \(-1\)(空串)即可。

  • 找到串 \(s\) 的一个子串 \(s_{l,r}\) 在 SAM 中对应的节点。

这个非常有用,在一些关于区间的题里会常用到。(如 CF666E

子串 \(s_{l,r}\) 就是前缀 \(s_{1,r}\) 的一段后缀。

我们考虑对于每个前缀先预处理出它在 SAM 中属于的节点。

然后从前缀 \(s_{1,r}\) 属于的节点开始,不断跳 link(相当于遍历该前缀的后缀),直到当前 \(maxlen<=r-l+1\)

由于跳 link 过程中 \(maxlen\) 是不断减小的,我们可以用倍增代替这个暴力跳的过程。

这样单次询问复杂度就做到了 \(\mathcal O(log n)\)

posted @ 2024-03-07 07:44  iorit  阅读(11)  评论(0)    收藏  举报