后缀数组(Suffix Array)是一种将字符串所有后缀按字典序排序后形成的整数数组,记录每个后缀在原始字符串中的起始位置。由Manber和Myers于1990年提出,后缀数组以O(n log n)的构建时间和O(n)的空间复杂度,提供了与后缀树相当的功能,同时实现更加简洁、内存更加高效。后缀数组被广泛应用于生物信息学(DNA序列比对)、文本检索、数据压缩和字符串匹配等领域。
核心思想
对于字符串S = “banana”,其所有后缀为:
– 0: banana
– 1: anana
– 2: nana
– 3: ana
– 4: na
– 5: a
按字典序排序后得到后缀数组SA = [5, 3, 1, 0, 4, 2],表示排序后第i个后缀在原串中的起始位置。
配合最长公共前缀数组LCP(Longest Common Prefix),后缀数组可以高效解决大量字符串问题。
倍增算法构建后缀数组
倍增算法是构建后缀数组的经典方法,时间复杂度O(n log n):
- 初始以单个字符为键排序,得到初始排名
- 每次将排序键长度翻倍:利用前一轮的排名,以(rank[i], rank[i+k])作为双关键字排序
- 重复直到所有后缀排名互不相同
private void buildSuffixArray(String s) {
int n = s.length();
int[] sa = new int[n]; // 后缀数组
int[] rank = new int[n]; // 排名
int[] temp = new int[n];
// 初始按单个字符排序
for (int i = 0; i < n; i++) {
sa[i] = i;
rank[i] = s.charAt(i);
}
// 倍增过程
for (int k = 1; k < n; k <<= 1) {
final int kk = k;
// 按双关键字排序
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) idx[i] = i;
Arrays.sort(idx, (a, b) -> {
if (rank[a] != rank[b]) return Integer.compare(rank[a], rank[b]);
int ra = (a + kk < n) ? rank[a + kk] : -1;
int rb = (b + kk < n) ? rank[b + kk] : -1;
return Integer.compare(ra, rb);
});
for (int i = 0; i < n; i++) sa[i] = idx[i];
// 重新计算排名
temp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
int prev = sa[i - 1], curr = sa[i];
int r1a = rank[prev], r1b = rank[curr];
int r2a = (prev + kk < n) ? rank[prev + kk] : -1;
int r2b = (curr + kk < n) ? rank[curr + kk] : -1;
temp[curr] = temp[prev] + (r1a != r1b || r2a != r2b ? 1 : 0);
}
System.arraycopy(temp, 0, rank, 0, n);
if (rank[sa[n - 1]] == n - 1) break; // 所有排名唯一,提前结束
}
this.suffixArray = sa;
}
最长公共前缀数组(LCP)
LCP数组记录排序后相邻后缀的最长公共前缀长度。使用Kasai算法可在O(n)时间内构建。
private void buildLCP(String s) {
int n = s.length();
int[] rank = new int[n];
for (int i = 0; i < n; i++) {
rank[suffixArray[i]] = i;
}
lcp = new int[n];
int h = 0;
for (int i = 0; i < n; i++) {
if (rank[i] > 0) {
int j = suffixArray[rank[i] - 1];
while (i + h < n && j + h < n && s.charAt(i + h) == s.charAt(j + h)) {
h++;
}
lcp[rank[i]] = h;
if (h > 0) h--;
}
}
}
完整可运行代码
import java.util.Arrays;
/**
* 后缀数组实现 - 基于倍增算法
* 构建时间复杂度: O(n log n)
* LCP构建: O(n)
* 空间复杂度: O(n)
*/
public class SuffixArray {
private final String text;
private final int[] suffixArray;
private final int[] lcp;
public SuffixArray(String text) {
this.text = text;
this.suffixArray = buildSuffixArray(text);
this.lcp = buildLCPArray(text);
}
private int[] buildSuffixArray(String s) {
int n = s.length();
int[] sa = new int[n];
int[] rank = new int[n];
int[] temp = new int[n];
// 初始按单个字符排序
Integer[] idx = new Integer[n];
for (int i = 0; i < n; i++) {
idx[i] = i;
rank[i] = s.charAt(i);
}
Arrays.sort(idx, (a, b) -> Integer.compare(rank[a], rank[b]));
for (int i = 0; i < n; i++) sa[i] = idx[i];
// 倍增过程
for (int k = 1; k < n; k <<= 1) {
final int kk = k;
Arrays.sort(idx, (a, b) -> {
if (rank[a] != rank[b]) return Integer.compare(rank[a], rank[b]);
int ra = (a + kk < n) ? rank[a + kk] : -1;
int rb = (b + kk < n) ? rank[b + kk] : -1;
return Integer.compare(ra, rb);
});
for (int i = 0; i < n; i++) sa[i] = idx[i];
temp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
int prev = sa[i - 1], curr = sa[i];
int r1a = rank[prev], r1b = rank[curr];
int r2a = (prev + kk < n) ? rank[prev + kk] : -1;
int r2b = (curr + kk < n) ? rank[curr + kk] : -1;
temp[curr] = temp[prev] + (r1a != r1b || r2a != r2b ? 1 : 0);
}
System.arraycopy(temp, 0, rank, 0, n);
if (rank[sa[n - 1]] == n - 1) break;
}
return sa;
}
private int[] buildLCPArray(String s) {
int n = s.length();
int[] rank = new int[n];
for (int i = 0; i < n; i++) {
rank[suffixArray[i]] = i;
}
int[] lcpArray = new int[n];
int h = 0;
for (int i = 0; i < n; i++) {
if (rank[i] > 0) {
int j = suffixArray[rank[i] - 1];
while (i + h < n && j + h < n && s.charAt(i + h) == s.charAt(j + h)) {
h++;
}
lcpArray[rank[i]] = h;
if (h > 0) h--;
}
}
return lcpArray;
}
/**
* 查找模式串在文本中的所有出现位置
* 时间复杂度: O(m log n)
*/
public java.util.List<Integer> search(String pattern) {
java.util.List<Integer> results = new java.util.ArrayList<>();
int n = text.length();
int m = pattern.length();
if (m > n) return results;
// 二分查找下界
int left = 0, right = n;
while (left < right) {
int mid = (left + right) / 2;
String suffix = text.substring(suffixArray[mid], Math.min(suffixArray[mid] + m, n));
if (suffix.compareTo(pattern) < 0) {
left = mid + 1;
} else {
right = mid;
}
}
int start = left;
// 二分查找上界
right = n;
while (left < right) {
int mid = (left + right) / 2;
String suffix = text.substring(suffixArray[mid], Math.min(suffixArray[mid] + m, n));
if (suffix.compareTo(pattern) <= 0) {
left = mid + 1;
} else {
right = mid;
}
}
int end = left;
for (int i = start; i < end; i++) {
results.add(suffixArray[i]);
}
return results;
}
/**
* 查找最长重复子串
*/
public String longestRepeatedSubstring() {
int maxLen = 0;
int pos = -1;
for (int i = 1; i < lcp.length; i++) {
if (lcp[i] > maxLen) {
maxLen = lcp[i];
pos = suffixArray[i];
}
}
return maxLen > 0 ? text.substring(pos, pos + maxLen) : "";
}
/**
* 查找两个字符串的最长公共子串
*/
public static String longestCommonSubstring(String s1, String s2) {
String combined = s1 + "#" + s2 + "$";
SuffixArray sa = new SuffixArray(combined);
int maxLen = 0;
int pos = -1;
int split = s1.length();
for (int i = 1; i < sa.lcp.length; i++) {
int prevPos = sa.suffixArray[i - 1];
int currPos = sa.suffixArray[i];
// 确保两个后缀分别来自两个不同字符串
boolean prevInS1 = prevPos < split;
boolean currInS1 = currPos < split;
if (prevInS1 != currInS1 && sa.lcp[i] > maxLen) {
maxLen = sa.lcp[i];
pos = currPos;
}
}
return maxLen > 0 ? combined.substring(pos, pos + maxLen) : "";
}
public void printSuffixes() {
System.out.println("后缀数组及对应后缀:");
for (int i = 0; i < suffixArray.length; i++) {
System.out.printf("SA[%d]=%d, LCP=%d, suffix=%s%n",
i, suffixArray[i], lcp[i],
text.substring(suffixArray[i]));
}
}
public int[] getSuffixArray() {
return suffixArray.clone();
}
public int[] getLCP() {
return lcp.clone();
}
public static void main(String[] args) {
String text = "banana";
SuffixArray sa = new SuffixArray(text);
sa.printSuffixes();
System.out.println("\n查找 'ana': " + sa.search("ana"));
System.out.println("查找 'na': " + sa.search("na"));
System.out.println("最长重复子串: " + sa.longestRepeatedSubstring());
String s1 = "abcdef";
String s2 = "zbcdefg";
System.out.println("\n最长公共子串 '" + s1 + "' 和 '" + s2 + "': "
+ longestCommonSubstring(s1, s2));
}
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 构建后缀数组 | O(n log n) | O(n) |
| 构建LCP数组 | O(n) | O(n) |
| 模式匹配 | O(m log n) | O(1) |
| 最长重复子串 | O(n) | O(1) |
其中n为文本长度,m为模式串长度。使用更高效的SA-IS算法可将构建时间降至O(n)。
经典应用场景
- 生物信息学:DNA序列比对、基因重复片段检测、短读段映射
- 全文检索:搜索引擎的倒排索引构建、大规模文本搜索
- 数据压缩:Burrows-Wheeler变换(bzip2压缩算法核心)
- 字符串分析:最长重复子串、最长公共子串、不同子串计数
- ** plagiarism检测**:文档相似度计算与抄袭片段定位
后缀数组是字符串算法领域的基石性数据结构,理解其构建原理与应用方法对于处理大规模文本数据至关重要。