后缀三兄弟$\big( \text{(alpha+1,beta]} 版本 \big)$
前言
后缀三兄弟是处理字符串问题的有力工具, 它们之间有共通之处, 即长存不灭的金子般的 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[i\in[1,n], i+2^{k-1}-1]\) 的排序结果, 就可以得到 \(S[i\in[1,n], i+2^k-1]\) 的排序结果。
每一次倍增的过程中都要用到一次二关键字排序, 用基数排序就可以将一次倍增做到 \(O(n)\)。
倍增法的总复杂度为 \(O(n\log_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}\) 和 \(\Delta\delta\) 性质, 定理就得证了。
有了这个定理, 就可以 \(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) \neq 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) \neq 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|)\) 的。
构造法
后缀自动机的构造方法是增量法。