IOI2004国家集训队论文 许智磊
第 1 页 共11页
后 缀 数 组
安徽省芜湖市第一中学 许智磊
【摘要】
本文介绍后缀数组的基本概念、方法以及应用。
首先介绍O(nlogn)复杂度构造后缀数组的倍增算法,接着介绍了配合后缀
数组的最长公共前缀 LCP(Longest Common Prefix)的计算方法,并给出一个
线性时间内计算height 数组(记录跨度为1 的LCP 值的数组)的算法。为了让
读者对如何运用后缀数组有一个感性认识,还介绍了两个应用后缀数组的例子:
多模式串的模式匹配(给出每次匹配O(m+logn)时间复杂度的算法)以及求最
长回文子串(给出O(nlogn)时间复杂度的算法)。最后对后缀数组和后缀树作了
一番比较。
【关键字】
字符串 后缀 k-前缀比较关系
后缀数组 名次数组 后缀树 倍增算法 基数排序
最长公共前缀 RMQ问题 模式匹配 回文串 最长回文子串
【正文】
在字符串处理当中,后缀树和后缀数组都是非常有力的工具,其中后缀树
大家了解得比较多,关于后缀数组则很少见于国内的资料。其实后缀数组是后
缀树的一个非常精巧的替代品,它比后缀树容易编程实现,能够实现后缀树的
很多功能而时间复杂度也不太逊色,并且,它比后缀树所占用的空间小很多。
可以说,在信息学竞赛中后缀数组比后缀树要更为实用。因此在本文中笔者想
介绍一下后缀数组的基本概念、构造方法,以及配合后缀数组的最长公共前缀
数组的构造方法,最后结合一些例子谈谈后缀数组的应用。
IOI2004国家集训队论文 许智磊
第 2 页 共11页
基本概念
首先明确一些必要的定义:
字符集 一个字符集Σ是一个建立了全序关系的集合,也就是说,Σ中
的任意两个不同的元素α 和β 都可以比较大小,要么α<β,要么β<α(也就是
α>β)。字符集Σ中的元素称为字符。
字符串 一个字符串S 是将n 个字符顺次排列形成的数组,n 称为S 的
长度,表示为len(S)。S 的第i个字符表示为S[i]。
子串 字符串S 的子串S[i..j],i≤j,表示S 串中从i 到j 这一段,也就
是顺次排列S[i],S[i+1],...,S[j]形成的字符串。
后缀 后缀是指从某个位置i 开始到整个串末尾结束的一个特殊子串。
字符串S 的从i开头的后缀表示为Suffix(S,i),也就是Suffix(S,i)=S[i..len(S)]。
关于字符串的大小比较,是指通常所说的“字典顺序”比较,也就是对于
两个字符串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)在这里不可能满
足。
下面我们约定一个字符集Σ和一个字符串S,设len(S)=n,且S[n]='$',也
就是说S 以一个特殊字符'$'结尾,并且'$'小于Σ中的任何一个字符。除了S[n]
之外,S 中的其他字符都属于Σ。对于约定的字符串S,从位置i 开头的后缀直
接写成Suffix(i),省去参数S。
后缀数组 后缀数组SA 是一个一维数组,它保存1..n 的某个排列
SA[1],SA[2],...SA[n],并且保证 Suffix(SA[i])<Suffix(SA[i+1]),1≤i<n。也就是将
S 的n 个后缀从小到大进行排序之后把排好序的后缀的开头位置顺次放入SA
中。
名次数组 名次数组Rank=SA-1,也就是说若SA[i]=j,则Rank[j]=i,不难
看出Rank[i]保存的是Suffix(i)在所有后缀中从小到大排列的“名次”。
构造方法
如何构造后缀数组呢?最直接最简单的方法当然是把S 的后缀都看作一些
普通的字符串,按照一般字符串排序的方法对它们从小到大进行排序。
不难看出,这种做法是很笨拙的,因为它没有利用到各个后缀之间的有机
联系,所以它的效率不可能很高。即使采用字符串排序中比较高效的Multi-key
Quick Sort,最坏情况的时间复杂度仍然是O(n2)的,不能满足我们的需要。
IOI2004国家集训队论文 许智磊
第 3 页 共11页
下面介绍倍增算法(Doubling Algorithm),它正是充分利用了各个后缀之间的
联系,将构造后缀数组的最坏时间复杂度成功降至O(nlogn)。
对一个字符串u,我们定义u的k-前缀
î í ì
<
³
=
u len u k
u k len u k
u k
, ( )
[1 .. ] , ( )
定义k-前缀比较关系<k、=k和≤k:
设两个字符串u 和v,
u<kv 当且仅当 uk<vk
u=kv 当且仅当 uk=vk
u≤kv 当且仅当 uk≤vk
直观地看这些加了一个下标k 的比较符号的意义就是对两个字符串的前k
个字符进行字典序比较,特别的一点就是在作大于和小于的比较时如果某个字
符串的长度不到k 也没有关系,只要能够在k 个字符比较结束之前得到第一个
字符串大于或者小于第二个字符串就可以了。
根据前缀比较符的性质我们可以得到以下的非常重要的性质:
性质1.1 对k≥n,Suffix(i)<kSuffix(j) 等价于 Suffix(i)<Suffix(j)。
性质1.2 Suffix(i)=2kSuffix(j)等价于
Suffix(i)=kSuffix(j) 且 Suffix(i+k)=kSuffix(j+k)。
性质1.3 Suffix(i)<2kSuffix(j) 等价于
Suffix(i)<kS(j) 或 (Suffix(i)=kSuffix(j) 且 Suffix(i+k)<kSuffix(j+k))。
这里有一个问题,当i+k>n 或者j+k>n 的时候Suffix(i+k)或Suffix(j+k)是无
明确定义的表达式,但实际上不需要考虑这个问题,因为此时Suffix(i)或者
Suffix(j)的长度不超过k,也就是说它们的k-前缀以'$'结尾,于是k-前缀比较的
结果不可能相等,也就是说前k 个字符已经能够比出大小,后面的表达式自然
可以忽略,这也就看出我们规定S 以'$'结尾的特殊用处了。
定义k-后缀数组SAk 保存1..n 的某个排列SAk[1],SAk[2],…SAk[n]使得
Suffix(SAk[i]) ≤kSuffix(SAk[i+1]),1≤i<n。也就是说对所有的后缀在k-前缀比较关
系下从小到大排序,并且把排序后的后缀的开头位置顺次放入数组SAk中。
定义k-名次数组Rankk,Rankk[i]代表Suffix(i)在k-前缀关系下从小到大的
“名次”,也就是1 加上满足Suffix(j)<kSuffix(i)的j 的个数。通过SAk很容易在
O(n)的时间内求出Rankk。
假设我们已经求出了SAk 和Rankk,那么我们可以很方便地求出SA2k 和
Rank2k,因为根据性质1.2 和1.3,2k-前缀比较关系可以由常数个k-前缀比较关
系组合起来等价地表达,而Rankk 数组实际上给出了在常数时间内进行<k 和=k
比较的方法,即:
Suffix(i)<kSuffix(j) 当且仅当 Rankk[i]<Rankk[j]
Suffix(i)=kSuffix(j) 当且仅当 Rankk[i]=Rankk[j]
因此,比较Suffix(i)和Suffix(j)在k-前缀比较关系下的大小可以在常数时间
内完成,于是对所有的后缀在≤k 关系下进行排序也就和一般的排序没有什么区
别了,它实际上就相当于每个Suffix(i)有一个主关键字Rankk[i]和一个次关键字
Rankk[i+k]。如果采用快速排序之类O(nlogn)的排序,那么从SAk和Rankk构造
IOI2004国家集训队论文 许智磊
第 4 页 共11页
出SA2k的复杂度就是O(nlogn)。更聪明的方法是采用基数排序,复杂度为O(n)。
求出SA2k之后就可以在O(n)的时间内根据SA2k构造出Rank2k。因此,从SAk
和Rankk推出SA2k和Rank2k可以在O(n)时间内完成。
下面只有一个问题需要解决:如何构造出SA1和Rank1。这个问题非常简单:
因为<1,=1 和≤1 这些运算符实际上就是对字符串的第一个字符进行比较,所以
只要把每个后缀按照它的第一个字符进行排序就可以求出SA1,不妨就采用快
速排序,复杂度为O(nlogn)。
于是,可以在O(nlogn)的时间内求出SA1和Rank1。
求出了SA1和Rank1,我们可以在O(n)的时间内求出SA2和Rank2,同样,
我们可以再用O(n)的时间求出SA4和Rank4,这样,我们依次求出:
SA2和Rank2,SA4和Rank4,SA8和Rank8,……直到SAm和Rankm,其中
m=2k且m≥n。而根据性质1.1,SAm和SA 是等价的。这样一共需要进行logn
次O(n)的过程,因此
可以在O(nlogn)的时间内计算出后缀数组SA和名次数组Rank。
最长公共前缀
现在一个字符串S 的后缀数组SA 可以在O(nlogn)的时间内计算出来。利用
SA 我们已经可以做很多事情,比如在O(mlogn)的时间内进行模式匹配,其中
m,n 分别为模式串和待匹配串的长度。但是要想更充分地发挥后缀数组的威力,
我们还需要计算一个辅助的工具——最长公共前缀(Longest Common Prefix)。
对两个字符串u,v 定义函数lcp(u,v)=max{i|u=iv},也就是从头开始顺次比较
u 和v 的对应字符,对应字符持续相等的最大位置,称为这两个字符串的最长
公共前缀。
对正整数i,j 定义LCP(i,j)=lcp(Suffix(SA[i]),Suffix(SA[j]),其中i,j 均为1 至
n 的整数。LCP(i,j)也就是后缀数组中第i 个和第j 个后缀的最长公共前缀的长
度。
关于LCP 有两个显而易见的性质:
性质2.1 LCP(i,j)=LCP(j,i)
性质2.2 LCP(i,i)=len(Suffix(SA[i]))=n-SA[i]+1
这两个性质的用处在于,我们计算LCP(i,j)时只需要考虑i<j 的情况,因为
i>j时可交换i,j,i=j时可以直接输出结果n-SA[i]+1。
直接根据定义,用顺次比较对应字符的方法来计算LCP(i,j)显然是很低效
的,时间复杂度为O(n),所以我们必须进行适当的预处理以降低每次计算LCP
的复杂度。
经过仔细分析,我们发现LCP 函数有一个非常好的性质:
设i<j,则LCP(i,j)=min{LCP(k-1,k)|i+1≤k≤j} (LCP Theorem)
要证明LCP Theorem,首先证明LCP Lemma:
对任意1≤i<j<k≤n,LCP(i,k)=min{LCP(i,j),LCP(j,k)}
证明:设p=min{LCP(i,j),LCP(j,k)},则有LCP(i,j)≥p,LCP(j,k)≥p。
IOI2004国家集训队论文 许智磊
第 5 页 共11页
设Suffix(SA[i])=u,Suffix(SA[j])=v,Suffix(SA[k])=w。
由u=LCP(i,j)v得u=pv;同理v=pw。
于是Suffix(SA[i])=pSuffix(SA[k]),即LCP(i,k)≥p。 (1)
又设LCP(i,k)=q>p,则
u[1]=w[1],u[2]=w[2],...u[q]=w[q]。
而min{LCP(i,j),LCP(j,k)}=p 说明u[p+1]≠v[p+1]或v[p+1]≠w[q+1],
设u[p+1]=x,v[p+1]=y,w[p+1]=z,显然有x≤y≤z,又由p<q 得p+1≤q,
应该有x=z,也就是x=y=z,这与u[p+1]≠v[p+1]或v[p+1]≠w[q+1]矛
盾。
于是,q>p 不成立,即LCP(i,k)≤p。 (2)
综合(1),(2)知 LCP(i,k)=p=min{LCP(i,j),LCP(j,k)},LCP Lemma 得
证。
于是LCP Theorem可以证明如下:
当j-i=1 和j-i=2 时,显然成立。
设j-i=m时LCP Theorem成立,当j-i=m+1 时,
由LCP Lemma 知LCP(i,j)=min{LCP(i,i+1),LCP(i+1,j)},
因j-(i+1)≤m,LCP(i+1,j)=min{LCP(k-1,k)|i+2≤k≤j},故当j-i=m+1 时,仍有
LCP(i,j)=min{LCP(i,i+1),min{LCP(k-1,k)|i+2≤k≤j}}=min{LCP(k-1,k}|i+1≤k≤j)
根据数学归纳法,LCP Theorem成立。
根据LCP Theorem得出必然的一个推论:
LCP Corollary 对i≤j<k,LCP(j,k)≥LCP(i,k)。
定义一维数组height,令height[i]=LCP(i-1,i),1<i≤n,并设height[1]=0。
由LCP Theorem,LCP(i,j)=min{height[k]|i+1≤k≤j},也就是说,计算LCP(i,j)
等同于询问一维数组height 中下标在i+1 到j 范围内的所有元素的最小值。如
果height 数组是固定的,这就是非常经典的RMQ(Range Minimum Query)问
题。
RMQ 问题可以用线段树或静态排序树在O(nlogn)时间内进行预处理,之后
每次询问花费时间O(logn),更好的方法是RMQ 标准算法,可以在O(n)时间内
进行预处理,每次询问可以在常数时间内完成。
对于一个固定的字符串S,其height 数组显然是固定的,只要我们能高效地
求出height 数组,那么运用RMQ 方法进行预处理之后,每次计算LCP(i,j)的时
间复杂度就是常数级了。于是只有一个问题——如何尽量高效地算出height 数
组。
根据计算后缀数组的经验,我们不应该把n 个后缀看作互不相关的普通字
符串,而应该尽量利用它们之间的联系,下面证明一个非常有用的性质:
为了描述方便,设h[i]=height[Rank[i]],即height[i]=h[SA[i]]。h 数组满足
一个性质:
性质3 对于i>1 且Rank[i]>1,一定有h[i]≥h[i-1]-1。
为了证明性质3,我们有必要明确两个事实:
IOI2004国家集训队论文 许智磊
第 6 页 共11页
设i<n,j<n,Suffix(i)和Suffix(j)满足lcp(Suffix(i),Suffix(j)>1,则成立以下两
点:
Fact 1 Suffix(i)<Suffix(j) 等价于 Suffix(i+1)<Suffix(j+1)。
Fact 2 一定有lcp(Suffix(i+1),Suffix(j+1))=lcp(Suffix(i),Suffix(j))-1。
看起来很神奇,但其实很自然:lcp(Suffix(i),Suffix(j))>1 说明Suffix(i)和
Suffix(j)的第一个字符是相同的,设它为α,则Suffix(i)相当于α 后连接
Suffix(i+1),Suffix(j)相当于α 后连接Suffix(j+1)。比较Suffix(i)和Suffix(j)时,
第一个字符α 是一定相等的,于是后面就等价于比较Suffix(i)和Suffix(j),因此
Fact 1 成立。Fact 2 可类似证明。
于是可以证明性质3:
当h[i-1]≤1 时,结论显然成立,因h[i]≥0≥h[i-1]-1。
当h[i-1]>1 时,也即height[Rank[i-1]]>1,可见Rank[i-1]>1,因height[1]=0。
令j=i-1,k=SA[Rank[j]-1]。显然有Suffix(k)<Suffix(j)。
根据h[i-1]=lcp(Suffix(k),Suffix(j))>1 和Suffix(k)<Suffix(j):
由Fact 2 知lcp(Suffix(k+1),Suffix(i))=h[i-1]-1。
由Fact 1 知Rank[k+1]<Rank[i],也就是Rank[k+1]≤Rank[i]-1。
于是根据LCP Corollary,有
LCP(Rank[i]-1,Rank[i])≥LCP(Rank[k+1],Rank[i])
=lcp(Suffix(k+1),Suffix(i))
=h[i-1]-1
由于h[i]=height[Rank[i]]=LCP(Rank[i]-1,Rank[i]),最终得到 h[i]≥h[i-1]-1。
根据性质3,可以令i从1 循环到n按照如下方法依次算出h[i]:
若Rank[i]=1,则h[i]=0。字符比较次数为0。
若i=1 或者h[i-1]≤1,则直接将Suffix(i)和Suffix(Rank[i]-1)从第一个字符开
始依次比较直到有字符不相同,由此计算出h[i]。字符比较次数为h[i]+1,不超
过h[i]-h[i-1]+2。
否则,说明i>1,Rank[i]>1,h[i-1]>1,根据性质3,Suffix(i)和Suffix(Rank[i]-1)
至少有前h[i-1]-1 个字符是相同的,于是字符比较可以从h[i-1]开始,直到某个
字符不相同,由此计算出h[i]。字符比较次数为h[i]-h[i-1]+2。
设SA[1]=p,那么不难看出总的字符比较次数不超过
h p p h n n p n
h h i h i h p h i h i
n
i p
p
i
1 [ 1] 2( 2) [ ] 2 2( ) 4
[1] 1 ( [ ] [ 1] 2) [ 1] ( [ ] [ 1] 2)
1
1
2
£ + - + - + + + - £
+ +å - - + + + + å - - +
= +
-
=
也就是说,整个算法的复杂度为O(n)。
求出了h 数组,根据关系式height[i]=h[SA[i]]可以在O(n)时间内求出height
数组,于是
可以在O(n)时间内求出height 数组。
IOI2004国家集训队论文 许智磊
第 7 页 共11页
结合RMQ 方法,在O(n)时间和空间进行预处理之后就能做到在常数时间
内计算出对任意(i,j)计算出LCP(i,j)。
因为lcp(Suffix(i),Suffix(j))=LCP(Rank[i],Rank[j]),所以我们也就可以在常数
时间内求出S 的任何两个后缀之间的最长公共前缀。这正是后缀数组能强有力
地处理很多字符串问题的重要原因之一。
后缀数组的应用
下面结合两个例子谈谈如何运用后缀数组。
例一 多模式串的模式匹配问题
给定一个固定待匹配串S,长度为n,然后每次输入一个模式串P,长度为
m,要求返回P 在S 中的一个匹配或者返回匹配失败。所谓匹配指某个位置i
满足1≤i≤n-m+1 使得S[i..(i+m-1)]=P,也即Suffix(i)=mP。
我们知道,如果只有一个模式串,最好的算法就是KMP 算法,时间复杂
度为O(n+m),但是如果有多个模式串,我们就要考虑做适当的预处理使得对每
个模式串进行匹配所花的时间小一些。
最简单的预处理莫过于建立S 的后缀数组(先在S 的后面添加'$'),然后每
次寻找匹配转化为用二分查找法在SA 中找到和P 的公共前缀最长的一个后缀,
判断这个最长的公共前缀是否等于m。
这样,每次比较P 和一个后缀的复杂度为O(m),因为最坏情况下可能比较
了m 个字符。二分查找需要调用比较的次数为O(logn),因此总复杂度为
O(mlogn),于是每次匹配的复杂度从O(n+m)变为O(mlogn),可以说改进了不
少。
可是这样仍然不能令我们满足。前面提到LCP 可以增加后缀数组的威力,
我们来试试用在这个问题上。
我们分析原始的二分查找算法,大体有以下几步:
Step 1 令left=1,right=n,max_match=0。
Step 2 令mid=(left+right)/2(这里“/”表示取整除法)。
Step 3 顺次比较Suffix(SA[mid])和P 的对应字符,找到两者的最长公共
前缀r,并判断出它们的大小关系。若r>max_match则令max_match=r,ans=mid。
Step 4 若Suffix(SA[mid])<P 则令left=mid+1,若Suffix(SA[mid])>P 则令
right=mid-1,若Suffix(SA[mid])=P 则转至Step 6。
Step 5 若left<right 则转至Step 2,否则至Step 6。
Step 6 若max_match=m则输出ans,否则输出“无匹配”。
注意力很快集中在Step 3,如果能够避免每次都从头开始比较Suffix(SA[mid])
和P 的对应字符,也许复杂度就可以进一步降低。
类似于前面求height 数组,我们考虑利用以前求得的最长公共前缀作为比
较的“基础”,避免冗余的字符比较。
IOI2004国家集训队论文 许智磊
第 8 页 共11页
在比较Suffix(SA[mid])和P 之前,我们先用常数时间计算LCP(mid,ans),
然后比较LCP(mid,ans)和max_match:
情况一:LCP(mid,ans)<max_match,则说明Suffix(SA[mid])和P 的最长公
共前缀就是LCP(mid,ans),即直接可以确定Step 3 中的r=LCP(mid,ans),所以
可以直接比较两者的第r+1 个字符(结果一定不会是相等)就可以确定
Suffix(SA[mid])和P 的大小。这种情况下,字符比较次数为1 次。
情况二:LCP(mid,ans)≥max_match,则说明Suffix(SA[mid])和Suffix(SA[ans])
的前max_match个字符一定是相同的,于是Suffix(SA[mid])和P 的前max_match
个字符也是相同的,于是比较两者的对应字符可以从第max_match+1 个开始,
最后求出的r 一定大于等于原先的max_match,字符比较的次数为rmax_
match+1,不难看出Step 3 执行过后max_match将等于r。
设每次Step 3 执行之后max_match 值增加的量为Δmax。在情况一中,
Δmax=0,字符比较次数为1=Δmax+1;在情况二中,Δmax=r-max_match,字符
比较次数为r-max_match+1,也是Δmax+1。综上所述,每次Step 3 进行字符比
较的次数为Δmax+1。
总共的字符比较次数为所有的Δmax累加起来再加上Step 3执行的次数。所
有Δmax累加的结果显然就是最后的max_match值,不会超过len(P)=m,而Step
3 执行的次数为O(logn),因此总共的字符比较次数为O(m+logn)。而整个算法
的复杂度显然和字符比较次数同阶,为O(m+logn)。
至此,问题得到圆满解决,通过O(nlogn)的时间进行预处理(构造后缀数
组、名词数组,计算height 数组,RMQ 预处理),之后就可以在O(m+logn)的
时间内对一个长度为m 的模式串P 进行模式匹配,这仅仅是在读入P 的复杂度
上附加了logn的复杂度,是非常优秀的。
例二 最长回文子串问题
一个回文串是指满足如下性质的字符串u:
u[i]=u[len(u)-i+1],对所有的1≤i≤len(u)。
也就是说,回文串u 是关于u 的中间位置“对称”的。
按照回文串的长度的奇偶性把回文串分为两类:长度为奇数的回文串称为
奇回文串,长度为偶数的回文串称为偶回文串。
设想我们在回文串u 的“中心位置”划一条直线,显然,对于奇回文串,
这条线划在中间一个字符(u[(len(u)+1)/2])上,而对于偶回文串,这条线划在
中间的两个字符(u[len(u)/2]和u[len(u)/2+1])之间。以下是两个例子:
回文串里的字符是关于这条中心线对称分布的。中心线左边的字符串颠倒过来
等于右边的字符串,我们称之为“反射相等”。
字符串T 的回文子串指的是T 的子串中同时又是回文串的那些字符串。T
的回文子串中长度最大的称为最长回文子串。类似地定义奇(偶)回文子串和
最长奇(偶)回文子串。
a b c b a c a l f f l a c
IOI2004国家集训队论文 许智磊
第 9 页 共11页
最长回文子串问题是给定一个字符串T,求T 的最长回文子串,简便起见,
只要求给出最长回文子串的长度即可。
下面我们分析求最长奇回文串的方法,最长偶回文串的求法是类似的。
因为每个奇回文子串一定有一个中心位置(是整个回文串的中间一个字
符),这个中心位置是T 串的某个字符,所以我们首先枚举定下它的中心位置。
对于一个固定的中心位置i,可能存在多个以T[i]为中心的奇回文子串,但是它
们满足一个性质:奇回文串在T[i]左边的部分和右边的部分长度相等,而且关
于T[i]对称,即跟T[i]距离相同的左右两个字符对应相等。
那么任何一个以T[i]为中心的奇回文子串都可以表示为T[i-r..i+r],r≥0。可以
看出r 越大这个以T[i]为中心的奇回文串也就越长。因此只要能够找到最大的
一个r 使得T[i-r..i-1]和T[i+1..i+r]关于T[i]对称,也就求出了以T[i]为中心的奇
回文子串中最长的一个(可以看出,也一定只有一个)。如果枚举i从1 到len(T),
求出以每个T[i]为中心的奇回文子串中的最长者,这些最长者里面长度最大的
一个也就是整个T串的最长奇回文子串。
如何求以T[i]为中心的奇回文子串中的最长者呢?首先,当r=0 时,T[i]单
个字符本身构成一个奇回文子串,它的长度为1。下面我们考虑将r 不断增加,
假设当r=k 时T[i-r..i+r]构成奇回文子串,也就是说T[i-r..i-1]和T[i+1..i+r]反射相
等,当r=k+1 时,我们比较T[i-(k+1)]与T[i+k+1]这两个字符,若相等则说明
T[i-(k+1)..i-1]与T[i+1..i+k+1]是关于T[i]对称的(因为根据假设T[i-k..i-1]与
T[i+1..i+k]已经关于T[i]对称),于是r可以扩展到k+1。如果T[i-(k+1)]和T[i+k+1]
不同,则说明T[i-(k+1)..i-1]和T[i+1..i+k+1]不对称,并且对任何r'>k+1,T[i-r'..i-1]
和T[i+1..i+r']也不可能关于T[i]对称了,所以r 最大只能到k。
我们把r 递增的过程称为向两边扩展,扩展一次就可以把以T[i]为中心的
奇回文子串的长度加2。最后r 扩展到的最大值决定了以T[i]为中心的奇回文子
串中的最长者的长度(为2r+1)。
设len(T)=m,如果用依次比较对应字符的方法来求向两边扩展的最大值,
则最多可能比较m-1 个字符。由于要枚举每个位置作为中心向两边扩展,所以
最坏情况下总的复杂度可以达到O(m2),不很理想。下面优化算法的核心部分
——以一个位置为中心求向两边扩展的最大值。
在T 串的末尾添加一个特殊字符'#',规定它不等于T 的任何一个字符,然
后把T 串颠倒,接在'#'后,在T'串后再添加特殊字符'$',规定它小于前面的任
何一个字符,拼接后形成的串称为S 串。
不难看出T 串中任何一个字符都可在T'中对称地找到一个相同的字符。如
果都用S里的字符来表示,S[1..m]是T串,S[m+2..2m+1]是T'串,则每个S[i](1≤i≤m)
关于'#'对称的字符是S[2m-i+2]。这样原先T 串里面的一个子串S[i..j](1≤i≤j≤m)
关于'#'也可以对称地找到一个反射相等的子串S[2m-j+2..2m-i+2]。
现在我们定下T 串的某个位置S[i]为中心,假设向两边扩展到了i-r 和i+r,
那么S[i-r..i-1]和S[i+1..i+r]是反射相等的,S[i]可以在T'中找到对称的字符
S[2m-i+2],设i'=2m-i+2,则S[i-r..i-1]也可以在T'中找到对称的子串S[i'+1..i'+r],
b a n a n a # a n a n a b $
T T'
i i'=2m-i+2
IOI2004国家集训队论文 许智磊
第 10 页 共11页
那么S[i+1..i+r] 和S[i'+1..i'+r] 同时与S[i-r..i-1]反射相等,也就是说,
S[i+1..i+r]=S[i'+1..i'+r]。又因为S[i]=S[i'],故S[i..i+r]=S[i'..i'+r]。也就是说,
Suffix(i)=r+1Suffix(i')。现在要求r 尽量大,也就是求max{r|Suffix(i)=r+1Suffix(i')},
不难看出,这里r=LCP(i,i')-1。
上面的推理还存在一个问题,即求出的LCP(i,i')-1 还只能看作r 的一个上
界,还不能当成r 的最大值,因为还需要证明给出Suffix(i)和Suffix(i')的最长公
共前缀,一定可以反过来在T 串中找到相应的以i 为中心的回文串,这个证明
与前面的推理类似,只是需要注意一点:这里利用到了'#'这个特殊字符避免了
潜在的LCP(i,i')超过实际的r 最大值的危险。这个证明留给读者自行完成。
总之,我们已经确定求以T[i]为中心向两边扩展的最大值等价于求
LCP(i,i'),根据前面后缀数组和LCP 的相关内容这一步操作可以在常数时间内
完成,只要我们预先花费O(nlogn)的复杂度计算后缀数组、height 数组和进行
预处理。其中n=len(S)=2m+2。
现在每次求以一个位置T[i]为中心的回文子串中的最长者的长度可以在常
数时间内完成,我们枚举i 从1 到m,依次求出所有的这些最长者,记录其中
最大的一个的长度,就是所要求的最长奇回文子串的长度。由于对每个中心花
费时间为常数,所以总的复杂度为O(m)。
因此整个算法的复杂度是O(nlogn+m)=O(2mlog(2m)+m)=O(mlogm),是非
常优秀的算法,比之前的平方级算法大为改进。
后缀数组与后缀树的比较
通过上面的两个例子相信读者已经对后缀数组的强大功能有所了解,另一
种数据结构——后缀树,也可以用在这些问题中,那么后缀数组和后缀树有什
么区别和联系呢?我们来比较一下:
首先,后缀数组比较容易理解,也易于编程实现,而且不像后缀树那样需
要涉及到指针操作,所以调试起来比较方便。
第二,后缀数组占用的空间比后缀树要小,刚才分析中我们并没有提到空
间复杂度的问题,这里简单说一下:后缀数组SA 和名词数组Rank 都只需要n
个整数的空间,而在由Rankk计算出SA2k 的过程中需要用两个一维数组来辅助
完成,各占n 个整数的空间,滚动地进行操作,整个算法只需要这四个一维数
组和常数个辅助变量,因此总的空间占用为4n个整数。
而后缀树通常有2n 个以上节点,通常每个节点要两个整数(即使采用一些
技巧,至少还是要保存一个整数),每个节点要有两个指针(假设采用儿子-兄
弟表示方法),因此总共的空间占用至少是4n 个指针和2n 个整数(至少是n 个
整数)。如果采用其他方法表示树状结构,需要的空间更大。
可以看出后缀数组的空间需求比后缀树小。
最后比较它们的复杂度:
首先按照字符总数|Σ|把字符集Σ分为三种类型:
若|Σ|是一个常数,则称Σ 为Constant Alphabet,
若|Σ|的大小是关于S 的长度n的多项式函数,则称Σ 为Integer Alphabet,
若|Σ|没有大小上的限制,则称Σ 为General Alphabet。
IOI2004国家集训队论文 许智磊
第 11 页 共11页
显然Constant Alphbet 属于Integer Alphabet 的一种,而Integer Alphabet 是
General Alphabet 的一种。
构造后缀数组的复杂度与字符集无关,因为它是直接针对General Alphabet
的算法。
对于普通方法构造后缀树,如果用儿子-兄弟方式表达树状结构,时间复杂
度达到O(n*|Σ|),显然对于Integer Alphabet 和 General Alphabet 都很低效,对|Σ|
较大的Constant Alphabet 也不适用。
解决的方法是用平衡二叉树来保存指向儿子的指针,这样复杂度变为
O(n*log|Σ|)。
可见后缀树在某些情况下相对后缀数组有速度上的优势,但是并不明显。
对于|Σ|很小的字符串,后缀树相比后缀数组的速度优势还是比较可观的。
尤其是对于很常见的0-1 串。
后缀数组实际上可以看作后缀树的所有叶结点按照从左到右的次序排列放
入数组中形成的,所以后缀数组的用途不可能超出后缀树的范围。甚至可以说,
如果不配合LCP,后缀数组的应用范围是很狭窄的。但是LCP 函数配合下的后
缀数组就非常强大,可以完成大多数后缀树所能完成的任务,因为LCP 函数实
际上给出了任意两个叶子结点的最近公共祖先,这方面的内容大家可以自行研
究。
后缀树和后缀数组都是字符串处理中非常优秀的数据结构,不能说一个肯
定优于另一个,对于不同场合、不同条件的问题,我们应该灵活应用,精心选
择地选择其中较为适合的一个。算法和数据结构都是死的,而运用它们的人,
才是真正的主角,对经典的算法和数据结构熟练掌握并适当地运用以发挥它们
最大的力量,这才是信息学研究和竞赛中最大的智慧,也是信息学竞赛的魅力
所在。
/**//*用sort排序*/
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
#define maxn 200010
int k, n;
int s[maxn], height[maxn], h[maxn], rk[maxn], sa[maxn];
bool cmp1(const int &a, const int &b) {
return s[a] < s[b];
}
bool cmp2(const int &a, const int &b) {
return rk[a] < rk[b] || (rk[a] == rk[b] &&
(a + k < n ? rk[a + k] : 0) < (b + k < n ? rk[b + k] : 0));
}
void suffixArray() {
for(int i = 0; i < n; i++) sa[i] = i;
sort(sa, sa + n, cmp1);
rk[n - 1] = 0;
for(int i = 1, j = 0; i < n; i++) {
if(s[sa[i]] != s[sa[i - 1]]) j++;
rk[sa[i]] = j;
}
for(k = 1; k < n; k *= 2) {
sort(sa, sa + n, cmp2);
h[n - 1] = 0;
for(int i = 1, j = 0; i < n; i++) {
if(cmp2(sa[i], sa[i - 1]) || cmp2(sa[i - 1], sa[i])) j++;
h[sa[i]] = j;
}
memcpy(rk, h, n * sizeof(int));
}
height[0] = 0;
for(int i = 0, j = 0; i < n - 1; i++) {
while(s[sa[rk[i] - 1] + j] == s[i + j]) j++;
height[rk[i]] = j;
if(j > 0) j--;
}
}
char str[maxn];
int main() {
int t;
scanf("%d\n", &t);
while(t--) {
gets(str);
int len = strlen(str);
str[len] = '$';
gets(str + len + 1);
for(int i = 0; str[i]; i++) s[i] = str[i];
n = strlen(str) + 1;
s[n - 1] = 0;
suffixArray();
int ans = 0;
for(int i = 1; i < n; i++)
if(height[i] > ans && ((sa[i] < len && sa[i - 1] > len) || (sa[i] > len && sa[i - 1] < len))) ans = height[i];
printf("%d\n", ans);
}
return 0;
}
/**//*基数排序版本*/
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
#define maxn 200010
int k, n;
int s[maxn], height[maxn], h[maxn], rk[maxn], sa[maxn], rdx[maxn];
int next(const int &a) { return a + k < n ? rk[a + k] : 0; }
bool cmp(const int &a, const int &b) { return s[a] < s[b]; }
void suffixArray() {
for(int i = 0; i < n; i++) sa[i] = i;
sort(sa, sa + n, cmp);
rk[n - 1] = 0;
for(int i = 1, j = 0; i < n; i++) {
if(s[sa[i]] != s[sa[i - 1]]) j++;
rk[sa[i]] = j;
}
for(k = 1; k < n; k *= 2) {
memset(rdx, 0, sizeof(rdx));
for(int i = 0; i < n; i++) rdx[next(i)]++;
for(int i = 1; i < n; i++) rdx[i] += rdx[i - 1];
for(int i = 0; i < n; i++) sa[--rdx[next(i)]] = i;
memset(rdx, 0, sizeof(rdx));
for(int i = 0; i < n; i++) rdx[rk[i]]++;
for(int i = 1; i < n; i++) rdx[i] += rdx[i - 1];
for(int i = n - 1; i >= 0; i--) h[--rdx[rk[sa[i]]]] = sa[i];
memcpy(sa, h, n * sizeof(int));
h[n - 1] = 0;
for(int i = 1, j = 0; i < n; i++) {
if(rk[sa[i]] != rk[sa[i - 1]] || next(sa[i]) != next(sa[i - 1])) j++;
h[sa[i]] = j;
}
memcpy(rk, h, n * sizeof(int));
}
height[0] = 0;
for(int i = 0, j = 0; i < n - 1; i++) {
while(s[sa[rk[i] - 1] + j] == s[i + j]) j++;
height[rk[i]] = j;
if(j > 0) j--;
}
}
char str[maxn];
int main() {
int t;
scanf("%d\n", &t);
while(t--) {
gets(str);
int len = strlen(str);
str[len] = '$';
gets(str + len + 1);
for(int i = 0; str[i]; i++) s[i] = str[i];
n = strlen(str) + 1;
s[n - 1] = 0;
suffixArray();
int ans = 0;
for(int i = 1; i < n; i++)
if(height[i] > ans && ((sa[i] < len && sa[i - 1] > len) || (sa[i] > len && sa[i - 1] < len))) ans = height[i];
printf("%d\n", ans);
}
return 0;
}