后缀数组及lcp学习笔记

本文详细介绍了后缀数组的构建方法,包括倍增法和基数排序,并讲解了LCP(最长公共前缀)的概念及其应用,如计算本质不同的子串个数和求至少出现k次的子串最大长度等。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

模板题

给出一个字符串,输出排名为 i i i 的后缀的编号, i = 1 , 2 , 3 , . . . n i=1,2,3,...n i=1,2,3,...n

一种求法

想当年我字符串题用哈希水遍天下=.=
可以二分 l c p lcp lcp,然后用哈希判断相不相等,套个 s o r t sort sort,就能快速对后缀排序。
复杂度是 O ( n l o g 2 n ) O(nlog^2n) O(nlog2n)

后缀数组

后缀数组指的是两个数组 s a i sa_i sai r k i rk_i rki
r k i rk_i rki 指的是后缀 [ i , n ] [i,n] [i,n] 的排名, s a i sa_i sai 指的是排名为 i i i 的后缀 [ s a i , n ] [sa_i,n] [sai,n]
在这里插入图片描述

性质满足 s a [ r k [ i ] ] = r k [ s a [ i ] ] = i sa[rk[i]]=rk[sa[i]]=i sa[rk[i]]=rk[sa[i]]=i
我们最终要求的就是 s a i sa_i sai 这个序列。

倍增求 s a sa sa

我们求后缀的排名,可以先比较前 1 1 1 位,再比较前 2 2 2 位,再比较前 4 4 4 位,再比较前 8 8 8 位…
也就是每次求出 [ i , i + 2 ∗ w − 1 ] [i,i+2*w-1] [i,i+2w1] 的排名, w = 1 , 2 , 4 , 8... w=1,2,4,8... w=1,2,4,8...
[ i , i + 2 ∗ w − 1 ] [i,i+2*w-1] [i,i+2w1] 的排名,我们可以将它分为 [ i , i + w − 1 ] [i,i+w-1] [i,i+w1] [ i + w , i + 2 ∗ w − 1 ] [i+w,i+2*w-1] [i+w,i+2w1] 两部分。这两部分的排名都已经在上一次循环中求出来了,我们要得到新的排名,其实是让这两部分作为双关键字进行排序。 [ i , i + w − 1 ] [i,i+w-1] [i,i+w1] 的排名为第一关键字, [ i + w , i + 2 ∗ w − 1 ] [i+w,i+2*w-1] [i+w,i+2w1] 的排名为第二关键字(如果没有第二关键字认为第二关键字为无穷小或 0 0 0),来对所有的 i i i 排序。
这个过程可以用一张图片来展现。
在这里插入图片描述

于是我们就可以用个 s o r t sort sort O ( n l o g 2 n ) O(nlog^2n) O(nlog2n) 内进行后缀排序了。

基数排序

s o r t sort sort 的复杂度和哈希一样,还不如用哈希呢!
注意到这是双关键字,我们可以采用基数排序,先对第二关键字进行排序,再对第一关键字进行排序,这样排序的复杂度是 O ( n ) O(n) O(n) 的。
总复杂度是 O ( n l o g n ) O(nlogn) O(nlogn) 的。

下面着重讲讲实现细节

x i = r k i x_i=rk_i xi=rki(这里可以看作第一关键字), y i y_i yi 表示第二关键字排名为 i i i 的后缀编号。
核心思想:我们对每一个第一关键字开一个桶,对桶做一遍前缀和,然后按第二关键字的排名倒着将该数对的编号放入第一关键字的桶中,就完成了排序。
代码长这样:

for(i = 1; i <= m; i++) c[i] = 0;//清空桶
for(i = 1; i <= n; i++) c[x[i]]++;//记录每个第一关键字的桶的大小
for(i = 1; i <= m; i++) c[i] += c[i - 1];//对桶做一遍前缀和
for(i = n; i >= 1; i--) sa[c[x[y[i]]]--] = y[i];//按第二关键字的排名倒着将该数对的编号放入第一关键字的桶中

下面举一个栗子。(栗子是盗来的>_<)

假设有这么一些数对,我们要对它按双关键字进行基数排序。

其中第一关键字为: 1   3   2   1   4   3   1   2 1\ 3\ 2\ 1\ 4\ 3\ 1\ 2 1 3 2 1 4 3 1 2

对应第二关键字为: 3   2   1   2   3   3   1   3 3\ 2\ 1\ 2\ 3\ 3\ 1\ 3 3 2 1 2 3 3 1 3

所以对应 y y y 数组为: 3   7   2   4   1   5   6   8 3\ 7\ 2\ 4\ 1\ 5\ 6\ 8 3 7 2 4 1 5 6 8

第一步

清空桶。

第二步

记录桶的大小。
桶的编号: 1   2   3   4 1\ 2\ 3\ 4 1 2 3 4
桶的大小: 3   2   2   1 3\ 2\ 2\ 1 3 2 2 1

第三步

对桶做前缀和。
桶的编号: 1   2   3   4 1\ 2\ 3\ 4 1 2 3 4
桶变成了: 3   5   7   8 3\ 5\ 7\ 8 3 5 7 8
这时候有个性质。
1 1 1 对应的编号为 1 − 3 1-3 13
2 2 2 对应的编号为 4 − 5 4-5 45
3 3 3 对应的编号为 6 − 7 6-7 67
4 4 4 对应的编号为 8 − 8 8-8 88

第四步

按第二关键字的排名倒着将该数对的编号放入第一关键字的桶中。
先看第二关键字排名第 8 8 8 大的编号,是 8 8 8,对应的数对是 ( 2 , 3 ) (2,3) (2,3),第一关键字是 2 2 2,于是我们要把这个数对放到 2 2 2 的桶中,就放到 5 5 5 那里吧(因为它一定是所有以 2 2 2 为第一关键字的数对中最小的)。
然后再看排名第 7 7 7 大的编号,是 6 6 6,对应的数对是 ( 3 , 3 ) (3,3) (3,3),第一关键字是 3 3 3,所以放进 3 3 3 的桶中,放到 7 7 7 那里。
以此类推。
最后的 s a sa sa 就是: 7   4   1   3   8   2   6   5 7\ 4\ 1\ 3\ 8\ 2\ 6\ 5 7 4 1 3 8 2 6 5

更新 r k rk rk

得到了新的 s a sa sa,我们还要求新的 r k rk rk
就是按着 s a sa sa 来更新 r k rk rk,如果 s a i sa_i sai s a i − 1 sa_{i-1} sai1 双关键字都相同,那么 r k [ s a [ i ] ] = r k [ s a [ i − 1 ] ] rk[sa[i]]=rk[sa[i-1]] rk[sa[i]]=rk[sa[i1]]。否则, r k [ s a [ i ] ] = r k [ s a [ i − 1 ] ] + 1 rk[sa[i]]=rk[sa[i-1]]+1 rk[sa[i]]=rk[sa[i1]]+1

总代码如下

#include <bits/stdc++.h>
#include<ext/pb_ds/hash_policy.hpp>
#include<ext/pb_ds/assoc_container.hpp>
using namespace __gnu_pbds;
using namespace std;
typedef long long LL;
typedef unsigned long long uLL;
struct custom_hash {
	   static uint64_t splitmix64(uint64_t x) {
	   	x += 0x9e3779b97f4a7c15;
		x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
		x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
		return x ^ (x >> 31);
	}
	size_t operator()(uint64_t x) const {
		static const uint64_t FIXED_RANDOM = chrono::steady_clock::now().time_since_epoch().count();
		return splitmix64(x + FIXED_RANDOM);
	}
};
LL z = 1;
int read(){
	int x, f = 1;
	char ch;
	while(ch = getchar(), ch < '0' || ch > '9') if(ch == '-') f = -1;
	x = ch - '0';
	while(ch = getchar(), ch >= '0' && ch <= '9') x = x * 10 + ch - 48;
	return x * f;
}
int ksm(int a, int b, int p){
	int s = 1;
	while(b){
		if(b & 1) s = z * s * a % p;
		a = z * a * a % p;
		b >>= 1;
	}
	return s;
}

const int N = 2e6 + 5;
int sa[N], x[N], y[N], c[N], num;
char s[N];

int get(int x){
	if(x >= '0' && x <= '9') return x - '0' + 1;
	if(x >= 'A' && x <= 'Z') return x - 'A' + 11;
	if(x >= 'a' && x <= 'z') return x - 'a' + 37;
}

int main(){
	int i, j, w, n, m;
	scanf("%s", s + 1);
	n = strlen(s + 1);
	m = 62;//m 是字符集大小
	for(i = 1; i <= n; i++) c[x[i] = get(s[i])]++;
	for(i = 1; i <= m; i++) c[i] += c[i - 1];
	for(i = n; i >= 1; i--) sa[c[x[i]]--] = i;
	
	for(w = 1; w <= n; w <<= 1){
		num = 0;
		for(i = n - w + 1; i <= n; i++) y[++num] = i;//这部分的第二关键字为无穷小
		for(i = 1; i <= n; i++) if(sa[i] > w) y[++num] = sa[i] - w;
	//如果 sa[i] 可以作为第二关键字,那么把编号 sa[i]-w 压入 y 中
		for(i = 1; i <= m; i++) c[i] = 0;
		for(i = 1; i <= n; i++) c[x[i]]++;
		for(i = 1; i <= m; i++) c[i] += c[i - 1];
		for(i = n; i >= 1; i--) sa[c[x[y[i]]]--] = y[i];
		
		swap(x, y);//由于要更新 x, 又要用到旧的排名,而且 y 已经没用了,就先用 y 来存 x
		
		int p = 0;
		for(i = 1; i <= n; i++){
			if(y[sa[i]] == y[sa[i - 1]] && y[sa[i] + w] == y[sa[i - 1] + w]) x[sa[i]] = p;
			else x[sa[i]] = ++p;
		}
		if(p == n) break;//如果排好序了就 break
		m = p;//一定要更新字符集大小
	}
	
	for(i = 1; i <= n; i++) printf("%d ", sa[i]);
	return 0;
}

lcp(longgest common prefix)——最长公共前缀

l c p ( i , j ) lcp(i,j) lcp(i,j) 表示 s a i sa_i sai s a j sa_j saj 的最长公共前缀(这个有点反人类),也就是第 i i i 小和第 j j j 小的 l c p lcp lcp
有两个重要结论:

  1. l c p ( i , j ) = m i n ( l c p ( i , k ) , l c p ( k , j ) )   1 ≤ i ≤ k ≤ j ≤ n lcp(i,j) = min(lcp(i,k),lcp(k,j))\ 1\leq i\leq k\leq j\leq n lcp(i,j)=min(lcp(i,k),lcp(k,j)) 1ikjn
  2. l c p ( i , j ) = m i n ( l c p ( k , k − 1 ) )   1 ≤ i < k ≤ j ≤ n lcp(i,j)=min(lcp(k,k-1))\ 1\leq i< k\leq j\leq n lcp(i,j)=min(lcp(k,k1)) 1i<kjn

证明的话因为我太弱了就略了=.=
h e i g h t i = l c p ( i , i − 1 ) height_i=lcp(i,i-1) heighti=lcp(i,i1) h e i g h t 1 = 0 height_1=0 height1=0
如果我们能求出 h e i g h t height height 数组,那么要求两个后缀的 l c p lcp lcp,我们可以用 R M Q RMQ RMQ O ( 1 ) O(1) O(1) 求得。
下面又有一个重要结论(我还是不会证):
h e i g h t [ r k [ i ] ] ≥ h e i g h t [ r k [ i − 1 ] ] − 1 height[rk[i]]\ge height[rk[i-1]]-1 height[rk[i]]height[rk[i1]]1
利用这个,我们可以 O ( n ) O(n) O(n) 来求得 h e i g h t height height 数组。
代码长这样:

void geth(){
	int i, j, k = 0;
	for(i = 1; i <= n; i++) x[sa[i]] = i; //求得 rk 数组
	for(i = 1; i <= n; i++){
		if(x[i] == 1) continue; //rk[i] = 1时,ht[i] = 0
		if(k) k--;
		j = sa[x[i] - 1];
		while(i + k <= n && j + k <= n && s[i + k] == s[j + k]) k++;
		ht[x[i]] = k;
	}
}

lcp 的应用

  1. 求本质不同的子串个数
    因为子串一定是后缀的前缀,我们用所有的子串个数,减掉重复的前缀即可。所有子串个数为 n ( n + 1 ) 2 \dfrac{n(n+1)}{2} 2n(n+1)。重复的前缀个数为 ∑ i = 1 n h e i g h t i \sum\limits_{i=1}^{n}height_i i=1nheighti
    其实就是按从小到大枚举后缀,减去这个后缀和前面一个重复的前缀。
    这样子做是对的是因为 l c p ( i , i − 1 ) lcp(i,i-1) lcp(i,i1) 一定是 l c p ( j , i )   ( 1 ≤ j < i ) lcp(j,i)\ (1\leq j<i) lcp(j,i) (1j<i) 里面最大的。
  2. 求至少出现 k k k 次的子串最大长度
    最后要求的子串一定是 k k k 个后缀的 l c p lcp lcp。然后贪心地想,要让这个 l c p lcp lcp 最大,这 k k k 个后缀一定是连续的(指字典序)。然后我们现在就是求 [ i , i + k − 1 ] [i,i+k-1] [i,i+k1] 的所有后缀的 l c p lcp lcp,这个其实就是 l c p ( i , i + k − 1 ) lcp(i,i+k-1) lcp(i,i+k1),又等价于 m i n ( h e i g h t j )   i < j ≤ i + k − 1 min(height_j)\ i<j\leq i+k-1 min(heightj) i<ji+k1。用个单调队列就可以了。
  3. 是否有子串不重叠地出现了至少两次
    二分子串长度 ∣ s ∣ |s| s,然后对每个 i i i,求出最大的 j j j,使得 l c p ( i , j ) ≥ s lcp(i,j)\ge s lcp(i,j)s。然后用 R M Q RMQ RMQ 查询 [ i , j ] [i,j] [i,j] 的最大下标和最小下标,判断一下即可。
    总复杂度还是 O ( n l o g n ) O(nlogn) O(nlogn) 的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值