后缀自动机 (SAM)
一些前置约定/定义
记
后缀自动机概述
后缀自动机(suffix automaton, SAM) 是一个能解决许多字符串相关问题的有力的数据结构。
举个例子,以下的字符串问题都可以在线性时间内通过 SAM 解决。
- 在另一个字符串中搜索一个字符串的所有出现位置。
- 计算给定的字符串中有多少个不同的子串。
直观上,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为
定义
字符串
换句话说:
- SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移。
- 图存在一个源点
,称作 初始状态,其它各结点均可从 出发到达。 - 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同。
- 存在一个或多个 终止状态。如果我们从初始状态
出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串 的一个后缀。 的每个后缀均可用一条从 到某个终止状态的路径构成。 - 在所有满足上述条件的自动机中,SAM 的结点数是最少的。
子串的性质
SAM 最简单、也最重要的性质是,它包含关于字符串
为了简化表达,我们称子串 对应 一条路径(从
到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径。
构建过程
我们将会在这里展示一些简单的字符串的后缀自动机。
我们用蓝色表示初始状态,用绿色表示终止状态。
对于字符串
对于字符串
对于字符串
对于字符串
对于字符串
对于字符串
在线性时间内构造
在我们描述线性时间内构造 SAM 的算法之前,我们需要引入几个对理解构造过程非常重要的概念并对其进行简单证明。
结束位置 endpos
考虑字符串
两个子串
显然,SAM 中的每个状态对应一个或多个
我们稍后将会用这个假设来介绍构造 SAM 的算法。我们将发现,SAM 需要满足的所有性质,除了最小性以外都满足了。由 Nerode 定理我们可以得出最小性(不会在这篇文章中证明)。
由
引理 1: 字符串
的两个非空子串 和 (假设 )的 相同,当且仅当字符串 在 中的每次出现,都是以 后缀的形式存在的。
引理显然成立。如果
引理 2: 考虑两个非空子串
和 (假设 )。那么要么 ,要么 ,取决于 是否为 的一个后缀:
证明:如果集合
引理 3: 考虑一个
等价类,将类中的所有子串按长度非递增的顺序排序。每个子串都不会比它前一个子串长,与此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中的子串长度恰好覆盖整个区间 。
证明
如果
由引理 1,两个不同的
记
后缀链接 link
考虑 SAM 中某个不是
我们还知道字符串
换句话说,一个 后缀链接
以下我们假设初始状态
引理 4: 所有后缀链接构成一棵根节点为
的树。
证明:考虑任意不是
引理 5: 通过
集合构造的树(每个子节点的 都包含在父节点的 中)与通过后缀链接 构造的树相同。
证明:由引理 2,任意一个 SAM 的
我们现在考虑任意不是
注意这里应该是
结合前面的引理有:后缀链接构成的树本质上是
以下是对字符串
小结
在学习算法本身前,我们总结一下之前学过的知识,并引入一些辅助记号。
的子串可以根据它们结束的位置 被划分为多个等价类;- SAM 由初始状态
和与每一个 等价类对应的每个状态组成; - 对于每一个状态
,一个或多个子串与之匹配。我们记 为其中最长的一个字符串,记 为它的长度。类似地,记 为最短的子串,它的长度为 。那么对应这个状态的所有字符串都是字符串 的不同的后缀,且所有字符串的长度恰好覆盖区间 中的每一个整数。 - 对于任意不是
的状态 ,定义后缀链接为连接到对应字符串 的长度为 的后缀的一条边。从根节点 出发的后缀链接可以形成一棵树。这棵树也表示 集合间的包含关系。 - 对于
以外的状态 ,可用后缀链接 表达 :
- 如果我们从任意状态
开始顺着后缀链接遍历,总会到达初始状态 。这种情况下我们可以得到一个互不相交的区间 的序列,且它们的并集形成了连续的区间 。
算法
现在我们可以学习构造 SAM 的算法了。这个算法是 在线 算法,我们可以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM。
为了保证线性的空间复杂度,我们将只保存
一开始 SAM 只包含一个状态
过程
现在,任务转化为实现给当前字符串添加一个字符
- 令
为添加字符 之前,整个字符串对应的状态(一开始我们设 ,算法的最后一步更新 )。 - 创建一个新的状态
,并将 赋值为 ,在这时 的值还未知。 - 现在我们按以下流程进行(从状态
开始)。如果还没有到字符 的转移,我们就添加一个到状态 的转移,遍历后缀链接。如果在某个点已经存在到字符 的转移,我们就停下来,并将这个状态标记为 。 - 如果没有找到这样的状态
,我们就到达了虚拟状态 ,我们将 赋值为 并退出。 - 假设现在我们找到了一个状态
,其可以通过字符 转移。我们将转移到的状态标记为 。 - 现在我们分类讨论两种状态,要么
,要么不是。 - 如果
,我们只要将 赋值为 并退出。 - 否则就会有些复杂。需要 复制 状态
:我们创建一个新的状态 ,复制 的除了 的值以外的所有信息(后缀链接和转移)。我们将 赋值为 。
复制之后,我们将后缀链接从 指向 ,也从 指向 。
最终我们需要使用后缀链接从状态 往回走,只要存在一条通过 到状态 的转移,就将该转移重定向到状态 。 - 以上三种情况,在完成这个过程之后,我们将
的值更新为状态 。
如果我们还想知道哪些状态是 终止状态 而哪些不是,我们可以在为字符串
在下一部分,我们将详细叙述算法每一步的细节,并证明它的 正确性。 因为我们只为
而线性规模的转移个数,以及算法总体的线性运行时间还不那么清楚。
正确性证明
- 若一个转移
满足 ,则我们称这个转移是 连续的。否则,即当 时,这个转移被称为 不连续的。从算法描述中可以看出,连续的、不连续的转移是算法的不同情况。连续的转移是固定的,我们不会再改变了。与此相反,当向字符串中插入一个新的字符时,不连续的转移可能会改变(转移边的端点可能会改变)。 - 为了避免引起歧义,我们记向 SAM 中插入当前字符
之前的字符串为 。 - 算法从创建一个新状态
开始,对应于整个字符串 。我们创建一个新的节点的原因很清楚。与此同时我们也创建了一个新的字符和一个新的等价类。 - 在创建一个新的状态之后,我们会从对应整个字符串
的状态通过后缀链接进行遍历。对于每一个状态,我们尝试添加一个通过字符 到新状态 的转移。然而我们只能添加与原有转移不冲突的转移。因此我们只要找到已存在的 的转移,我们就必须停止。 - 最简单的情况是我们到达了虚拟状态
,这意味着我们为所有 的后缀添加了 的转移。这也意味着,字符 从未在字符串 中出现过。因此 的后缀链接为状态 。 - 第二种情况下,我们找到了现有的转移
。这意味着我们尝试向自动机内添加一个 已经存在的 字符串 (其中 为 的一个后缀,且字符串 已经作为 的一个子串出现过了)。因为我们假设字符串 的自动机的构造是正确的,我们不应该在这里添加一个新的转移。然而,难点在于,从状态 出发的后缀链接应该连接到哪个状态呢?我们要把后缀链接连到一个状态上,且其中最长的一个字符串恰好是 ,即这个状态的 应该是 。然而还不存在这样的状态,即 。这种情况下,我们必须通过拆开状态 来创建一个这样的状态。 - 如果转移
是连续的,那么 。在这种情况下一切都很简单。我们只需要将 的后缀链接指向状态 。 - 否则转移是不连续的,即
,这意味着状态 不只对应于长度为 的后缀 ,还对应于 的更长的子串。除了将状态 拆成两个子状态以外我们别无他法,所以第一个子状态的长度就是 了。
我们如何拆开一个状态呢?我们 复制 状态 ,产生一个状态 ,我们将 赋值为 。由于我们不想改变遍历到 的路径,我们将 的所有转移复制到 。我们也将从 出发的后缀链接设置为 的后缀链接的目标,并设置 的后缀链接为 。
在拆开状态后,我们将从 出发的后缀链接设置为 。
最后一步我们将一些到 转移重定向到 。我们需要修改哪些转移呢?只重定向相当于所有字符串 (其中 是 的最长字符串)的后缀就够了。即,我们需要继续沿着后缀链接遍历,从结点 直到虚拟状态 或者是转移到不是状态 的一个转移。
对操作次数为线性的证明
首先我们假设字符集大小为 常数。如果字符集大小不是常数,SAM 的时间复杂度就不是线性的。从一个结点出发的转移存储在支持快速查询和插入的平衡树中。因此如果我们记
所以我们将认为字符集的大小为常数,即每次对一个字符搜索转移、添加转移、查找下一个转移。这些操作的时间复杂度都为
如果我们考虑算法的各个部分,算法中有三处时间复杂度不明显是线性的:
- 第一处是遍历所有状态
的后缀链接,添加字符 的转移。 - 第二处是当状态
被复制到一个新的状态 时复制转移的过程。 - 第三处是修改指向
的转移,将它们重定向到 的过程。
我们使用 SAM 的大小(状态数和转移数)为 线性的 的事实(对状态数是线性的的证明就是算法本身,对转移数为线性的的证明将在稍后实现算法后给出)。
因此上述 第一处和第二处 的总复杂度显然为线性的,因为单次操作均摊只为自动机添加了一个新转移。
还需为 第三处 估计总复杂度,我们将最初指向
因此,循环中的每次迭代都会使作为当前字符串的后缀的字符串
实现
首先,我们实现一种存储一个转移的全部信息的数据结构。如果需要的话,你可以在这里加入一个终止标记,也可以是一些其它信息。我们将用一个 map
存储转移的列表,允许我们在总计 next
定义为 int[26]
更方便)
1 2 3 4 |
|
SAM 本身将会存储在一个 state
结构体数组中。我们记录当前自动机的大小 sz
和变量 last
,当前整个字符串对应的状态。
1 2 3 |
|
我们定义一个函数来初始化 SAM(创建一个只有初始状态的 SAM)。
1 2 3 4 5 6 |
|
最终我们给出主函数的实现:给当前行末增加一个字符,对应地在之前的基础上建造自动机。
实现
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 |
|
正如之前提到的一样,如果你用内存换时间(空间复杂度为
更多性质
状态数
对于一个长度为
算法本身即可证明该结论。一开始,自动机含有一个状态,第一次和第二次迭代中只会创建一个节点,剩余的
然而我们也能在 不借助这个算法 的情况下 证明 这个估计值。我们回忆一下状态数等于不同的
字符串
转移数
对于一个长度为
证明如下:
我们首先估计连续的转移的数量。考虑自动机中从状态
现在我们来估计不连续的转移的数量。令当前不连续转移为
将以上两个估计值相加,我们可以得到上界
因此我们可以获得更为紧确的 SAM 的转移数的上界:
额外信息
观察 实现 中的结构体的每个变量。实际上,尽管 SAM 本身由 next
组成,但 SAM 构造算法中作为辅助变量的 link
和 len
在应用中常常比 next
重要,甚至可以抛开 next
单独使用。
设字符串的长度为 extend
操作中 cur
变量的值,这个节点对应的状态是执行 extend
操作时的当前字符串,即字符串的一个前缀,每个前缀有一个终点。这样得到的
考虑给 SAM 赋予树形结构,树的根为 0,且其余节点
- 每个节点的终点集合等于其 子树 内所有终点节点对应的终点的集合。
在此基础上可以给每个节点赋予一个最长字符串,是其终点集合中 任意 一个终点开始 往前 取 len
个字符得到的字符串。每个这样的字符串都一样,且 len
恰好是满足这个条件的最大值。
这些字符串满足的性质是:
- 如果节点 A 是 B 的祖先,则节点 A 对应的字符串是节点 B 对应的字符串的 后缀。
这条性质把字符串所有前缀组成了一棵树,且有许多符合直觉的树的性质。例如,
每个状态
应用
下面我们来看一些可以用 SAM 解决的问题。简单起见,假设字符集的大小
检查字符串是否出现
给一个文本串
和多个模式串 ,我们要检查字符串 是否作为 的一个子串出现。
我们在
对于每个字符串
不同子串个数
给一个字符串
,计算不同子串的个数。
对字符串
每个
考虑到 SAM 为有向无环图,不同路径的条数可以通过动态规划计算。即令
即,
所以不同子串的个数为
总时间复杂度为:
另一种方法是利用上述后缀自动机的树形结构。每个节点对应的子串数量是
所有不同子串的总长度
给定一个字符串
,计算所有不同子串的总长度。
本题做法与上一题类似,只是现在我们需要考虑分两部分进行动态规划:不同子串的数量
我们已经在上一题中介绍了如何计算
我们取每个邻接结点
算法的时间复杂度仍然是
同样可以利用上述后缀自动机的树形结构。每个节点对应的所有后缀长度是
字典序第 k 大子串
给定一个字符串
。多组询问,每组询问给定一个数 ,查询 的所有子串中字典序第 大的子串。
解决这个问题的思路可以从解决前两个问题的思路发展而来。字典序第
预处理的时间复杂度为
虽然该题是后缀自动机的经典题,但实际上这题由于涉及字典序,用后缀数组做最方便。
最小循环移位
给定一个字符串
。找出字典序最小的循环移位。
容易发现字符串
所以问题简化为在
总的时间复杂度为
出现次数
对于一个给定的文本串
,有多组询问,每组询问给一个模式串 ,回答模式串 在字符串 中作为子串出现了多少次。
利用后缀自动机的树形结构,进行 dfs 即可预处理每个节点的终点集合大小。在自动机上查找模式串
以下为原方法:
对文本串
构造后缀自动机。 接下来做预处理:对于自动机中的每个状态
,预处理 ,使之等于 集合的大小。事实上,对应同一状态 的所有子串在文本串 中的出现次数相同,这相当于集合 中的位置数。 然而我们不能明确的构造集合
,因此我们只考虑它们的大小 。 为了计算这些值,我们进行以下操作。对于每个状态,如果它不是通过复制创建的(且它不是初始状态
),我们将它的 初始化为 1。然后我们按它们的长度 降序遍历所有状态,并将当前的 的值加到后缀链接指向的状态上,即: 这样做每个状态的答案都是正确的。
为什么这是正确的?不是通过复制获得的状态,恰好有
个,并且它们中的前 个在我们插入前 个字符时产生。因此对于每个这样的状态,我们在它被处理时计算它们所对应的位置的数量。因此我们初始将这些状态的 的值赋为 ,其它状态的 值赋为 。 接下来我们对每一个
执行以下操作: 。其背后的含义是,如果有一个字符串 出现了 次,那么它的所有后缀也在完全相同的地方结束,即也出现了 次。 为什么我们在这个过程中不会重复计数(即把某些位置数了两次)呢?因为我们只将一个状态的位置添加到 一个 其它的状态上,所以一个状态不可能以两种不同的方式将其位置重复地指向另一个状态。
因此,我们可以在
的时间内计算出所有状态的 的值。 最后回答询问只需要查找值
,其中 为模式串对应的状态,如果该模式串不存在答案就为 。单次查询的时间复杂度为 。
第一次出现的位置
给定一个文本串
,多组查询。每次查询字符串 在字符串 中第一次出现的位置( 的开头位置)。
我们构造一个后缀自动机。我们对 SAM 中的所有状态预处理位置
为了维护 sam_extend()
。当我们创建新状态
;当我们将结点
(因为值的唯一的其它选项
那么查询的答案就是
所有出现的位置
问题同上,这一次需要查询文本串
中模式串出现的所有位置。
利用后缀自动机的树形结构,遍历子树,一旦发现终点节点就输出。
以下为原解法:
我们还是对文本串
构造后缀自动机。与上一个问题相似,我们为所有状态计算位置 。 如果
为对应于模式串 的状态,显然 为答案的一部分。需要查找的其它位置怎么办?我们使用了含有字符串 的自动机,我们还需要将哪些状态纳入自动机呢?所有对应于以 为后缀的字符串的状态。换句话说我们要找到所有可以通过后缀链接到达状态 的状态。 因此为了解决这个问题,我们需要为每一个状态保存一个指向它的后缀引用列表。查询的答案就包含了对于每个我们能从状态
只使用后缀引用进行 DFS 或 BFS 的所有状态的 值。 这种变通方案的时间复杂度为
,因为我们不会重复访问一个状态(因为对于仅有一个后缀链接指向一个状态,所以不存在两条不同的路径指向同一状态)。 我们只需要考虑两个可能有相同
值的不同状态。如果一个状态是由另一个复制而来的,则这种情况会发生。然而,这并不会对复杂度分析造成影响,因为每个状态至多被复制一次。 此外,如果我们不从被复制的节点输出位置,我们也可以去除重复的位置。事实上对于一个状态,如果经过被复制状态可以到达,则经过原状态也可以到达。因此,如果我们给每个状态记录标记
is_clone
来代表这个状态是不是被复制出来的,我们就可以简单地忽略掉被复制的状态,只输出其它所有状态的的值。 以下是大致的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15struct 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 |
|
例题:SPOJ Longest Common Substring
多个字符串间的最长公共子串
给定
个字符串 。我们需要找到它们的最长公共子串,即作为子串出现在每个字符串中的字符串 。
我们将所有的子串连接成一个较长的字符串
然后对字符串
现在我们需要在自动机中找到存在于所有字符串
因此我们需要计算可达性,即对于自动机中的每个状态和每个字符
例题:SPOJ Longest Common Substring II
例题
- HihoCoder #1441 : 后缀自动机一·基本概念
- 【模板】后缀自动机
- SDOI2016 生成魔咒
- SPOJ - SUBLEX
- TJOI2015 弦论
- SPOJ Longest Common Substring
- SPOJ Longest Common Substring II
- Codeforces 1037H Security
- Codeforces 666E Forensic Examination
- HDU4416 Good Article Good sentence
- HDU4436 str2int
- HDU6583 Typewriter
- Codeforces 235C Cyclical Quest
- CTSC2012 熟悉的文章
- NOI2018 你的名字
相关资料
我们先给出与 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]
另外,还有一些资料:
- 《后缀自动机》,陈立杰。
- 《后缀自动机在字典树上的拓展》,刘研绎。
- 《后缀自动机及其应用》,张天扬。
- https://www.cnblogs.com/zinthos/p/3899679.html
- https://codeforces.com/blog/entry/20861
- https://zhuanlan.zhihu.com/p/25948077
本页面主要译自博文 Суффиксный автомат 与其英文翻译版 Suffix Automaton。其中俄文版版权协议为 Public Domain + Leave a Link;英文版版权协议为 CC-BY-SA 4.0。
本页面最近更新:2023/9/16 16:44:07,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:Chrogeek, Enter-tainer, ksyx, abc1763613206, Backl1ght, CCXXXI, ChungZH, countercurrent-time, Dev-XYS, FFjet, frank-xjh, fstqwq, GeZiyue, Ghastlcon, H-J-Granger, Heartfirey, Henry-ZHR, iamtwz, interestingLSY, Ir1d, Kinandra, LeoJacob, longlongzhu123, Luckyblock233, mao1t, Marcythm, MingqiHuang, opsiff, ouuan, ranwen, shuzhouliu, sshwy, SukkaW, Suyun514, Tiphereth-A, vincent-163, WhenMelancholy, Xeonacid, yjl9903, ZXyaang
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用