后缀自动机 学习笔记

后缀自动机学习笔记

学完感觉学懂了又没学懂,主要是不会做题啊/ll。

于是有了学习笔记。

可能并不全面,只是针对自己在学习过程中不清楚的地方进行的梳理总结。

  • 为了方便,所以我们有如下约定:

\(s\) :字符串,如未特殊说明,标号从 \(1\) 开始.

\(t_0\) :初始状态.

\(endpos(t)\) :字符串 \(s\) 中子串 \(t\) 的结束为止的集合.

\(link(t)\) :状态 \(v\) 的后缀链接.

\(len(v)\) :状态 \(v\) 对应的最长子串的长度.

\(longest(v)\) :状态 \(v\) 对应的最长子串.

\(minlen(v)\) :状态 \(v\) 对应的最短子串的长度.

\(shortest(v)\) :状态 \(v\) 对应的最长子串.

1.概述

后缀自动机可以在较优秀的时间复杂度内解决许多字符串相关问题,比如 “在另一个字符串中搜索一个字符串出现的所有位置” 或者 “计算给定的字符串内有多少个不同的子串”。

从直观上理解,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。而其与 AC 自动机的不同之处在于, SAM 的节点数是 \(O(n)\) 的。

2.相关性质

首先需要明白,结束位置 endpos 定义了 SAM 中的节点(也是节点可以进行合并的充要条件),而后缀链接则对应了 AC 自动机的失配指针。

2.1 结束位置 endpos

考虑字符串 \(s\) 的任意非空子串 \(t\),我们记 \(endpos(t)\) 表示字符串 \(s\)\(t\) 的所有结束位置的集合。如果两个子串 \(t1\)\(t2\) 的结束位置完全相同,即 \(endpos(t1)=endpos(t2)\),那么我们称这两个子串是 等价的。因此,字符串 \(s\) 的所有非空子串可以根据他们的 endpos 集合分为若干 等价类

在 SAM 上,每个等价类都对应着一个状态,也就是一个节点。SAM 中每个非初始状态都对应一个或多个 endpos 相同的非空子串。

然后有一些结论:

  • \(s\) 的两个非空子串 \(u\)\(v\) 的 endpos 相同(假设 \(u\) 的长度较小),当且仅当字符串 \(u\)\(s\) 中每次出现时,都是以 \(v\) 的后缀出现的。反之依然成立。

  • 考虑两个非空子串 \(u\)\(v\)(假设 \(u\) 的长度较小),要么 \(endpos(u) \cap endpos(v)=\varnothing\) ,要么 \(endpos(v)\subseteq endpos(u)\) ,取决于 \(u\) 是否是 \(v\) 的一个后缀。

  • 考虑一个 endpos 相同的等价类,将类中所有子串按长度非递增的顺序排序,那么每个子串都是它前一个子串的后缀,且该等价类的子串长度是连续的。

感性理解较为显然,详细证明请参考 OI-wiki。

考虑 SAM 中某个状态 \(v\)\(v\ne t_0\)),那么状态 \(v\) 对应于一个 endpos 等价类。若定义 \(w\) 是这些字符串中最长的一个,则其他这个类中的字符串都是它的后缀。

我们还知道 \(w\) 的前几个后缀全部包含于这个等价类,且其他后缀(至少有一个空后缀)在别的等价类中,我们记 \(t\) 为其他后缀中最长的,将 \(v\) 的后缀链接在 \(t\) 上。

也就是说,\(v\) 的后缀链接 \(link(v)\) 所连接到的状态,对应于 \(w\) 的后缀中与它 endpos 集合不同(此时这个 endpos 集合一定包含当前 endpos 集合)且最长的那个。因此,我们有 \(minlen(v)=len(link(v))+1\)

有结论:所有后缀链接构成一棵根节点为 \(t_0\) 的树。我们称其为 parent 树

2.3 小结

SAM 中存储着字符串所有子串的信息,可以通过两个角度理解:

  • SAM 本身可以看作是字符串全体后缀的 AC 自动机的压缩版本,因此存储了全体后缀所有前缀的信息,相当于存储了所有子串的信息。
  • SAM 的 parent 树可以看作字符串全体前缀的的后缀u压缩版本,因此存储了全体前缀所有后缀的信息。

3.构造

采用增量算法,即每次往原有的 SAM 里添加新的字符。

  1. \(lst\) 为添加字符 \(c\) 之前,整个字符串对应的状态。创建一个新的状态 \(cur\),并将 \(len(cur)\) 的值赋成 \(len(lst)+1\)

  2. 现在从状态 \(lst\) 开始,如果当前状态还没有标号为字符 \(c\) 的转移,添加,并将当前状态沿 link 移动。如果这个过程中遇到某个状态已经存在字符 \(c\) 的转移,就 break 掉,并将这个状态标记为 \(p\)

  3. 如果没有找到这个状态,我们将 \(link(cur)\) 赋值为 \(0\) 并退出。

  4. 否则对于这个找到的状态 \(p\),要么 \(len(p)+1=len(q)\),要么\(len(p)+1<len(q)\)。对于前者,直接将 \(link(cur)\) 赋值为 \(q\) 并退出。

  5. 对于没有退出的其他情况,需要 复制 状态 \(q\),新建一个状态 \(clone\),复制 \(q\) 的后缀链接和转移(除了 \(len\)),将 \(len(clone)\) 赋值为 \(len(p)+1\)。复制之后,将后缀链接从 \(cur\) 指向 \(clone\),从 \(q\) 指向 \(clone\)。然后沿着后缀链接从状态 \(p\) 往回走,只要经过的状态存在指向状态 \(q\) 的转移,就将该转移重新连接到状态 \(clone\)

  6. \(lst\) 更新为 \(cur\)

接下来我们考虑这样构造为什么是对的(尤其是对于为什么要复制节点的说明。

首先对于一个转移,要么 \(len(p)+1=len(q)\),要么\(len(p)+1<len(q)\)。前者我们称其为连续的,否则为不连续的。当每插入一个新的字符时,不连续的转移可能会改变。

设插入字符前的字符串为 \(s\),当插入一个字符 \(c\) 的时候,我们创建一个新的节点。然后从状态 \(lst\)(也就是原先的字符串 \(s\) 对应的节点)沿着后缀链接开始移动,对于经过的每一个状态,我们尝试添加一个通过字符 \(c\) 到当前状态的转移。只要遇见已经存在的 \(c\) 的转移,就必须停止,因为只能添加与原有转移不冲突的转移。

如果到达了空节点,也就是我们为所有 \(s\) 的后缀都添加了字符 \(c\) 的转移,所以 link 为 \(0\)

如果我们找到了现有的转移,并且 \(len(p)+1=len(q)\)。这意味着我们尝试添加一个已经存在的字符串 \(x+c\),(\(x\)\(s\) 的一个后缀,且字符串 \(x+c\) 已经作为 \(s\) 的一个子串出现过了)所以我们不应该添加这个转移。

这个时候思考,对于当前节点的 link 应该连在哪个状态上?我们需要这个状态所对应的最长字符串恰好是 \(x+c\),即这个状态的 \(len\) 应该是 \(len(p)+1\),如果存在这样的节点,直接连接到状态 \(q\)

否则,转移不连续这意味着状态 \(q\) 不只对应长度为 \(len(p)+1\) 的后缀 \(s+c\),还对应于 \(s\) 中更长的子串。所以为了保证正确性,只能讲这个状态 \(q\) 拆成两个子状态,其中一个子状态的长度就是 \(len(p)+1\)

对应到上面的过程中,就是需要 复制 状态 \(q\),新建一个状态 \(clone\),复制 \(q\) 的后缀链接和转移,将 \(len(clone)\) 赋值为 \(len(p)+1\)。因为我们不想改变经过 \(q\) 的路径,所以将 \(q\) 的所有转移复制到 \(clone\),并且将 \(clone\) 的 link 设置为 \(q\) 的后缀链接,并将 \(q\) 的链接设置为 \(clone\)。将从当前状态的 link 设置为 \(clone\)

最后还需要将一些原本指向 \(q\) 的转移重新连接到 \(clone\) ,只需要重新连接相当于所有字符串 \(w+c\)\(w\)是状态 \(p\) 对应的最长字符串)就够了,所以需要沿着 link 移动,修改转移。

4.parent 树

okk其实很多题不需要 SAM 而只需要 parent 树即可。而且这些性质往往比 SAM 的转移更加重要!往往是喷雾瓶小朋友做不出来题的罪魁祸首

首先需要了解,在构造 SAM 的过程中,需要更新 \(lst\) 状态的值。它对应的所有前缀在 SAM 中所对应的节点,这些状态称为前缀节点,记为 \(pos_i\)。规定初始状态 \(t_0\)\(pos_{-1}\)

我们注意到 parent 树有优美性质!

  • 祖先节点对应的字符串总是儿子节点所对应的字符串的后缀。
  • 每个节点处的 endpos 集合就是它子树内所有 前缀节点\(v_i\) 的下标 \(i\) 的集合。
  • 每个节点的 len 的值就是它子树内所有前缀节点所对应的前缀的最长公共后缀的长度(如果做题要求后缀的最长公共前缀,把串反过来做即可~
  • 除根节点 \(t_0\) 外,每个节点对应的本质不同子串的数目,就是它的 len 值减去它的父节点的 len 值,即 \(len(v)-len(link(v))\)

5.应用

5.1 不同子串个数

  1. 由 parent 树的性质第四条,每个节点对应的子串数量是 \(len(v)-len(link(v))\),对所有自动机节点求和即可。
  2. 对字符串 \(s\) 构建 SAM,所以每个 \(s\) 的子串都相当于自动机中的一些路径。因此,不同子串的个数就等于自动机中以 \(t_0\) 为起点的不同路径的总条数。因为 SAM 是一个 DAG,所以可以通过 dp 求出。令 \(d_v\) 为从状态 \(v\) 开始的路径数量(包括长度为 \(0\) 的路径,则有 \(d_v=1+\Sigma d_w\),其中\((v,w,c)\in DAWG\),即后缀自动机中存在自 \(v\)\(c\)\(w\) 的转移。不同子串个数即为 \(d_{t_0}-1\)(减去空字串。

5.2 所有不同子串总长度

利用 parent 树的信息,每个节点对应的最长子串的所有后缀长度是 $\frac{len(v)*(len(v)+1)}{2} $。减去 link 节点的值就是该节点的净贡献,对自动机所有节点求和即可。

5.3 字典序第 k 小子串

考虑字典序第 \(k\) 大的子串对应到 SAM 中字典序第 \(k\) 大的路径,所以可以计算每个状态(也就是走哪个字母)的路径数,然后通过前缀和就可以求当前应该走哪个字母。

如果要求的是第 \(k\) 小本质不同的子串,所以所有节点对于排名的贡献都是 $1,否则需要求出每个节点对应的子串个数。

但是这个还是用 SA 做比较方便。

5.4 最长公共子串

我们考虑求 \(n\) 个串的最长公共子串!

这个可以用广义 SAM 做,不过也可以用 SAM 暴力解决。

考虑把所有串拼接起来,中间加入特殊字符,构造 SAM。只要当前状态对应的 endpos 集合中的位置覆盖了所有 \(n\) 个字符串,那么这个等价类的所有子串都是公共子串,求最大值即可。

然后考虑如何维护 endpos 集合,这个可以线段树合并做一下。考虑对于每个状态(也就是每个节点)都建立一棵线段树,表示当前对于每个小字符串的覆盖情况,然后自底向上合并,若可以全部覆盖就取 \(\max\)

不过我还是觉得广义 SAM 更好用一点嘻嘻,所以这个做法并没有写过。

5.5 最长公共前缀

继续利用 parent 树的优美性质!

首先我们发现在 parent 树上父亲节点对应的字符串总是子节点所对应的字符串的后缀,然后我们现在要求两个后缀的 LCP,所以反转字符串插入 SAM,就可以满足父亲子串是儿子子串的前缀啦!

然后先找出原先两个后缀对应的反转后的子串,求它们的 LCA 节点的 len 即可。

当然这个也可以用 SA 做,而且省去了求 LCA 的 log,时间复杂度严格线性并且好写好调


posted @ 2026-01-17 15:38  Aapwp  阅读(2)  评论(1)    收藏  举报
我给你一个没有信仰的人的忠诚