后缀数组(Suffix Array)的构建与应用
字数 2994 2025-12-08 15:22:35

后缀数组(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"

将这些后缀按字典序排序:

  1. "a" (起始位置5)
  2. "ana" (起始位置3)
  3. "anana" (起始位置1)
  4. "banana" (起始位置0)
  5. "na" (起始位置4)
  6. "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. 初始化:对每个后缀,考虑其第一个字符(即长度为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
  2. 倍增:接下来考虑长度为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开始)。
  3. 迭代:重复倍增过程,直到所有后缀的排名都不同(即每个后缀都有唯一排名)。由于每次长度加倍,最多需要 \(\lceil \log n \rceil\) 轮。

    • 每一轮排序可以使用基数排序(对二元组进行两次计数排序,先按第二关键字排序,再按第一关键字排序),每轮复杂度为 \(O(n)\),总复杂度 \(O(n \log n)\)
  4. 得到后缀数组:当所有排名唯一后,后缀数组 \(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 数组可以高效解决:

  1. 构建 S 的后缀数组 SA 和 LCP 数组。
  2. 最长重复子串的长度就是 LCP 数组中的最大值,记为 \(\text{maxLCP}\)
  3. 对应的子串为 S[SA[i] : SA[i] + maxLCP],其中 i 是 LCP 中最大值的位置。

原理:排序后的后缀数组中,相邻后缀的公共前缀就是重复的子串。最长公共前缀的最大值对应最长的重复子串。

步骤6:实现注意事项

  • 通常会在字符串末尾添加一个特殊字符(如 '$'),保证后缀排序时,短后缀不会成为另一个后缀的前缀,从而简化比较。
  • 实际实现中,可以使用更高效的算法,如 SA-IS 算法(诱导排序),能在 \(O(n)\) 时间内构建后缀数组,但较复杂。

总结
后缀数组是一种强大的字符串索引结构,通过倍增算法可以在 \(O(n \log n)\) 时间内构建,结合 LCP 数组可高效解决多种字符串问题,如最长重复子串、多字符串匹配、最长公共子串等。它的空间开销为 \(O(n)\),比后缀树更小,是处理大规模字符串数据的常用工具。

后缀数组(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 倍增 :接下来考虑长度为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开始)。 迭代 :重复倍增过程,直到所有后缀的排名都不同(即每个后缀都有唯一排名)。由于每次长度加倍,最多需要 \( \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) \),比后缀树更小,是处理大规模字符串数据的常用工具。