摘要
后缀数组是处理字符串子串相关问题的一种有力的工具,而从大到小合并 height 数组则是其一个常见而重要的应用技巧。本文在简要介绍后缀数组及 height 数组的概念和求法的基础上,着重分析这一应用技巧,点明其适用情形、核心思想,并在其实现方法方面,指出在其特殊结构之下,在利用并查集进行合并这一似被广泛提及的方法之外,有同样简明而复杂度更优的实现方法。最后概括了应用该技巧时的一般性思考框架。本文也选取了笔者曾练习过的4道不同来源的有关题目作为例题进行分析。此些意在使后缀数组中从大到小合并 height 数组的应用技巧更完整全面地呈现出来。
目录
1. [Codeforces 873F] Forbidden Indices
2. [POJ 3415] Common Substrings
一、 后缀数组及 height 数组的概述和求法
本文所谈基于后缀数组及该结构下的height数组,当然其不为重点。故先对后缀数组及height数组进行概述性讲解,包括其在算法竞赛中一般的求解方法。
1. 后缀数组
后缀数组(Suffix Array)针对某一字符串,考虑将其所有n个后缀中的按字典序排序。注意到某一下标能用来指代以该下标位置为首的后缀。对于长为n的字符串S[1…n],定义:
- sa[i] (1≤i≤n):S的n个后缀中排名为i的后缀的起始下标
- rank[i] (1≤i≤n):后缀S[i, n]在S的n个后缀中的排名
上图描述了串aabaaaab的sa与rank数组。显然,可将sa与rank数组视为互为“反函数”,即有sa[rank[i]] = i,rank[sa[i]] = i (1≤i≤n)。
关于后缀数组的求解方法,对所有后缀朴素地排序,运用 的排序方法,则复杂度是
的,通常不可接受。尽管有
的求解方法,但在算法竞赛中往往使用以下的
的倍增法求解。
注意到两字符串(假设长度相同)可通过比较前一半与后一半来进行比较,从而想到对长度进行倍增。
如图,我们先做第1次排序,求出S中每个字符S[i]比较的排名,而后开始倍增,每次进行双关键字排序,第2次排序得到子串S[1, 2], S[2, 3], S[n-1, n], S[n, n]的比较排名,第k(k≥2)次双关键字排序得到子串S[1, 1+p], S[2, 2+p], … , S[i, min(i+p, n)], … , S[n]的排名,其中p=2k-1。如此倍增下去,直至2k-1≥n,此时已经得到所有后缀排序的结果。
如果使用 的排序方法,则总时间复杂度
。然而,注意到倍增过程中的“双关键字”为不超过n的“排名”,因此我们可用基数排序将每次排序复杂度优化至
,从而总复杂度为
。此法在实现时还有一定的常数优化技巧,此从略。
2. height数组
单纯的后缀数组能解决一些特定的问题,结合上height数组则更能解决一些涉及字串(包括前、后缀)相关的字符串问题。
height 数组描述 sa 数组上相邻两后缀的最长公共前缀的长度。若记 LCP(i, j(2≤i, j≤n)为后缀S[i, n]和S[j, n]的最长公共前缀的长度,则 height[i] = LCP(sa[i], sa[i-1])(2≤i≤n),令 height[1] = 0。
height 数组的求解基于如下性质:height[rank[i]] ≥ height[rank[i-1]] – 1,我们从小到大枚举 i,依次计算height[rank[1]], … , height[rank[n]],维护当前的 height,暴力匹配 sa 上相邻两后缀的前缀。时间复杂度 。
height 数组有个非常重要的性质:LCP(i, j) = min(height[i+1], height[i+2], … , height[j])。这将后缀的 LCP 转化成了 height 数组上的 RMQ,这也是height数组有效、常用的重要原因。
二、 从大到小合并height数组的应用技巧
1. 适用情形
“从大到小合并height数组”的应用技巧,往往适用于给定一至两个字符串,基于公共子串,或可转化为公共子串相关的,且涉及附加信息的离线型全局统计(如求和、求最值)问题。典型情况下,题目所求常能写成下面的形式之一:
⦁ 对F(i, j) = G(LCP(i, j), f(i, j)) (i<j) 做全局统计
⦁ 对所有k,对所有满足LCP(i, j) = k 的i, j (i<j) 做F(i, j) 的全局统计
若直接暴力枚举i, j,尽管通过预处理height数组的ST表,我们可以O(1) 计算LCP(i, j) 而后统计答案,但是显然暴力枚举的O(n2) 代价常不可取。
2. 核心思想
由于LCP(i, j) = min(height[rank[i]+1], height[rank[i]+2], … , height[rank[j]]) = height[p] (不妨rank[i]<rank[j]),形象地看,height数组上一段的“height”被“卡在”中间最小的那个位置。因而,在上文所述的情形下,不难想到我们应该着眼于位置p,统计对应的 i, j 的答案。进一步地,p 位置可视为对应了height数组上的一段,而height[p]小的段会包含height[p]大的段。这启发我们维护所有的段,并根据height值“从大到小”枚举p,每次用height[p]及此时p位置“关联”的上下两段的信息统计答案,随后将两段“合并”为一段,并维护所需信息。
具体地,称上面说的“段”为A,其对应上下边界L[A], R[A],同时记有题目所需的附加信息I[A]。按如下步骤统计答案:
⦁ 初始时,后缀数组上每个后缀自成一段,有A1, A2, … , An,L[Ai] = R[Ai] = i,及初始的I[Ai]。
⦁ 将height数组降序排序而获得值降序(不增)的索引顺序。
⦁ 按此顺序循环枚举p。p=1则跳过,否则有p处所关联,即当前下边界为p-1以及上边界为p的上、下两段Ap1与Ap2。首先,通过I[Ap1], I[Ap2] 以及height[p] 统计所有rank[i] 属于Ap1,rank[j] 属于Ap2的i, j的答案。随后合并Ap1, Ap2形成更长的一段Ap,计算其信息I[Ap]。
⦁ 经 3)后即得题目所求。
应当指出,这个过程对答案的统计是不重不漏的,这是因为后缀数组上一对i’, j’ (rank[i]=i' < j’=rank[j])的答案必然会在其对应的p处合并时被统计,且仅被统计这一次。
3. 实现方法及补充
关于该算法的具体实现,似乎并查集被更多地采用。但其实在其特殊结构之下有同样简明的纯线性方法。
使用并查集
不难想到,我们可以运用并查集来实现这一过程。并查集的一个集合就代表了当前的一段,同时这段的信息在该集合的代表元上。合并相当于union(find(p-1), find(p))。此部分时间复杂度 。
直接维护段的上下边界
因为该结构下,本质上,段是纵向连续的,且合并处是段的上、下边界,因而并查集不是必需的。我们将信息记录在上下边界上,并直接维护:所有作为段的上边界的位置x对应的下边界r[x]、所有作为段的下边界的位置y对应的上边界l[y],合并时更新信息并令l[r[p]] = l[p-1], r[l[p-1]] = r[p] 即可。此部分时间复杂度 。
所以,与将这一算法直接概括成“后缀数组结合并查集”相比,还是称“从大到小合并height数组”更佳。
4. 应用时的思考框架归纳
至此,不妨概括一下尝试运用该技巧时的思考框架:
- 在后缀数组上进行观察与思考。
- 着眼于某个p处,思考段上需要记录什么信息,以及如何通过height[p],尤其是上下两段的信息计算题目所求。
- 思考信息如何合并。
- 若上述问题解决,则此法适用,从而套用上文的实现方法即可。
三、 例题分析
1. [Codeforces 873F] Forbidden Indices
【题目大意】
给定长为 n 的字符串 S 并给出每个位置是否“允许”。对于 S 的一个子串 s, 记 f(s) 表示 s 在 S 中出现且其末尾在“允许”位置的次数。求 |s|×f(s) 的最大值,|s| 表示串s的长度。
。
【分析】
可以先说明的是,本题所求可能不属于前文所述典型适用情况下的形式之一,但该法完全适用。
考虑先将 S 反转,从而将末尾在 “允许” 位置转化为开头在 “允许” 位置。基于上文的思考框架,在某个 p 处,此时的上、下段每个后缀有长 height[p] 的公共前缀,相当于我们找到了这个前缀在 S 中作为子串的出现位置。所以我们只需要维护段中 “允许” 的开头个数即可统计答案。注意每次未必找到前缀在 S 中作为子串的所有出现位置,但在关于这个前缀最后一次合并时能找到,所以一定能找到最终答案。
通过上述分析,不难想到用单调栈算出每个位置作为 height 最小位置的最长延伸区间亦能做这道题。然而结合单调栈的做法有时可能因为信息较难维护和统计等而不适用。可看后面的例题。
2. [POJ 3415] Common Substrings
【题目大意】
给定字符串 A, B 和整数 K。问有多少对子串 (A’, B’),满足 A’=B’ 且 |A|=|B|≥K,其中 A’, B’ 分别是 A, B 的子串(位置不同则算不同)。
A, B 各自串长 n, m 不超过。
【分析】
首先,前文已经提到,将 A, B 两串以特殊字符(如“#”等)连接成 S,从而经 S 的后缀数组、height 数组的求解后我们能求出 A 的一个后缀与 B 的一个后缀的 LCP。而后可发现该题所求为前文所述“对F(i, j) = G(LCP(i, j), f(i, j)) (i<j) 做全局统计”的情形。
基于上文的思考框架,在某个p处,只要height[p]≥K,则此时的上、下段中各取一后缀,其LCP=height[p]≥K,有height[p]-K+1个长度≥K的子串。我们只要记录每段内属于A的后缀与属于B的后缀的个数,统计答案时可用乘法原理计算贡献。合并时的维护容易。
3. [NOI 2015] 品酒大会
【题目大意】
给定长为n的描述n杯“酒”的字符串 S 和权值数组 a[1…n]。若 S[p, p+r-1] = S[q, q+r-1],则称第p, q杯酒是“r相似”的。此外,把第p杯酒与第q杯酒调兑在一起的收益为 。对于r=0, 1, 2, …. , n−1,求有多少种方法可以选出 2杯“r相似”的酒以及 2杯“r相似”的酒调兑可以得到最大收益。
。
【分析】
2杯“r相似”(r>0) 的酒也是“r-1相似”的,故答案显然具有由小向大的“继承性”。因而事实上,该题所求即为前文所述“对所有k,对所有满足LCP(i, j) = k 的i, j (i<j) 做F(i, j) 的全局统计”的情形。
基于上文的思考框架,在某个p处,此时的上、下段中各取一后缀,其 LCP=height[p]。容易发现此处对所求第一个答案的贡献即为上、下两段长度的乘积。为了统计所求第二个答案,注意到权值 ai 可能小于零,经简单的数学上的分析,我们只要维护每段内权值的最大、最小值,统计答案时比较两段最大值的乘积和两段最小值的乘积即可。合并时的维护容易。
4. [LOJ 6198] 谢特
【题目大意】
给定长为n的字符串S和权值数组w[1…n],求LCP(i, j) + (w[i] xor w[j]) (1<i, j<n, i≠j) 的最大值,其中xor是异或运算。
。
【分析】
该题所求为前文所述“对F(i, j) = G(LCP(i, j), f(i, j)) (i<j) 做全局统计”的情形。
基于上文的思考框架,在某个 p 处,已有题目所求式中的 LCP(i, j) = height[p],关键是考虑 “w[i] xor w[j]” 这一部分。我们希望求出当前上段与下段各一个后缀的权值异或和的最大值,这很接近于 01 Trie树的一个经典问题。
所以,我们可以对每个段维护一个其所含后缀的权值的 01 Trie树,并以 “启发式合并” 的思想,在统计答案时枚举长度小(即所含后缀个数少)的段A1中的每个权值,将其在长段 A2 的01 Trie树上做“异或最大值”的查询,合并时,暴力地将A1的中每个权值插入到 A2 的 01 Trie树中即可。时间复杂度 。
四、 总结
“从大到小合并height数组” 的应用技巧往往适用于给定一至两个字符串,基于公共子串,或可转化为公共子串相关的,且涉及附加信息的离线型全局统计问题。它为求解相关问题提供了一个通用的框架,并且在一些情况下可以代替单调栈的做法,是后缀数组的一个重要应用技巧。而其“合并”这一过程的实现,一般既可以直接使用并查集,也可以直接维护段的上下边界。