轉載自后綴數組 學習筆記
后綴數組 最詳細(maybe)講解
后綴數組這個東西真的是神仙操作……
但是這個比較神仙的東西在網上的講解一般都僅限於思想而不是代碼,而且這個東西開一堆數組,很多初學者寫代碼的時候很容易發生歧義理解,所以這里給出一個比較詳細的講解。筆者自己也是和后綴數組硬剛了一個上午外加一個中午才理解的板子。
本人版權意識薄弱,如有侵權現象請聯系博主郵箱xmzl200201@126.com
參考文獻:
以下是不認識的dalao們:
特別感謝以下的兩位dalao,寫的特別好,打call
什么是后綴數組
我們先看幾條定義:
子串
在字符串s中,取任意i<=j,那么在s中截取從i到j的這一段就叫做s的一個子串
后綴
后綴就是從字符串的某個位置i到字符串末尾的子串,我們定義以s的第i個字符為第一個元素的后綴為suff(i)
后綴數組
把s的每個后綴按照字典序排序,
后綴數組sa[i]就表示排名為i的后綴的起始位置的下標
而它的映射數組rk[i]就表示起始位置的下標為i的后綴的排名
簡單來說,sa表示排名為i的是啥,rk表示第i個的排名是啥
一定要記牢這些數組的意思,后面看代碼的時候如果記不牢的話就絕對看不懂
后綴數組的思想
先說最暴力的情況,快排(n log n)每個后綴,但是這是字符串,所以比較任意兩個后綴的復雜度其實是O(n),這樣一來就是接近O(n^2 log n)的復雜度,數據大了肯定是不行的,所以我們這里有兩個優化。
ps:本文中的^表示平方而不是異或
倍增
首先讀入字符串之后我們現根據單個字符排序,當然也可以理解為先按照每個后綴的第一個字符排序。對於每個字符,我們按照字典序給一個排名(當然可以並列),這里稱作關鍵字。
接下來我們再把相鄰的兩個關鍵字合並到一起,就相當於根據每一個后綴的前兩個字符進行排序。想想看,這樣就是以第一個字符(也就是自己本身)的排名為第一關鍵字,以第二個字符的排名為第二關鍵字,把組成的新數排完序之后再次標號。沒有第二關鍵字的補零。
既然是倍增,就要有點倍增的樣子。接下來我們對於一個在第i位上的關鍵字,它的第二關鍵字就是第(i+2)位置上的,聯想一下,因為現在第i位上的關鍵字是suff(i)的前兩個字符的排名,第i+2位置上的關鍵字是suff(i+2)的前兩個字符的排名,這兩個一合並,不就是suff(i)的前四個字符的排名嗎?方法同上,排序之后重新標號,沒有第二關鍵字的補零。同理我們可以證明,下一次我們要合並的是第i位和第i+4位,以此類推即可……
ps:本文中的“第i位”表示下標而不是排名。排名的話我會說“排名為i”
那么我們什么時候結束呢?很簡單,當所有的排名都不同的時候我們直接退出就可以了,因為已經排好了。
顯然這樣排序的速度穩定在(log n)
基數排序
如果我們用快排的話,復雜度就是(n log^2 n) 還是太大。
這里我們用一波基數排序優化一下。在這里我們可以注意到,每一次排序都是排兩位數,所以基數排序可以將它優化到O(n)級別,總復雜度就是(n log n)。
介紹一下什么是基數排序,這里就拿兩位數舉例
我們要建兩個桶,一個裝個位,一個裝十位,我們先把數加到個位桶里面,再加到十位桶里面,這樣就能保證對於每個十位桶,桶內的順序肯定是按個位升序的,很好理解。
最長公共前綴——后綴數組的輔助工具
話說這個費了我好長時間,就為了證明幾條定理……懶得證明的話背過就行了,不過筆者還是覺得知道證明用起來更踏實一些,話說我的證明過程應該比較好懂,適合初學者理解……
什么是LCP?
我們定義LCP(i,j)為suff(sa[i])與suff(sa[j])的最長公共前綴
為什么要求LCP?
后綴數組這個東西,不可能只讓你排個序就完事了……大多數情況下我們都需要用到這個輔助工具LCP來做題的
關於LCP的幾條性質
顯而易見的
- LCP(i,j)=LCP(j,i);
- LCP(i,i)=len(sa[i])=n-sa[i]+1;
這兩條性質有什么用呢?對於i>j的情況,我們可以把它轉化成i<j,對於i==j的情況,我們可以直接算長度,所以我們直接討論i<j的情況就可以了。
我們每次依次比較字符肯定是不行的,單次復雜度為O(n),太高了,所以我們要做一定的預處理才行。
LCP Lemma
LCP(i,k)=min(LCP(i,j),LCP(j,k)) 對於任意1<=i<=j<=k<=n
證明:設p=min{LCP(i,j),LCP(j,k)},則有LCP(i,j)≥p,LCP(j,k)≥p。
設suff(sa[i])=u,suff(sa[j])=v,suff(sa[k])=w;
所以u和v的前p個字符相等,v和w的前p個字符相等
所以u和w的前p的字符相等,LCP(i,k)>=p
設LCP(i,k)=q>p 那么q>=p+1
因為p=min{LCP(i,j),LCP(j,k)},所以u[p+1]!=v[p+1] 或者 v[p+1]!=w[p+1]
但是u[p+1]=w[p+1] 這不就自相矛盾了嗎
所以LCP(i,k)<=p
綜上所述LCP(i,k)=p=min{LCP(i,j),LCP(j,k)}
LCP Theorem
LCP(i,k)=min(LCP(j,j-1)) 對於任意1<i<=j<=k<=n
這個結合LCP Lemma就很好理解了
我們可以把i~k拆成兩部分i~(i+1)以及(i+1)~k
那么LCP(i,k)=min(LCP(i,i+1),LCP(i+1,k))
我們可以把(i+1)~k再拆,這樣就像一個DP,正確性顯然
怎么求LCP?
我們設height[i]為LCP(i,i-1),1<i<=n,顯然height[1]=0;
由LCP Theorem可得,LCP(i,k)=min(height[j]) i+1<=j<=k
那么height怎么求,枚舉嗎?NONONO,我們要利用這些后綴之間的聯系
設h[i]=height[rk[i]],同樣的,height[i]=h[sa[i]];
那么現在來證明最關鍵的一條定理:
h[i]>=h[i-1]-1;
證明過程來自曲神學長的blog,我做了一點改動方便初學者理解:
首先我們不妨設第i-1個字符串按排名來的前面的那個字符串是第k個字符串,注意k不一定是i-2,因為第k個字符串是按字典序排名來的i-1前面那個,並不是指在原字符串中位置在i-1前面的那個第i-2個字符串。
這時,依據height[]的定義,第k個字符串和第i-1個字符串的公共前綴自然是height[rk[i-1]],現在先討論一下第k+1個字符串和第i個字符串的關系。
第一種情況,第k個字符串和第i-1個字符串的首字符不同,那么第k+1個字符串的排名既可能在i的前面,也可能在i的后面,但沒有關系,因為height[rk[i-1]]就是0了呀,那么無論height[rk[i]]是多少都會有height[rk[i]]>=height[rk[i-1]]-1,也就是h[i]>=h[i-1]-1。
第二種情況,第k個字符串和第i-1個字符串的首字符相同,那么由於第k+1個字符串就是第k個字符串去掉首字符得到的,第i個字符串也是第i-1個字符串去掉首字符得到的,那么顯然第k+1個字符串要排在第i個字符串前面。同時,第k個字符串和第i-1個字符串的最長公共前綴是height[rk[i-1]],
那么自然第k+1個字符串和第i個字符串的最長公共前綴就是height[rk[i-1]]-1。
到此為止,第二種情況的證明還沒有完,我們可以試想一下,對於比第i個字符串的排名更靠前的那些字符串,誰和第i個字符串的相似度最高(這里說的相似度是指最長公共前綴的長度)?顯然是排名緊鄰第i個字符串的那個字符串了呀,即sa[rank[i]-1]。但是我們前面求得,有一個排在i前面的字符串k+1,LCP(rk[i],rk[k+1])=height[rk[i-1]]-1;
又因為height[rk[i]]=LCP(i,i-1)>=LCP(i,k+1)
所以height[rk[i]]>=height[rk[i-1]]-1,也即h[i]>=h[i-1]-1。
代碼(詳細注釋)
注意上面那個題不用求lcp……看代碼建議先大略掃一遍,因為的確有點繞
#include<iostream>
#include<cstdio>
#include<cstring>
#define rint register int
#define inv inline void
#define ini inline int
#define maxn 1000050
using namespace std;
char s[maxn];
int y[maxn],x[maxn],c[maxn],sa[maxn],rk[maxn],height[maxn],wt[30];
int n,m;
inv putout(int x) {
if(!x) {
putchar(48);
return;
}
rint l=0;
while(x) wt[++l]=x%10,x/=10;
while(l) putchar(wt[l--]+48);
}
inv get_SA() {
for (rint i=1; i<=n; ++i) ++c[x[i]=s[i]];
//c數組是桶
//x[i]是第i個元素的第一關鍵字
for (rint i=2; i<=m; ++i) c[i]+=c[i-1];
//做c的前綴和,我們就可以得出每個關鍵字最多是在第幾名
for (rint i=n; i>=1; --i) sa[c[x[i]]--]=i;
for (rint k=1; k<=n; k<<=1) {
rint num=0;
for (rint i=n-k+1; i<=n; ++i) y[++num]=i;
//y[i]表示第二關鍵字排名為i的數,第一關鍵字的位置
//第n-k+1到第n位是沒有第二關鍵字的 所以排名在最前面
for (rint i=1; i<=n; ++i) if (sa[i]>k) y[++num]=sa[i]-k;
//排名為i的數 在數組中是否在第k位以后
//如果滿足(sa[i]>k) 那么它可以作為別人的第二關鍵字,就把它的第一關鍵字的位置添加進y就行了
//所以i枚舉的是第二關鍵字的排名,第二關鍵字靠前的先入隊
for (rint i=1; i<=m; ++i) c[i]=0;
//初始化c桶
for (rint i=1; i<=n; ++i) ++c[x[i]];
//因為上一次循環已經算出了這次的第一關鍵字 所以直接加就行了
for (rint i=2; i<=m; ++i) c[i]+=c[i-1]; //第一關鍵字排名為1~i的數有多少個
for (rint i=n; i>=1; --i) sa[c[x[y[i]]]--]=y[i],y[i]=0;
//因為y的順序是按照第二關鍵字的順序來排的
//第二關鍵字靠后的,在同一個第一關鍵字桶中排名越靠后
//基數排序
swap(x,y);
//這里不用想太多,因為要生成新的x時要用到舊的,就把舊的復制下來,沒別的意思
x[sa[1]]=1;
num=1;
for (rint i=2; i<=n; ++i)
x[sa[i]]=(y[sa[i]]==y[sa[i-1]] && y[sa[i]+k]==y[sa[i-1]+k]) ? num : ++num;
//因為sa[i]已經排好序了,所以可以按排名枚舉,生成下一次的第一關鍵字
if (num==n) break;
m=num;
//這里就不用那個122了,因為都有新的編號了
}
for (rint i=1; i<=n; ++i) putout(sa[i]),putchar(' ');
}
inv get_height() {
rint k=0;
for (rint i=1; i<=n; ++i) rk[sa[i]]=i;
for (rint i=1; i<=n; ++i) {
if (rk[i]==1) continue;//第一名height為0
if (k) --k;//h[i]>=h[i-1]-1;
rint j=sa[rk[i]-1];
while (j+k<=n && i+k<=n && s[i+k]==s[j+k]) ++k;
height[rk[i]]=k;//h[i]=height[rk[i]];
}
putchar(10);
for (rint i=1; i<=n; ++i) putout(height[i]),putchar(' ');
}
int main() {
gets(s+1);
n=strlen(s+1);
m=122;
//因為這個題不讀入n和m所以要自己設
//n表示原字符串長度,m表示字符個數,ascll('z')=122
//我們第一次讀入字符直接不用轉化,按原來的ascll碼來就可以了
//因為轉化數字和大小寫字母還得分類討論,怪麻煩的
get_SA();
//get_height();
}