跳转至

后缀自动机 (SAM)

一些记号

  • :字符集。字符集大小
  • :字符串。字符串长 ,标号自 开始。
  • :初始状态。
  • :字符串 中子串 的结束位置的集合。
  • :状态 的后缀链接。
  • :状态 对应的最长子串的长度。
  • :状态 对应的最长子串。
  • :状态 对应的最短子串的长度。
  • :状态 对应的最短子串。

后缀自动机概述

后缀自动机(suffix automaton, SAM)是一个能解决许多字符串相关问题的有力的数据结构。

举个例子,以下的字符串问题都可以在线性时间内通过 SAM 解决:

  • 在另一个字符串中搜索一个字符串的所有出现位置;
  • 计算给定的字符串中有多少个不同的子串。

直观上,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为 的字符串,它的空间复杂度仅为 。而且,构造 SAM 的时间复杂度也仅为 。准确地说,一个 SAM 最多有 个结点和 条转移边。

定义

字符串 的 SAM 是一个接受 的所有后缀的最小 DFA(确定性有限自动机或确定性有限状态机)。

换句话说:

  • SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移
  • 图存在一个源点 ,称作 初始状态,其它各结点均可从 出发到达。
  • 每个 转移 都标有某个字符。从一个结点出发的所有转移均 不同
  • 存在一个或多个 终止状态。如果我们从初始状态 出发,最终转移到了一个终止状态,则路径上的所有转移的标号连接起来一定是字符串 的一个后缀。反过来, 的每个后缀均可用一条从 到某个终止状态的路径构成。
  • 在所有满足上述条件的自动机中,SAM 的结点数是最少的。

子串和路径

SAM 最简单、也最重要的性质是,它包含关于字符串 的所有子串的信息。任意从初始状态 开始的路径,如果我们将路径上的所有转移的标号写下来,都会形成 的一个 子串。反之,每个 的子串都对应从 开始的某条路径。

为了简化表达,我们称子串 对应 这条(从 出发且它上面所有转移的标号构成这个子串的)路径。反过来,我们说任意一条路径都 对应 它的标号构成的字符串。

到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合中的字符串分别对应着这些路径。

简单例子

我们将会在这里展示一些简单的字符串的后缀自动机。

我们用蓝色表示初始状态,用绿色表示终止状态。

对于字符串

对于字符串

对于字符串

对于字符串

对于字符串

对于字符串

线性复杂度的构造算法

在我们描述线性时间内构造 SAM 的算法之前,我们需要引入几个对理解构造过程非常重要的概念,并对其性质进行简单证明。

结束位置 endpos

考虑字符串 的任意非空子串 ,我们记 为在字符串 的所有结束位置的集合(假设对字符串中字符的编号从零开始)。例如,对于字符串 ,我们有

两个子串 的结束位置可能完全相同:。这定义了字符串 的子串之间的等价关系。字符串 的所有非空子串可以根据它们的结束位置集合 分为若干 等价类

一个事实是,每个这样的等价类都对应 SAM 的一个状态1。也就是说,SAM 中的每个非初始状态都对应一个或多个 相同的非空子串。换句话说,SAM 中的状态数等于所有非空子串的等价类的个数,再加上初始状态。

暂且接受这个事实,我们将基于它介绍构造 SAM 的算法。我们还将说明,SAM 需要满足的所有性质,除了最小性以外都满足了;而最小性可以由 Nerode 定理得出(不会在这篇文章中证明)。

的值我们可以得到一些重要结论,它们解释了同一个状态对应的不同的子串之间的关系。

引理 1

字符串 的两个非空子串 (假设 )的 相同,当且仅当字符串 中每次出现时,都是以 后缀的形式存在的。

证明

引理显然成立。如果 相同,则 的一个后缀,且在 中只以 的后缀的形式出现。反过来,根据定义,如果 的一个后缀,且只以 的后缀的形式在 中出现,那么两个子串的 相同。

引理 2

考虑两个非空子串 (假设 )。那么,要么 ,要么 ,取决于 是否为 的一个后缀:

证明

如果集合 有至少一个公共元素,那么由于字符串 在相同位置结束, 的一个后缀。所以在每次 出现的位置,子串 也会出现。所以

引理 3

考虑一个 相同的子串等价类,将类中的所有子串按长度非递增的顺序排序。那么,每个子串都不会比它前一个子串长,与此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任意两子串,较短者为较长者的后缀,且该等价类中的子串长度是连续的,取遍某个区间内的所有整数值。

证明

如果等价类中只包含一个子串,引理显然成立。现在我们来讨论子串元素个数大于 的等价类。

由引理 1, 相同的两个不同字符串中,必定一长一短,且较短者总是较长者的真后缀。也就是说,等价类中没有等长的字符串。

为等价类中最长的字符串, 为等价类中最短的字符串。由引理 1,字符串 是字符串 的真后缀。现在考虑长度在区间 中的 的任意后缀。容易看出,这个后缀也在同一等价类中,因为这个后缀只能在字符串 中以 的一个后缀的形式存在(这是因为较短的后缀 中只以 的后缀的形式存在)。因此,由引理 1,这个后缀和字符串 相同。

考虑 SAM 中某个状态 。我们已经知道,状态 对应于具有相同 的子串等价类。我们如果定义 为这些字符串中最长的一个,则所有其它的字符串都是 的后缀。

我们还知道字符串 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且其它后缀(至少有一个——空后缀)在别的等价类中。我们记 为其它后缀中最长的,然后将 的后缀链接连到 上。

换句话说,后缀链接 连接到的状态,对应于 的后缀中与它的 集合不同且最长的那个,也是 的后缀中在 中的出现次数比 更多且最长的那个。

为方便讨论,我们规定初始状态 对应的等价类,只包含一个空字符串,而且

引理 4

所有后缀链接构成一棵根节点为 的树。

证明

考虑任意状态 ,后缀链接 连接到的状态对应于严格更短的字符串(后缀链接的定义、引理 3)。因此,沿后缀链接移动,我们总是能到达对应空串的初始状态

引理 5

集合为结点、集合的包含关系作为边,这样构造的树(即每个子节点的 集合都包含在父节点的 集合中)与通过后缀链接 构造的树相同。

证明

由引理 2,任意一个 SAM 的 集合形成了一棵树(因为两个集合要么完全没有交集要么其中一个是另一个的子集)。

我们现在考虑任意状态 及后缀链接 ,由后缀链接和引理 2,我们可以得到

注意这里应该是 而不是 ,因为若 ,那么 应该被合并为一个结点。

结合前面的引理有:后缀链接构成的树本质上是 集合构成的一棵树。

以下是对字符串 构造 SAM 时产生的后缀链接树的一个 例子,结点被标记为对应等价类中最长的子串。

对图示的解释

结合图示,如果能够形成一些对于后缀自动机的认识,将对下文理解其构造算法和应用都有所帮助。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-   SAM 上存在一条最长的路径,其标号恰好为字符串 $\texttt{abcbc}$ 本身。该路径从初始状态开始,经过的每个状态都对应着字符串 $\texttt{abcbc}$ 的前缀($\varnothing,\texttt{a},\texttt{ab},\texttt{abc},\texttt{abcb},\texttt{abcbc}$)。这些状态在后续 [应用](#后缀链接树) 中至关重要。

-   后缀链接树可以看做是将这些「前缀状态」沿着后缀链接移动到根节点(即初始状态)的路径「压缩」得到。

    -   沿着每条路径,结点对应的字符串集合构成了相应的前缀的所有后缀的分划。例如,标记为 $\texttt{abcbc}$ 的状态沿着后缀链接移动到根节点的路径为 $\texttt{abcbc}\rightarrow\texttt{bc}\rightarrow\varnothing$。其中,结点 $\texttt{abcbc}$ 实际对应着字符串集合 $\{\texttt{abcbc},\texttt{bcbc},\texttt{cbc}\}$,结点 $\texttt{bc}$ 实际对应着字符串集合 $\{\texttt{bc},\texttt{c}\}$,结点 $\varnothing$ 就对应空字符串。

    -   不同的路径可能共用同一个结点,这就是为什么会有「压缩」。例如,路径 $\texttt{abc}\rightarrow\texttt{bc}\rightarrow\varnothing$ 和路径 $\texttt{abcbc}\rightarrow\texttt{bc}\rightarrow\varnothing$ 共用了结点 $\texttt{bc}$。这是因为 $\operatorname{endpos}(\texttt{bc})=\{2,4\}$,而结束在位置 $2$ 的字符串 $\texttt{bc}$ 前紧接着字符 $\texttt{a}$ 而结束在位置 $4$ 的字符串 $\texttt{bc}$ 前紧接着字符 $\texttt{c}$,因此,当在前方添加字符(即逆着后缀链接移动)时,结束位置集合(即状态)会分裂。

    -   后缀链接树只需要将这些后缀路径合理地「压缩」在一起即可,而不需要考虑别的结点。这是因为,所有子串都是某个前缀的后缀,故而必然出现在某个这样的路径中。后文的构造算法本质上就是逐个添加字符,并为每个新增加的前缀,构造这样一条后缀路径,并使其合理地「压缩」到之前已有的路径中(即不重复构造已经存在的状态和转移)。

    -   终止状态恰为字符串 $\texttt{abcbc}$ 本身所在的后缀路径上的所有结点。

-   到达同一个状态的转移必然具有相同的标号,而且这些转移的起点一定是位于后缀链接树上的某条(连续的)路径。比如,转移到状态 $\texttt{abcb}$ 的状态就有两个:$\texttt{abc}$ 和 $\texttt{bc}$。它们位于后缀树上的路径 $\texttt{abc}\rightarrow\texttt{bc}$ 上。注意,它们分别对应于字符串集合 $\{\texttt{abc}\}$ 和 $\{\texttt{bc},\texttt{c}\}$,这些字符串在后面添加字符 $\texttt{b}$,就得到状态 $\texttt{abcb}$ 对应的字符串集合 $\{\texttt{abcb},\texttt{bcb},\texttt{cb}\}$。

    -   添加字符后,不同状态可能转移到同一个状态,是因为新添加的字符使得结束位置的增加更为困难。

-   后缀链接树上,每个结点的 $\operatorname{endpos}$ 集合都是其子节点的 $\operatorname{endpos}$ 集合的并集,至多再增加一个位置。而且,这个新位置存在,当且仅当该结点恰好对应着结束在该位置的原字符串的前缀。因为图示中,后缀链接树的非根非叶的结点都不对应着字符串 $\texttt{abcbc}$ 的前缀,所以不存在这种情形。

小结

在讨论算法本身前,我们总结一下之前的内容,并引入一些辅助记号。

  • 的子串可以根据它们的结束位置集合 划分为多个等价类;

  • SAM 由初始状态 和与每一个(非空子串的) 等价类对应的每个状态组成;

  • 每一个状态 都匹配一个或多个子串。我们记 为其中最长的一个字符串,记 为它的长度。类似地,记 为最短的子串,它的长度为 。那么对应这个状态的所有字符串都是字符串 的不同的后缀,且所有字符串的长度恰好取遍区间 中的每一个整数。

  • 对于任意状态 ,定义后缀链接为连接到对应字符串 的长度为 的后缀的一条边。从根节点 出发的后缀链接可以形成一棵树。这棵树也表示 集合间的包含关系。

  • 对于任意状态 ,可用后缀链接 表达

  • 如果我们从任意状态 开始顺着后缀链接遍历,总会到达初始状态 。这种情况下我们可以得到一个互不相交的区间 的序列,且它们的并集形成了连续的区间

算法

现在我们可以讨论构造 SAM 的算法了。这个算法是 在线 算法,我们可以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM。

为了保证线性的空间复杂度,我们将只保存 的值和每个状态的转移列表,我们不会标记终止状态(但是我们稍后会展示在构造 SAM 后如何分配这些标记)。

一开始 SAM 只包含一个状态 ,编号为 (其它状态的编号为 )。为了方便,对于状态 我们指定 表示虚拟状态)。

过程

现在,只需要实现给当前字符串添加一个字符 的过程。算法流程如下:

  • 为添加字符 之前,整个字符串对应的状态(一开始我们设 ,算法的最后一步更新 )。

  • 创建一个新的状态 ,并将 赋值为 ,在这时 的值还未知。

  • 现在我们进行如下流程:从状态 开始,如果当前状态还没有标号为字符 的转移,我们就添加一个经字符 到状态 的转移,并将当前状态沿后缀链接移动。如果过程中遇到某个状态已经存在到字符 的转移,我们就停下来,并将这个状态标记为

  • 情况一:如果没有找到这样的状态 ,我们就到达了虚拟状态 ,我们将 赋值为 并退出。

  • 假设现在我们找到了一个状态 ,它可以通过字符 转移。我们将转移到的状态标记为 。此时,要么 ,要么

  • 情况二:如果 ,我们只要将 赋值为 并退出。

  • 情况三:否则就会有些复杂,需要 复制 状态 :我们创建一个新的状态 ,复制 的除了 的值以外的所有信息(后缀链接和转移)。我们将 赋值为

    复制之后,我们将后缀链接从 指向 ,也从 指向

    最终我们需要沿着后缀链接从状态 往回走,只要经过的状态存在指向状态 的转移,就将该转移重新连接到状态

  • 处理完以上三种情况后,我们都需要将 的值更新为状态

如果我们还想知道哪些状态是 终止状态 而哪些不是,我们可以在为字符串 构造完完整的 SAM 后找到所有的终止状态。为此,我们从对应整个字符串的状态(存储在变量 中),遍历它的后缀链接,直到到达初始状态。我们将所有遍历到的状态都标记为终止状态。容易理解这样做我们会准确地标记字符串 的所有后缀,这些状态都是终止状态。

因为我们只为 的每个字符创建一个或两个新状态,所以 SAM 只包含 线性个 状态。而 SAM 只有线性规模的转移个数,以及算法总体的线性运行时间,都还没有说清楚,将在后文说明。

解释

我们详细解释算法每一步的细节,并说明它的 正确性

对算法的详细解释
  • 若一个转移 满足 ,则我们称这个转移是 连续的。否则,即当 时,这个转移被称为 不连续的

    从算法描述中可以看出,连续的和不连续的转移,在算法中的处理也并不相同。连续的转移是固定的,我们不会再改变了。与此相反,当向字符串中插入一个新的字符时,不连续的转移可能会改变(转移边的端点可能会改变)。

  • 为了避免引起歧义,我们记向 SAM 中插入当前字符 之前的字符串为

  • 算法从创建一个新状态 开始,对应于整个字符串 。我们创建一个新的节点的原因很清楚。与此同时我们也创建了一个新的字符和一个新的等价类。

  • 在创建一个新的状态之后,我们会从对应整个字符串 的状态 沿着后缀链接进行移动。对于经过的每一个状态,我们尝试添加一个通过字符 到新状态 的转移。

    然而我们只能添加与原有转移不冲突的转移。因此我们只要找到已存在的 的转移,我们就必须停止。

  • 最简单的情况是我们到达了虚拟状态 ,这意味着我们为所有 的后缀添加了 的转移。这也意味着,字符 从未在字符串 中出现过。因此 的后缀链接为状态

  • 第二种情况下,我们找到了现有的转移 。这意味着我们尝试向自动机内添加一个 已经存在的 字符串 (其中 的一个后缀,且字符串 已经作为 的一个子串出现过了)。因为我们假设字符串 的自动机的构造是正确的,我们不应该在这里添加一个新的转移。

    然而,难点在于,从状态 出发的后缀链接应该连接到哪个状态呢?我们要把后缀链接连到一个状态上,且对应的最长的字符串恰好是 ,即这个状态的 应该是 。然而这样的状态有可能并不存在,即 。这种情况下,我们必须通过拆开状态 来创建一个这样的状态。

  • 当然,如果转移 是连续的,那么 。在这种情况下一切都很简单。我们只需要将 的后缀链接指向状态

  • 否则转移是不连续的,即 ,这意味着状态 不只对应于长度为 的后缀 ,还对应于 的更长的子串。除了将状态 拆成两个子状态以外我们别无他法,所以第一个子状态的长度就是 了。

    我们如何拆开一个状态呢?我们 复制 状态 ,产生一个状态 ,我们将 赋值为 。由于我们不想改变经过 的路径,我们将 的所有转移复制到 。我们也将从 出发的后缀链接设置为 的后缀链接的目标,并设置 的后缀链接为

    在拆开状态后,我们将从 出发的后缀链接设置为

    最后一步我们将一些原本指向 的转移重新连接到 。我们需要修改哪些转移呢?只重新连接相当于所有字符串 (其中 是状态 对应的最长字符串)的后缀就够了。也就是说,我们需要继续沿着后缀链接移动,从结点 直到虚拟状态 ,或者当前状态经 的转移不再指向状态

为了进一步理解算法的操作,可以从 集合的角度理解状态和转移的变化。

基于 集合理解该算法
  • 设字符串 长度为 ,在添加新的字符 后, 集合可能发生的变化就只有增加了新位置 这一种可能。(下标从 开始)

    而且,这些发生变化的状态一定对应 的形式的字符串,其中的 的某个后缀。所以,只需要令 初始为 ,沿着后缀链接移动,就能找到 的所有后缀对应的状态,再考察状态 沿着字符 的转移即可。这样一定能处理到所有可能发生变化的状态。

  • 如果字符串 原来的 集合为空集,即不存在这样的状态,则该集合变成 就相当于新建了状态 。状态 对应的字符串 只在位置 处出现过,这就意味着字符串 对应的状态,没有经由字符 的转移。

    这就是算法最开始的操作:将 沿着后缀链接移动,如果没有经由 的转移,就建立它,并指向新的状态

  • 如果字符串 原来的 集合不为空集,就说明 之前已经在 中出现过。设字符串 的 SAM 中对应的状态是 。因为 可能对应着多个字符串,而添加了字符 后,未必每个字符串的结束位置都增加了新位置 。也就是说,同一个旧的状态 中对应的字符串的 集合的变化未必是一致的。

    当然,变化只有两种可能,即增加新位置 和不增加新位置 ,因此单个旧状态至多分裂成两个新状态。

    而且,只要字符串 可以结束在新位置 ,那么对于所有 的后缀 ,字符串 都一定可以结束在新的位置 。这说明,从字符串 对应的状态出发,沿着后缀链接移动找到的所有状态,它们对应的所有字符串的 集合都同样地增加了一个新位置 。因此,这些状态都不会分裂。

    前面已经说明,发生变化的字符串 一定都是 的后缀,因此都可以通过后缀链接找到。如果过程中,有某个状态分裂,就说明它对应的一部分字符串的结束位置增加,也就说明继续沿着后缀链接移动,经过的状态都将不再分裂。因此,分裂至多发生一次。

  • 算法之前将 沿着后缀链接移动,直到找到当前状态经由 的转移为止。如果没有这样的状态,就是算法中的 情况一,最为简单。

    否则,转移指向的状态 就是可能分裂的旧状态。因为 经过的状态对应的字符串 都是 的后缀,所以由 转移到的状态 一定对应着 ,而字符串 集合一定会新增位置 。这就说明, 对应的字符串一定有一部分,结束位置变多了。

    因此,要么它不分裂,所有字符串结束位置都变多;要么它分裂,一部分字符串结束位置变多,一部分字符串结束位置不变。而且,无论它分不分裂,之后再沿着后缀链接移动,找到的状态 经由 转移到的状态,就都不用分裂了。

  • 那怎么判断是否需要分裂呢?因为对于 对应的字符串 ,字符串 结束位置都增加了。所以,只需要看 中有没有字符串不具有 的形式即可,其中, 必须对应状态 。怎么看呢?因为结束位置相同,所以只需要比较长度即可。状态 对应的字符串 最长为 ,故而它们相应的 最长就是 ;同时,状态 对应的字符串最长为 。所以,状态 存在结束位置不会增加的字符串,当且仅当

    如果 ,则状态 不会分裂,此即 情况二;否则,状态 会分裂,需要为结束位置没有增加的元素和结束位置增加的元素分别创建一个节点,此即 情况三

    情况二容易处理,因为无需调整经由 指向 的转移;情况三则稍显复杂。因为,我们为那些结束位置增加的字符串复制了新状态 。设 ,也就是说, 对应的字符串中结束位置增加的最长的那个。现在,根据上面的讨论,需要把状态 的后缀都挪到新状态 中。为此,只需要继续将 沿着后缀链接移动,就能找到所有这样的 的后缀,再将那些原本指向 的调整到 即可。

线性时间复杂度

我们假设字符集大小为 常数,即每次对一个字符搜索转移、添加转移、查找下一个转移这些操作的时间复杂度都为 的。如果将每个结点的转移分别存储为一个长度为 的数组(用于快速查询给定标号的转移)和一个动态列表(用于快速遍历所有可用转移),以空间换时间,那么算法的时间复杂度2,空间复杂度为

证明

如果我们考虑算法的各个部分,算法中有三处时间复杂度不明显是线性的:

  • 第一处是遍历所有状态 的后缀链接,添加字符 的转移。
  • 第二处是当状态 被复制到一个新的状态 时复制转移的过程。
  • 第三处是修改指向 的转移,将它们重新连接到 的过程。

我们使用 SAM 的大小(状态数和转移数)为 线性的 的事实(对状态数是线性的的证明就是算法本身,对转移数为线性的的证明将在稍后实现算法后给出)。

因此上述 第一处和第二处 的总复杂度显然为线性的,因为单次操作均摊只为自动机添加了一个新转移。

还需为 第三处 估计总复杂度,我们将最初指向 的转移重新连接到 。我们记 ,这是字符串 的一个后缀。每迭代一次, 的长度都减小,因而 作为 的后缀的起始位置必然在后移。因此,循环中 沿后缀链接移动的次数,不超过 作为 的后缀的起始位置向后移动的距离。因为 至少要向后移动一次,才能终止循环,而且 至少是 沿后缀链接移动一次的结果,因此循环终止时, 作为 的后缀的的起始位置并不比字符串 更靠前。而且,循环终止时,字符串 作为 的后缀的起始位置将恰好是 作为 的后缀的起始位置,而作为 的后缀,字符串 恰好是字符串 。因为 是更新后的 的值,所以循环中移动的次数不会超过更新前后 作为当前字符串后缀的起始位置向后移动的距离,再加一(即为了终止循环必须移动的次数)。

因为作为当前字符串后缀的字符串 的位置在整个 SAM 构造过程中单调递增3,它的总移动距离必然不超过 。这就说明,需要修改指向 的转移的循环中,迭代次数不超过 。这正是我们需要证明的。

当然,如果字符集大小不是常数,SAM 的时间复杂度就不是线性的。从一个结点出发的转移需要存储在支持快速查询和插入的平衡树中。因此如果我们记 为字符集, 为字符集大小,则算法的渐进时间复杂度为 ,空间复杂度为

实现

首先,我们实现一种存储一个转移的全部信息的数据结构。如果需要的话,你可以在这里加入一个终止标记,也可以是一些其它信息。我们将用一个 map 存储转移的列表,允许我们在总计 的空间复杂度和 的时间复杂度内处理整个字符串。当然,在字符集大小为较小的常数 (比如 26)时,将 next 声明为 int[K] 更方便。

1
2
3
4
struct state {
  int len, link;
  std::map<char, int> next;
};

SAM 本身将会存储在一个 state 结构体数组中。我们记录当前自动机的大小 sz 和变量 last,当前整个字符串对应的状态。

1
2
3
constexpr int MAXLEN = 100000;
state st[MAXLEN * 2];
int sz, last;

我们定义一个函数来初始化 SAM(创建一个只有初始状态的 SAM)。

1
2
3
4
5
6
void sam_init() {
  st[0].len = 0;
  st[0].link = -1;
  sz++;
  last = 0;
}

最终我们给出主函数的实现:给当前行末增加一个字符,对应地在之前的基础上建造自动机。

实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void sam_extend(char c) {
  int cur = sz++;
  st[cur].len = st[last].len + 1;
  int p = last;
  while (p != -1 && !st[p].next.count(c)) {
    st[p].next[c] = cur;
    p = st[p].link;
  }
  if (p == -1) {
    st[cur].link = 0;
  } else {
    int q = st[p].next[c];
    if (st[p].len + 1 == st[q].len) {
      st[cur].link = q;
    } else {
      int clone = sz++;
      st[clone].len = st[p].len + 1;
      st[clone].next = st[q].next;
      st[clone].link = st[q].link;
      while (p != -1 && st[p].next[c] == q) {
        st[p].next[c] = clone;
        p = st[p].link;
      }
      st[q].link = st[cur].link = clone;
    }
  }
  last = cur;
}

正如之前提到的一样,如果你用内存换时间(空间复杂度为 ,其中 为字符集大小),你可以在 的时间2内构造字符集大小任意的 SAM。但是这样你需要为每一个状态储存一个大小为 的数组(用于快速根据字符找到相应的转移)以及一个包含所有可用转移的列表(用于快速遍历所有可用的转移)。

更多性质

状态数

对于一个长度为 的字符串 ,它的 SAM 中的状态数 不会超过 (假设 )。

证明

算法本身即可证明该结论。一开始,自动机含有一个状态,第一次和第二次迭代中只会创建一个节点,剩余的 步中每步会创建至多 个状态。

然而我们也能在 不借助这个算法 的情况下 证明 这个估计值。我们回忆一下状态数等于不同的 集合个数。这些 集合形成了一棵树(父节点的 集合包含子节点的 集合)。考虑将这棵树稍微变形一下:只要它有一个只有一个子节点的内部节点(这意味着该子节点的集合至少遗漏了它的父节点的集合中的一个位置),我们就创建一个含有这些遗漏位置的集合作为它的子节点。最后我们可以获得一棵每一个内部结点的度数都大于一的树,且叶子节点的个数不超过 。这样的树里有不超过 个节点,因此,原来的不同的 集合个数也不超过

字符串 的状态数达到了该上界:从第三次迭代后的每次迭代,算法都会拆开一个状态,最终产生恰好 个状态。

转移数

对于一个长度为 的字符串 ,它的 SAM 中的转移数 不会超过 (假设 )。

证明

我们首先估计连续的转移的数量。考虑自动机中由从状态 开始到达所有状态的最长路径组成的生成树。生成树只包含连续的边,因此数量少于状态数,即边数不会超过

现在我们来估计不连续的转移的数量。令当前不连续转移为 ,其字符为 。我们取它的对应字符串 ,其中字符串 对应于初始状态到 的最长路径, 对应于从 到任意终止状态的最长路径。一方面,每个不完整的字符串所对应的形如 的字符串是不同的(因为字符串 仅由完整的转移组成)。另一方面,由终止状态的定义,每个形如 的字符串都是整个字符串 的后缀。因为 只有 个非空后缀,且形如 的字符串都不包含 (因为整个字符串只包含完整的转移),所以非完整的转移的总数不会超过

将以上两个估计值相加,我们可以得到上界 。然而,最大的状态数只能在类似于 的情况中产生,而此时转移数量显然少于

因此我们可以获得更为紧确的 SAM 的转移数的上界:。字符串 就达到了这个上界。

后缀链接树

尽管构造 SAM 是为了得到它的状态和转移的信息,但是构造过程中记录的后缀链接 和该状态对应的最长子串长度 在应用中常常比 SAM 的转移更为重要,甚至可以抛开转移单独使用。

在构建 SAM 的过程中,需要更新 状态的值。它对应的是每次添加字符前(后)的字符串,也就是整个字符串 的所有前缀。将第 个前缀对应的状态记为 ,这样就得到 共计 个状态。另外,规定初始状态 ,对应着空前缀。这些状态姑且称为「前缀节点」。

引理 4 中提及,所有状态和所有后缀链接构成根为 的根向树,这个树也称为 后缀链接树(国内 OI 选手也常称它为 parent 树)。

后缀链接树有如下性质:

  • 祖先节点对应的字符串总是子孙节点对应的字符串的后缀。
  • 每个节点处的 集合就是它的子树内的所有「前缀节点」 的下标 的集合。
  • 后缀链接树的祖先节点的 集合总是严格包含子孙节点的 集合。
  • 每个节点处的 的值就是它的子树内的所有「前缀节点」 对应前缀的最长公共后缀的长度。
  • 除根节点 外,每个节点对应的不同子串的数目,就是它的 值,减去它的父节点的 值,即

这些性质有很多应用。比如,第 个前缀和第 个前缀的最长公共后缀对应的字符串就是 的 LCA 对应的最长字符串。

最后,对字符串 建立的后缀链接树与对它的翻转 建立的 后缀树 有相同的结构。这一点常常用于离线构造后缀树。

应用

下面我们来看一些可以用 SAM 解决的问题。简单起见,假设字符集的大小 为常数。这允许我们认为增加一个字符和遍历的复杂度为常数。

检查字符串是否出现

问题

给一个文本串 和多个模式串 ,我们要检查字符串 是否作为 的一个子串出现。

解法

我们在 的时间内对文本串 构造后缀自动机。为了检查模式串 是否在 中出现,我们沿转移(边)从 开始根据 的字符进行转移。如果在某个点无法转移下去,则模式串 不是 的一个子串。如果我们能够这样处理完整个字符串 ,那么模式串在 中出现过。

对于每个字符串 ,算法的时间复杂度为 。此外,这个算法还找到了模式串 在文本串中出现的最大前缀长度。

不同子串个数

问题

给一个字符串 ,计算不同子串的个数。

解法一

对字符串 构造后缀自动机。

每个 的子串都相当于自动机中的一些路径。因此不同子串的个数等于自动机中以 为起点的不同路径的条数。

考虑到 SAM 为有向无环图,不同路径的条数可以通过动态规划计算。即令 为从状态 开始的路径数量(包括长度为零的路径),则我们有如下递推方程:

即, 可以表示为所有 的转移的末端的和, 中的三元组 表示后缀自动机中存在自 的转移。

所以不同子串的个数为 (因为要去掉空子串)。

总时间复杂度为:

解法二

另一种方法是在构造完后缀自动机后,利用得到的后缀链接树的信息。每个节点对应的子串数量是 ,对自动机所有节点求和即可。

总时间复杂度仍然为:

例题:【模板】后缀自动机SDOI2016 生成魔咒

所有不同子串的总长度

问题

给定一个字符串 ,计算所有不同子串的总长度。

解法一

本题做法与上一题类似,只是现在我们需要考虑分两部分进行动态规划:不同子串的数量 和它们的总长度

我们已经在上一题中介绍了如何计算 的值可以通过以下递推式计算:

我们取每个邻接结点 的答案,并加上 (因为从状态 出发的子串都增加了一个字符)。

算法的时间复杂度仍然是

解法二

同样可以利用后缀链接树的信息。每个节点对应的最长子串的所有后缀长度是

减去其 节点的对应值就是该节点的净贡献,对自动机所有节点求和即可。

总时间复杂度仍然为:

字典序第 k 大子串

问题

给定一个字符串 。多组询问,每组询问给定一个数 ,查询 的所有子串中字典序第 大的子串。

解法

解决这个问题的思路可以从解决前两个问题的思路发展而来。字典序第 大的子串对应于 SAM 中字典序第 大的路径,因此在计算每个状态的路径数后,我们可以很容易地从 SAM 的根开始找到第 大的路径。

预处理的时间复杂度为 ,单次查询的复杂度为 (其中 是查询的答案, 为字符集的大小)。

另注

虽然该题是后缀自动机的经典题,但实际上这题由于涉及字典序,用后缀数组做最方便。

例题:SPOJ - SUBLEXTJOI2015 弦论

最小循环移位

问题

给定一个字符串 。找出字典序最小的循环移位。

解法

容易发现字符串 包含字符串 的所有循环移位作为子串。

所以问题简化为在 对应的后缀自动机上寻找最小的长度为 的路径,这可以通过平凡的方法做到:我们从初始状态开始,贪心地访问最小的字符即可。

总的时间复杂度为

出现次数

问题

对于一个给定的文本串 ,有多组询问,每组询问给一个模式串 ,回答模式串 在字符串 中作为子串出现了多少次。

解法一

利用后缀链接树的信息,进行 dfs 即可预处理每个节点的 集合的大小。

所有「前缀节点」的初始集合大小为 ,非「前缀节点」的初始集合大小为 。然后,沿着后缀链接自下向上回溯时,每个父节点的集合大小都加上它的所有子节点的集合大小(不要遗漏父节点本身的初始值)。这样得到的每个节点处的值,就是该节点的 集合的大小。不同子节点的集合大小可以直接相加的理由是,同一个 只会出现在一个子树内,故而相加不会重复。

查询时,在自动机上查找模式串 对应的节点,如果存在,则答案就是该节点的 集合的大小;如果不存在,则答案为

预处理时间复杂度为 。单次查询的时间复杂度为

解法二

对文本串 构造后缀自动机。

接下来做预处理:对于自动机中的每个状态 ,预处理 ,使之等于 集合的大小。事实上,对应同一状态 的所有子串在文本串 中的出现次数相同,这相当于集合 中的位置数。

然而我们不能明确的构造集合 ,因此我们只考虑它们的大小

为了计算这些值,我们进行以下操作。对于每个状态,如果它不是通过复制创建的(且它不是初始状态 ),我们将它的 初始化为 1。然后我们按它们的长度 降序遍历所有状态,并将当前的 的值加到后缀链接指向的状态上,即:

这样做每个状态的答案都是正确的。

为什么这是正确的?不是通过复制获得的状态,恰好有 个,并且它们中的前 个在我们插入前 个字符时产生。因此对于每个这样的状态,我们在它被处理时计算它们所对应的位置的数量。因此我们初始将这些状态的 的值赋为 ,其它状态的 值赋为

接下来我们对每一个 执行以下操作:。其背后的含义是,如果有一个字符串 出现了 次,那么它的所有后缀也在完全相同的地方结束,即也出现了 次。

为什么我们在这个过程中不会重复计数(即把某些位置数了两次)呢?因为我们只将一个状态的位置添加到 一个 其它的状态上,所以一个状态不可能以两种不同的方式将其位置重复地指向另一个状态。

因此,我们可以在 的时间内计算出所有状态的 的值。

最后回答询问只需要查找值 ,其中 为模式串对应的状态,如果该模式串不存在答案就为 。单次查询的时间复杂度为

第一次出现的位置

问题

给定一个文本串 ,多组查询。每次查询字符串 在字符串 中第一次出现的位置( 的开头位置)。

解法一

利用后缀链接树的信息,进行 dfs 即可预处理每个节点的 集合中的最小值。

所有「前缀节点」 的初始值为 ,非「前缀节点」的初始值为 。然后,沿着后缀链接自下向上回溯时,每个父节点的值都与它的所有子节点的值比较,取最小值(不要遗漏父节点本身的初始值)。这样得到的每个节点处的值,就是该节点的 集合中的最小值。

查询时,在自动机上查找模式串 对应的节点,如果存在,则答案就是该节点的值,减去 ;如果不存在,则答案不存在。

预处理时间复杂度为 。单次查询的时间复杂度为

解法二

我们构造一个后缀自动机。我们对 SAM 中的所有状态预处理位置 。即,对每个状态 我们想要找到第一次出现这个状态的末端的位置 。换句话说,我们希望先找到每个集合 中的最小的元素(显然我们不能显式地维护所有 集合)。

为了维护 这些位置,我们对函数 sam_extend() 进行扩展。当我们创建新状态 时,我们令:

当我们将结点 复制到 时,我们令:

(因为值的唯一的其它选项 显然太大了)。

那么查询的答案就是 ,其中 为对应字符串 的状态。单次查询只需要 的时间。

所有出现的位置

问题

问题同上,这一次需要查询文本串 中模式串 出现的所有位置。

解法一

找到模式串 对应的节点后,利用后缀链接树的信息,遍历子树,一旦发现终点节点就输出。

单次查询复杂度为 ,其中, 为本次询问的答案。仿照 状态数为线性的证明 可以说明,后缀链接树的子树大小不会超过该节点的 集合的大小的二倍,因此遍历子树的复杂度是 的。

解法二

我们还是对文本串 构造后缀自动机。与上一个问题相似,我们为所有状态计算位置

如果 为对应于模式串 的状态,显然 为答案之一。我们已经找到了自动机中对应于 的状态。还需要找到其它哪些位置?正是那些对应于以 为后缀的字符串的状态。换句话说,我们要找到所有可以通过后缀链接到达状态 的状态。

因此为了解决这个问题,我们需要为每一个状态保存一个指向它的后缀连接列表。查询的答案就包含了对于每个我们能从状态 只使用反向的后缀链接进行 DFS 或 BFS 找到的所有状态的 值。

预处理的复杂度为 ,单次查询的复杂度为

我们不会重复访问一个状态(因为对于仅有一个后缀链接指向一个状态,所以不存在两条不同的路径指向同一状态)。

我们只需要考虑两个可能有相同 值的不同状态。这种情形只在一个状态是由另一个状态复制而来时发生。然而,这并不会对复杂度分析造成影响。仿照 状态数为线性的证明,所有这种后缀为 的状态数目不会超过

此外,我们可以通过不考虑复制而来的节点的 值来去除重复的位置。事实上对于一个状态,如果经过被复制状态可以到达,则经过原状态也可以到达。因此,如果我们给每个状态记录标记 is_clone 来代表这个状态是不是被复制出来的,我们就可以简单地忽略掉被复制的状态,只输出其它所有状态的 的值。

以下是大致的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct state {
  bool is_clone;
  int first_pos;
  std::vector<int> inv_link;
  // some other variables
};

// 在构造 SAM 后
for (int v = 1; v < sz; v++) st[st[v].link].inv_link.push_back(v);

// 输出所有出现位置
void output_all_occurrences(int v, int P_length) {
  if (!st[v].is_clone) cout << st[v].first_pos - P_length + 1 << endl;
  for (int u : st[v].inv_link) output_all_occurrences(u, P_length);
}

最短的没有出现的字符串

问题

给定一个字符串 和一个特定的字符集,我们要找一个长度最短的没有在 中出现过的字符串。

解法

我们在字符串 的后缀自动机上做动态规划。

假定我们已经处理完了子串的一部分,当前在状态 ,想找到不连续的转移需要添加的最小字符数量,将节点 处的这个数量记作

计算 非常简单。如果不存在使用字符集中至少一个字符的转移,则 。否则添加一个字符是不够的,我们需要求出所有转移中的最小值:

问题的答案就是 ,字符串可以通过计算过的数组 逆推回去。

两个字符串的最长公共子串

问题

给定两个字符串 ,求出最长公共子串,公共子串定义为在 中都作为子串出现过的字符串

解法

我们对字符串 构造后缀自动机。

我们现在处理字符串 ,对于每一个前缀,都在 中寻找这个前缀的最长后缀。换句话说,对于每个字符串 中的位置,我们想要找到这个位置结束的 的最长公共子串的长度。

为了达到这一目的,我们使用两个变量,当前状态 当前长度 。这两个变量描述当前匹配的部分:它的长度和它们对应的状态。

一开始 ,即,匹配为空串。

现在我们来描述如何添加一个字符 并为其重新计算答案:

  • 如果存在一个从 到字符 的转移,我们只需要转移并让 自增一。
  • 如果不存在这样的转移,我们需要缩短当前匹配的部分,这意味着我们需要按照后缀链接进行转移:

    与此同时,需要缩短当前长度。显然我们需要将 赋值为 ,因为经过这个后缀链接后我们到达的状态所对应的最长字符串是一个子串。

  • 如果仍然没有使用这一字符的转移,我们继续重复经过后缀链接并减小 ,直到我们找到一个转移或到达虚拟状态 (这意味着字符 根本没有在 中出现过,所以我们设置 )。

显然问题的答案就是所有 的最大值。

这一部分的时间复杂度为 ,因为每次移动我们要么可以使 增加一,要么可以在后缀链接间移动几次,每次都减小 的值。

代码实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
string lcs(const string &S, const string &T) {
  sam_init();
  for (int i = 0; i < S.size(); i++) sam_extend(S[i]);

  int v = 0, l = 0, best = 0, bestpos = 0;
  for (int i = 0; i < T.size(); i++) {
    while (v && !st[v].next.count(T[i])) {
      v = st[v].link;
      l = st[v].length;
    }
    if (st[v].next.count(T[i])) {
      v = st[v].next[T[i]];
      l++;
    }
    if (l > best) {
      best = l;
      bestpos = i;
    }
  }
  return T.substr(bestpos - best + 1, best);
}

例题:SPOJ Longest Common Substring

多个字符串间的最长公共子串

问题

给定 个字符串 。我们需要找到它们的最长公共子串,即作为子串出现在每个字符串中的字符串

解法一

我们将所有的子串连接成一个较长的字符串 ,以特殊字符 分开每个字符串(一个字符对应一个字符串):

然后对字符串 构造后缀自动机。

现在我们需要在自动机中找到存在于所有字符串 中的一个字符串,为此可以利用添加的特殊字符。如果 包含了一个子串 ,则从子串 对应的节点 出发,必然存在一条到达 但是不经过任何其它特殊字符 的路径。对于公共子串 ,应当对每个特殊字符 都存在这样的路径。

因此我们需要计算可达性,即对于自动机中的每个状态和每个字符 ,是否存在这样的一条路径。这可以容易地通过 DFS 或 BFS 及动态规划计算。这之后,问题的答案就是所有能够达到所有特殊字符的状态 对应的最长子串 中最长的那个。

解法二

不妨设 最短 的字符串为 ,对它构造 SAM。利用解决两个字符串最长公共子串的算法,计算剩余的每个字符串与 的最长公共子串长度。在匹配过程中,每添加一个要匹配的字符串 中的字符,就相应地在 SAM 上移动,因此,可以直接记录 在匹配过程中 SAM 每个状态能够匹配上的 的最长子串的长度。

因为匹配过程中,每次匹配到 SAM 的一个状态时,必然同时匹配到了它在后缀链接树上的所有祖先节点,但是祖先节点的匹配长度的信息并没有更新。所以,在完成对字符串 的匹配后,需要自下而上地沿着后缀链接更新,将子节点匹配到的最长子串的信息更新到父节点。此时,需要注意父节点记录的最长匹配长度不能超过它自身的 值。这样,就得到了 的 SAM 上每个状态 实际能够匹配到 的最长字串长度。

最后,只需要对每个 都匹配一遍,再对 SAM 上每个状态记录的实际匹配到的长度取最小值,就得到 SAM 上每个状态实际能够匹配到的 的最长公共子串的长度。然后,遍历 SAM 所有状态,取最大值就是这 个串的最长公共子串长度。

算法时间复杂度是 的。字符串 的 SAM 虽然遍历了 遍,但是因为 是最小的,所以 ,复杂度的主要项依然是匹配过程遍历所有子串。

例题:SPOJ Longest Common Substring II

例题

相关资料

我们先给出与 SAM 有关的最初的一些文献:

  • A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler, R. McConnell. Linear Size Finite Automata for the Set of All Subwords of a Word. An Outline of Results. [1983]
  • A. Blumer, J. Blumer, A. Ehrenfeucht, D. Haussler. The Smallest Automaton Recognizing the Subwords of a Text. [1984]
  • Maxime Crochemore. Optimal Factor Transducers. [1985]
  • Maxime Crochemore. Transducers and Repetitions. [1986]
  • A. Nerode. Linear automaton transformations. [1958]

另外,在更新的一些资源以及很多关于字符串算法的书中,都能找到这个主题:

  • Maxime Crochemore, Rytter Wowjcieh. Jewels of Stringology. [2002]
  • Bill Smyth. Computing Patterns in Strings. [2003]
  • Bill Smith. Methods and algorithms of calculations on lines. [2006]

另外,还有一些资料:

本页面主要译自博文 Суффиксный автомат 与其英文翻译版 Suffix Automaton。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。


  1. 需要将每个状态都取作一个 等价类的原因,其实就是本段提到的 Nerode 定理。简单来说,如果两个字符串 集合不同,那么它们不能对应于 SAM 的同一个状态:同一个状态到达终止状态的路径总是一样的,这意味着在 末尾添加字符到达 的结尾的方式也是一样的,而这正说明 在字符串 中的结束位置一样。反过来,只要两个字符串 集合相同,就可以将它们对应到 SAM 的同一个状态。这样做可行,就是 Nerode 定理的证明的内容,在此不多讨论。但是,此处的讨论至少可以相信,将 集合相同的字符串放到同一个状态,这样得到的 SAM 一定是最小的,因为进一步合并节点是不可能的。 

  2. 如果不额外使用列表记录当前状态的可用转移,只用数组存储所有可能的转移(无论是否存在)并在复制节点时直接复制,那么时间复杂度也是 的。 

  3. 此处正文没有解释的是,在第一种和第二种情况中, 的位置是否也是单调(弱)递增的。第一种情况容易验证,因为更新后 是空串,起止位置在字符串 的末尾。第二种情况,转移是连续的,说明 。然而,向子串的末尾添加新的字符只会使得该子串更难以出现在字符串中,也就是说,当字符串 的长度为 的后缀的结束位置集合严格包含 时,字符串 的长度为 的后缀的结束位置集合可能仍然与 相同。故而,,亦即 作为 的后缀的起始位置必然不大于 作为 的后缀的起始位置。而当一次找到状态 使得存在经由 的转移时,必定移动了至少一次,这说明 作为 的后缀的起始位置不小于 作为 的后缀的起始位置。最后,。这就说明,在第二种情况中, 的位置也是单调递增的。