后缀数组

目录
摘要 …………………………………………………………………………………4
关键字 ………………………………………………………………………………4
正文 …………………………………………………………………………………4
一、后缀数组的实现 …………………………………………………………………4
1.1 基本定义 …………………………………………………………………4
1.2 倍增算法 …………………………………………………………………6
1.3 DC3 算法 …………………………………………………………………9
1.4 倍增算法与 DC3 算法的比较 ……………………………………………14
二、后缀数组的应用 ………………………………………………………………15
2.1 最长公共前缀 ……………………………………………………………15
例 1:最长公共前缀 ……………………………………………………17
2.2 单个字符串的相关问题 …………………………………………………17
2.2.1 重复子串 ………………………………………………………18
例 2:可重叠最长重复子串 ………………………………………18
例 3:不可重叠最长重复子串(pku1743)…………………………18
例 4:可重叠的最长重复子串(pku3261)…………………………19
2.2.2 子串的个数 ……………………………………………………19
例 5:不相同的子串的个数(spoj694,spoj705)………………19
2.2.3 回文子串 ………………………………………………………19
例 6:最长回文子串(ural1297)…………………………………20
2.2.4 连续重复子串 …………………………………………………20
例 7:连续重复子串(pku2406)……………………………………20
例 8:重复次数最多的连续重复子串(spoj687,pku3693)………21
2.3 两个字符串的相关问题 …………………………………………………21
2.3.1 公共子串 ………………………………………………………22
例 9:最长公共子串(pku2774,ural1517) ………………………22
2.3.2 子串的个数 ……………………………………………………23
IOI2009 国家集训队论文 后缀数组 罗穗骞
3
例 10:长度不小于 k 的公共子串的个数(pku3415) ……………23
2.4 多个字符串的相关问题 …………………………………………………23
例 11:不小于 k 个字符串中的最长子串(pku3294) ……………………24
例 12:每个字符串至少出现两次且不重叠的最长子串(spoj220)……24
例 13:出现或反转后出现在每个字符串中的最长子串(pku3294)……24
三、结束语 …………………………………………………………………………25
参考文献 ……………………………………………………………………………25
致谢 …………………………………………………………………………………25
IOI2009 国家集训队论文 后缀数组 罗穗骞
4
后 缀 数 组
----处理字符串的有力工具 处理字符串的有力工具 处理字符串的有力工具 处理字符串的有力工具
【摘要】
后缀数组是处理字符串的有力工具。后缀数组是后缀树的一个非常精巧的
替代品,它比后缀树容易编程实现,能够实现后缀树的很多功能而时间复杂度也
并不逊色,而且它比后缀树所占用的内存空间小很多。可以说,在信息学竞赛中
后缀数组比后缀树要更为实用。本文分两部分。第一部分介绍两种构造后缀数组
的方法,重点介绍如何用简洁高效的代码实现,并对两种算法进行了比较。第二
部分介绍后缀数组在各种类型题目中的具体应用。
【关键字】
字符串 后缀 后缀数组 名次数组 基数排序
【正文】
一、后缀数组的实现 一、后缀数组的实现 一、后缀数组的实现 一、后缀数组的实现
本节主要介绍后缀数组的两种实现方法:倍增算法和 DC3 算法,并对两种算
法进行了比较。可能有的读者会认为这两种算法难以理解,即使理解了也难以用
程序实现。本节针对这个问题,在介绍这两种算法的基础上,还给出了简洁高效
的代码。其中倍增算法只有 25 行,DC3 算法只有 40 行。
1.1 基本定义
子串:字符串 S 的子串 r[i..j],i≤j,表示 r 串中从 i 到 j 这一段,
也就是顺次排列 r[i],r[i+1],...,r[j]形成的字符串。
后缀:后缀是指从某个位置 i 开始到整个串末尾结束的一个特殊子串。字
IOI2009 国家集训队论文 后缀数组 罗穗骞
5
符 串 r 的 从 第 i 个 字 符 开 始 的 后 缀 表 示 为 Suffix(i) , 也 就 是
Suffix(i)=r[i..len(r)]。
大小比较:关于字符串的大小比较,是指通常所说的“字典顺序”比较,也
就是对于两个字符串 u、v,令 i 从 1 开始顺次比较 u[i]和 v[i],如果
u[i]=v[i]则令 i 加 1,否则若 u[i]<v[i]则认为 u<v,u[i]>v[i]则认为 u>v
(也就是 v<u),比较结束。如果 i>len(u)或者 i>len(v)仍比较不出结果,那
么 若 len(u)<len(v) 则 认 为 u<v , 若 len(u)=len(v) 则 认 为 u=v , 若
len(u)>len(v)则 u>v。
从字符串的大小比较的定义来看,S 的两个开头位置不同的后缀 u 和 v 进
行比较的结果不可能是相等,因为 u=v 的必要条件 len(u)=len(v)在这里不可
能满足。
后缀数组:后缀数组 SA 是一个一维数组,它保存 1..n 的某个排列 SA[1],
SA[2],……,SA[n],并且保证 Suffix(SA[i]) < Suffix(SA[i+1]),1≤i<n。
也就是将 S 的 n 个后缀从小到大进行排序之后把排好序的后缀的开头位置顺
次放入 SA 中。
名次数组:名次数组 Rank[i]保存的是 Suffix(i)在所有后缀中从小到大排
列的“名次”。
简单的说,后缀数组是“排第几的是谁?”,名次数组是“你排第几?”。容
易看出,后缀数组和名次数组为互逆运算。如图 1 所示。
IOI2009 国家集训队论文 后缀数组 罗穗骞
6
设字符串的长度为 n。为了方便比较大小,可以在字符串后面添加一个字符, 这个字符没有在前面的字符中出现过,而且比前面的字符都要小。在求出名次数
组后,可以仅用 O(1)的时间比较任意两个后缀的大小。在求出后缀数组或名次
数组中的其中一个以后,便可以用 O(n)的时间求出另外一个。任意两个后缀如
果直接比较大小,最多需要比较字符 n 次,也就是说最迟在比较第 n 个字符时一
定能分出“胜负”。 1.2倍增算法 1.2倍增算法 1.2倍增算法 1.2倍增算法
倍增算法的主要思路是:用倍增的方法对每个字符开始的长度为 2k 的子字
符串进行排序,求出排名,即 rank 值。k 从 0 开始,每次加 1,当 2k 大于 n 以
后,每个字符开始的长度为 2k 的子字符串便相当于所有的后缀。并且这些子字
符串都一定已经比较出大小,即 rank 值中没有相同的值,那么此时的 rank 值就
是最后的结果。每一次排序都利用上次长度为 2k-1的字符串的 rank 值,那么长
度为 2k 的字符串就可以用两个长度为 2k-1的字符串的排名作为关键字表示,然
后进行基数排序,便得出了长度为 2k的字符串的 rank 值。以字符串“aabaaaab”
为例,整个过程如图 2 所示。其中 x、y 是表示长度为 2k的字符串的两个关键字 。
IOI2009 国家集训队论文 后缀数组 罗穗骞
7
具体实现:
int wa[maxn],wb[maxn],wv[maxn],ws[maxn];
int cmp(int *r,int a,int b,int l)
{return r[a]==r[b]&&r[a+l]==r[b+l];}
void da(int *r,int *sa,int n,int m)
{
int i,j,p,*x=wa,*y=wb,*t;
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[x[i]=r[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) sa[--ws[x[i]]]=i;
for(j=1,p=1;p<n;j*=2,m=p)
{
for(p=0,i=n-j;i<n;i++) y[p++]=i;
for(i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
for(i=0;i<n;i++) wv[i]=x[y[i]];
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[wv[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) sa[--ws[wv[i]]]=y[i];
for(t=x,x=y,y=t,p=1,x[sa[0]]=0,i=1;i<n;i++)
x[sa[i]]=cmp(y,sa[i-1],sa[i],j)?p-1:p++;
}
return;
}
待排序的字符串放在 r 数组中,从 r[0]到 r[n-1],长度为 n,且最大值小
于 m。为了函数操作的方便,约定除 r[n-1]外所有的 r[i]都大于 0, r[n-1]=0。
函数结束后,结果放在 sa 数组中,从 sa[0]到 sa[n-1]。
IOI2009 国家集训队论文 后缀数组 罗穗骞
8
函数的第一步,要对长度为 1 的字符串进行排序。一般来说,在字符串的题
目中,r 的最大值不会很大,所以这里使用了基数排序。如果 r 的最大值很大,
那么把这段代码改成快速排序。代码:
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[x[i]=r[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) sa[--ws[x[i]]]=i;
这里 x 数组保存的值相当于是 rank 值。下面的操作只是用 x 数组来比较字
符的大小,所以没有必要求出当前真实的 rank 值。
接下来进行若干次基数排序,在实现的时候,这里有一个小优化。基数排序
要分两次,第一次是对第二关键字排序,第二次是对第一关键字排序。对第二关
键字排序的结果实际上可以利用上一次求得的 sa 直接算出,没有必要再算一次。
代码:
for(p=0,i=n-j;i<n;i++) y[p++]=i;
for(i=0;i<n;i++) if(sa[i]>=j) y[p++]=sa[i]-j;
其中变量j是当前字符串的长度,数组y保存的是对第二关键字排序的结果 。
然后要对第一关键字进行排序,代码:
for(i=0;i<n;i++) wv[i]=x[y[i]];
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[wv[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) sa[--ws[wv[i]]]=y[i];
这样便求出了新的 sa 值。在求出 sa 后,下一步是计算 rank 值。这里要注
意的是,可能有多个字符串的 rank 值是相同的,所以必须比较两个字符串是否
完全相同,y 数组的值已经没有必要保存,为了节省空间,这里用 y 数组保存 rank
值。这里又有一个小优化,将 x 和 y 定义为指针类型,复制整个数组的操作可以
用交换指针的值代替,不必将数组中值一个一个的复制。代码:
for(t=x,x=y,y=t,p=1,x[sa[0]]=0,i=1;i<n;i++)
x[sa[i]]=cmp(y,sa[i-1],sa[i],j)?p-1:p++;
IOI2009 国家集训队论文 后缀数组 罗穗骞
9
其中 cmp 函数的代码是:
int cmp(int *r,int a,int b,int l)
{return r[a]==r[b]&&r[a+l]==r[b+l];}
这里可以看到规定 r[n-1]=0 的好处,如果 r[a]=r[b],说明以 r[a]或 r[b]
开头的长度为l的字符串肯定不包括字符r[n-1],所以调用变量 r[a+l]和 r[b+l]
不会导致数组下标越界,这样就不需要做特殊判断。执行完上面的代码后,rank
值保存在 x 数组中,而变量 p 的结果实际上就是不同的字符串的个数。这里可以
加一个小优化,如果 p 等于 n,那么函数可以结束。因为在当前长度的字符串中 ,
已经没有相同的字符串,接下来的排序不会改变 rank 值。例如图 1 中的第四次
排序,实际上是没有必要的。对上面的两段代码,循环的初始赋值和终止条件可
以这样写:
for(j=1,p=1;p<n;j*=2,m=p) {…………}
在第一次排序以后,rank 数组中的最大值小于 p,所以让 m=p。
整个倍增算法基本写好,代码大约 25 行。
算法分析:
倍增算法的时间复杂度比较容易分析。每次基数排序的时间复杂度为 O(n),
排序的次数决定于最长公共子串的长度,最坏情况下,排序次数为 logn 次,所
以总的时间复杂度为 O(nlogn)。 1.3DC3 算法
DC3 算法分 3 步:
(1)、先将后缀分成两部分,然后对第一部分的后缀排序。
将后缀分成两部分,第一部分是后缀 k(k 模 3 不等于 0),第二部分是后缀
k(k 模 3 等于 0)。先对所有起始位置模 3 不等于 0 的后缀进行排序,即对
suffix(1), suffix(2), suffix(4), suffix(5), suffix(7)……进行排序。做
法是将 suffix(1)和 suffix(2)连接,如果这两个后缀的长度不是 3 的倍数,那
先各自在末尾添 0 使得长度都变成 3 的倍数。然后每 3 个字符为一组,进行基数
排序,将每组字符“合并”成一个新的字符。然后用递归的方法求这个新的字符
串的后缀数组。如图 3 所示。在得到新的字符串的 sa 后,便可以计算出原字符
IOI2009 国家集训队论文 后缀数组 罗穗骞
10
串所有起始位置模 3 不等于 0 的后缀的 sa。要注意的是,原字符串必须以一个
最小的且前面没有出现过的字符结尾,这样才能保证结果正确(请读者思考为什
么 )。
(2)、利用(1)的结果,对第二部分的后缀排序。
剩下的后缀是起始位置模 3 等于 0 的后缀,而这些后缀都可以看成是一个字
符加上一个在(1)中已经求出 rank 的后缀,所以只要一次基数排序便可以求出
剩下的后缀的 sa。
(3)、将(1)和(2)的结果合并,即完成对所有后缀排序。
这个合并操作跟合并排序中的合并操作一样。每次需要比较两个后缀的大
小。分两种情况考虑,第一种情况是 suffix(3*i)和 suffix(3*j+1)的比较,可
以把 suffix(3*i)和 suffix(3*j+1)表示成:
suffix(3*i) = r[3*i] + suffix(3*i+1)
suffix(3*j+1) = r[3*j+1] + suffix(3*j+2)
其中 suffix(3*i+1)和 suffix(3*j+2)的比较可以利用(2)的结果快速得到 。
第二种情况是 suffix(3*i)和 suffix(3*j+2)的比较,可以把 suffix(3*i)和
suffix(3*j+2)表示成:
suffix(3*i) = r[3*i] + r[3*i+1] + suffix(3*i+2)
suffix(3*j+2) = r[3*j+2] + r[3*j+3] + suffix(3*(j+1)+1)
同样的道理,suffix(3*i+2)和 suffix(3*(j+1)+1) 的比较可以利用(2)
的结果快速得到。所以每次的比较都可以高效的完成,这也是之前要每 3 个字符
合并,而不是每 2 个字符合并的原因。
具体实现:
IOI2009 国家集训队论文 后缀数组 罗穗骞
11
#define F(x) ((x)/3+((x)%3==1?0:tb))
#define G(x) ((x)<tb?(x)*3+1:((x)-tb)*3+2)
int wa[maxn],wb[maxn],wv[maxn],ws[maxn];
int c0(int *r,int a,int b)
{return r[a]==r[b]&&r[a+1]==r[b+1]&&r[a+2]==r[b+2];}
int c12(int k,int *r,int a,int b)
{if(k==2) return r[a]<r[b]||r[a]==r[b]&&c12(1,r,a+1,b+1);
else return r[a]<r[b]||r[a]==r[b]&&wv[a+1]<wv[b+1];}
void sort(int *r,int *a,int *b,int n,int m)
{
int i;
for(i=0;i<n;i++) wv[i]=r[a[i]];
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[wv[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) b[--ws[wv[i]]]=a[i];
return;
}
void dc3(int *r,int *sa,int n,int m)
{
int i,j,*rn=r+n,*san=sa+n,ta=0,tb=(n+1)/3,tbc=0,p;
r[n]=r[n+1]=0;
for(i=0;i<n;i++) if(i%3!=0) wa[tbc++]=i;
sort(r+2,wa,wb,tbc,m);
sort(r+1,wb,wa,tbc,m);
sort(r,wa,wb,tbc,m);
for(p=1,rn[F(wb[0])]=0,i=1;i<tbc;i++)
rn[F(wb[i])]=c0(r,wb[i-1],wb[i])?p-1:p++;
if(p<tbc) dc3(rn,san,tbc,p);
IOI2009 国家集训队论文 后缀数组 罗穗骞
12
else for(i=0;i<tbc;i++) san[rn[i]]=i;
for(i=0;i<tbc;i++) if(san[i]<tb) wb[ta++]=san[i]*3;
if(n%3==1) wb[ta++]=n-1;
sort(r,wb,wa,ta,m);
for(i=0;i<tbc;i++) wv[wb[i]=G(san[i])]=i;
for(i=0,j=0,p=0;i<ta && j<tbc;p++)
sa[p]=c12(wb[j]%3,r,wa[i],wb[j])?wa[i++]:wb[j++];
for(;i<ta;p++) sa[p]=wa[i++];
for(;j<tbc;p++) sa[p]=wb[j++];
return;
}
各个参数的作用和前面的倍增算法一样,不同的地方是 r 数组和 sa 数组的
大小都要是 3*n,这为了方便下面的递归处理,不用每次都申请新的内存空间。
函数中用到的变量:
int i,j,*rn=r+n,*san=sa+n,ta=0,tb=(n+1)/3,tbc=0,p;
rn 数组保存的是(1)中要递归处理的新字符串,san 数组是新字符串的 sa。
变量 ta 表示起始位置模 3 为 0 的后缀个数,变量 tb 表示起始位置模 3 为 1 的后
缀个数,已经直接算出。变量 tbc 表示起始位置模 3 为 1 或 2 的后缀个数。先按
(1)中所说的用基数排序把 3 个字符“合并”成一个新的字符。为了方便操作,
先将 r[n]和 r[n+1]赋值为 0。
代码:
r[n]=r[n+1]=0;
for(i=0;i<n;i++) if(i%3!=0) wa[tbc++]=i;
sort(r+2,wa,wb,tbc,m);
sort(r+1,wb,wa,tbc,m);
sort(r,wa,wb,tbc,m);
其中 sort 函数的作用是进行基数排序。代码:
void sort(int *r,int *a,int *b,int n,int m)
IOI2009 国家集训队论文 后缀数组 罗穗骞
13
{
int i;
for(i=0;i<n;i++) wv[i]=r[a[i]];
for(i=0;i<m;i++) ws[i]=0;
for(i=0;i<n;i++) ws[wv[i]]++;
for(i=1;i<m;i++) ws[i]+=ws[i-1];
for(i=n-1;i>=0;i--) b[--ws[wv[i]]]=a[i];
return;
}
基数排序结束后,新的字符的排名保存在 wb 数组中。
跟倍增算法一样,在基数排序以后,求新的字符串时要判断两个字符组是否
完全相同。代码:
for(p=1,rn[F(wb[0])]=0,i=1;i<tbc;i++)
rn[F(wb[i])]=c0(r,wb[i-1],wb[i])?p-1:p++;
其中 F(x)是计算出原字符串的 suffix(x)在新的字符串中的起始位置,c0
函数是比较是否完全相同,在开头加一段代码:
#define F(x) ((x)/3+((x)%3==1?0:tb))
inline int c0(int *r,int a,int b)
{return r[a]==r[b]&&r[a+1]==r[b+1]&&r[a+2]==r[b+2];}
接下来是递归处理新的字符串,这里和倍增算法一样,可以加一个小优化,
如果 p 等于 tbc,那么说明在新的字符串中没有相同的字符,这样可以直接求出
san 数组,并不用递归处理。代码:
if(p<tbc) dc3(rn,san,tbc,p);
else for(i=0;i<tbc;i++) san[rn[i]]=i;
然后是第(2)步,将所有起始位置模 3 等于 0 的后缀进行排序。其中对第
二关键字的排序结果可以由新字符串的 sa 直接计算得到,没有必要再排一次。
代码:
for(i=0;i<tbc;i++) if(san[i]<tb) wb[ta++]=san[i]*3;
if(n%3==1) wb[ta++]=n-1;
IOI2009 国家集训队论文 后缀数组 罗穗骞
14
sort(r,wb,wa,ta,m);
for(i=0;i<tbc;i++) wv[wb[i]=G(san[i])]=i;
要注意的是,如果 n%3==1,要特殊处理 suffix(n-1),因为在 san 数组里并
没有 suffix(n)。G(x)是计算新字符串的 suffix(x)在原字符串中的位置,和 F(x)
为互逆运算。在开头加一段:
#define G(x) ((x)<tb?(x)*3+1:((x)-tb)*3+2)。
最后是第(3)步,合并所有后缀的排序结果,保存在 sa 数组中。代码:
for(i=0,j=0,p=0;i<ta && j<tbc;p++)
sa[p]=c12(wb[j]%3,r,wa[i],wb[j])?wa[i++]:wb[j++];
for(;i<ta;p++) sa[p]=wa[i++];
for(;j<tbc;p++) sa[p]=wb[j++];
其中 c12 函数是按(3)中所说的比较后缀大小的函数,k=1 是第一种情况,
k=2 是第二种情况。代码:
int c12(int k,int *r,int a,int b)
{if(k==2) return r[a]<r[b]||r[a]==r[b]&&c12(1,r,a+1,b+1);
else return r[a]<r[b]||r[a]==r[b]&&wv[a+1]<wv[b+1];}
整个 DC3 算法基本写好,代码大约 40 行。
算法分析:
假设这个算法的时间复杂度为 f(n)。容易看出第(1)步排序的时间为 O(n)
(一般来说,m 比较小,这里忽略不计),新的字符串的长度不超过 2n/3,求新
字符串的 sa 的时间为 f(2n/3),第(2)和第(3)步的时间都是 O(n)。所以
f(n) = O(n) + f(2n/3)
f(n) ≤ c×n + f(2n/3)
f(n) ≤ c×n + c×(2n/3) + c×(4n/9) + c×(8n/27) + …… ≤ 3c×n
所以 f(n) = O(n)
由此看出,DC3 算法是一个优秀的线性算法。
1.4 倍增算法与 DC3 算法的比较
从时间复杂度、空间复杂度、编程复杂度和实际效率等方面对倍增算法与
IOI2009 国家集训队论文 后缀数组 罗穗骞
15
DC3 算法进行比较。
时间复杂度:
倍增算法的时间复杂度为 O(nlogn),DC3 算法的时间复杂度为 O(n)。从常
数上看,DC3 算法的常数要比倍增算法大。
空间复杂度:
倍增算法和 DC3 算法的空间复杂度都是 O(n)。按前面所讲的实现方法,倍
增算法所需数组总大小为 6n,DC3 算法所需数组总大小为 10n。
编程复杂度:
倍增算法的源程序长度为 25 行,DC3 算法的源程序长度为 40 行。
实际效率:
测试环境:NOI-linux Pentium(R) 4 CPU 2.80GHz
(不包括读入和输出的时间,单位:ms)
从表中可以看出,DC3 算法在实际效率上还是有一定优势的。倍增算法容易
实现,DC3 算法效率比较高,但是实现起来比倍增算法复杂一些。对于不同的题
目,应当根据数据规模的大小决定使用哪个算法。
二、后缀数组的应用 二、后缀数组的应用 二、后缀数组的应用 二、后缀数组的应用
本节主要介绍后缀数组在各种类型的字符串问题中的应用。各题的原题请见
附件二,参考代码请见附件三。
2.1最长公共前缀 2.1最长公共前缀 2.1最长公共前缀 2.1最长公共前缀
这里先介绍后缀数组的一些性质。
N 倍增算法 DC3 算法
200000 192 140
300000 367 244
500000 750 499
1000000 1693 1248
IOI2009 国家集训队论文 后缀数组 罗穗骞
16
height 数组:定义 height[i]=suffix(sa[i-1])和 suffix(sa[i])的最长公
共前缀,也就是排名相邻的两个后缀的最长公共前缀。那么对于 j 和 k,不妨设
rank[j]<rank[k],则有以下性质:
suffix(j) 和 suffix(k) 的 最 长 公 共 前 缀 为 height[rank[j]+1],
height[rank[j]+2], height[rank[j]+3], … ,height[rank[k]]中的最小值。
例如,字符串为“aabaaaab”,求后缀“abaaaab”和后缀“aaab”的最长公
共前缀,如图 4 所示:
那么应该如何高效的求出 height 值呢?
如果按 height[2],height[3],……,height[n]的顺序计算,最坏情况下
时 间 复 杂 度 为 O(n2) 。 这 样 做 并 没 有 利 用 字 符 串 的 性 质 。 定 义
h[i]=height[rank[i]],也就是 suffix(i)和在它前一名的后缀的最长公共前
缀。
h 数组有以下性质:
h[i]≥h[i-1]-1
证明:
设 suffix(k)是排在 suffix(i-1)前一名的后缀,则它们的最长公共前缀是
h[i-1]。那么 suffix(k+1)将排在 suffix(i)的前面(这里要求 h[i-1]>1,如果
h[i-1]≤1,原式显然成立)并且 suffix(k+1)和 suffix(i)的最长公共前缀是
IOI2009 国家集训队论文 后缀数组 罗穗骞
17
h[i-1]-1,所以 suffix(i)和在它前一名的后缀的最长公共前缀至少是 h[i-1]-
1。按照 h[1],h[2],……,h[n]的顺序计算,并利用 h 数组的性质,时间复杂度可
以降为 O(n)。
具体实现:
实现的时候其实没有必要保存 h 数组,只须按照 h[1],h[2],……,h[n]的顺
序计算即可。代码:
int rank[maxn],height[maxn];
void calheight(int *r,int *sa,int n)
{
int i,j,k=0;
for(i=1;i<=n;i++) rank[sa[i]]=i;
for(i=0;i<n;height[rank[i++]]=k)
for(k?k--:0,j=sa[rank[i]-1];r[i+k]==r[j+k];k++);
return;
}
例 1:最长公共前缀
给定一个字符串,询问某两个后缀的最长公共前缀。
算法分析:
按照上面所说的做法,求两个后缀的最长公共前缀可以转化为求某个区间上
的最小值。对于这个 RMQ 问题(如果对 RMQ 问题不熟悉,请阅读其他相关资料),
可以用 O(nlogn)的时间先预处理,以后每次回答询问的时间为 O(1)。所以对于
本问题,预处理时间为 O(nlogn),每次回答询问的时间为 O(1)。如果 RMQ 问题
用 O(n)的时间预处理,那么本问题预处理的时间可以做到 O(n)。 2.2单个字符串的相关问题 2.2单个字符串的相关问题 2.2单个字符串的相关问题 2.2单个字符串的相关问题
这类问题的一个常用做法是先求后缀数组和 height 数组,然后利用 height
数组进行求解。
IOI2009 国家集训队论文 后缀数组 罗穗骞
18
2.2.1重复子串 2.2.1重复子串 2.2.1重复子串 2.2.1重复子串
重复子串:字符串 R 在字符串 L 中至少出现两次,则称 R 是 L 的重复子串。
例 2:可重叠最长重复子串
给定一个字符串,求最长重复子串,这两个子串可以重叠。
算法分析:
这道题是后缀数组的一个简单应用。做法比较简单,只需要求 height 数组
里的最大值即可。首先求最长重复子串,等价于求两个后缀的最长公共前缀的最
大值。因为任意两个后缀的最长公共前缀都是 height 数组里某一段的最小值,
那么这个值一定不大于 height 数组里的最大值。所以最长重复子串的长度就是
height 数组里的最大值。这个做法的时间复杂度为 O(n)。
例 3:不可重叠最长重复子串(pku1743)
给定一个字符串,求最长重复子串,这两个子串不能重叠。
算法分析:
这题比上一题稍复杂一点。先二分答案,把题目变成判定性问题:判断是否
存在两个长度为 k 的子串是相同的,且不重叠。解决这个问题的关键还是利用
height 数组。把排序后的后缀分成若干组,其中每组的后缀之间的 height 值都
不小于 k。例如,字符串为“aabaaaab”,当 k=2 时,后缀分成了 4 组,如图 5
所示。
IOI2009 国家集训队论文 后缀数组 罗穗骞
19
容易看出,有希望成为最长公共前缀不小于 k 的两个后缀一定在同一组。然
后对于每组后缀,只须判断每个后缀的 sa 值的最大值和最小值之差是否不小于
k。如果有一组满足,则说明存在,否则不存在。整个做法的时间复杂度为
O(nlogn)。本题中利用 height 值对后缀进行分组的方法很常用,请读者认真体
会。
例 4:可重叠的 k 次最长重复子串(pku3261)
给定一个字符串,求至少出现 k 次的最长重复子串,这 k 个子串可以重叠。
算法分析:
这题的做法和上一题差不多,也是先二分答案,然后将后缀分成若干组。不
同的是,这里要判断的是有没有一个组的后缀个数不小于 k。如果有,那么存在
k 个相同的子串满足条件,否则不存在。这个做法的时间复杂度为 O(nlogn)。 2.2.2子串的个数 2.2.2子串的个数 2.2.2子串的个数 2.2.2子串的个数
例 5:不相同的子串的个数(spoj694,spoj705)
给定一个字符串,求不相同的子串的个数。
算法分析:
每个子串一定是某个后缀的前缀,那么原问题等价于求所有后缀之间的不相
同的前缀的个数。如果所有的后缀按照 suffix(sa[1]), suffix(sa[2]),
suffix(sa[3]), …… ,suffix(sa[n])的顺序计算,不难发现,对于每一次新加
进来的后缀 suffix(sa[k]),它将产生 n-sa[k]+1 个新的前缀。但是其中有
height[k]个是和前面的字符串的前缀是相同的。所以 suffix(sa[k])将“贡献”
出 n-sa[k]+1- height[k]个不同的子串。累加后便是原问题的答案。这个做法
的时间复杂度为 O(n)。 2.2.3回文子串 2.2.3回文子串 2.2.3回文子串 2.2.3回文子串
回文子串:如果将字符串 L 的某个子字符串 R 反过来写后和原来的字符串 R
一样,则称字符串 R 是字符串 L 的回文子串。
IOI2009 国家集训队论文 后缀数组 罗穗骞
20
例 6:最长回文子串(ural1297)
给定一个字符串,求最长回文子串。
算法分析:
穷举每一位,然后计算以这个字符为中心的最长回文子串。注意这里要分两
种情况,一是回文子串的长度为奇数,二是长度为偶数。两种情况都可以转化为
求一个后缀和一个反过来写的后缀的最长公共前缀。具体的做法是:将整个字符
串反过来写在原字符串后面,中间用一个特殊的字符隔开。这样就把问题变为了
求这个新的字符串的某两个后缀的最长公共前缀。如图 6 所示。
这个做法的时间复杂度为 O(nlogn)。如果 RMQ 问题用时间为 O(n)的方法预
处理,那么本题的时间复杂度可以降为 O(n)。 2.2.4 连续重复子串
连续重复串:如果一个字符串 L 是由某个字符串 S 重复 R 次而得到的,则称
L 是一个连续重复串。R 是这个字符串的重复次数。
例 7:连续重复子串(pku2406)
给定一个字符串 L,已知这个字符串是由某个字符串 S 重复 R 次而得到的,
求 R 的最大值。
IOI2009 国家集训队论文 后缀数组 罗穗骞
21
算法分析:
做法比较简单,穷举字符串 S 的长度 k,然后判断是否满足。判断的时候,
先看字符串 L 的长度能否被 k 整除,再看 suffix(1)和 suffix(k+1)的最长公共
前缀是否等于 n-k。在询问最长公共前缀的时候,suffix(1)是固定的,所以 RMQ
问题没有必要做所有的预处理,只需求出 height 数组中的每一个数到
height[rank[1]]之间的最小值即可。整个做法的时间复杂度为 O(n)。
例 8:重复次数最多的连续重复子串(spoj687,pku3693)
给定一个字符串,求重复次数最多的连续重复子串。
算法分析:
先穷举长度 L,然后求长度为 L 的子串最多能连续出现几次。首先连续出现
1 次是肯定可以的,所以这里只考虑至少 2 次的情况。假设在原字符串中连续出
现 2 次,记这个子字符串为 S,那么 S 肯定包括了字符 r[0], r[L], r[L*2],
r[L*3], ……中的某相邻的两个。所以只须看字符 r[L*i]和 r[L*(i+1)]往前和
往后各能匹配到多远,记这个总长度为 K,那么这里连续出现了 K/L+1 次。最后
看最大值是多少。如图 7 所示。
穷举长度 L 的时间是 n,每次计算的时间是 n/L。所以整个做法的时间复杂
度是 O(n/1+n/2+n/3+……+n/n)=O(nlogn)。 2.3两个字符串的相关问题 2.3两个字符串的相关问题 2.3两个字符串的相关问题 2.3两个字符串的相关问题
这类问题的一个常用做法是,先连接这两个字符串,然后求后缀数组和
height 数组,再利用 height 数组进行求解。
IOI2009 国家集训队论文 后缀数组 罗穗骞
22
2.3.1 公共子串
公共子串:如果字符串 L 同时出现在字符串 A 和字符串 B 中,则称字符串 L
是字符串 A 和字符串 B 的公共子串。
例 9:最长公共子串(pku2774,ural1517)
给定两个字符串 A 和 B,求最长公共子串。
算法分析:
字符串的任何一个子串都是这个字符串的某个后缀的前缀。求 A 和 B 的最长
公共子串等价于求 A 的后缀和 B 的后缀的最长公共前缀的最大值。如果枚举 A
和 B 的所有的后缀,那么这样做显然效率低下。由于要计算 A 的后缀和 B 的后缀
的最长公共前缀,所以先将第二个字符串写在第一个字符串后面,中间用一个没
有出现过的字符隔开,再求这个新的字符串的后缀数组。观察一下,看看能不能
从这个新的字符串的后缀数组中找到一些规律。以 A=“aaaba”,B=“abaa”为
例,如图 8 所示。
那么是不是所有的 height 值中的最大值就是答案呢?不一定!有可能这两
个后缀是在同一个字符串中的,所以实际上只有当 suffix(sa[i-1])和
IOI2009 国家集训队论文 后缀数组 罗穗骞
23
suffix(sa[i])不是同一个字符串中的两个后缀时,height[i]才是满足条件的。
而这其中的最大值就是答案。记字符串 A 和字符串 B 的长度分别为|A|和|B|。求
新的字符串的后缀数组和 height 数组的时间是 O(|A|+|B|),然后求排名相邻但
原来不在同一个字符串中的两个后缀的 height 值的最大值,时间也是
O(|A|+|B|),所以整个做法的时间复杂度为 O(|A|+|B|)。时间复杂度已经取到
下限,由此看出,这是一个非常优秀的算法。
2.3.2 子串的个数
例 10:长度不小于 k 的公共子串的个数(pku3415)
给定两个字符串 A 和 B,求长度不小于 k 的公共子串的个数(可以相同)。
样例 1:
A=“xx”,B=“xx”,k=1,长度不小于 k 的公共子串的个数是 5。
样例 2:
A =“aababaa”,B =“abaabaa”,k=2,长度不小于 k 的公共子串的个数是22。
算法分析:
基本思路是计算 A 的所有后缀和 B 的所有后缀之间的最长公共前缀的长度,
把最长公共前缀长度不小于 k 的部分全部加起来。先将两个字符串连起来,中间
用一个没有出现过的字符隔开。按 height 值分组后,接下来的工作便是快速的
统计每组中后缀之间的最长公共前缀之和。扫描一遍,每遇到一个 B 的后缀就统
计与前面的 A 的后缀能产生多少个长度不小于 k 的公共子串,这里 A 的后缀需要
用一个单调的栈来高效的维护。然后对 A 也这样做一次。具体的细节留给读者思
考。
2.4 多个字符串的相关问题 多个字符串的相关问题 多个字符串的相关问题 多个字符串的相关问题
这类问题的一个常用做法是,先将所有的字符串连接起来,然后求后缀数组
和 height 数组,再利用 height 数组进行求解。这中间可能需要二分答案。
例 11:不小于 k 个字符串中的最长子串(pku3294)
IOI2009 国家集训队论文 后缀数组 罗穗骞
24
给定 n 个字符串,求出现在不小于 k 个字符串中的最长子串。
算法分析:
将 n 个字符串连起来,中间用不相同的且没有出现在字符串中的字符隔开,
求后缀数组。然后二分答案,用和例 3 同样的方法将后缀分成若干组,判断每组
的后缀是否出现在不小于 k 个的原串中。这个做法的时间复杂度为 O(nlogn)。
例 12:每个字符串至少出现两次且不重叠的最长子串(spoj220)
给定 n 个字符串,求在每个字符串中至少出现两次且不重叠的最长子串。
算法分析:
做法和上题大同小异,也是先将 n 个字符串连起来,中间用不相同的且没有
出现在字符串中的字符隔开,求后缀数组。然后二分答案,再将后缀分组。判断
的时候,要看是否有一组后缀在每个原来的字符串中至少出现两次,并且在每个
原来的字符串中,后缀的起始位置的最大值与最小值之差是否不小于当前答案
(判断能否做到不重叠,如果题目中没有不重叠的要求,那么不用做此判断)。
这个做法的时间复杂度为 O(nlogn)。
例 13:出现或反转后出现在每个字符串中的最长子串(PKU3294)
给定 n 个字符串,求出现或反转后出现在每个字符串中的最长子串。
算法分析:
这题不同的地方在于要判断是否在反转后的字符串中出现。其实这并没有加
大题目的难度。只需要先将每个字符串都反过来写一遍,中间用一个互不相同的
且没有出现在字符串中的字符隔开,再将 n 个字符串全部连起来,中间也是用一
个互不相同的且没有出现在字符串中的字符隔开,求后缀数组。然后二分答案,
再将后缀分组。判断的时候,要看是否有一组后缀在每个原来的字符串或反转后
的字符串中出现。这个做法的时间复杂度为 O(nlogn)。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值