前言
后缀三兄弟是处理字符串问题的有力工具, 它们之间有共通之处, 即长存不灭的金子般的 idea; 它们之间也可以用算法相互转化。
后缀数组
后缀数组是三个数组, 分别名为 (sa)、(rank)、 (height), 它们包含的信息都是同一个字符串的。
sa数组
sa 数组的定义
一个串 (S) 的后缀 (S[l:|S|]) 用 (l) 表示, 将 (S) 的所有后缀排序, sa 数组存储的就是有序的后缀编号 (l)。
暴力求 sa
如果用 C++ 带的 sort 函数来后缀排序的话, 时间复杂度是 (O(n^2 log_2 n)) 的, 因为两个字符串的比较的时间复杂度是 (O(n)) 的。
快速排序与基数排序
对于用 k 个关键字排序 n 个数, 快速排序能做到 (O(kn log_2 n)), 基数排序能做到 (O(kn))。
下面简要分析一下它们的排序过程。
快速排序: 都知道 sort 函数提供了 cmp 函数的接口。
基数排序: 基数排序是将多个关键字分开, 从低优先级的关键字到高优先级的关键字依次排一遍(稳定排序)。
倍增法
倍增法的低复杂度依托于后缀排序这个问题的特殊性质。
有了 (S[iin[1,n], i+2^{k-1}-1]) 的排序结果, 就可以得到 (S[iin[1,n], i+2^k-1]) 的排序结果。
每一次倍增的过程中都要用到一次二关键字排序, 用基数排序就可以将一次倍增做到 (O(n))。
倍增法的总复杂度为 (O(nlog_2 n))。
LCP 与 sa数组
设 (lcp(i,j)) 表示 (LCP(sa_i, sa_j))
+
(Delta delta) 性质(自己瞎取的名, 这个性质不仅适用于后缀排序问题, 它适用于所有多关键字排序问题):对于在 sa 数组中的一个串 (sa_i), 以及在 sa 数组中与其相对位置一样的若干串 (sa_j), (|rk[i]-rk[j]|) 变大, (lcp(i,j)) 不增。
证明:对于两个串 (A)、 (B), 它们都有 (n) 个关键字(不足补空), (A_i) 即 (A) 的第 (i) 关键字, (LCP(A,B) = k) 即 (A)、 (B) 的前 (k) 个关键字都相等, 第 (k+1) 关键字不同。
由于排序的性质, 对于一个串 (S) 和与其相对位置相同的一些串来说, 排序后第一关键字与 (S) 相同的那些串一定比第一关键字与 (S) 不同的那些串近 (因为第一关键字相同的在整个数组中一定是连续的), 这也就说明了与 (S) 的最长公共前缀为 (0) 的串总是离 (S) 较与 (S) 的最长公共前缀为 (ge 1) 的串远。
在与 (S) 的 (LCP) 大于等于 (1) 的那个连续段中, 第二关键字就是第一关键字, 用归纳法就可以发现这个 (Delta delta) 性质。
+
有了这个 (Delta delta) 性质, 可以证明一个定理:
证明:
首先 (lcp(i,k) ge min{ lcp(i,k), lcp(k,j) }) 且 (lcp(k,j) ge min{ lcp(i,k), lcp(k,j) }), 由此推出 i、j 前 (min{ lcp(i,k), lcp(k,j) }) 个字符相等, 即 (lcp(i,j) ge min{ lcp(i,k), lcp(k,j) })。
由 (Delta delta) 性质, (lcp(i,j) le lcp(i,k)) 且 (lcp(j,i) le lcp(j,k)), 即 (lcp(i,j) le min{ lcp(i,k), lcp(k,j) })。
综上, (lcp(i,j) = min{ lcp(i,k), lcp(k,j) })。
需要注意的是, 这个定理也适用于任意字符串排序的结果数组, 而不仅仅是后缀排序的结果数组
这个定理有一个应用:
证明:
将 (lcp(i,j)) 按照定理展开, 最终就可以得到这个式子。(至于展开的过程中 (k) 怎么选, 当然是随便选啦owo)
rk 数组
rk 数组是 sa 数组的反函数, 意即 (sa[rk[i]] = i)。
height 数组
定义
意即排名为 i 的后缀与排名为 i-1 的后缀的 LCP。
又有
意即后缀 i 与排在它前面一个的后缀的 LCP。
这里有一个定理:
证明:
如果 (h_{i-1} le 1), 那么显然成立。
如果 (h_{i-1} > 1), 那么将 (i-1) 和 (sa_{rank_{i-1} - 1}) 都去掉第一个字符, 得到 (i) 和 (sa_{rank_{i-1} - 1}+1), 它们的 (LCP) 就是 (h_{i-1}-1); 由字典序的知识可知 (sa_{rank_{i-1} - 1}+1) 依然排在 (i) 前面, 再由 (i) 前面离 (i) 最近的是 (sa_{rank_i - 1}) 和 (Deltadelta) 性质, 定理就得证了。
有了这个定理, 就可以 (O(n)) 求出 (h) 数组, 进而 (height) 数组也就可以求出了。
以上就是后缀数组的基本知识了。
「」
闲话一下: 很久以前看到了有人写边压缩的 Trie (这里), 当时看到评论里有人说这类似后缀树的思想, 当时还被吓到觉得后缀树是个什么东西, 好高端好niubi啊; 现在可以拿出[这篇博文]压惊, 于是我很少被nb玩意吓到了。
后缀树定义: 将一个串的所有后缀不重不漏地插入一个空 Tire, 就得到了它的后缀树。(的前身)
小性质: 朴素的后缀树节点个数是 (O(n^2)) 的。 然而由于叶子节点数是 (O(n)) 的, 得出子节点超过一个的节点数也是 (O(n)) 的。(感性理解下: 每插入一个串最多产生一个新的子节点超过一个的节点)
优化节点个数的思想——虚树:
对于树 (T = (V,E)), 给定一堆关键点 (S subseteq V), 那么通过这些关键点可以定义这棵树的虚树 (T_0 = (V_0, E_0))。 其中, 节点集合 (V_0 subseteq V), 使得 (u in V_0) 当且仅当 (u in S) 或 (exists x,y in S, u = LCA(x,y)); ((u,v) in E_0), 当且仅当 (u,v in V_0) 且 (u) 是 (v) 在 (V_0) 中深度最浅的祖先。
建出虚树, 减小了树的规模, 但同时压缩了树的信息(指保留了一部分信息并丢掉了另一部分信息), 上面那个定义需要细细品味, 或是在题目中加强对它的认识(如果想练习虚树的话)。
虚树的构建法:
依据树 (T = (V,E)) 和关键点集 (S subseteq V) 构造虚树 (T_0 = (V_0, E_0)) 的方法如下 :
首先给 (forall u in V) 标上 dfs序 : (dfn(u))。
然后将 (S) 里的节点按照 dfs序 由小到大排序, 依次取出, 同时用栈维护当前 (T_0) 的 “右链” (也就是所谓树的 “右脊”:根节点到dfn最大的节点的路径构成的一条链):
设这次取出的点为 (u), 右链中最深的点为 (v), (w = LCA(u,v)), 将 (dep_q > dep_w) 的 (q) 弹栈。 注意, 加边操作总是发生在弹栈的过程中, 具体的实现还要加亿点细节, 在此不再赘述。
后缀树的线性在线构建——Ukknonen算法:
Ukkonen算法类似于构建后缀自动机(SAM),采用增量法: 在线构造每次末尾插入一个字符利用旧的后缀树维护出新的后缀树。 -- Magolor
Ukkonen算法(简称ukk算法)是一个online算法,它与mcc算法的一个显著区别是每次只对S的一个前缀生成隐式后缀树(implicit suffix tree),然后考虑S的下一个字符S[i+1]并将S[0...i+1]的所有后缀加入到上一个阶段中生成的隐式后缀树中,形成一个新的隐式后缀树。最后用一个特殊字符将隐式后缀树自动转换成真实的后缀树。这样ukk的一个最大优点就是不需要事先知道输入字串的全部内容,只需使用增量方式生成后缀树。和mcc算法类似,也是采用压缩存储Trie,以达到节省空间的目的。通过使用implicit extensions和suffix link两大技巧,时间复杂度可以达到线性。 -- ljsspace
上面两段分别是两篇介绍 Ukkonen算法 的博文的开头奠基部分。写一份真正能用的知识点教程会花费较多的精力, 由于这点,网上找好教程是真的难,如果要学知识点, 建议大家尽量少看高中算法竞赛选手现役时写的博客,很难找到良品。
真的心态崩了, 能把这么简单的东西讲得跟天书一样
要学 Ukkonen算法, 看这篇完全足够了, 我会抽空在这里再写一遍。
一个神必概念和它的衍生物:
「隐式后缀树」(implicit suffix tree):一个后缀的终止节点可能是叶子结点,也可能是非叶子结点, 这样的后缀树叫做隐式后缀树。
如果在输入串中最后加一个不同于这个串的其它所有字符的 “终止符”,那么所有的后缀都将终止于叶子结点,不会有后缀隐藏在内部结点中。(因为这个字符不会出现在非叶子节点中, 出现了就表示它在这个串里)
cls 太强啦
一个串 (S) 的后缀自动机 (SAM) 是可以且仅可以接受 (S) 的所有后缀的 DFA(有限状态自动机)。
最简 (SAM) 是指拥有最少状态与转移的 SAM, 我很喜欢最简 SAM, so 以下说到 SAM 都是指最简 SAM。
几个约定:
以下讨论 SAM 时, 母串为 (S), (S) 的长度为 (|S|), 下标从 (1) 开始。
分别用 (fac[l:r])、 (pre[i])、(suf[i]) 表示 (S) 的 “从 (l) 到 (r) 的子串”、 “以 (i) 结束的前缀”、 “从 (i) 开始的后缀”; 分别用 (Fac)、 (Pre)、(Suf) 表示 (S) 的 “所有子串组成的集合”, “所有前缀组成的集合”、 “所有后缀组成的集合”。
用 (SAM(string)) 表示 (delta(Start, string))
对于状态 (u), 令 (Reg(u)) 表示所有使得 (delta(u,str)) 被 SAM 接受的串 (str) 组成的集合。
对于 (forall str in Fac), 若其在 (S) 中的出现集合为 ({ S[l_1:r_1], cdots,S[l_n,r_n] }), 定义 (Right(str) = {r_1,cdots,r_n })。
SAM 的时空复杂度证明
显然, (SAM(str)
eq Null) 当且仅当 (str in Fac), 但是如果对于 (forall str in Fac) 都建一个单独的状态, SAM 的状态总数就是 (O(|S|^2)) 的了。
对于 (forall str in Fac), 若 (Right(str) = {r_1,cdots,r_n }), 那么 (str + S[r_i+1:|S|] in Suf), 意即 (right) 集合决定了能够通过往后面加字符串转移到的状态集合, 那么 (right) 集合相等的两个串, 往它们后面加同样的字符, 两个串能否转移到后缀状态是同步的, 它们的转移过程也很相似, 所以可以把这两个串对应的状态合并成一个, 相关的后续状态也合并成一个, 那么 SAM 中的一个状态就代表一个 “right等价类”。
接下来考虑 (S) 的两个不同的子串 (A) 和 (B), 如果 (right(A) cap right(B)
eq Null), 那么显然其中一个是另一个的后缀, 不妨设 (A) 是 (B) 的后缀, 此时显然 (right(B) subseteq right(A))。 所以对于 (S) 的两个子串, 它们的 (right) 集合要么没有交集, 要么一个包含另一个。
令一个状态 (u) 的父状态 ((fa_u)) 为满足 (right(u) subseteq right((fa_u))) 且 (|right((fa_u))|) 最小的状态, 这时候所有的状态会形成一个树结构, 把它叫做 (parent) 树。 SAM 的 (parent) 树至多只有 (O(|S|)) 个叶子结点(叶子结点就是 (|right| = 1) 的节点, 由于每个节点代表的都是一个 (right) 等价类, 又因为不同的大小为 1 的等价类个数是 (O(|S|)) 的), 而对于非叶子结点, 如果其只有一个子节点, 那么就可以把它和它的子节点合并, 故每个非叶子结点的子节点多于 1 个, 所以 SAM 的状态数就是 (O(|S|)) 的(具体证明跟后缀树的差不多)。
考虑转移。注意到状态数是 (O(|S|)) 的, 考虑 SAM 的一个从初始状态开始的树形图, 这个树形图的树边数显然是 (O(|S|)) 的, 只需要考虑非树边。
对于非树边 (delta(a,c) = b), 构造: 根到 a 的路径 + (a->b) + b 到任意一个接受状态的集合。 所以一个非树边对应着至少一个原串的后缀。对于一个后缀,沿着自动机走,使其对应其路径上经过的第一条非树边, 这是一个后缀对应了一个非树边, 而每个非树边至少被一个后缀所对应, 那么非树边的数量也就是 (O(|S|)) 的了。
所以 SAM 的转移树也是 (O(|S|)) 的。
构造法
后缀自动机的构造方法是增量法。