后缀数组(SA)入门:字符串的排序利器
极难2后缀数组(SA)入门:给字符串排个队,快速查找子串
想象你有一本字典,里面所有单词按字母顺序排列。当你查找一个单词时,可以直接翻到相应页码。对于字符串来说,如果我们把所有后缀(从每个位置开始到末尾的子串)按字典序排序,就能快速找到某个子串的位置或比较子串大小。后缀数组就是这样一个结构:它保存了字符串所有后缀排好序后的起始位置列表。
后缀数组(Suffix Array, SA)通常配合排名数组(Rank)和高度数组(LCP)使用,能解决很多字符串问题:子串匹配、最长公共前缀、不同子串个数、重复子串等等。虽然求后缀数组的朴素排序是 O(n² log n),但我们可以用倍增法在 O(n log n) 时间内构造。
1. 什么是后缀?为什么给它排序?
一个字符串 s,长度为 n,从位置 i 开始到末尾的子串叫做“后缀 i”。例如 s = "banana",后缀有:
- 位置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 就是按排序顺序存放起始位置的数组:SA = [5, 3, 1, 0, 4, 2]。
同时,我们还可以得到排名数组 Rank:Rank[i] 表示后缀 i 排第几名。例如 Rank[0]=3(因为 "banana" 排第4,索引从0开始)。
为什么这样做有用?
因为任何子串都是某个后缀的前缀。如果我们想知道子串 "ana" 是否出现,只需找到后缀 "ana" 的位置(即起始位置3),再通过二分查找或 LCP 就能快速判断。这比逐个字符比较快得多。
2. 核心概念:SA、Rank、LCP
- SA(后缀数组):长度为 n 的整数数组,SA[0] 是字典序最小后缀的起始位置,SA[1] 是第二小……依此类推。
- Rank(排名数组):Rank[i] 表示后缀 i 在所有后缀中的排名(0到 n-1)。
- LCP(高度数组):LCP[i] 表示排名为 i 和 i-1 的两个后缀的最长公共前缀长度。例如 LCP[1] 是 "a" 和 "ana" 的公共前缀长度("a" 只有1个字符,公共前缀 "a" 长度1)。LCP 能帮我们快速比较任意两个后缀,并解决重复子串问题。
这三位一体是字符串处理的利器。
3. 倍增法:为什么能快?怎么做到的?
生活类比:班级座位排序
假设班里同学按学号站成一排,现在要按姓氏拼音和名字拼音的字典序重新排队。你手里只有一张按姓氏拼音排好的表,但姓氏相同的同学很多,需要进一步按名字排序。
你可以先按姓氏的第一个字母分组,组内再按名字的第一个字母排;如果还相同,再比姓氏第二个字母和名字第二个字母……这就是“倍增”思想:每次比较的范围扩大一倍。
算法步骤
对于字符串 s(下标0~n-1),我们想比较所有后缀。比较两个后缀 i 和 j,就是从 i 和 j 开始逐字符比较。但直接比较整个字符串太慢。
倍增法的做法:
- 第一次,只比较每个后缀的第一个字符。给每个后缀一个初始排名(比如字符的 ASCII 码)。
- 第二次,比较前 2 个字符。因为我们已经知道每个后缀第一个字符的排名,那么比较前两个字符就相当于比较一个二元组:(第一个字符的排名, 第二个字符的排名)。第二个字符就是后缀 i+1 的第一个字符,所以我们可以用上一轮的排名。
- 第三次,比较前 4 个字符:二元组变为 (前2个字符的排名, 从i+2开始的2个字符的排名)。
- 以此类推,每次比较范围翻倍,直到所有后缀的排名各不相同,或者达到 n。
每次排序我们只需要对二元组排序,可以用基数排序(先按第二关键字排序,再按第一关键字)达到 O(n) 一次。总复杂度 O(n log n)。
核心代码片段(C++)
// 比较两个后缀i和j,先比较rank[i]和rank[j](第一关键字),
// 若相等,再比较rank[i+k]和rank[j+k](第二关键字)
bool cmp(int i, int j) {
if (rank[i] != rank[j]) return rank[i] < rank[j];
int ri = i + k < n ? rank[i + k] : -1; // 如果i+k超出范围,设为-1(比任何排名都小)
int rj = j + k < n ? rank[j + k] : -1;
return ri < rj;
}
注意:当 i+k ≥ n 时,说明后缀 i 的剩余长度不足 k,这时它应该排在所有足长后缀之前(因为空串字典序最小),所以我们把它的第二关键字设为 -1,比任何排名(非负)都小。
4. 完整代码实现(C++ 和 Python)
C++ 版(倍增法,使用 std::sort,清晰易懂)
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <cstring>
using namespace std;
const int MAXN = 200010;
int sa[MAXN]; // 后缀数组
int rank[MAXN]; // 当前排名(每次迭代更新)
int tmp[MAXN]; // 临时排名
int k, n; // k:当前比较长度;n:字符串长度
// 比较函数,用于std::sort
bool cmp(int i, int j) {
if (rank[i] != rank[j]) return rank[i] < rank[j];
int ri = i + k < n ? rank[i + k] : -1; // 如果i+k超出范围,用-1
int rj = j + k < n ? rank[j + k] : -1;
return ri < rj;
}
void buildSA(const string &s) {
n = s.size();
// 初始化:每个后缀的排名就是它的第一个字符的ASCII值
for (int i = 0; i < n; i++) rank[i] = s[i];
// 倍增循环,k表示当前比较长度的半长(实际比较2k个字符?注意下文)
// 初识k=1,比较前1个字符已经完成(rank已初始化)。然后每次k*=2,比较前2k个字符。
// 为方便,代码中k表示当前已经比较的长度,每次循环用k作为偏移。
for (k = 1; k < n; k *= 2) {
// 准备排序:把所有后缀的索引放入sa
for (int i = 0; i < n; i++) sa[i] = i;
// 排序
sort(sa, sa + n, cmp);
// 重新计算排名:将排序结果中相等的后缀赋予相同排名
tmp[sa[0]] = 0;
for (int i = 1; i < n; i++) {
// 如果sa[i-1]和sa[i]比较结果不相等(即cmp返回true),则排名递增
tmp[sa[i]] = tmp[sa[i-1]] + (cmp(sa[i-1], sa[i]) ? 1 : 0);
}
// 将临时排名复制回rank
for (int i = 0; i < n; i++) rank[i] = tmp[i];
// 如果最大排名等于n-1,说明所有后缀都已区分开,提前结束
if (rank[sa[n-1]] == n-1) break;
}
}
int main() {
string s = "banana"; // 测试字符串
buildSA(s);
cout << "后缀数组 (起始索引): ";
for (int i = 0; i < n; i++) cout << sa[i] << " ";
cout << endl;
cout << "排序后的后缀:" << endl;
for (int i = 0; i < n; i++) {
cout << s.substr(sa[i]) << endl;
}
return 0;
}
代码解释:
rank[i]初始保存每个字符的 ASCII 码,作为单字符排名。- 每次循环
k翻倍,排序依据的二元组是(rank[i], rank[i+k]),其中rank[i]是上一轮得到的i开头长度为k的子串排名,rank[i+k]是上一轮得到的i+k开头长度为k的子串排名,合起来就是长度为2k的子串排名。 - 边界:
i+k可能超出 n,此时该后缀较短,我们用 -1 表示空串,确保其排在最前面。 - 排序后,计算
tmp[sa[i]]:如果相邻两个后缀的二元组不同,排名才递增;否则相同。 - 当最大排名等于 n-1 时,所有后缀排名各不相同,可以提前退出。
Python 版(简洁演示思路,复杂度 O(n² log n) 但易理解)
def build_sa(s):
n = len(s)
# 初始排名:每个字符的ASCII码
rank = [ord(c) for c in s]
k = 1
sa = list(range(n)) # sa初始为所有索引
while True:
# 按二元组 (rank[i], rank[i+k]) 排序
sa.sort(key=lambda x: (rank[x], rank[x + k] if x + k < n else -1))
# 计算新排名
new_rank = [0] * n
new_rank[sa[0]] = 0
for i in range(1, n):
prev, cur = sa[i-1], sa[i]
prev_key = (rank[prev], rank[prev + k] if prev + k < n else -1)
cur_key = (rank[cur], rank[cur + k] if cur + k < n else -1)
new_rank[cur] = new_rank[prev] + (1 if prev_key != cur_key else 0)
rank = new_rank
if rank[sa[-1]] == n - 1: # 所有排名都不同
break
k *= 2
return sa
# 测试
s = "banana"
sa = build_sa(s)
print("后缀数组:", sa)
print("排序后的后缀:")
for idx in sa:
print(s[idx:])
说明:Python 的 sort 每次都要创建元组,且字符串切片和比较较重,但用来理解原理非常直观。
5. 新手容易犯的错误
错误一:边界处理不当
在 C++ 的 cmp 函数中,当 i+k >= n 时,要给第二关键字赋一个极小值(如 -1),否则访问越界或产生错误结果。Python 中也要用条件判断。
错误二:混淆 SA 和 Rank 的索引
SA[i]是排名第 i 的后缀的起始下标。Rank[i]是起始下标为 i 的后缀的排名。
两者互为逆关系:Rank[ SA[i] ] = i。
错误三:排序时不处理相同排名
如果两个后缀在目前比较的长度下相等,它们应该获得相同的排名。否则后续比较会错误地认为它们不同,导致排序出错。注意代码中只有 cmp 返回 true 时排名才递增。
错误四:忘记提前退出
当所有后缀排名已经互不相同时,继续倍增没有意义,可以跳出循环。否则可能导致死循环或错误结果。
错误五:计数排序实现细节
竞赛中常使用计数排序(基数排序)代替 std::sort 以获得更优常数。此时要注意计数排序的稳定性,先按第二关键字排序,再按第一关键字排序。
6. 更多例子对比
用字符串 "mississippi" 测试:
s = "mississippi"
后缀数组结果(起始索引):[4, 3, 6, 2, 5, 1, 0, 9, 8, 7, 10]
排序后的后缀:
i (4)
ippi (3)
issippi (6)
ississippi (2)
mississippi (0)
pi (9)
ppi (8)
sippi (7)
sissippi (1)
ssippi (10? 注意n=11,索引10是 'i'? 实际是 "i"? 等等需要验证,这里只是演示)
你可以试着运行代码验证。
7. 相关知识点指引
掌握后缀数组后,可以继续学习:
- 高度数组(LCP):利用 LCP 可以 O(1) 查询任意两个后缀的最长公共前缀,进而处理最长重复子串、不同子串个数等。
- 后缀自动机(SAM):另一种强大的字符串数据结构,功能更丰富,但理论更复杂。
- KMP 与 Z 算法:解决单模式串匹配问题,更简单。
- 字符串哈希:配合二分也能解决不少问题,但不如后缀数组系统。
- AC 自动机:多模式串匹配,常用于敏感词过滤。
初学者建议先用手动模拟理解倍增过程,再在OJ上练习构造,例如洛谷 P3809 【模板】后缀排序。不断调试和测试小数据,很快就能掌握。
后缀数组的信息论之美在于:它把一个连续的字符串切成若干碎片,通过巧妙的排序,让信息变得井井有条。无论是分析DNA序列、处理搜索引擎的关键词,还是检测代码抄袭,它都是不可或缺的工具。
例题精讲
已知字符串S的长度为n,其后缀数组为sa[1..n],其中sa[i]表示排名为i的后缀的起始位置。若S="banana",其sa数组为[6,4,2,1,5,3](下标从1开始),则起始位置为3的后缀的排名是?
关于后缀数组中的height数组,以下说法不正确的是?
使用倍增法构建后缀数组的时间复杂度为O(n log n),其中n为字符串长度。
以下是构建后缀数组的倍增法代码片段(部分),请在空白处填入正确的表达式。
void build_sa(char *s, int n) {
int *sa = new int[n+5];
int *rk = new int[n+5];
int *oldrk = new int[n+5];
int *cnt = new int[n+5];
int m = 128;
for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i-1];
for (int i = n; i >= 1; i--) sa[cnt[rk[i]]--] = i;
for (int k = 1; k < n; k <<= 1) {
int p = 0;
for (int i = n - k + 1; i <= n; i++) oldrk[++p] = i;
for (int i = 1; i <= n; i++) if (sa[i] > k) oldrk[++p] = sa[i] - k;
memset(cnt, 0, sizeof(cnt[0]) * (m+1));
for (int i = 1; i <= n; i++) cnt[rk[oldrk[i]]]++;
for (int i = 1; i <= m; i++) cnt[i] += cnt[i-1];
for (int i = n; i >= 1; i--) sa[___] = oldrk[i]; // 填空
swap(rk, oldrk);
p = 0; rk[sa[1]] = ++p;
for (int i = 2; i <= n; i++) {
if (oldrk[sa[i]] == oldrk[sa[i-1]] && oldrk[sa[i]+k] == oldrk[sa[i-1]+k]) rk[sa[i]] = p;
else rk[sa[i]] = ++p;
}
if (p == n) break;
m = p;
}
}以下是计算height数组的代码片段,请在空白处填入正确的条件表达式。
void get_height(char *s, int n, int *sa, int *rk, int *height) {
for (int i = 1, k = 0; i <= n; i++) {
if (rk[i] == 1) continue;
while (___ && s[i + k] == s[sa[rk[i] - 1] + k]) k++;
height[rk[i]] = k;
if (k > 0) k--;
}
}