什么是后缀数组
字符串后缀(Suffix)指的是从字符串的某个位置开始到其末尾的字符串子串。我们认为原串和空串也是后缀。反之,从字符串开头到某个位置的字符串子串则称为前缀。
后缀数组(Suffix Array)指的是将某个字符串的所有后缀按字典序排序后得到的数组。不过数组中并不需要直接保存所有的后缀字符串,只要记录对应的起始位置就好了。
后缀数组的计算
假设我们要计算长度为n的字符串S的后缀数组。最朴素的做法就是直接把所有后缀进行排序,将n个长度为O(n)的字符串进行排序的时间复杂度为 O ( N 2 l o g N ) O(N^2logN) O(N2logN)。而如果灵活运用所有的字符串都是S的后缀这一性质,就可以得到更高效的算法。下面介绍一种由Manver和Myers发明的O(N log N)复杂度的算法。
该算法的基本思想是倍增。首先计算从每个位置开始的长度为2的子串的顺序,再利用这个结果计算长度为4的子串的顺序,接下来计算长度为8的子串的顺序,不断倍增,直到长度大于等于n就得到了后缀数组。下面,我们用 S[i , k] 表示从位置i开始的长度为k的字符串子串。 其中,剩余字符不足k个时,表示的是从位置 i 开始到字符串末尾的子串。
要计算长度为 2 的子串顺序,只要排序两个字符组成的数对就好了。现在假设已经求得了长度为 k 的字串的顺序,要求长度为 2k 的子串顺序。记 r a n k k rank_k rankk为S[i , k] 在所有排好序的长度为k的子串中是第几小的。要计算长度为 2k 的子串的顺序,就只要对两个rank组成的数对进行排序就好了。我们通过对 r a n k k ( i ) rank_k(i) rankk(i) 与 r a n k k ( j + k ) rank_k(j+k) rankk(j+k) 的数对的比较来 替代对 S[i , 2k] 和 S[j , 2k] ,比较 r a n d k k ( i + k ) randk_k(i+k) randkk(i+k)和 r a n k j ( j + k ) rank_j(j+k) rankj(j+k)就相当于比较S[i+k , k] 和 S[j+k , k] 。所以,我们可以这样高效的比较长度为 2k 的子串 ,并将它们排序。
算法模板
该代码共用到了三个数组,tmp , sa , rank;其中tmp数组是用来临时存放新计算的rank的,而 sa[i] 则存放的当前长度为k时字典序第 i 小的字符起始位置是sa[i]。
#include<algorithm>
#include<cstdio>
#include<cstring>
using namespace std;
const int N = 1e6+10;
int n,k;
char str[N];
int sa[N],rank[N],tmp[N];
//比较(rank[i],rank[i+k])和(rank[j],rank[j+k])
bool compare_sa(int i,int j){
if(rank[i] != rank[j]) return rank[i] < rank[j];
int ri = i+k <= n ? rank[i+k] : -1;
int rj = j+k <= n ? rank[j+k] : -1;
return ri < rj;
}
//计算字符串S的后缀数组
void construct_sa(char* S,int sa[]){
n = strlen(str);
//初始长度为1,rank直接取字符的编码
for(int i = 0;i <= n;i++){
sa[i] = i;
rank[i] = i < n ? S[i] : -1;
}
//利用对长度为k的排序结果对长度为2k的排序
for(k = 1;k <= n;k *= 2){
sort(sa,sa+n+1,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])?1:0);
for(int i = 0;i <= n;i++) rank[i] = tmp[i];
}
}
int main(){
while(scanf("%s",str) != EOF){
construct_sa(str,sa);
for(int i = 1;i <= n;i++)
printf("%d ",sa[i]+1);
puts("");
}
return 0;
}
例题
POJ3518:Sequence
测试地址
题意简述:
给定 N 个数字组成的序列
A
1
,
A
2
,
.
.
.
,
A
n
A_1 , A_2 , ... , A_n
A1,A2,...,An。其中
A
1
A_1
A1比其它数字都大。现在要把这个序列分成三个子段,并将每段分别反转,求能得到的字典序最小的序列是什么?要求分得的每段都不为空。
解题思路:
确定第一段是很简单的,因为第一个数最大,所以肯定旋转后要在末尾,所以只要把A数组倒置后求后缀数组,取字典序最小且合法的后缀,即为第一段的答案。这里的合法是指剩下的元素个数要大于2,不然没法分成3段。
然后要想把剩余的部分分成两段。不过这次的两端并不是独立的,不能简单的比较前半部分的字典序取最小者。不过,将序列分割成两端再分别反转得到的序列,可以看作是两个原序列拼接后得到的新序列中的某个子串反转后得到的序列。因此,只需要计算拼接后的序列的后缀数组,从中选择字典序尽量小并且合法的即可。
比如:从ABCDE中选俩段反转后字典序最小。等同于从EDCBAEDCBA中选择一个字典序最小的合法子串。
代码示例:
#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 2e5+20;
const int INF = 0x3f3f3f3f;
int A[N],rev[2*N],sa[2*N];
int n,k,m,tmp[2*N],rank[2*N];
bool compare_sa(int i,int j){
if(rank[i] != rank[j]) return rank[i] < rank[j];
int ri = i+k <= n ? rank[i+k]:-INF-1;//这里不要搞错
int rj = j+k <= n ? rank[j+k]:-INF-1;
return ri < rj;
}
//注意A的下标是[0,len)的
void construct_sa(int A[],int len,int sa[]){
n = len;
for(int i = 0;i <= n;i++){
sa[i] = i;
rank[i] = i < n ? A[i] : -INF;
}
for(k = 1;k <= n;k *= 2){
sort(sa,sa+n+1,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])?1:0);
for(int i = 0;i <= n;i++) rank[i] = tmp[i];
}
}
void solve(){
reverse_copy(A,A+m,rev);
construct_sa(rev,m,sa);
int p1,p2;
//考虑一下p1需要满足的条件
for(int i = 1;i <= m;i++){
p1 = m-sa[i];
if(p1 >= 1 && m-p1 >= 2) break;
}
reverse_copy(p1+A,A+m,rev);
reverse_copy(p1+A,A+m,rev+m-p1);
construct_sa(rev,2*(m-p1),sa);
//考虑p2的范围
for(int i = 1;i <= 2*(m-p1);i++){
p2 = sa[i];
if(p2 > 0 && p2 < m-p1) break;
}
reverse(A,A+p1);
//输出答案
for(int i = 0;i < p1;i++) printf("%d\n",A[i]);
for(int i = p2;i < p2+m-p1;i++) printf("%d\n",rev[i]);
}
int main(){
scanf("%d",&m);
for(int i = 0;i < m;i++) scanf("%d",A+i);
solve();
return 0;
}
参考资料
- 秋叶拓哉,挑战程序设计竞赛第2版,北京:人民邮电出版社,2013.6,378-381