后缀自动机学习笔记

后缀自动机学习笔记

前言

为了方便,下文有如下约定:

  1. 在下文中,后缀自动机简称 \(\text{SAM}\)
  2. \(|S|\) 为字符串 \(S\) 的长度。
  3. \(\sum\) 为字符集,\(|\sum|\) 为字符集大小。

定义

  • 字符串 \(S\)\(\text{SAM}\) 是一个可以接受 \(S\) 的所有后缀的最小 \(\text{DFA}\)(确定性有限状态自动机)。

这是后缀自动机的基本定义,用更位直白的话翻译过来就是:

  1. \(\text{SAM}\) 是一张 \(\text{DAG}\)\(\text{DAG}\) 上的结点称为状态,而边称为状态间的转移。
  2. \(\text{DAG}\) 存在一个初始结点 \(P\),其他的所有结点均可以从 \(P\) 出发到达。
  3. 每条边被标记上字符集中的一个字母,从一个结点出发的所有边所标记的字母都不同。
  4. 存在至少一个终止结点。如果从 \(P\) 出发,通过若干条边到达了一个终止节点,则路径上的所有边所标记的字母依次连接起来所形成的字符串 \(s\) 必然是字符串 \(S\) 的一个后缀。倒过来说,字符串 \(S\) 的每一个后缀均可以表示成 \(\text{DAG}\) 上一条从 \(P\) 出发到某个终止结点的路径。
  5. 在所有满足上述条件的自动机中,\(\text{SAM}\) 的结点数最少。

功能

\(\text{SAM}\) 作为一个 \(\text{DFA}\),它可以接受一个字符串的所有后缀。但 \(\text{SAM}\) 最常用的功能是存储一个字符串 \(S\)每一个子串,这也是做题时最主要利用的性质。

对于一个长度为 \(n\) 的字符串 \(S\)\(\text{SAM}\) 可以在 \(O(n)\) 的空间内存储 \(S\) 的所有子串的信息,并且,构造 \(\text{SAM}\) 的时间复杂度同样为 \(O(n)\)

综上所述,\(\text{SAM}\) 最主要的功能就是在线性时空内存储一个字符串的所有子串信息。

压缩子串信息

  • \(\text{SAM}\) 包含了字符串 \(S\) 的所有子串。

这是 \(\text{SAM}\) 的最重要的性质,其原理也很简单。对于从 \(P\) 到某个终止结点的一条路径来说,将所有边的标记组成的字符串称为 \(s\)\(x\) 是这条路径上的某个结点,将从 \(P\)\(u\) 的所有边上的标记组成的字符串成为 \(s'\),显然有 \(s'\)\(s\) 的前缀。又因为 \(\text{SAM}\) 存储了字符串 \(S\) 的所有后缀 \(s\),而也能在 \(\text{SAM}\) 上找到每个 \(s\) 的前缀 \(s'\),因此能够在 \(\text{SAM}\) 上找到字符串 \(S\) 的所有子串 \(s'\)

通过这一点我们可以在 \(O(n)\) 的时间内判断一个给出的串是否原串的子串,做法就是从 \(P\) 开始向下走,如果有对应的标记的边就顺着走到下一个结点。如果整个字符串都走完了,那么给定的串就是原字符串的子串;如果没有走完就走不下去了,那么给定的串就不是原字符串的子串。

同时我们还可以通过结束结点是不是终止结点来判断给定的串是不是原字符串的后缀。

后缀树与后缀链接

  • 后缀树和 \(\text{fail}\) 树一样,也许不能将它们算作自动机的一部分,因为它们是在自动机的基础上建立起的全新数据结构。

事实上,因为在 \(\text{SAM}\) 表达的每一个子串,都作为一个后缀的前缀被统计一次,因此在一个后缀中的非前缀子串并没有被统计,因此单纯从 \(P\) 开始遍历并不能保证找到所有字符串中的子串信息。

因此我们需要引进另一种数据结构——后缀树来统计所有本质相同的子串的信息。

首先,后缀树是基于 \(\text{SAM}\) 建立起来的数据结构,是一个完整的 \(\text{SAM}\) 的一部分。\(\text{SAM}\)\(\text{DAG}\) 和后缀树的关系类似于 \(\text{ACAM}\) 中的 \(\text{Trie}\)\(\text{fail}\) 树的关系。

结束位置 \(\text{endpos}\)\(\text{endpos}\) 等价类

定义

对于字符串 \(S\) 的任意子串 \(s\),记 \(\text{endpos}(s)\) 表示 \(s\) 在字符串 \(S\) 每次出现的结束位置的集合(令字符串的下标从 \(1\) 开始),显然 \(\text{endpos}(s)\) 应该是一个非空集合。

对于字符串 \(s=\) abab 来说,\(\text{endpos}\) 的情况如下所示:

子串 a b,ab aba,ba abab,bab
\(\text{endpos}\) \(\{1,3\}\) \(\{2,4\}\) \(\{3\}\) \(\{4\}\)

实际意义

回到 \(\text{SAM}\)\(\text{DAG}\) 上来。刚才说过,从 \(P\) 出发,到任意结点 \(x\) 结束的路径都是原串 \(S\) 的一个子串 \(s\)。那么如果我们求解出了每一个子串 \(s\)\(\text{endpos}\),那么我们就能够知道这个子串在哪些位置出现、总共出现了多少次。如此一来,我们只需要在每一个结点的位置上维护对应的 \(\text{endpos}\),就可以解决子串信息遗漏的问题。

另外,显然两个子串 \(s_1,s_2\)\(\text{endpos}\) 可能相等,即 \(\text{endpos}(s_1)=\text{endpos}(s_2)\)。这样,根据每一个非空子串的 \(\text{endpos}\) 就可以将字符串 \(S\) 的所有子串 \(s\) 分为若干个等价类

  • 每一个等价类 \(E\) 是由字符串构成的集合,且对于 \(\forall s_1,s_2\in E,\text{endpos}(s_1)=\text{endpos}(s_2)\),不能发现这样的定义符合等价类的要求(即自反性,对称性,传递性)。

  • 在下文中,规定 \(\text{endpos}(E)\) 代表等价类 \(E\) 对应的 \(\text{endpos}\) 集合。需要注意的是,等价类 \(E\) 是一个字符串集,而 \(\text{endpos}\) 则是一个数集。

事实上,对于两个 \(\text{endpos}\) 相等的子串 \(s_1,s_2\),在 \(\text{DAG}\) 上一定会对应到同一个结点。也就是一个结点 \(x\) 对应了一个 \(\text{endpos}\) 等价类 \(E_x\)

更多的,一个 \(DAG\) 除了 \(P\) 以外有 \(n\) 个结点,那么字符串 \(S\) 的所有子串集关于 \(\text{endpos}\) 就有 \(n\) 个等价类;如果从 \(P\) 出发到结点 \(x\) 共有 \(n\) 条不同的路径,那么结点 \(x\) 对应的等价类 \(E_x\) 的大小就为 \(n\)​。

相关引理及证明

引理 \(1\)

对于两个非空子串 \(s_1,s_2\),令 \(|s_1|\ge|s_2|\)

\(1.1\):如果 \(\text{endpos}(s_1)=\text{endpos}(s_2)\),那么 \(s_2\)\(s_1\) 的后缀,且 \(s_2\)\(S\) 中每一次都以 \(s_1\) 的后缀的形式出现。

\(1.2\):如果 \(s_2\)\(s_1\) 的后缀,并且 \(s_2\)\(S\) 中每一次都以 \(s_1\) 的后缀的形式出现,那么 \(\text{endpos}(s_1)=\text{endpos}(s_2)\)

证明 \(1.1\)

​ 因为两个子串的 \(\text{endpos}\) 相同,那么说明它们均是一个位置的长度不同的后缀,又因为 \(|s_1|\ge|s_2|\),所以 \(s_2\)\(s_1\) 的后缀。

​ 由此可以知道当 \(s_1\) 出现的时候,一定同时出现了 \(s_2\)。因为两个子串的 \(\text{endpos}\) 相同,那么 \(s_2\) 就不会单独出现了。

\(\text{Q.E.D}\)

证明 \(1.2\)

​ 因为 \(s_2\)\(s_1\) 的后缀,所以当 \(s_1\) 出现的时候,一定同时出现了 \(s_2\)。又因为 \(s_2\)\(S\) 中每一次都以 \(s_1\) 的后缀的形式出现,那么 \(\text{endpos}(s_1)=\text{endpos}(s_2)\)

\(\text{Q.E.D}\)

事实上,通过引理 \(1.1\),我们可以知道两个 \(\text{endpos}\) 相等的子串,在作为两个不同的后缀的前缀时,两者一定有共同的结束位置,因此在 \(\text{SAM}\) 上一定会对应到同一个结点上。

引理 \(2\)

对于两个非空子串 \(s_1,s_2\),令 \(|s_1|\ge|s_2|\)

引理 \(2.1\):如果 \(s_2\)\(s_1\) 的一个后缀,那么 \(\text{endpos}(s_1)\subseteq\text{endpos}(s_2)\)

引理 \(2.2\):如果 \(s_2\) 不是 \(s_1\) 的一个后缀,那么 \(\text{endpos}(s_1)\cap\text{endpos}(s_2)=\varnothing\)

证明 \(2.1\)

​ 当 \(s_2\)\(s_1\) 的一个后缀时,如果 \(s_1\) 出现了,那么 \(s_2\) 一定跟着出现;但如果 \(s_2\) 出现了,那么 \(s_1\) 不一定会出现,因此有 \(\text{endpos}(s_1)\subseteq\text{endpos}(s_2)\)

\(\text{Q.E.D}\)

证明 \(2.2\)

​ 假设 \(s_1,s_2\) 存在一个共同的结束位置,那么若要使得 \(s_2\) 不为 \(s_1\) 的后缀,则 \(|s_2|>|s_1|\),与 \(|s_1|\ge|s_2|\) 矛盾。所以 \(\text{endpos}(s_1)\cap\text{endpos}(s_2)=\varnothing\)

\(\text{Q.E.D}\)

引理 \(3\)

一个 \(\text{endpos}\) 等价类中不会包括两个本质不同而长度相同的串。

证明:

​ 规定 \(S_{l\to r}\) 表示字符串 \(S\) 中起始位置为 \(l\),结束位置为 \(r\) 的子串。

​ 如果在一个 \(\text{endpos}\) 等价类中存在两个本质不同而长度相同的串,设 \(|s_1|=|s_2|=L\),那么有 \(s_1=S_{x-L+1\to x}=s_2\),与 \(s_1\ne s_2\) 矛盾。因此一个 \(\text{endpos}\) 等价类中不会包括两个本质不同而长度相同的串。

\(\text{Q.E.D}\)

引理 \(4\)

对于一个 \(\text{endpos}\) 等价类 \(E\),令 \(E_i\) 表示 \(E\) 中的第 \(i\) 个字符串。

\(4.1\):对于 \(\forall s_1,s_2\in E,|s_1|\ge|s_2|\),要么 \(s_1=s_2\),要么 \(s_2\)\(s_1\) 的后缀。

\(4.2\):设 \(l,r\) 分别表示 \(E\) 中最短和最长串的长度,那么有 \(\bigcup|E_i|=\{l\le x\le r,x\in\mathbb{N}_+\}\)

证明 \(4.1\)

​ 由引理 \(1.1,3\) 可知,对于同属于一个定价类 \(E\) 的两个字符串 \(s_1,s_2\),一定符合要么 \(s_1=s_2\),要么 \(s_2\)\(s_1\) 的后缀。

\(\text{Q.E.D}\)

证明 \(4.2\)​:

​ 设 \(E\) 中最长的串为 \(s_1\),最短的串为 \(s_2\)

​ 我们显然可以发现,我们一定可以找到一个字符串集 \(Q\),使得 \(Q\) 中的每个字符串都是 \(s_1\) 的后缀,并且 \(\bigcup|Q_i|=\{|s2|\le x\le|s_1|,x\in\mathbb{N}_+\}\)

​ 此时我们发现,集合 \(Q\) 就是我们想要求解的 \(E\),因此我们需要证明 \(E=Q\),即分别证明 \(E\subseteq Q,Q\subseteq E\)

​ 由引理 \(4.1\) 可得,\(E\) 中的所有字符串都是 \(s_1\) 的后缀,而 \(Q\) 中的字符串是每一个长度在 \(|s_2|\)\(|s_1|\) 之间的 \(s_1\) 的后缀,所以有 \(E\subseteq Q\)

​ 如果 \(Q\nsubseteq E\),那么说明在 \(Q\) 中存在一个串 \(s_i\notin E\),我们可以可知,\(s_i\) 应该是 \(s_1\) 的后缀,\(s_2\)\(s_i\) 的后缀。设 \(s_i\in E'\),通过引理 \(2.1\) 可知,\(E\subseteq E'\subseteq E\),从而可以得到 \(E'=E\),这与 \(s_i\notin E\) 矛盾,从而 \(Q\subseteq E\)

​ 所以有 \(E=Q\),即 \(\bigcup|E_i|=\{l\le x\le r,x\in\mathbb{N}_+\}\)

\(\text{Q.E.D}\)

后缀链接 \(\text{link}\) 和后缀树

定义

对于 \(\text{DAG}\) 上某个不是 \(P\) 的结点 \(v\)。定义 \(w\) 是结点 \(v\) 所代表的等价类中的字符串最长的那一个。记 \(t\) 为最长的且不和 \(w\) 属于同一个等价类的一个 \(w\) 的后缀,将结点 \(v\) 的后缀连接连接到代表 \(t\) 所属的等价类的结点 \(u\) 上。

实际意义

根据引理 \(4.2\) 可知,所有长度比 \(t\) 长的 \(w\) 的后缀都一定和 \(w\) 属于同一个等价类,也就是可以通过跳 \(\text{link}\) 来顺次访问 \(E\) 中最长子串 \(w\)​ 的每一个后缀。

  • 在下文中,令 \(\text{link}(E)\) 标志等价类 \(E\) 所对应的 \(\text{link}\) 指向的等价类 \(E'\),即结点代表的等价类和被指向结点代表的等价类。

相关引理及证明

引理 \(5\)

对于一个 \(\text{endpos}\) 等价类 \(E\)\(E\) 中最短的串为 \(s\)

\(5.1\)\(\text{link}(E)\) 中最长的串是 \(s\) 的长度为 \(|s|-1\) 的后缀。

\(5.2\)\(\text{endpos}(E)\subsetneq\text{endpos}(\text{link}(E))\)

证明 \(5.1\)

​ 根据 \(\text{link}\) 的定义,可以得到 \(\text{link}(E)\) 长度最长的串为 \(t\)\(w\) 的长度为 \(|t|+1\) 的后缀 \(s\in E\)。因为 \(t\notin E\),通过引理 \(4.2\) 可得,\(\bigcup|E_i|=\{|s|\le x\le |w|,x\in\mathbb{N}_+\}\)。所以 \(s\)\(E\) 中最短的串,因此有 \(\text{link}(E)\) 中最长的串是 \(s\) 的长度为 \(|s|-1\) 的后缀。

\(\text{Q.E.D}\)

​ 当然我们也可以转换得到 \(E\) 中最短的串是 \(\text{link}(E)\) 中最长的串的长度加 \(1\)。结合引理 \(4.2\),这意味着可以通过维护每一个等价类的最长串得到对应的发出 \(\text{link}\) 的等价类的最短串。于是只通过维护每一个等价类的最长长度,就能够得到每一个等价类的最短长度、最长长度,从而就能够得到每一个等价类中有多少个字符串。

证明 \(5.2\)

​ 显然有 \(t\)\(w\) 的后缀,由引理 \(2.1\) 得到 \(\text{endpos}(s_1)\subseteq\text{endpos}(s_2)\),即 \(\text{endpos}(E)\subseteq\text{endpos}(\text{link}(E))\)。通过 \(\text{link}\) 的定义可以得到 \(\text{endpos}(E)\neq\text{endpos}(\text{link}(E))\),所以 \(\text{endpos}(E)\subsetneq\text{endpos}(\text{link}(E))\)

\(\text{Q.E.D}\)

引理 \(6\)

所有后缀链接构成一颗以 \(P\) 为根节点的内向树。

证明:

​ 显然,通过 \(\text{link}\) 的定义可以得到:每个结点通过 \(\text{link}\),都可以跳转到 \(P\) 结点,因为等价类中最长的字符串的后缀个数一定是有限的,并且除 \(P\) 结点以外的每个结点有且只有一条 \(\text{link}\),因此整张后缀链接形成的后缀图连通且是一棵树。根据 \(\text{link}\) 的方向可以发现,整棵树是一棵内向树。综上所述,所有后缀链接构成一颗以 \(P\) 为根节点的内向树。我们也将这棵树成为后缀树。

\(\text{Q.E.D}\)

总结

  • \(\text{SAM}\) 是由 \(\text{DAG}\) 和后缀树共同组成的整体。

  • 后缀树不是建立在 \(\text{DAG}\)​ 上的,即两者是独立的个体,但是它们共用顶点,二者的边分别存在。

下面引进一些符号,并写用这些符号对上文所讲述的内容进行更详细的总结。

  • 原串 \(S\) 中的每一个子串可以根据它们的 \(\text{endpos}\) 分成若干个等价类,每个等价类又在 \(\text{DAG}\) 和后缀树上对应了一个结点。
  • 对于每一个结点 \(v\),其对应等价类 \(E\),其中最长的串为 \(\text{r}(v)\),最短的串为 \(\text{l}(v)\)。那么 \(E\) 中的每个串都是 \(\text{r}(v)\) 的后缀,并且这些后缀是连续存在的,长度范围在 \(|\text{l}(v)|\)\(|\text{r}(v)|\) 之间。
  • 对于每一个结点 \(v\),顺着它的后缀链接遍历,总可以找到 \(P\)。途中经过的结点为 \(v_i\),则每个 \(v_i\) 对应的等价类中的所有字符串都是 \(\text{r}(v)\) 的后缀,并且这些后缀没有重复和遗漏,即这些字符串的长度组合起来的集合为 \(\{0\le x\le|\text{r}(v)|,x\in\mathbb{N}_+\}\)
  • 对于非 \(P\) 的一个结点 \(v\)\(|\text{l}(v)|=|\text{r}(\text{link(v)})|+1\)。所以在 \(\text{SAM}\) 中我们只记录 \(|\text{r}(v)|\)
  • 所有后缀链接形成一棵以 \(P\) 为根的内向树,称为后缀树。

构造方法

\(\text{SAM}\) 的构造和其他的自动机一样,是在线的。即我们将字符依次加入 \(\text{SAM}\) 中,并动态维护。

在下面先展示算法流程,后说明原理,在最后会有代码。

算法流程

初始时,\(\text{SAM}\) 有且仅有一个结点 \(P\),编号为 \(0\)​。为了方便,我们规定 \(|\text{r}(P)|=0,\text{link}(P)=-1\)

现在将一个字符 \(c\) 添加到 \(\text{SAM}\) 中,算法流程如下:

  1. \(\text{last}\) 为添加 \(c\) 之前整个字符串 \(S\) 所对应的节点。更简单的说,从 \(P\) 出发按照 \(S\) 的字符走到的结点称为 \(\text{last}\)。初始时 \(\text{last}=0\)
  2. 创建一个新的点 \(\text{cur}\)\(|\text{r}(\text{cur})|\leftarrow|\text{r}(\text{last})|+1\)
  3. \(\text{last}\) 开始遍历,如果当前结点 \(v\) 没有标记字符 \(c\) 的出边,创建一条 \(v\to \text{cnr}\) 的边,标记为 \(c\)
  4. 如果当前结点 \(v\) 有标记为字符 \(c\) 的出边,停止遍历,并记这个结点为 \(p\),通过标记为字符 \(c\) 的这条边到达的点为 \(q\)
    1. 如果 \(|\text{r}(p)|+1=|\text{r}(q)|\)\(\text{link}(\text{cur})\leftarrow q\)
    2. 否则令 \(\text{copy}=q\)(包括 \(\text{link}(q)\)\(q\)\(\text{DAG}\) 上的所有出边),\(|\text{r}(\text{copy})|\leftarrow|\text{r}(p)|+1,\text{link}(q)\leftarrow\text{copy},\text{link}(\text{cur})\leftarrow\text{copy}\)。接着从 \(p\) 开始遍历。
      1. 如果当前结点 \(v\) 有标记为 \(c\) 的出边 \(v\to q\),则重定向这条边为 \(v\to\text{copy}\)
      2. 如果 \(v\) 没有标记为 \(c\) 的出边或者从标记为 \(c\) 的边到达的结点不是 \(q\),停止遍历。
  5. 如果遍历到了 \(P\) 依旧不符合条件,\(\text{link}(\text{cur})\leftarrow 0\)
  6. \(\text{last}\leftarrow\text{cur}\),结束插入。

算法原理

按照算法的各个步骤分别说明。

  1. 找到 \(\text{last}\) 能够为之后的更新作准备。

  2. 加入 \(c\) 之后,串便从 \(S\) 变为了 \(S+c\),这一部操作后显然会出现新的等价类,我们于是创建新的结点 \(\text{cnr}\) 存储新的等价类。

    显然 \(\text{last}\) 中所代表的等价类中最长的字符串是 \(S\)。而在新的等价类中显然也有 \(S+c\) 作为最长的字符串,所以有 \(|\text{r}(\text{cur})|=|\text{r}(\text{last})|+1\)​。

  3. 遍历后缀链接是为了尝试将原有字符串添加到 \(\text{cur}\) 中。

    \(\text{SAM}\) 中记录了串的每一个后缀,现在的新串是 \(S+c\),我们只需要在原来的后缀后面各加一个 \(c\)​ 即为新的后缀。

    总结时说过,从 \(\text{last}\) 开始遍历一定可以遍历到原串 \(S\) 的所有后缀,所以当没有连接过字符 \(c\)​ 时,便说明加入这个字符之后,一定属于这个新加入的等价类。

  4. 前面是辅助性的操作,不作说明。

    首先,在跳后缀链接的过程中,访问到的每个等价类 \(p\) 中,\(p\) 所包含的所有字符串都是原串 \(S\) 的后缀,并且 \(|\text{r}(p)|\) 单调递减,由此得到 \(|\text{r}(p)+c|=|\text{r}(p)|+1\) 单调递减。

    我们从 \(\text{last}\) 开始跳后缀链接,设遇到的第一个拥有一条 \(c\) 的出边的结点为 \(p\),从 \(p\) 经过 \(c\) 的出边到达了 \(q\)

    因为 \(p\) 中一定包含 \(\text{r}(p)\),因此沿着 \(c\) 走就形成了 \(\text{r}(p)+c\)\(\text{r}(p)\) 是原串 \(S\) 的后缀,所以 \(\text{r}(p)+c\)\(S+c\) 的后缀。而 \(\text{r}(p)+c\) 又在一个非后缀的地方出现了,也就是说 \(\text{r}(p)+c\) 是一个与 \(\text{r}(\text{cur})\) 不在同一个等价类的 \(\text{r}(\text{cur})\) 的后缀。而其他后缀因为没有这样的出边,因此和 \(\text{r}(\text{cur})\) 同在一个等价类,因此,\(\text{r}(p)+c\) 是一个最长的且与 \(\text{r}(\text{cur})\) 不在同一个等价类的 \(\text{r}(\text{cur})\) 的后缀。这正是 \(\text{link}\) 的定义,因此 \(\text{r}(\text{p})+c\) 应当是 \(\text{link}(\text{cur})\) 中的最长字符串。

    我们发现 \(q\) 结点显然包含了 \(\text{r}(p)+c\) 这个字符串,但是 \(q\) 结点中的最长字符串不一定是 \(\text{r}(p)+c\),因此进行分类讨论。

    1. 如果 \(|\text{r}(p)|+1=|\text{r}(q)|\),通过引理 \(3\) 可以得到 \(q\) 中最长的只有 \(\text{r}(p)+c\) 这个字符串。所以 \(\text{link}(\text{cur})=q\)

    2. 如果不满足上面的式子,那么说明一定存在比 \(\text{r}(p)+c\)​ 更长的字符串,便不能直接赋值。

      于是考虑将 \(q\) 中字符串分割成两部分:一部分包含 \(\text{r}(p)+c\) 和它的所有后缀,另一部分则是 \(q\) 中剩下的所有字符串。如果把前一部分称为 \(\text{copy}\),那么我们发现 \(\text{copy}\) 就是 \(\text{cur}\) 需要的 \(\text{link}\)。所以有 \(\text{link}(\text{cur})=\text{copy}\)

      但是单纯的分裂肯定有影响,现在我们需要考虑拆分后的影响。下面提到的等价类 \(q\) 是原来的等价类,被拆分后的等价类记作 \(\text{copy}\)\(q'\)​​,其中 \(q'\)\(q\) 的编号一致,\(\text{copy}\) 是新增的结点。

      那么为了使 \(\text{copy}\) 符合成为 \(\text{link}\) 的条件,那么 \(\text{copy}\) 中的字符串的长度一定在区间 \([|\text{l}(q)|,|\text{r}(p)|+1]\) 内,相应的 \(q'\) 中的字符串的长度一定在区间 \((|\text{r}(p)|+1,|\text{r}(q)|]\) 中。

      考虑 \(\text{copy}\)\(\text{link}\),根据引理 \(5.1\) 所说,\(|\text{l}(q)|=|\text{r}(\text{link}(q))|+1\)。因为字符串 \(\text{l}(q)\) 被分到了等价类 \(\text{copy}\) 中,因此有 \(|\text{l}(\text{copy})|=|\text{r}(\text{link}(q))|+1\),所以 \(\text{link}(\text{copy})=\text{link}(q)\)​。

      接着考虑 \(q'\)\(\text{link}\),因为根据上述长度的分布情况有 \(|\text{l}(q')|-1=|\text{r}(\text{copy})|\),所以有 \(\text{link}(q')=\text{copy}\)

      接着考虑 \(\text{copy}\) 的出边,因为之前 \(\text{copy}\)\(q'\) 共用 \(q\) 的顶点,因此两者需要有相同的出边,因此将 \(q\) 的出边复制到 \(\text{copy}\) 中即可。

      同时我们还需要重新给一些入边定向,因为这些入边都对应了原先的等价类 \(q\) 中的一个字符串,因此在将 \(q\) 分裂后,我们需要知道哪一些边指向等价类 \(\text{copy}\) 中的字符串,哪一些指向等价类 \(q'\) 中的字符串。

      因为 \(\text{copy}\) 中的字符串都是 \(\text{r}(p)+c\) 的后缀,所以指向 \(\text{copy}\) 中的字符串的串一定是 \(\text{r}(p)\) 的某个后缀通过一条 \(c\) 边得到的,因此从 \(p\) 开始跳后缀链接得到每一个 \(\text{r}(p)\)​ 的后缀即可。

      1. 对于每一个有 \(c\) 边的 \(\text{r}(p)\) 的后缀,并且指向 \(q\),这说明它能够形成 \(\text{copy}\) 中的字符串将这条边重新定向到 \(\text{copy}\) 即可。
      2. 如果从某个后缀开始,不存在 \(c\) 边,或者指向的点不再是 \(q\),那么这说明从一开始这个后缀就不会指向到 \(q\) 中,并且通过引理 \(4.2\) 可以得到之后也不会有后缀能够连接到 \(\text{copy}\),退出循环即可。
  5. 引理 \(7\)

    特别的,当从 \(\text{last}\) 开始遍历后缀链接时一直跳转到了 \(P\) 结点均找不到标记为字符 \(c\) 的出边,说明加入的字符 \(c\) 在之前的串 \(S\) 中没有出现过。

    证明:

    \(\text{SAM}\) 通过从 \(P\) 开始到任意一个终止节点结束记录了一个后缀,对于一个后缀的一些前缀能够构成原串的任意子串。也就是说从 \(P\) 出发走一条边,边上的标记一定代表了原串 \(S\) 中的一个字符。而到 \(P\) 后依旧找不到则说明不存在这个字符 \(c\)

    \(\text{Q.E.D}\)

    我们发现,当字符 \(c\) 在原串 \(S\) 中根本不出现的时候,意味着 \(S\) 中没有 \(S+c\) 的后缀存在,也就是原串的后缀追加字符 \(c\) 后不可能在除了 \(S+c\) 的最后一位出现。因此,\(\text{link}(\text{cur})=P=0\)

  6. 记录 \(\text{last}\)​ 便于下一次添加。

时空复杂度

事实上,\(\text{SAM}\) 的复杂度为线性的条件是建立在 \(|\sum|\) 为常数的基础上。如果 \(|\sum|\) 不是常数,那么朴素的 \(\text{SAM}\) 的空间复杂度将退化至 \(O(n|\sum|)\)。在这种情况下可以利用 map 进行操作,建立映射关系从而将空间缩至 \(O(n)\),但相应的时间复杂度会上升至 \(O(n\log|\sum|)\)

结点数

对于一个长度为 \(n\) 的字符串,\(\text{SAM}\) 中的结点数不会超过 \(2n-1\)。这意味着 \(\text{SAM}\) 的数组大小要开到 \(2\) 倍。

具体的,abbb...bbb 可以到达这个上限。

边数

对于一个长度为 \(n\) 的字符串,\(\text{SAM}\) 中的边数不会超过 \(3n-4\)。如果使用 map 可以达到这个复杂度。

具体的,abbb...bbbc 可以到达这个上限。

代码实现

我个人更喜欢封装后的实现,将结构体去掉也可以直接当普通写法用。

#include <bits/stdc++.h>
using namespace std;

struct SAM{
    static const int N=1e6+5;
    int n,tot,lst;
    char s[N];
    vector<int>v[N<<1];
    struct Node{
        int len,link;
        int son[26];
    }p[N<<1];
    void Init(){
        for(int i=0;i<=tot;i++){
            p[i].len=p[i].link=0;
            memset(p[i].son,0,sizeof p[i].son);
            v[i].clear();
        }
        p[0].len=0;
        p[0].link=-1;
        lst=tot=0;
        return ;
    }
    void Insert(int i){
        p[++tot].len=p[lst].len+1;
        int ch=s[i]-'a',pos=lst;
        while(pos!=-1&&p[pos].son[ch]==0){
            p[pos].son[ch]=tot;
            pos=p[pos].link;
        }
        lst=tot;
        if(pos==-1)p[tot].link=0;
        else{
            int u=pos,v=p[pos].son[ch];
            if(p[u].len+1==p[v].len)p[tot].link=v;
            else{
                p[++tot]=p[v];
                p[tot].len=p[u].len+1;
                p[v].link=p[tot-1].link=tot;
                while(pos!=-1&&p[pos].son[ch]==v){
                    p[pos].son[ch]=tot;
                    pos=p[pos].link;
                }
            }
        }
        return ;
    }
    void Build(){
        scanf("%s",s);
        n=strlen(s);
        Init();
        for(int i=0;i<n;i++)Insert(i);
        for(int i=1;i<=tot;i++)v[p[i].link].push_back(i);
        return ;
    }
};

特殊实现

终止位置标记

回到最开始我们所说的,\(\text{SAM}\) 是一个可以接受字符串的所有后缀的最小 \(\text{DFA}\)。实现的方法就在于终止位置标记。

之前提到的终止标记说明每一个从 \(P\) 到终止标记的路径上的标记连起来是原字符串 \(S\) 的一个后缀。而 \(\text{last}\) 存储的又是整个字符串所在的等价类,而通过跳后缀链接的方式可以找到所有原串的后缀,将跳到的每一个结点都打上终止位置标记即可。

子串最长公共后缀

原串 \(S\) 的两个子串的最长公共后缀即为两者在后缀树上的 \(\text{LCA}\) 结点代表的等价类中的最长字符串。

在后缀树上,从 \(P\) 到当前结点的链上面的所有点代表的所有字符串都是当前结点的某一个字符串的后缀。两条链的交点部分就是共同拥有的后缀,而且最长的公共后缀部分便是第一个重叠的点,即 \(\text{LCA}\)​ 对应的等价类中最长的字符串。

子串出现次数

之前提到过,\(\text{endpos}\) 的出现就是为了求解每个子串的出现次数。那么怎么利用 \(\text{endpos}\) 的性质求解?

考虑某个等价类中的字符串出现过的次数等价于这个等价类的 \(\text{endpos}\) 中有多少个数。首先考虑到一个字符串的所有子串可以认为是原字符串的每个前缀的所有后缀。又考虑到当一个字符串出现时,它的所有后缀也会紧跟着出现,因此不用所有子串都暴力添加,而只用增加最长的那个子串,也就是原字符串的前缀。而原字符串的前缀一定是每一个添加完结点后的 \(\text{last}\) 代表的结点。而因为需要让所有后缀同步增加,所以需要将该点最后到 \(P\) 的后缀树上的所有结点都增加,复杂度是 \(O(n^2)\) 的。考虑到每一个结点如果被增加贡献,那么贡献只能来自它的子树内,因此选择树形 \(\text{DP}\)\(O(n)\) 求解。

注意这里不能和回文自动机一样选择用 \(\text{for}\)​ 循环遍历,因为结点的复制导致了每个结点在后缀树上的父亲结点不一定比自己的编号小。

最长公共子串

一般而言,给出 \(t\) 个字符串 \(S_i\),求解这些字符串的最长公共子串。

首先找出长度最短的那个字符串 \(S\),对它建立 \(\text{SAM}\) 树。接着对于剩下的每一个字符串 \(T\) 进行如下操作:

  1. 对于每一个字符进行匹配操作,并且设定上一个匹配到的位置 \(\text{last}\) 和已经匹配到的长度 \(\text{len}\)
  2. 对于当前匹配的字符 \(c\),如果 \(\text{last}\) 有一条标记为 \(c\) 的出边,那么 \(\text{len}\leftarrow\text{len}+1\),并且将 \(\text{last}\) 转移到从 \(\text{last}\) 走标记为 \(c\) 的边到达的顶点。
  3. 不然从 \(\text{last}\) 开始跳转后缀连接。直到跳转到一个有标记为 \(c\) 的出边的顶点 \(v\)。令 \(\text{len}\leftarrow|\text{r}(v)|+1\),并且将 \(\text{last}\) 转移到从 \(\text{v}\) 走标记为 \(c\) 的边到达的顶点。
  4. 在每一次操作结束之后,将对应的 \(\text{last}\) 节点对应的等价类的最大对应长度的标记和 \(\text{len}\) 取最大值。
  5. 在所有操作结束后,将每一个等价类的最大公共对应长度的标记与每一个等价类的最大对应长度的标记取最小值,并清空每一个等价类的最大对应长的的标记。

在所有字符串的操作结束后,在所有等价类的最大公共对应长度的标记中取最大值即为答案。

下面进行步骤解释:

  1. 对于整体过程的整体描述,不做详细解释。

  2. 显然对于原先匹配到的字符串,如果能够直接添加字符 \(c\) ,那么显然最优,直接转移即可。

  3. 跳后缀链接相当于找到原匹配串的所有后缀,并且试图添加字符 \(c\),这样做保证了匹配到的以字符 \(c\) 为结尾的子串是连续的。现在问题在于 \(\text{len}\) 直接赋值为 \(|\text{r}(v)|+1\) 是否存在错误。

    证明:

    ​ 首先最后的加 \(1\) 是字符 \(c\) 单独的贡献,那么我们只需要讨论不包含字符 \(c\) 的最长真实匹配串 \(s\) 的长度 \(\text{len}'\)\(|\text{r}(v)|\) 的大小关系。因为等价类中的字符串都是 \(\text{r}(v)\) 的后缀,所以 \(\text{len}'\le|\text{r}(v)|\)

    ​ 如果 \(\text{len}'<|\text{r}(v)|\),那么 \(\text{r}(v)\) 将不能通过字符 \(c\) 得到其他字符串。这与顶点 \(v\) 是一个有标记为 \(c\) 的出边的顶点矛盾。因此有 \(\text{len}'=|\text{r}(v)|\)

    接着是更新步骤。

  4. 考虑对答案的更新,我们显然只在意在每一个位置结尾的两个字符串的最长公共子串,因为最长公共子串的后缀显然也是公共的,所以需要在处理中维护每一次处理后的匹配长度最大值。

  5. 考虑答案是所有字符串的公共部分,因此每一个位置为结尾的最长公共子串的长度要取最小值。

最后在每一个位置为结尾的最长公共子串的长度中取最大值即可。

判断字符串是否能出现

之前提到过的,从 \(P\) 开始向下走,如果有对应的标记的边就顺着走到下一个结点。如果整个字符串都走完了,那么给定的串就是原字符串的子串;如果没有走完就走不下去了,那么给定的串就不是原字符串的子串。特别的,通过终止位置标记,可以额外判断是否是原字符串的后缀。

不同子串个数

考虑每一次插入完之后答案的求解。\(\text{SAM}\) 的一个性质是:每一个字符串的所有子串都可以看作是字符串的所有前缀的所有后缀组成的可重集合。那么我们现在新增了一个字符,实际上只用统计以新加入的字符为结尾的子串的个数即可。那么如果已经在之前的子串中出现过,那么 \(\text{endpos}\) 肯定不是单纯的一个,我们只需要知道只以当前位置为结束位置的子串个数即可,也就是插入过后的 \(\text{last}\) 结点所代表的等价类中的字符串的个数。因为等价类中的字符串的长度是连续的,并且每个长度的字符串只有一个,因此用当前等价类的最长字符串的长度减去 \(\text{link}\) 等价类的最长字符串的长度即可。

\(k\) 小子串

当建好 \(\text{SAM}\) 后,我们考虑因为从 \(P\) 出发走到的每一个结点是所对应的字符串都是原字符串 \(S\) 的一个子串,因此我们考虑 \(\text{DFS}\)

那么为了让字典序最小,显然应该从标记最小的边开始走,每走到一个点判断该字符串对答案的贡献,树形 \(\text{DP}\) 维护即可。直到走完排名或者一直没有走完排名。

但是这样的时间复杂度非常垃圾,大约是 \(O(n^2|\sum|)\) 的,因此考虑优化。容易发现从一个结点作为起点开始 \(\text{DFS}\),每一次走的路径都相同,占用的排名也相同,因此考虑记忆化剪枝。对于每一个结点求出从这个结点开始 \(\text{DFS}\) 会占用多少排名,如果剩余的位置多于这些排名,那么答案肯定不在其中,直接跳过即可,反之则答案一定在其中,直接找到答案即可。

posted @ 2024-03-16 14:50  DycIsMyName  阅读(18)  评论(1编辑  收藏  举报