数据结构-后缀数组

后缀数组

给定一个长度为 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) 的构造,是字符串处理中的经典算法。简单说,它的目的是:

  1. 把字符串的所有后缀按字典序排序,用 SA[i] 记录排名第 i 的后缀的起始位置。

  2. 计算相邻排名的后缀的最长公共前缀长度 ,存到 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 + 1n。当 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] = 11 > 1? 否,跳过。

  • sa[2] = 33 > 1,将 3 - 1 = 2 放入 yy[2] = 2

  • sa[3] = 55 > 1,放入 5 - 1 = 4y[3] = 4

  • sa[4] = 7,放入 7 - 1 = 6y[4] = 6

  • sa[5] = 2,放入 2 - 1 = 1y[5] = 1

  • sa[6] = 4,放入 4 - 1 = 3y[6] = 3

  • sa[7] = 6,放入 6 - 1 = 5y[7] = 5

  • sa[8] = 8,放入 8 - 1 = 7y[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] = 7c[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+12k 个字符,这恰好对应后缀 i+k 的前 k 个字符,也就是后缀 i+k 的旧第一关键字排名。所以直接取 y[sa[i]+k] 即可。

对于 k=1y 是旧的 x(首字符排名)。比较:

  • sa[1]=1, sa[2]=3y[1]=1, y[3]=1(都是 'a'),y[1+1]=y[2]=2, y[3+1]=y[4]=2(第二字符排名都是2,即 'b'),相等,所以 x[1]=x[3]=1num=1

  • 同理 sa[3]=5, sa[4]=7,新旧关键字都相等,x[5]=x[7]=1

  • sa[5]=8:与 sa[4]=7 比较:y[8]=2y[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]=4y[4]=2 == y[2]=2, y[5]=1y[3]=1 相等? y[4+1]=y[5]=1, y[2+1]=y[3]=1,相等,所以 x[4]=3

  • sa[8]=6y[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-1p 都切掉第一个字符 ,得到的就是后缀 ip+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;
	   }
   }
}
相关推荐
鹿角片ljp6 小时前
LeetCode 46. 全排列|吃透回溯
算法·leetcode·职场和发展
鼎艺创新科技6 小时前
不依赖 UE/Unity:我们如何从零搭建一套国产三维 GIS 渲染引擎
人工智能·算法·unity·游戏引擎·三维电子沙盘
石头猫灯7 小时前
WordPress wp2shell 漏洞链完整拆解流程
网络·数据结构·安全·web安全
.道阻且长.8 小时前
11.LeetCode算法习题讲解--滑动窗口--将x减到0的最小操作数
算法·leetcode·职场和发展
wenyq79 小时前
LeetCode 2460. Apply Operations to an Array
算法·leetcode
.格子衫.10 小时前
032动态规划之区间DP——算法备赛
算法·动态规划
小欣加油10 小时前
leetcode3069 将元素分配到两个数组中I
数据结构·c++·算法·leetcode·职场和发展
不会代码的小猴10 小时前
7. JSON
开发语言·c++·笔记·qt·算法·json
怪奇云呼军11 小时前
知识库也会注入指令?闪电智能VoiceAgent 如何防住 Prompt Injection
人工智能·python·算法·云计算·音视频
阿里云大数据AI技术11 小时前
基于 EMR Serverless Ray 实现 Qwen 模型批量推理实践
人工智能·算法·agent