后缀数组常见应用

后缀数组的应用

摘要

后缀数组是处理字符串相关问题的有力工具,后缀数组的题型与解法相对固定,因此对于本文中的几种题型要掌握解法与原理。本文假设读者已经掌握求后缀数组(sa)以及高度数组(lcp)的算法。
本文将介绍4类后缀数组的应用,分别是:

  • 最长公共前缀
  • 单个字符串相关问题
  • 两个字符串相关问题
  • 多个字符串相关问题

相关符号说明

S 是原字符串。
suffix( i )表示字符串 S 从位置 i 开始的后缀。
sa( i )表示排名为第 i 的后缀的起始位置。
rk( i )表示 起始位置为 i 的后缀的排名。
lcp( i )表示排名为 i 的后缀与排名为 i+1 的后缀的最长公共前缀的长度。

最长公共前缀

例1: 给定一个字符串,询问某两个后缀的最长公共前缀。
解题思路:
我们已经知道lcp[ i ]是排名为 i 的后缀与排名为 i+1 的后缀的最长公共前缀的长度,而我们又可以通过sa数组来得到任意俩个后缀的排名,因此对于给定的两个后缀,我们可以先求出它们的排名,分别设为 i 和 j (i <= j),那么ans = min{ lcp[i] , lcp[i+1] , … , lcp[j - 1] },也就相当于求RMQ问题,所以接下来的询问可以当做RMQ问题来采用合适的算法(例如st表)。

单个字符串相关问题

这类问题的一个常用做法是先求后缀数组和lcp数组,然后利用lcp数组求解。

重复子串问题

重复子串: 字符串R在字符串L中至少出现两次,则称 R 是 L 的重复子串。

例2:可重叠最长重复子串
给定一个字符串,求最长重复子串,这两个子串可以重叠。
解题思路:
求最长重复子串,等价于求两个后缀的最长公共前缀的最大值。于是我们只需要求出lcp数组中的最大值即可。由于任意两个后缀的最长公共前缀一定是lcp数组中某一段的最小值,那么这个值一定不大于lcp数组里的最大值。所以最长重复子串的长度就是lcp数组里的最大值。该做法时间复杂度为O(N)。

例3:不可重叠最长重复子串
给定一个字符串,求最长重复子串,这两个子串不能重叠。
Musical Theme
测试地址
题意简述
给n个数组成的串,求是否有多个“相似”且不重叠的子串的长度大于等于5,两个子串相似当且仅当长度相等且每一位的数字差都相等。

解题思路
本题题意有些难理解。就是利用后缀数组+高度数组解决最长不重复子串问题,解法很套路,就是二分+判断。

代码示例

//POJ1743
#include<cmath>
#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 2e4+10;
int n,a[N],b[N];
int getInt() {
	int ans = 0;
	bool neg = false;
	char c = getchar();
	while (c!='-' && (c<'0' || c>'9')) c = getchar();
	if (c == '-') neg = true, c = getchar();
	while (c>='0' && c<='9')
		ans = ans*10 + c-'0', c = getchar();
	return neg ? -ans : ans; 
}
int sa[N],rk[N],lcp[N],tmp[N];
int len,k;
bool compare_sa(int i,int j){
	if(rk[i] != rk[j]) return rk[i] < rk[j];
	int ri = i+k <= len?rk[i+k]:-1;
	int rj = j+k <= len?rk[j+k]:-1;
	return ri < rj;
}
void construct_sa(int S[],int sa[]){
	len = n;
	for(int i = 0;i <= len;i++){
		sa[i] = i;
		rk[i] = i < len?S[i]:-1;
	}
	for(k = 1;k <= n;k *= 2){
		sort(sa,sa+1+len,compare_sa);
		tmp[sa[0]] = 0;
		for(int i = 1;i <= len;i++)
			tmp[sa[i]] = tmp[sa[i-1]]+compare_sa(sa[i-1],sa[i]);
		for(int i = 0;i <= len;i++) rk[i] = tmp[i];
	}
}
void construct_lcp(int S[],int sa[],int lcp[]){
	for(int i = 0;i <= len;i++) rk[sa[i]] = i;
	int h = 0; lcp[0] = 0;
	for(int i = 0;i < len;i++){
		int j = sa[rk[i]-1];
		if(h > 0) h--;
		for(;j + h < len && i + h < len;h++)
			if(S[j+h] != S[i+h]) break;
		lcp[rk[i]-1] = h;
	}
}
bool check(int x){
	int pre = 0;
	if(lcp[1] >= x) pre = 1;
	for(int i = 2;i < len;i++){
		if(!pre && lcp[i] >= x) pre = i;
		if(lcp[i-1] >= x)//注意一下到底是i还是i-1
			if(abs(sa[pre] - sa[i]) > x) return true; 
	}
	return false;
}
void solve(){
	construct_sa(a,sa);
	construct_lcp(a,sa,lcp);
	int l = 0,r = n;
	while(l <= r){
		int mid = l+r>>1;
		if(check(mid)) l = mid+1;
		else r = mid-1;
	}
	if(++r < 5) r = 0;
	printf("%d\n",r);
}
int main(){
	while(scanf("%d",&n) && n){
		//注意,该模板必须从下标0开始存!
		for(int i = 0;i < n;i++) b[i] = getInt();
		for(int i = 1;i < n;i++) a[i] = b[i]-b[i-1]+100;
		a[0] = b[0]+100; solve();
	}
	return 0;
}

例4:可重叠的k次最长重复子串POJ3261
测试地址
题意简述
求可重叠的,出现k次的最长重复子串。
解题思路
这个就是可重叠的最长重复子串了,要求求出最长的,重复出现k次以上的子串,通过题意以及样例观察可知,是允许子串重复的。解决方法和上题类似,也是后缀数组+二分。

代码示例

//POJ3261
#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 2e4+10;
int n,k,a[N],kk;
int rk[N],sa[N],lcp[N],tmp[N];
bool compare_sa(int i,int j){
	if(rk[i] != rk[j]) return rk[i] < rk[j];
	int ri = i+k <= n?rk[i+k]:-1;
	int rj = j+k <= n?rk[j+k]:-1;
	return ri < rj;
}
void construct_sa(int S[],int sa[]){
	for(int i = 0;i <= n;i++){
		sa[i] = i;
		rk[i] = i < n?S[i]:-1;
	}
	//别把全局变量的k覆盖了!!!
	for(k = 1;k <= n;k *= 2){
		sort(sa,sa+1+n,compare_sa);
		tmp[sa[0]] = 0;
		for(int i = 1;i <= n;i++) 
			tmp[sa[i]] = tmp[sa[i-1]] + compare_sa(sa[i-1],sa[i]);
		for(int i = 0;i <= n;i++) rk[i] = tmp[i];
	}
}
void construct_lcp(int S[],int sa[],int lcp[]){
	for(int i = 0;i <= n;i++) rk[sa[i]] = i;
	int h = 0; lcp[0] = 0;
	for(int i = 0;i < n;i++){
		int j = sa[rk[i]-1];
		if(h > 0) h--;
		for(;j+h < n && i+h < n;h++)
			if(S[j+h] != S[i+h]) break;
		lcp[rk[i]-1] = h;
	}
}
bool check(int x){
	int cnt = 1;
	for(int i = 2;i <= n;i++){
		if(lcp[i-1] >= x) cnt++;
		else{
			if(cnt >= kk) return true;
			cnt = 1;
		} 
	}
	return cnt >= kk;
}
void solve(){
	construct_sa(a,sa);
	construct_lcp(a,sa,lcp);
	int l = 0, r = n;
	//for(int i = 1;i <= n;i++) printf("%d ",lcp[i]);
	while(l <= r){
		int mid = l+r>>1;
		if(check(mid)) l = mid+1;
		else r = mid-1;
	}
	printf("%d\n",r);
}
int main(){
	while(scanf("%d%d",&n,&kk) != EOF){
		for(int i = 0;i < n;i++) scanf("%d",a+i);
		solve();
	}
	return 0;
}
子串的个数

例5:求不同子串的个数
题意简述
给定一个字符串,求其所有子串中,不同的子串数量。
解题思路
利用后缀数组+高度数组求解。已知高度数组lcp,那么首先可以知道第一个sa[1]有n - sa[1]个不同子串;而2~n,每个sa[i] 有 n - sa[i] - lcp[i-1] 个不同子串。累加即可,注意结果可能爆 int。
代码示例

//P2408不同子串的个数
#include<cstdio>
#include<algorithm>
using namespace std;
typedef long long ll;
const int N = 1e5+10;
char str[N];
int n;
int sa[N],lcp[N],tmp[N],k,rk[N];
bool compare_sa(int i,int j){
	if(rk[i] != rk[j]) return rk[i] < rk[j];
	int ri = i+k <= n?rk[i+k]:-1;
	int rj = j+k <= n?rk[j+k]:-1;
	return ri < rj;
}
void construct_sa(const char *S,int sa[]){
	for(int i = 0;i <= n;i++){
		sa[i] = i;
		rk[i] = i < n?S[i]:-1;
	}
	for(k = 1;k <= n;k *= 2){
		sort(sa,sa+1+n,compare_sa);
		tmp[sa[0]] = 0;
		for(int i = 1;i <= n;i++) 
			tmp[sa[i]] = tmp[sa[i-1]] + compare_sa(sa[i-1],sa[i]);
		for(int i = 0;i <= n;i++) rk[i] = tmp[i];
	}
}
void construct_lcp(const char* S,int sa[],int lcp[]){
	for(int i = 0;i <= n;i++) rk[sa[i]] = i;
	int h = 0; lcp[0] = 0;
	for(int i = 0;i < n;i++){
		int j = sa[rk[i]-1];
		if(h > 0) h--;
		for(;j+h < n && i+h < n;h++)
			if(S[j+h] != S[i+h]) break;
		lcp[rk[i]-1] = h;
	}
}
ll sum = 0;
void calc(){
	sum = n - sa[1];
	for(int i = 2;i <= n;i++)
		sum += n - sa[i] - lcp[i-1];
}
void solve(){
	construct_sa(str,sa);
	construct_lcp(str,sa,lcp);
	calc();
	printf("%lld\n",sum);
}
int main(){
	#ifdef LOCAL
		freopen("123.txt","r",stdin);
		freopen("222.txt","w",stdout);
	#endif
	while(~scanf("%d",&n)){
		scanf("%s",str);
		solve();
	}
	return 0;
}
回文子串

例6:求最长回文子串

穷举每一位,然后计算以这个字符为中心的最长回文子串。注意这里要对长度分奇偶讨论。两种情况都可以转化为求一个后缀和一个反过来写的后缀的最长公共前缀。具体做法是:将整个字符反过来放在原字符串后面,中间用一个特殊字符隔开,这样就转化为了求新串的某两个后缀的最长公共前缀。

连续重复子串

例7:重复次数最多的连续子串
Maximum repetition substring
题意简述
求一个字符串中,重复次数最多的连续重复子串是什么(循环次数最多的循环子串)。要求输出字典序最小的。

例如:ccabababc中,连续循环次数最多的子串是ababab。

解题思路
这题我对照题解写了很久,思想勉强搞懂。
先说做法,是枚举长度len(从1到n),然后判断以 len 为循环长度的 连续循环次数 最多的 子串 长度 是多少;假设这个子串长度是 l ,那么它的连续循环次数就是 l/len。
我们考虑如何求子串长度 l 。如果有一个字符串的循环节长度为len,那么必然有s[1,n-len] = s[len , n],因此,如果字符串 s[len, n] 与 s[ 2 * len, n] 的公共前缀长度为 lcp,那么 s[len ,n] 的循环节个数为lcp / len + 1(加上的一个是开头为计算在lcp内的循环节 )。
因此我们只需要对s[0] , s[len] ,s[2 * len] … , 判断相差 len 的两个相邻后缀的 lcp,就可以计算该子串循环节个数。这是由于如果一个子串的循环节长度为 len,且循环至少两次,那么显然它会包含相邻的两个len,否则就构不成循环了。当然可能起点并不在len的倍数位置上,因此我们还需要向前拓展一下。

代码示例

#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1e5+10;
char str[N];
int cse = 0,n;
int k,rk[N],lcp[N],sa[N],tmp[N];
bool compare_sa(int i,int j){
	if(rk[i] != rk[j]) return rk[i] < rk[j];
	int ri = i+k <= n?rk[i+k]:-1;
	int rj = j+k <= n?rk[j+k]:-1;
	return ri < rj;
}
void construct_sa(const char* S,int sa[]){
	for(int i = 0;i <= n;i++){
		sa[i] = i;
		rk[i] = i < n?S[i]:-1;
	}
	for(k = 1;k <= n;k *= 2){
		sort(sa,sa+1+n,compare_sa);
		tmp[sa[0]] = 0;
		for(int i = 1;i <= n;i++)
			tmp[sa[i]] = tmp[sa[i-1]]+compare_sa(sa[i-1],sa[i]);
		for(int i = 0;i <= n;i++) rk[i] = tmp[i];
	}
}
void construct_lcp(const char* S,int sa[],int lcp[]){
	for(int i = 0;i <= n;i++) rk[sa[i]] = i;
	int h = 0; lcp[0] = 0;
	for(int i = 0;i < n;i++){
		int j = sa[rk[i]-1];
		if(h > 0) h--;
		for(;j+h < n && i+h < n;h++)
			if(S[j+h] != S[i+h]) break;
		lcp[rk[i]-1] = h;
	}
}
int st[N][22],Log[N];
void st_init(){
	for(int i = 1;i <= n;i++) st[i][0] = lcp[i];
	for(int i = 2;i <= n;i++) Log[i] = Log[i/2]+1;
	for(int j = 1;(1<<j) <= n;j++){
		for(int i = 1;i + (1<<j-1) <= n;i++)
			st[i][j] = min(st[i][j-1],st[i+(1<<j-1)][j-1]);
	}
}
int ask(int l,int r){
	if(l > r) swap(l,r); r--;
	int kk = Log[r-l+1];
	return min(st[l][kk],st[r-(1<<kk)+1][kk]);
}
int mxtc = 0;
int q[N],cnt = 0;
void calc(int len){
	for(int i = 0;i+len < n;i += len){
		int l = ask(rk[i],rk[i+len]);
		int res = l/len+1;
		int pre = i - (len-l%len);
		if(pre >= 0 && ask(rk[pre],rk[pre+len]) >= len) res++;
		if(mxtc < res) mxtc = res,cnt = 0,q[++cnt] = len;
		else if(mxtc == res && len != q[cnt]) q[++cnt] = len;
	}
}
void printAns(){
	for(int i = 1;i <= n;i++)
		for(int j = 1;j <= cnt;j++)
			if(ask(rk[sa[i]],rk[sa[i]+q[j]]) >= q[j]*(mxtc-1)){
				str[sa[i]+q[j]*mxtc] = '\0';
				puts(str+sa[i]); return;
			}
}
void solve(){
	construct_sa(str,sa);
	construct_lcp(str,sa,lcp);//lcp[n]是恒等于0的
	/*下一步求重复次数最多的连续重复子串*/
	st_init();
	mxtc = cnt = 0;
	for(int i = 1;i <= n;i++) calc(i); 
	printf("Case %d: ",++cse);
	printAns();
}
int main(){
	while(scanf("%s",str) != EOF){
		n = strlen(str);
		if(n == 1 && str[0] == '#') break;
		solve();
	}
	return 0;
}

两个字符串相关问题

这类问题的一个常用做法是,先连接这两个字符串,然后求后缀数组和高度数组,再利用高度数组求解。

公共子串

例8:公共子串的数量:Common Substrings

题意简述
给定2个字符串A和B,以及一个整数k。目标是求出这两个字符串中公共子串的数量,例如A:xx , B:xx,k = 1,那么公共子串数量就是5。
解题思路
将A和B拼接在一起,中间用一个未出现过的字符间隔,新串S = A+’#’+B;然后对S求高度数组,利用高度数组来求解。
对于属于 B 的每一个后缀,统计它与所有属于 A 的后缀的 lcp(最长公共前缀),并统计lcp-m+1,这就是该后缀和 A 的公共子串数量。这样做的复杂度是O(N^2)。

我们可以利用单调栈来在 O(N) 时间内解决;我们从前向后遍历lcp数组,将属于A的后缀的 lcp 压入栈;由于两个字符串的后缀是取它们中间的最小值,所以我们应该维护单调递减栈,同时需要维护的是 sum,sum代表当前 A 中相同的子串数量,也就是说如果当前后缀属于 B ,则直接加上sum即可。

由于我们是顺序遍历的,只统计了A对B的贡献,再反过来统计一次B对A的贡献即可。
代码示例

#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 2e5+10;
char A[N],B[N];
int rk[N],tmp[N],lcp[N],sa[N],k;
int n,m;
typedef long long ll;
bool compare_sa(int i,int j){
	if(rk[i] != rk[j]) return rk[i] < rk[j];
	int ri = i+k <= n?rk[i+k]:-1;
	int rj = i+k <= n?rk[j+k]:-1;
	return ri < rj;
}
void construct_sa(const char* S,int sa[]){
	for(int i = 0;i <= n;i++){
		sa[i] = i;
		rk[i] = i < n?S[i]:-1;
	}
	for(k = 1;k <= n;k *= 2){
		sort(sa,sa+1+n,compare_sa);
		tmp[sa[0]] = 0;
		for(int i = 1;i <= n;i++)
			tmp[sa[i]] = tmp[sa[i-1]] + compare_sa(sa[i-1],sa[i]);
		for(int i = 0;i <= n;i++) rk[i] = tmp[i];
	}
}
void construct_lcp(const char *S,int sa[],int lcp[]){
	for(int i = 0;i <= n;i++) rk[sa[i]] = i;
	int h = 0; lcp[0] = 0;
	for(int i = 0;i < n;i++){
		int j =sa[rk[i]-1];
		if(h > 0) h--;
		for(;j+h < n && i+h < n;h++)
			if(S[j+h] != S[i+h]) break;
		lcp[rk[i]-1] = h;
	}
}
int idx[N];//初始化为0
struct Node{
	ll h,cnt;
}Stack[N];
void solve(){
	construct_sa(A,sa);
	construct_lcp(A,sa,lcp);
	ll ans = 0,sum = 0;
	int top = 0;
	for(int i = 2;i <= n;i++){
		ll cnt = 0;
		while(top && Stack[top].h >= lcp[i-1]){
			cnt += Stack[top].cnt;
			sum -= Stack[top].cnt * (Stack[top].h-m+1);
			top--;
		}
		if(lcp[i-1] >= m){
			cnt += idx[sa[i-1]] == 0;
			if(cnt) Stack[++top] = Node{lcp[i-1],cnt};
			sum += (lcp[i-1]-m+1)*cnt;
		}
		if(idx[sa[i]] == 1) ans += sum;
	}
	top = 0; sum = 0;
	for(int i = 2;i <= n;i++){
		ll cnt = 0;
		while(top && Stack[top].h >= lcp[i-1]){
			cnt += Stack[top].cnt;
			sum -= Stack[top].cnt * (Stack[top].h-m+1);
			top--;
		}
		if(lcp[i-1] >= m){
			cnt += idx[sa[i-1]] == 1;
			if(cnt) Stack[++top] = Node{lcp[i-1],cnt};
			sum += (lcp[i-1]-m+1)*cnt;
		}
		if(!idx[sa[i]]) ans += sum;
	}
	printf("%lld\n",ans);
}
int main(){
	while(scanf("%d",&m) != EOF && m){
		scanf("%s",A);
		memset(idx,0,sizeof idx);
		n = strlen(A); 
		idx[n] = 2; A[n++] = '#';
		for(int i = 0;i < n;i++) idx[i] = 1;
		scanf("%s",A+n);
		n = strlen(A);
		solve();
	}
	return 0;
}

多个字符串相关问题

这类问题的一个常用做法是,先将所有的字符串连接起来,然后求后缀数组和高度数组,再利用高度数组进行求解。中间可能还要利用二分答案。

例9:不小于k个字符串中的最长子串。

Life Forms
测试地址
题意简述
给定n个字符串,请问它们之间出现次数超过一半的最长公共子串是什么?如果有多个,按字典序输出。

解题思路
利用后缀数组+高度数组求解。我们将n个字符串拼接中一个“母串”,中间用不同的字符间隔;然后对母串求高度数组lcp,我们依旧是用二分搜索判定长度p是否合法,每次判定从前向后用O(N)时间遍历统计一遍即可。当我们得到最长的合法长度 p 后,再利用类似的方法输出所有的子串。

代码示例

#include<cstdio>
#include<cstring>
#include<algorithm>
#include<iostream>
using namespace std;
string str,s;
int n,k,len;
const int N = 2e5+10;
int sa[N],rk[N],lcp[N],tmp[N];
bool compare_sa(int i,int j){
    if(rk[i] != rk[j]) return rk[i] < rk[j];
    int ri = i+k <= len?rk[i+k]:-1;
    int rj = j+k <= len?rk[j+k]:-1;
    return ri < rj;
}
void construct_sa(string S,int sa[]){
    len = S.length();
    for(int i = 0;i <= len;i++){
        sa[i] = i;
        rk[i] = i < len ? S[i]:-1;
    }
    //k一定不能设置成局部变量导致覆盖全局变量!!!
    for(k = 1;k <= len;k *= 2){
        sort(sa,sa+len+1,compare_sa);
        tmp[sa[0]] = 0;
        for(int i = 1;i <= len;i++)
            tmp[sa[i]] = tmp[sa[i-1]] + compare_sa(sa[i-1],sa[i]);
        for(int i = 0;i <= len;i++) rk[i] = tmp[i];
    }
}
void construct_lcp(string S,int sa[],int lcp[]){
    len = S.length();
    for(int i = 0;i <= len;i++) rk[sa[i]] = i;
    int h = 0; lcp[0] = 0;
    for(int i = 0;i < len;i++){
        int j = sa[rk[i]-1];
        if(h > 0) h--;
        for(;j+h < len && i+h < len;h++)
            if(S[j+h] != S[i+h]) break;
        lcp[rk[i]-1] = h;
    }
}
int vis[110],idx[N];
bool check(int p){
    int cnt = 1; memset(vis,0,sizeof vis);
    vis[idx[sa[1]]] = 1;
    for(int i = 2;i <= len;i++){
        if(lcp[i-1] < p){
            cnt = 1; memset(vis,0,sizeof vis);vis[idx[sa[i]]] = 1;
        }else if(!vis[idx[sa[i]]]) cnt++,vis[idx[sa[i]]] = 1;
        if(cnt >= n/2+1) return true;
    }
    return false;
}
void print(int p){
    int cnt = 1; memset(vis,0,sizeof vis);
    vis[idx[sa[1]]] = 1;
    for(int i = 2;i <= len+1;i++){  //lcp[len+1] = 0,作为结束标志
        if(lcp[i-1] < p){
            if(cnt >= n/2+1) {
                for(int j = sa[i-1];j < sa[i-1]+p;j++) cout << str[j];
                cout << endl;
            }
            cnt = 1; memset(vis,0,sizeof vis);vis[idx[sa[i]]] = 1;
        }else if(!vis[idx[sa[i]]]) cnt++,vis[idx[sa[i]]] = 1;
    }
}
void solve(){
    construct_sa(str,sa);
    construct_lcp(str,sa,lcp);
    /*sa[i]:存放排名第i的后缀的起始位置*/
    int l = 0, r = len;
    while(l <= r){
        int mid = l+r>>1;
        if(check(mid)) l = mid+1;
        else r = mid-1;
    }
    if(r <= 0) cout << "?" << endl;
    else print(r);
}
int main(){
    while(cin >> n && n){
        if(n == 1){
            cin >> str; cout << str << endl << endl;
            continue;
        }
        int cnt = 2,tot = 0; str = "";
        for(int i = 1;i <= n;i++){
            cin >> s; str += s+char(++cnt);
            for(int j = 0;j <= s.length();j++) idx[tot++] = i;
        } 
        solve();
        cout << endl;
    }
    return 0;
}

参考资料

  • 罗穗骞,后缀数组——处理字符串的有利工具,2009.1
已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 程序猿惹谁了 设计师:白松林 返回首页