直观上,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为 n 的字符串,它的空间复杂度仅为 O(n)。而且,构造 SAM 的时间复杂度也仅为 O(n)。准确地说,一个 SAM 最多有 2n−1 个结点和 3n−4 条转移边。
存在一个或多个 终止状态。如果我们从初始状态 t0 出发,最终转移到了一个终止状态,则路径上的所有转移的标号连接起来一定是字符串 s 的一个后缀。反过来,s 的每个后缀均可用一条从 t0 到某个终止状态的路径构成。
在所有满足上述条件的自动机中,SAM 的结点数是最少的。
SAM 的关键恰在于这个最小性。实际上,直接对字符串 s 的所有后缀建立 AC 自动机 同样可以得到一个接受 s 的所有后缀的 DFA。但是,最差情况下,这样得到的自动机有 Θ(n2) 个结点,复杂度难以接受。从下面的例子可以看出,对所有后缀建立 AC 自动机得到的 DFA 中很多结点是重复的,因而可以合并。SAM 正是将结点的合并做到了极致,故而将得到的 DFA 的规模控制在 O(n)。从这个意义上,SAM 是字符串的全体后缀的「压缩」的 AC 自动机。
子串和路径
SAM 最简单、也最重要的性质是,它包含关于字符串 s 的所有子串的信息。任意从初始状态 t0 开始的路径,如果我们将路径上的所有转移的标号写下来,都会形成 s 的一个 子串。反之,每个 s 的子串都对应从 t0 开始的某条路径。
记 w 为等价类中最长的字符串,u 为等价类中最短的字符串。由引理 1,字符串 u 是字符串 w 的真后缀。现在考虑长度在区间 [∣u∣,∣w∣] 中的 w 的任意后缀。容易看出,这个后缀也在同一等价类中,因为这个后缀只能在字符串 s 中以 w 的一个后缀的形式存在(这是因为较短的后缀 u 在 s 中只以 w 的后缀的形式存在)。因此,由引理 1,这个后缀和字符串 w 的 endpos 相同。
对于这个图示,如果要在原字符串 s 的末尾添加一个字符 c,并构造出相应的后缀路径,会发生什么变化呢?答案是如下图所示:
因为结点 q0 由原字符串 s 对应结点 p0 经由字符 c 转移而来,它就对应新字符串 s+c。所以,它的后缀路径 q0→q1′′→q2→q3→t0 就是新增的后缀路径。如果原来的后缀路径上的结点 pi 本就有经由 c 的转移,那么新的后缀路径也必然会经过这些转移到达的结点,因此可以直接复用旧有的结点。新的后缀路径上有且只有一个完全新建的节点 q0,用于接受原来的后缀路径上起始的那些没有经由 c 的转移的结点的转移。
最终我们需要沿着后缀链接从状态 p 往回走,只要经过的状态存在指向状态 q 的转移,就将该转移重新连接到状态 clone。
处理完以上三种情况后,我们都需要将 last 的值更新为状态 cur。
如果我们还想知道哪些状态是 终止状态 而哪些不是,我们可以在为字符串 s 构造完完整的 SAM 后找到所有的终止状态。为此,我们从对应整个字符串的状态(存储在变量 last 中),遍历它的后缀链接,直到到达初始状态。我们将所有遍历到的状态都标记为终止状态。容易理解这样做我们会准确地标记字符串 s 的所有后缀,这些状态都是终止状态。
因为我们只为 s 的每个字符创建一个或两个新状态,所以 SAM 只包含 线性个 状态。而 SAM 只有线性规模的转移个数,以及算法总体的线性运行时间,都还没有说清楚,将在后文说明。
算法从创建一个新状态 cur 开始,对应于整个字符串 s+c。我们创建一个新的节点的原因很清楚。与此同时我们也创建了一个新的字符和一个新的等价类。
在创建一个新的状态之后,我们会从对应整个字符串 s 的状态 last 沿着后缀链接进行移动。对于经过的每一个状态,我们尝试添加一个通过字符 c 到新状态 cur 的转移。
然而我们只能添加与原有转移不冲突的转移。因此我们只要找到已存在的 c 的转移,我们就必须停止。
最简单的情况是我们到达了虚拟状态 −1,这意味着我们为所有 s 的后缀添加了 c 的转移。这也意味着,字符 c 从未在字符串 s 中出现过。因此 cur 的后缀链接为状态 0。
第二种情况下,我们找到了现有的转移 (p,q)。这意味着我们尝试向自动机内添加一个 已经存在的 字符串 x+c(其中 x 为 s 的一个后缀,且字符串 x+c 已经作为 s 的一个子串出现过了)。因为我们假设字符串 s 的自动机的构造是正确的,我们不应该在这里添加一个新的转移。
然而,难点在于,从状态 cur 出发的后缀链接应该连接到哪个状态呢?我们要把后缀链接连到一个状态上,且对应的最长的字符串恰好是 x+c,即这个状态的 len 应该是 len(p)+1。然而这样的状态有可能并不存在,即 len(q)>len(p)+1。这种情况下,我们必须通过拆开状态 q 来创建一个这样的状态。
当然,如果转移 (p,q) 是连续的,那么 len(q)=len(p)+1。在这种情况下一切都很简单。我们只需要将 cur 的后缀链接指向状态 q。
我们使用 SAM 的大小(状态数和转移数)为 线性的 的事实(对状态数是线性的的证明就是算法本身,对转移数为线性的的证明将在稍后实现算法后给出)。
因此上述 第一处和第二处 的总复杂度显然为线性的,因为单次操作均摊只为自动机添加了一个新转移。
还需为 第三处 估计总复杂度,我们将最初指向 q 的转移重新连接到 clone。我们记 v=longest(p),这是字符串 s 的一个后缀。每迭代一次,v 的长度都减小,因而 v 作为 s 的后缀的起始位置必然在后移。因此,循环中 p 沿后缀链接移动的次数,不超过 v 作为 s 的后缀的起始位置向后移动的距离。因为 p 至少要向后移动一次,才能终止循环,而且 p 至少是 last 沿后缀链接移动一次的结果,因此循环终止时,v 作为 s 的后缀的的起始位置并不比字符串 longest(link(link(last)) 更靠前。而且,循环终止时,字符串 v 作为 s 的后缀的起始位置将恰好是 v+c 作为 s+c 的后缀的起始位置,而作为 s+c 的后缀,字符串 v+c 恰好是字符串 longest(link(link(cur))。因为 cur 是更新后的 last 的值,所以循环中移动的次数不会超过更新前后 longest(link(link(last)) 作为当前字符串后缀的起始位置向后移动的距离,再加一(即为了终止循环必须移动的次数)。
因为作为当前字符串后缀的字符串 longest(link(link(last)) 的位置在整个 SAM 构造过程中单调递增 3,它的总移动距离必然不超过 n。这就说明,需要修改指向 q 的转移的循环中,迭代次数不超过 2n。这正是我们需要证明的。
现在我们来估计不连续的转移的数量。令当前不连续转移为 (p,q),其字符为 c。我们取它的对应字符串 u+c+w,其中字符串 u 对应于初始状态到 p 的最长路径,w 对应于从 q 到任意终止状态的最长路径。一方面,每个不完整的字符串所对应的形如 u+c+w 的字符串是不同的(因为字符串 u 和 w 仅由完整的转移组成)。另一方面,由终止状态的定义,每个形如 u+c+w 的字符串都是整个字符串 s 的后缀。因为 s 只有 n 个非空后缀,且形如 u+c+w 的字符串都不包含 s(因为整个字符串只包含完整的转移),所以非完整的转移的总数不会超过 n−1。
因此我们可以获得更为紧确的 SAM 的转移数的上界:3n−4。字符串 abbb⋯bbbc 就达到了这个上界。
后缀链接树
尽管构造 SAM 是为了得到它的状态和转移的信息,但是构造过程中记录的后缀链接 link 和该状态对应的最长子串长度 len 在应用中常常比 SAM 的转移更为重要,甚至可以抛开转移单独使用。
在构建 SAM 的过程中,需要更新 last 状态的值。它对应的是每次添加字符前(后)的字符串,也就是整个字符串 s 的所有前缀。将第 i 个前缀对应的状态记为 vi,这样就得到 v0,v1,⋯,vn−1 共计 n 个状态。另外,规定初始状态 t0 为 v−1,对应着空前缀。这些状态姑且称为「前缀节点」。
引理 4 中提及,所有状态和所有后缀链接构成根为 t0 的根向树,这个树也称为 后缀链接树(国内 OI 选手也常称它为 parent 树)。它记录了字符串全体前缀的所有后缀的信息,亦即全体子串的信息。
后缀链接树有如下性质:
祖先节点对应的字符串总是子孙节点对应的字符串的后缀。
每个节点处的 endpos 集合就是它的子树内的所有「前缀节点」vi 的下标 i 的集合。
后缀链接树的祖先节点的 endpos 集合总是严格包含子孙节点的 endpos 集合。
每个节点处的 len 的值就是它的子树内的所有「前缀节点」vi 对应前缀的最长公共后缀的长度。
除根节点 t0 外,每个节点对应的不同子串的数目,就是它的 len 值,减去它的父节点的 len 值,即 len(v)−len(link(v))。
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);}
最短的没有出现的字符串
问题
给定一个字符串 S 和一个特定的字符集,我们要找一个长度最短的没有在 S 中出现过的字符串。
解法
我们在字符串 S 的后缀自动机上做动态规划。
假定我们已经处理完了子串的一部分,当前在状态 v,想找到不连续的转移需要添加的最小字符数量,将节点 v 处的这个数量记作 dv。
与此同时,需要缩短当前长度。显然我们需要将 l 赋值为 len(v),因为经过这个后缀链接后我们到达的状态所对应的最长字符串是一个子串。
如果仍然没有使用这一字符的转移,我们继续重复经过后缀链接并减小 l,直到我们找到一个转移或到达虚拟状态 −1(这意味着字符 Ti 根本没有在 S 中出现过,所以我们设置 v=l=0)。
显然问题的答案就是所有 l 的最大值。
这一部分的时间复杂度为 O(∣T∣),因为每次移动我们要么可以使 l 增加一,要么可以在后缀链接间移动几次,每次都减小 l 的值。
代码实现:
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);}
现在我们需要在自动机中找到存在于所有字符串 Si 中的一个字符串,为此可以利用添加的特殊字符。如果 Sj 包含了一个子串 X,则从子串 X 对应的节点 t 出发,必然存在一条到达 Dj 但是不经过任何其它特殊字符 D1,⋯,Dj−1,Dj+1,⋯,Dk 的路径。对于公共子串 X,应当对每个特殊字符 Dj 都存在这样的路径。
因此我们需要计算可达性,即对于自动机中的每个状态和每个字符 Di,是否存在这样的一条路径。这可以容易地通过 DFS 或 BFS 及动态规划计算。这之后,问题的答案就是所有能够达到所有特殊字符的状态 v 对应的最长子串 longest(v) 中最长的那个。
解法二
不妨设 最短 的字符串为 S1,对它构造 SAM。利用解决两个字符串最长公共子串的算法,计算剩余的每个字符串与 S1 的最长公共子串长度。在匹配过程中,每添加一个要匹配的字符串 Sj 中的字符,就相应地在 SAM 上移动,因此,可以直接记录 在匹配过程中 SAM 每个状态能够匹配上的 Sj 的最长子串的长度。
因为匹配过程中,每次匹配到 SAM 的一个状态时,必然同时匹配到了它在后缀链接树上的所有祖先节点,但是祖先节点的匹配长度的信息并没有更新。所以,在完成对字符串 Sj 的匹配后,需要自下而上地沿着后缀链接更新,将子节点匹配到的最长子串的信息更新到父节点。此时,需要注意父节点记录的最长匹配长度不能超过它自身的 len 值。这样,就得到了 S1 的 SAM 上每个状态 实际能够匹配到 的 Sj 的最长字串长度。
最后,只需要对每个 S2,⋯,Sk 都匹配一遍,再对 SAM 上每个状态记录的实际匹配到的长度取最小值,就得到 SAM 上每个状态实际能够匹配到的 S2,⋯,Sk 的最长公共子串的长度。然后,遍历 SAM 所有状态,取最大值就是这 k 个串的最长公共子串长度。
算法时间复杂度是 O(∑i∣Si∣) 的。字符串 S1 的 SAM 虽然遍历了 k 遍,但是因为 ∣S1∣ 是最小的,所以 k∣S1∣≤∑i∣Si∣,复杂度的主要项依然是匹配过程遍历所有子串。
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]
需要将每个状态都取作一个 endpos 等价类的原因,其实就是本段提到的 Myhill–Nerode 定理。简单来说,如果两个字符串 t 和 u 的 endpos 集合不同,那么它们不能对应于 SAM 的同一个状态:同一个状态到达终止状态的路径总是一样的,这意味着在 t 和 u 末尾添加字符到达 s 的结尾的方式也是一样的,而这正说明 t 和 u 在字符串 s 中的结束位置一样。反过来,只要两个字符串 t 和 u 的 endpos 集合相同,就可以将它们对应到 SAM 的同一个状态。这样做可行,就是 Nerode 定理的证明的内容,在此不多讨论。但是,此处的讨论至少可以相信,将 endpos 集合相同的字符串放到同一个状态,这样得到的 SAM 一定是最小的,因为进一步合并节点是不可能的。 ↩