后缀数组(Suffix Array)的构建与应用
描述
后缀数组(Suffix Array)是一种用于字符串处理的重要数据结构,它保存了一个字符串所有后缀的排序信息。具体来说,对于一个长度为 \(n\) 的字符串 \(S\),后缀数组 \(SA\) 是一个长度为 \(n\) 的整数数组,其中 \(SA[i]\) 表示第 \(i\) 小的后缀的起始位置(从0开始索引)。后缀数组广泛应用于字符串匹配、最长公共前缀、最长重复子串等问题,是后缀树的轻量级替代,其构建和查询通常更高效。
解题过程讲解
我将循序渐进地讲解后缀数组的构建与应用,重点包括构建方法(如排序法、倍增算法)和基本应用(如最长公共前缀、最长重复子串)。
步骤1:理解后缀数组的定义
考虑一个字符串 \(S = "banana"\),假设索引从0开始(即 S[0]='b', S[1]='a', ...)。它的所有后缀(以每个位置开头的子串)为:
- 后缀0: "banana"
- 后缀1: "anana"
- 后缀2: "nana"
- 后缀3: "ana"
- 后缀4: "na"
- 后缀5: "a"
将这些后缀按字典序排序:
- "a" (起始位置5)
- "ana" (起始位置3)
- "anana" (起始位置1)
- "banana" (起始位置0)
- "na" (起始位置4)
- "nana" (起始位置2)
对应的起始位置数组就是后缀数组:\(SA = [5, 3, 1, 0, 4, 2]\)。
步骤2:后缀数组的朴素构建方法
最直观的方法是生成所有后缀,直接排序。对于长度为 \(n\) 的字符串:
- 生成后缀:需要 \(O(n)\) 空间存储所有后缀(如果用指针或子串表示,但实际存储会占用 \(O(n^2)\) 空间,需避免)。
- 排序后缀:使用比较排序(如快速排序),每次比较两个后缀需要 \(O(n)\) 时间,总复杂度为 \(O(n^2 \log n)\)。
这种方法简单但效率低,不适用于长字符串。实际中通常采用更高效的算法。
步骤3:倍增算法(Doubling Algorithm)构建后缀数组
倍增算法是构建后缀数组的经典方法,时间复杂度为 \(O(n \log n)\)。其核心思想是通过比较后缀的“前缀”来排序,并利用已排序的较短前缀来加速排序较长前缀。
过程详解(以字符串 S = "banana" 为例,假设末尾添加一个特殊字符 '\(' 表示结尾,S = "banana\)",n=7):
-
初始化:对每个后缀,考虑其第一个字符(即长度为1的前缀)。将所有后缀按第一个字符的ASCII值排序。如果字符相同,则保持原始顺序(稳定排序)。可以使用计数排序(基数排序的一部分),因为字符范围有限。
- 对 S = "banana\(",初始后缀(起始位置0~6)按第一个字符排序: 字符:b, a, n, a, n, a, \)
排序后(按字符值):\( (6), a (1), a (3), a (5), b (0), n (2), n (4) 得到初始排名数组 rank(记录每个位置当前排名,从0开始): 位置: 0(b) 1(a) 2(n) 3(a) 4(n) 5(a) 6(\))
排名: 4 1 5 2 6 3 0
- 对 S = "banana\(",初始后缀(起始位置0~6)按第一个字符排序: 字符:b, a, n, a, n, a, \)
-
倍增:接下来考虑长度为2、4、8...的前缀。在第 k 轮,我们比较每个后缀的长度为 \(2^k\) 的前缀。关键技巧是:比较两个后缀的长度为 \(2^k\) 的前缀时,可以将它视为一个“二元组”(前一半排名,后一半排名),然后对二元组排序。
- 例如,第1轮(k=1):比较长度为2的前缀。对后缀 i,其“二元组”为 (rank[i], rank[i+1])。如果 i+1 超出范围,则排名设为-1(或其他最小值)。
对 S = "banana\(": 后缀0: ("ba") -> (rank[0]=4, rank[1]=1) -> (4,1) 后缀1: ("an") -> (1,5) 后缀2: ("na") -> (5,2) 后缀3: ("an") -> (2,6) 后缀4: ("na") -> (6,3) 后缀5: ("a\)") -> (3,0)
后缀6: ("$") -> (0,-1)
对所有二元组排序(先按第一个元素,再按第二个元素)。排序后,分配新的排名(相同二元组排名相同)。
新排名数组:位置0~6的排名可能为 [4,1,5,2,6,3,0](但排名值会重新分配连续整数,从0开始)。
- 例如,第1轮(k=1):比较长度为2的前缀。对后缀 i,其“二元组”为 (rank[i], rank[i+1])。如果 i+1 超出范围,则排名设为-1(或其他最小值)。
-
迭代:重复倍增过程,直到所有后缀的排名都不同(即每个后缀都有唯一排名)。由于每次长度加倍,最多需要 \(\lceil \log n \rceil\) 轮。
- 每一轮排序可以使用基数排序(对二元组进行两次计数排序,先按第二关键字排序,再按第一关键字排序),每轮复杂度为 \(O(n)\),总复杂度 \(O(n \log n)\)。
-
得到后缀数组:当所有排名唯一后,后缀数组 \(SA\) 就是按排名排序的起始位置列表。即 \(SA[排名] = 起始位置\)。
步骤4:最长公共前缀(LCP)数组
后缀数组常与最长公共前缀数组配合使用。LCP数组 \(LCP[i]\) 存储后缀 \(SA[i]\) 和 \(SA[i-1]\) 的最长公共前缀长度(对于 i=0,LCP[0] 通常定义为0)。计算 LCP 数组有高效算法(如 Kasai 算法,复杂度 \(O(n)\))。
Kasai 算法:
- 利用性质:如果已知后缀 i 和 j 的 LCP 为 k,那么后缀 i+1 和 j+1 的 LCP 至少为 k-1。这允许在扫描过程中线性计算。
- 过程:从排名0的后缀开始,按原串顺序扫描,维护当前 LCP 值,避免从头比较。
步骤5:应用示例——最长重复子串
给定字符串 S,找到最长的重复子串(子串至少出现两次)。利用后缀数组和 LCP 数组可以高效解决:
- 构建 S 的后缀数组 SA 和 LCP 数组。
- 最长重复子串的长度就是 LCP 数组中的最大值,记为 \(\text{maxLCP}\)。
- 对应的子串为 S[SA[i] : SA[i] + maxLCP],其中 i 是 LCP 中最大值的位置。
原理:排序后的后缀数组中,相邻后缀的公共前缀就是重复的子串。最长公共前缀的最大值对应最长的重复子串。
步骤6:实现注意事项
- 通常会在字符串末尾添加一个特殊字符(如 '$'),保证后缀排序时,短后缀不会成为另一个后缀的前缀,从而简化比较。
- 实际实现中,可以使用更高效的算法,如 SA-IS 算法(诱导排序),能在 \(O(n)\) 时间内构建后缀数组,但较复杂。
总结
后缀数组是一种强大的字符串索引结构,通过倍增算法可以在 \(O(n \log n)\) 时间内构建,结合 LCP 数组可高效解决多种字符串问题,如最长重复子串、多字符串匹配、最长公共子串等。它的空间开销为 \(O(n)\),比后缀树更小,是处理大规模字符串数据的常用工具。