后缀数组
给定一个长度为 n 的字符串,只包含大小写英文字母和数字。
将字符串中的 n 个字符的位置编号按顺序设为 1∼n。
并将该字符串的 n 个非空后缀用其起始字符在字符串中的位置编号表示。
现在要对这 n 个非空后缀进行字典序排序,并给定两个数组 SA 和 Height。
排序完成后,用 SAi 来记录排名为 i 的非空后缀的编号,用 Heighti 来记录排名为 i 的非空后缀与排名为 i−1 的非空后缀的最长公共前缀的长度(1≤i≤n)。
特别的,规定 Height1=0。
请你求出这两个数组。
输入格式
共一行,包含一个长度为 n 的仅包含大小写英文字母或数字的字符串。
输出格式
第一行包含 n 个整数,表示 SA 数组。
第二行包含 n 个整数,表示 Height 数组。
数据范围
1≤n≤106
输入样例:
abababab
输出样例:
7 5 3 1 8 6 4 2
0 2 4 6 0 1 3 5
代码实现了后缀数组(Suffix Array) 和 Height 数组(LCP) 的构造,是字符串处理中的经典算法。简单说,它的目的是:
-
把字符串的所有后缀按字典序排序,用
SA[i]记录排名第 i 的后缀的起始位置。 -
计算相邻排名的后缀的最长公共前缀长度 ,存到
Height[i]中。
下面分步讲解代码的思路。
一、全局数组的含义
cpp
char s[N]; // 字符串,从 s[1] 开始存
int sa[N]; // 后缀数组,排名 i → 后缀起始位置
int x[N], y[N]; // x: 当前每个后缀的第一关键字排名
// y: 按第二关键字排序后的后缀列表
int c[N]; // 计数排序用的桶
int rk[N]; // 排名数组,位置 i → 排名(是 sa 的逆)
int height[N]; // height[i] = LCP(sa[i], sa[i-1])
二、get_sa() ------ 倍增法求后缀数组
后缀数组的核心是把所有后缀排序。直接排序 O(n² log n) 太慢,这里用的是倍增 + 基数排序,时间复杂度 O(n log n)。
1. 初始化:按第一个字符排序
for (int i = 1; i <= n; i ++ ) c[x[i] = s[i]] ++ ;
for (int i = 2; i <= m; i ++ ) c[i] += c[i - 1];
for (int i = n; i; i -- ) sa[c[x[i]] -- ] = i;
-
x[i]暂时存后缀 i 的首字符 ASCII 码,当作初始排名(第一关键字)。 -
c[]是计数数组,m是字符集大小(初始 122,包含大小写字母和数字)。 -
最后按
x做一次计数排序,得到只考虑第一个字符时的sa数组(排名→位置)。
我们以字符串 "abababab" 为例,长度 n = 8。
先看初始状态(在倍增开始前,我们已经按第一个字符排好序了):
text
s = a b a b a b a b
位置:1 2 3 4 5 6 7 8
按照 get_sa() 开头的初始化,只按首字符排序后的 sa 数组为:
text
sa[1] = 1 (后缀 "abababab" 首字符 'a')
sa[2] = 3 ("ababab")
sa[3] = 5 ("abab")
sa[4] = 7 ("ab")
sa[5] = 2 ("bababab" 首字符 'b')
sa[6] = 4 ("babab")
sa[7] = 6 ("bab")
sa[8] = 8 ("b")
x 数组存储的是当前每个后缀的排名 (即前 k 个字符的排名),此时 k 还没开始倍增,相当于当前只排好了前 1 个字符,x 就是首字符的排名:
text
位置 i: 1 2 3 4 5 6 7 8
x[i]: 1 2 1 2 1 2 1 2 ('a'的排名为1,'b'的排名为2)
m 初始为 122,经过第一轮后 m = 2(实际只有两种不同排名)。
现在进入倍增循环,第一轮 k = 1
目标是:把排序长度从 1 扩展到 2。也就是每个后缀,我们要看它的前 2 个字符。
一个后缀的前 2 个字符 = 前 1 个字符(第一关键字) + 后 1 个字符(第二关键字)。
(1) 按第二关键字排序,结果放入 y
第二关键字对后缀 i 来说,就是它开头跳过第一个字符后的那个字符,即 s[i+1]。
但是我们并不直接比较字符,而是利用已经排好的顺序 :后缀 i 的后 k 个字符,实际上就是后缀 i+k 的前 k 个字符,而后缀 i+k 的排名我们已经知道(存在 x 里,且 sa 就是按 x 排序的结果)。
所以按第二关键字排序 = 把所有后缀按照它们的 i+k 的排名来排序。
具体做法:
- 有一些后缀长度不足
k,它们没有第二关键字,视为最小值,放在最前面。这些后缀的起始位置是n - k + 1到n。当k=1时,8 - 1 + 1 = 8,只有位置8的这个后缀"b"没有第二关键字,所以先把它放进y:
text
y[1] = 8
- 然后,我们遍历
sa数组(当前已经按前k个字符排好序的后缀列表)。对于每个sa[i],如果sa[i] > k(即sa[i] > 1),那么以sa[i] - k开始的后缀,它的第二关键字恰好就是后缀sa[i]的前k个字符。因为sa已经是按前k个字符排好序的,所以按sa的顺序把sa[i] - k放进y,就相当于按第二关键字排序了。
遍历 sa:
-
sa[1] = 1,1 > 1? 否,跳过。 -
sa[2] = 3,3 > 1,将3 - 1 = 2放入y→y[2] = 2 -
sa[3] = 5,5 > 1,放入5 - 1 = 4→y[3] = 4 -
sa[4] = 7,放入7 - 1 = 6→y[4] = 6 -
sa[5] = 2,放入2 - 1 = 1→y[5] = 1 -
sa[6] = 4,放入4 - 1 = 3→y[6] = 3 -
sa[7] = 6,放入6 - 1 = 5→y[7] = 5 -
sa[8] = 8,放入8 - 1 = 7→y[8] = 7
于是 y 数组现在是:
text
y = [8, 2, 4, 6, 1, 3, 5, 7]
这个顺序表示:第二关键字最小的后缀是位置 8(没有第二关键字),其次是位置 2(它的第二关键字是位置 3 的 'a',等等)。注意这里其实已经按第二关键字排好序了。
(2) 按第一关键字计数排序,更新 sa
现在我们要对后缀进行双关键字排序:先按第一关键字(前 k 个字符的排名 x[i])排序,第一关键字相同的,再按第二关键字(已经反映在 y 的顺序里)排序。
因为第二关键字已经通过 y 排好了,我们只需要对第一关键字做一次稳定的计数排序,就能得到最终顺序。
代码:
cpp
for (int i = 1; i <= m; i++) c[i] = 0;
for (int i = 1; i <= n; i++) c[x[i]]++; // 统计第一关键字每个排名的次数
for (int i = 2; i <= m; i++) c[i] += c[i-1];
for (int i = n; i; i--) sa[c[x[y[i]]]--] = y[i], y[i] = 0;
先看后半句的 y[i] = 0,它就是清空数组,为下一轮做准备,不影响排序逻辑。
核心是前半句:sa[c[x[y[i]]]--] = y[i],它在做"稳定的基数排序"。
最后一行是关键:我们从 y 的后面往前 遍历(保证稳定性:相同第一关键字时,第二关键字靠后的会放在更后面)。
x[y[i]] 就是后缀 y[i] 的第一关键字排名。用它去找在 sa 中的位置。
我们手动模拟一下 k=1 时这一步:
当前的 x(首字符排名):1:a, 2:b, 1:a, 2:b, 1:a, 2:b, 1:a, 2:b
y 顺序:[8(b), 2(b), 4(b), 6(b), 1(a), 3(a), 5(a), 7(a)](括号里是它们的首字符)
计数 c 后,c[1] 代表第一关键字为 1 的后缀个数=4,c[2]=4。
从 i = n 到 1:
-
i=8, y[8]=7(第一关键字=1, 'a') → 放在sa[c[1]],即sa[4] = 7,c[1]减为 3 -
i=7, y[7]=5(a) →sa[3] = 5, c1=2 -
i=6, y[6]=3(a) →sa[2] = 3, c1=1 -
i=5, y[5]=1(a) →sa[1] = 1, c1=0 -
i=4, y[4]=6(b) →sa[8] = 6, c2=3 -
i=3, y[3]=4(b) →sa[7] = 4, c2=2 -
i=2, y[2]=2(b) →sa[6] = 2, c2=1 -
i=1, y[1]=8(b) →sa[5] = 8, c2=0
最后 sa 变为:
text
sa[1]=1 ("abababab")
sa[2]=3 ("ababab")
sa[3]=5 ("abab")
sa[4]=7 ("ab")
sa[5]=8 ("b")
sa[6]=2 ("bababab")
sa[7]=4 ("babab")
sa[8]=6 ("bab")
注意,现在 sa 是按前 2 个字符排好序的。验证:前4个后缀都以 "a" 开头,但比较第二个字符:位置1、3、5、7的第二字符分别是 'b','b','b','b',它们前2字符都是 "ab",相同,所以它们在 sa 中的相对顺序由第二关键字决定(这里相等,实际上稳定保留了原来按第一关键字时的顺序)。位置8后缀 "b" 的第二字符不存在,最小,所以它在所有 'b' 开头的后缀中排第一。位置2、4、6 的第二字符分别是 'a','a','a',所以它们排在 "b" 后面。
同时,循环中的 y[i] = 0 只是清空 y 数组方便下次使用。
(3) 更新 x(新的排名),为下一轮 k=2 准备
现在我们已经知道每个后缀的前 2k 个字符的排序 ,我们需要给每个后缀分配新的排名 ,替换旧的 x,以便下一轮排序使用(下一轮将用这个新排名作为第一关键字去排前 4 个字符)。
代码:
swap(x, y); // 现在 y 是旧排名,x 准备存新排名
x[sa[1]] = 1, num = 1;
for (int i = 2; i <= n; i++)
x[sa[i]] = (y[sa[i]] == y[sa[i-1]] && y[sa[i]+k] == y[sa[i-1]+k]) ? num : ++num;
比较相邻两个后缀 sa[i] 和 sa[i-1],如果它们的旧第一关键字 (y[sa[i]] 和 y[sa[i-1]])相等,且旧第二关键字 (y[sa[i]+k] 和 y[sa[i-1]+k])也相等,说明它们的前 2k 个字符完全一样,新排名相同;否则新排名加 1。
这里的 y[sa[i]+k] 为什么代表旧第二关键字?因为上一轮我们排序时的第二关键字,就是每个后缀 i 的第 k+1 到 2k 个字符,这恰好对应后缀 i+k 的前 k 个字符,也就是后缀 i+k 的旧第一关键字排名。所以直接取 y[sa[i]+k] 即可。
对于 k=1,y 是旧的 x(首字符排名)。比较:
-
sa[1]=1, sa[2]=3:y[1]=1, y[3]=1(都是 'a'),y[1+1]=y[2]=2,y[3+1]=y[4]=2(第二字符排名都是2,即 'b'),相等,所以x[1]=x[3]=1,num=1。 -
同理
sa[3]=5,sa[4]=7,新旧关键字都相等,x[5]=x[7]=1。 -
sa[5]=8:与sa[4]=7比较:y[8]=2≠y[7]=1(首字符不同),所以x[8]=++num=2。 -
sa[6]=2:与sa[5]=8比较:y[2]=2 == y[8]=2,但y[2+1]=y[3]=1,y[8+1]越界?注意8+1=9 > n,此时y[9]是未定义的值(全局数组初始为0),而y[3]=1,不相等,所以x[2]=++num=3。 -
sa[7]=4:y[4]=2 == y[2]=2,y[5]=1与y[3]=1相等?y[4+1]=y[5]=1,y[2+1]=y[3]=1,相等,所以x[4]=3。 -
sa[8]=6:y[6]=2 == y[4]=2,y[7]=1 == y[5]=1,相等,x[6]=3。
最终新 x 为:
text
位置: 1 2 3 4 5 6 7 8
x: 1 3 1 3 1 3 1 2
num = 3,即有三个不同的排名(对应 "ab","b","ba" 三类前2字符组合)。
因为 num < n,不触发 break。
m = num = 3。
至此第一轮倍增结束。下一轮 k=2,将用新 x(代表前 2 字符排名)作为第一关键字,去排前 4 个字符。过程完全重复 (1)(2)(3),直到 num == n 即所有后缀都有不同排名为止。
get_height() 是在快速计算相邻排名的后缀的公共前缀长度。
要是直接比,每对后缀都要从头扫描,复杂度 O(n²),太慢了。
这里用了一个巧妙的性质来省掉重复的比较。
1. 要算的东西是什么?
-
rk[i]:后缀i的排名(谁是第几名) -
height[i]:排名第i的后缀,和排名第i-1的后缀,前几个字符相同
例如字符串 "abababab" 排好后:
text
排名 1:后缀 7 → "ab"
排名 2:后缀 5 → "abab"
排名 3:后缀 3 → "ababab"
排名 4:后缀 1 → "abababab"
...
height[4] 就是 "abababab" 和 "ababab" 的公共前缀长度,是 6。
2. 核心性质(怎么省事)
关键发现 :如果后缀 i-1 和它前一名有长度为 h 的公共前缀,那后缀 i 至少会有 h-1 的公共前缀(和后缀 i 的前一名比)。
为什么?
后缀 i 就是后缀 i-1 切掉第一个字符 。
后缀 i-1 的前一名是某个 p,它们开头 h 个字符一样。
那把 i-1 和 p 都切掉第一个字符 ,得到的就是后缀 i 和 p+1,它们至少前 h-1 个字符相同。
所以后缀 i 和它的前一名(即使不是 p+1)的公共前缀,不会少于 h-1。
用公式写就是:
如果 height[rk[i-1]] = h,那么 height[rk[i]] ≥ h-1。
3. 代码怎么利用这个性质
cpp
for (int i = 1, k = 0; i <= n; i++) {
if (rk[i] == 1) continue; // 排名第1的后缀没有前一名,height值为0,直接跳过
if (k) k--; // 利用核心性质:从上一次匹配长度k-1处开始比较
int j = sa[ rk[i] - 1 ]; // 获取排名前一位的后缀起始位置
while (i + k <= n && j + k <= n && s[i + k] == s[j + k]) k++;
height[rk[i]] = k;
}
我们把所有后缀按位置顺序 i=1,2,...,n 依次处理 (而不是按排名顺序)。
维护一个变量 k,表示当前正在算的那个公共前缀长度。
-
上一次 循环处理的是后缀
i-1,结束时k = height[rk[i-1]](后缀i-1的公共前缀长度)。 -
这次 循环处理后缀
i,根据性质,height[rk[i]]至少是k-1。所以先
if (k) k--,从k-1开始比较。 -
然后往后看还能不能再多匹配几个字符,用
while暴力扩展。 -
最后把新的
k存入height[rk[i]],这个k也正好留给下一轮(i+1)使用。
总工作量 :
k 每次最多只减少 1,增加只发生在 while 里,且 k 最大不超过 n。
所以 while 总共执行次数不会超过 2n,整个循环 O(n)。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
public class Main {
static int N=1000010;
static int n,m;
static int sa[]=new int[N];
static int rk[]=new int[N];
static int height[]=new int[N];
static int c[]=new int[N];
static int x[]=new int[N];
static int y[]=new int[N];
public static void main(String[] args) throws IOException {
BufferedReader br=new BufferedReader(new InputStreamReader(System.in));
BufferedWriter bw=new BufferedWriter(new OutputStreamWriter(System.out));
// StringTokenizer st=new StringTokenizer(br.readLine());
// int n=Integer.parseInt(br.readLine());
// int m=Integer.parseInt(st.nextToken());
char s[]=(" "+br.readLine()).toCharArray();
n=s.length-1;
get_Sa(s);
get_Height(s);
for (int i = 1; i <= n; i++) {
bw.write(sa[i]+" ");
}
bw.write("\n");
for (int i = 1; i <= n; i++) {
bw.write(height[i]+" ");
}
br.close();
bw.flush();
bw.close();
}
static void get_Sa(char s[]){
//每个后缀按第一个关键字来排序 也就是单个字符
for (int i = 1; i <= n; i++) {
c[x[i]=s[i]]++;
}
m=122;//'z'的ASCII
for (int i = 1; i <= m; i++) {
c[i]+=c[i-1];
}
for (int i = n; i > 0; i--) {//倒序 稳定
sa[c[x[i]]--]=i;
}
for (int k = 1; k <= n; k<<=1) {//倍增思想
int num=0;
for (int i = n-k+1; i <= n; i++) {//没有第二关键字的一定是最小的 因为相当于是空格
y[++num]=i;
}
for (int i = 1; i <= n; i++) {//按第二关键字来进行排序
if(sa[i]>k)y[++num]=sa[i]-k;
// 后缀 i 的后 k 个字符,实际上就是后缀 i+k 的前 k 个字符,
// 而后缀 i+k 的排名我们已经知道(存在 x 里,且 sa 就是按 x 排序的结果)。
}
//按第二关键字排序已经存储在了y中 只需要按第一关键词进行一个稳定排序
for (int i = 0; i <= m; i++) {
c[i]=0;
}
for (int i = 1; i <= n; i++) {
c[x[i]]++;
}
for (int i = 2; i <= m; i++) {
c[i] += c[i - 1];
}
for (int i = n; i > 0; i--) {//更新sa
sa[c[x[y[i]]]--]=y[i];
y[i]=0;
}
//为下一轮次做准备
int tmp[]=y;
y=x;
x=tmp;
x[sa[1]]=1;
num=1;
for (int i = 2; i <= n; i++) {
int pre=sa[i-1],cur=sa[i];
if(y[pre]==y[cur] && y[pre+k]==y[cur+k]){
x[cur]=num;
}else{
x[cur]=++num;
}
}
m=num;
if(num==n)break;
}
}
static void get_Height(char s[]){
for (int i = 1; i <= n; i++) {
rk[sa[i]]=i;
}
for (int i = 1,k=0; i <= n; i++) {
if(rk[i]==1)continue;
if(k>0)k--;//利用性质
int j=sa[rk[i]-1];
while(i+k<=n && j+k<=n && s[i+k]==s[j+k])k++;
height[rk[i]]=k;
}
}
}