上一次用这样的标题还是在上一次。
Part0 定义
给定一个长度为 n n n 的字符串 s s s,将其 n n n 个后缀按字典序排序(本文均为从小到大),排序后可以得到两个数组:
- s a [ i ] sa[i] sa[i] 表示排名为 i i i 的后缀的起始位置
- r k [ i ] rk[i] rk[i] 表示起始位置为 i i i 的后缀的排名
这就是后缀数组的组成部分,一定要熟记这两个定义qwq。
根据定义,得到两个性质:
-
s
a
[
r
k
[
i
]
]
=
i
sa[rk[i]]=i
sa[rk[i]]=i,即排名为(起始位置为
i
i
i 的后缀的排名)的后缀的起始位置是
i
i
i
(不打括号就太拗口了 - r k [ s a [ i ] ] = i rk[sa[i]]=i rk[sa[i]]=i,即以(排名为 i i i 的后缀的起始位置)为起始位置的后缀排名是 i i i
Part1 思想原理
n 2 log n n^2\log n n2logn
STL大法,string比较 O ( n ) O(n) O(n) + sort快排 O ( n l o g n ) O(nlogn) O(nlogn)。
n log 2 n n\log^2n nlog2n
考虑倍增,当对字符串 s 1 s1 s1 和 s 2 s2 s2 进行比较的时候,将两个字符串分别从中间断开,得到 s 1 = l 1 + r 1 s1=l1+r1 s1=l1+r1, s 2 = l 2 + r 2 s2=l2+r2 s2=l2+r2,那么 l 1 ≠ l 2 l1 \neq l2 l1=l2 时直接根据 l 1 l1 l1 和 l 2 l2 l2 大小关系比较, l 1 = l 2 l1 = l2 l1=l2 时再根据 r 1 r1 r1 和 r 2 r2 r2 进行比较即可。
将这个倍增的思路应用到求后缀数组的过程中,具体地,对于字符串"aabaaaab",有:
(图片来源OI wiki qwq
这样的话每次比较的字符串数都翻倍,但永远比较的是二元组 ( l , r ) (l,r) (l,r),最多需要 O ( log n ) O(\log n) O(logn) 次排序,内部用快排实现就可以很容易把时间复杂度降为 O ( n log 2 n ) O(n\log^2n) O(nlog2n),有了很大的进步,但是仍然不够优秀。
n log n n\log n nlogn
不难发现以上算法的复杂度瓶颈在排序,由于字符种类数有限,可以使用基数排序把排序复杂度降为 O ( n ) O(n) O(n),那么整个时间复杂度就变为 O ( n log n ) O(n\log n) O(nlogn)。
其实理解了那张图也就能明白原理,下面就只要写出代码就好了。
Part2 代码实现
由上可知,排序比较的是一个二元组,假设当前比较到 i i i,倍增长度为 k k k。那么二元组的第一关键字就是 r k [ i ] rk[i] rk[i],第二关键字就是 r k [ i + k ] rk[i+k] rk[i+k]。
根据基数排序的思想,代码实现就是先对第二关键字排序,再对第一关键字排序,再更新数组 r k rk rk 的过程。
代码一定要结合 s a sa sa 和 r k rk rk 的定义理解,可以解决很多问题qwq
Code:
int tong[N],rk[N],pre[N],sa[N],id[N],px[N];
inline bool cmp(int x,int y,int k){
//用函数来写,同样可以减少不连续的内存访问,优化常数
return pre[x]==pre[y]&&pre[x+k]==pre[y+k];
//两个串相同即第一关键字和第二关键字都相同
}
inline void SA(){
//初始先设定一个m,为字符串的值域
//以后的每一个循环都要分清n和m啊qwq
//先处理单个字符,开桶进行计数排序,初始化它们的排名
ff(i,1,n) tong[rk[i]=s[i]]++;
ff(i,1,m) tong[i]+=tong[i-1];
//求前缀和得出每个关键字最大名次
for(int i=n;i>=1;--i) sa[tong[rk[i]]--]=i;
//没有第二关键字,正序倒序均可
for(int k=1;k<=n;k<<=1){
//排序第二关键字,id[i]表示第二关键字排名为i的数第一关键字的位置
int cnt=0;
ff(i,n-k+1,n) id[++cnt]=i;
//i+k>n,没有第二关键字,排名最靠前
ff(i,1,n) if(sa[i]>k) id[++cnt]=sa[i]-k;
//顺序枚举sa,保证排名从小到大,加入可以作为第二关键字(sa[i]>k)的点
//计数排序第一关键字
ff(i,1,m) tong[i]=0;
ff(i,1,n) tong[px[i]=rk[id[i]]]++;
//用px[i]记录id[i]的排名,保证内存连续访问,可以卡常
ff(i,1,m) tong[i]+=tong[i-1];
//做前缀和,使桶变为排名
for(int i=n;i>=1;--i) sa[tong[px[i]]--]=id[i];
//一定要倒序
//因为tong[px[i]]展开就是tong[rk[id[i]]]
//这个桶里存了若干个第一关键字相同的串,要根据第二关键字排序
//所以倒着枚举i可以使第二关键字排名递减,先取出来的就是最大的,就能求出正确的排名
//更新rk
ff(i,1,n) pre[i]=rk[i];
//用pre存一下原来的rk数组,用于比较是否相同
cnt=0;
ff(i,1,n) rk[sa[i]]=(cmp(sa[i],sa[i-1],k)?cnt:++cnt);
//sa已经在排序第一关键字的时候更新了,直接顺序枚举即可
//用cnt来记录当前不同的后缀个数
if(cnt==n) return;
//已经有n个不同的后缀了,无需再排序
m=cnt;
//缩小值域,卡常
}
}
Part3 一些应用
求LCP
后缀数组还包括一个数组 h e i g h t height height(以下简称 h h h), h [ i ] h[i] h[i] 表示 s a [ i − 1 ] sa[i-1] sa[i−1] 和 s a [ i ] sa[i] sa[i] 的最长公共前缀(即LCP)的长度。
怎么用 h h h 数组求出任意两个后缀的最长公共前缀呢?假设当前求后缀 i i i 和 j j j 的最长公共前缀,设 r k [ i [ < r k [ j ] rk[i[<rk[j] rk[i[<rk[j],那么有 ∀ k ∈ [ i , j ] \forall k\in[i,j] ∀k∈[i,j], l c p ( i , j ) = min ( l c p ( i , k ) , l c p ( k , j ) ) lcp(i,j)=\min(lcp(i,k),lcp(k,j)) lcp(i,j)=min(lcp(i,k),lcp(k,j))。
证明: min ( l c p ( i , k ) , l c p ( k , j ) ) \min(lcp(i,k),lcp(k,j)) min(lcp(i,k),lcp(k,j)) 是 k k k 与 i i i 的LCP与 k k k 与 j j j 的LCP的交集,就一定是 i i i 与 j j j 的公共前缀,故 l c p ( i , j ) > = m i n ( l c p ( i , k ) , l c p ( k , j ) ) lcp(i,j)>=min(lcp(i,k),lcp(k,j)) lcp(i,j)>=min(lcp(i,k),lcp(k,j));而由于后缀已经排好序,不可能 i i i 到变 k k k 之后 k k k到 j j j 又变回来,故 l c p ( i , j ) < = m i n ( l c p ( i , k ) , l c p ( k , j ) ) lcp(i,j)<=min(lcp(i,k),lcp(k,j)) lcp(i,j)<=min(lcp(i,k),lcp(k,j)),得证。
根据定义计算 h h h 数组的时间复杂度是 O ( n 2 ) O(n^2) O(n2),显然不可取。但是我们可以根据这样一个定理在 O ( n ) O(n) O(n) 的时间完成计算: h [ r k [ i ] ] > = h [ r k [ i − 1 ] ] − 1 h[rk[i]]>=h[rk[i-1]]-1 h[rk[i]]>=h[rk[i−1]]−1。
证明:感谢 Maverik 告诉我证明挂了,所以我选择背下来!
于是我们就可以写出求 h h h 数组的代码,再用ST表处理就可以 O ( 1 ) O(1) O(1) 查询任意两个后缀的LCP了。
inline void geth(){
for(int i=1,k=0;i<=n;++i){
if(!rk[i]) continue;
if(k) --k;
while(s[i+k]==s[sa[rk[i]-1]+k]) ++k;
h[rk[i]]=k;
}
}
(或许)未完待续。