1. 程式人生 > 實用技巧 >「學習筆記」字尾陣列SA

「學習筆記」字尾陣列SA

字尾會針對字串上的操作,就像這個題裡面 “ 把字串的所有非空字尾按字典序從小到大排序 ”

字尾排序就是構建字尾陣列的過程(就是把字尾排個序,排完序的陣列就叫字尾陣列)

字尾排序的實現主要依靠倍增法和基數排序來實現

原理

先說字尾排序

定義:

\(sa[i]:\) 排名為\(i\)的字尾的位置

\(rak[i]:\) 從第 \(i\) 個位置開始的字尾的排名,下文為了敘述方便,把從第\(i\)個位置開始的字尾簡稱為"字尾\(i\)"

\(tp[i]\):基數排序的第二關鍵字,意義與\(sa\)一樣

\(tax[i]\)\(i\)號元素出現了多少次,基數排序的桶

(構造字尾陣列還有DC3演算法(三分?)和建字尾樹,跑dfs序的兩種\(O(n)\)

做法,但是常用的是字尾排序)

觀察易得,\(rk\)\(sa\) 是個互逆的陣列,也就是 \(rk[sa[i]]=i,sa[rk[i]]=i\)

如果直接sort會爆掉複雜度,因為我們的字串長度和比較的時候複雜度和長度相關

所以我們就對每一位做文章

先是基數排序,把所有的字尾按照第一位的字元排個序

然後考慮倍增

然後比較巧妙的一步就是在於“\(i\)號字尾的前\(\frac{w}{2}\)個字元形成的字串是\(i-\) \(\frac{w}{2}\)號字尾的後\(\frac{w}{2}\)個字元形成的字串”

這裡大概需要意會我們去掉了字尾\(i\)的後面部分

我們每一次執行迴圈中的內容的時候有一個部分排好序的字尾陣列(可以意會的)

這裡每一次考慮倍增新出來的那些位數,對它們進行基數排序就可以了

CODE

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define reg register
namespace yspm{
    inline int read(){
        int res=0,f=1; char k;
        while(!isdigit(k=getchar())) if(k=='-') f=-1;
        while(isdigit(k)) res=res*10+k-'0',k=getchar();
        return res*f;
    }
    const int N=1e6+10;
    int sa[N],rk[N],ton[N],sec[N],n,m,h[N];
    char s[N];
    inline void gb(){
        memset(ton,0,sizeof(ton));
        for(reg int i=1;i<=n;++i) ton[rk[i]]++;
        for(reg int i=1;i<=m;++i) ton[i]+=ton[i-1];
        for(reg int i=n;i>=1;--i) sa[ton[rk[sec[i]]]--]=sec[i];
        return ;
    }
    signed main(){
        scanf("%s",s+1); n=strlen(s+1);
        m=75; for(reg int i=1;i<=n;++i) rk[i]=s[i]-'0'+1,sec[i]=i; gb();
        for(reg int w=1,p=0;p<n;m=p,w<<=1){ 
            p=0; for(reg int i=1;i<=w;++i) sec[++p]=n-w+i;
            for(reg int i=1;i<=n;++i) if(sa[i]>w) sec[++p]=sa[i]-w;
            gb(); swap(rk,sec); rk[sa[1]]=p=1;  
            for(reg int i=2;i<=n;++i){
                if(sec[sa[i]]==sec[sa[i-1]]&&sec[sa[i]+w]==sec[sa[i-1]+w]) rk[sa[i]]=p; else rk[sa[i]]=++p;
            } 
        }
        int k=0;
        for(reg int i=1;i<=n;++i){
            if(rk[i]==1){h[1]=k=0; continue;}
            int j=sa[rk[i]-1];
            if(k>0) --k; while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) ++k;
            h[rk[i]]=k;
        } 
        for(reg int i=1;i<=n;++i) printf("%lld ",h[i]); puts("");
        return 0;
    }
}
signed main(){return yspm::main();}

然後字尾陣列比較重要的是 \(h\) 陣列和 \(height\) 陣列

\[height[i]=LCP(sa[i-1],sa[i]) \]

\(height\) 陣列有以下性質:

若兩個下標 \(j,k\) 滿足 \(rk_j<rk_k\)

那麼\(LCP(j,k)=\min\limits_{l=j+1}^k height_l\)

這個感覺就比較有用了

然而不太好求

再定義:\(h_i=height[rk_i]\)

然後 \(h\) 陣列有以下性質:

\[h_i\ge h_{i-1}-1 \]

關於上面兩條性質的證明?link

運用 \(h\) 陣列的性質,我們就可以在較低複雜度內暴力計算出 \(h,height\)

例題

\(Suffix\ Array\) 求本質不同的子串個數的方法:

求出來 \(sa\)\(height\) 陣列,然後

\[ans=\sum _ {i=1}^n n+1-sa_i-height_i \]


字尾陣列求任意子串 \(lcs\) 的方法:

把原串逆過來複制一次放到原串末尾(中間要新增字元的)

然後就又變成了 \(lcp\) 問題


求一些字尾的 \(lcp\) 和的問題

(好像可以用字尾虛樹,即 \(bzoj\ \ SvT\) 一題的全稱就是 \(Suffix\ virtual\ Tree\) ,那題目直接搞個樹然後每次按照消耗戰的做法做就行了)

用字尾陣列就單調棧就好了


套路題小結

\(1.\) 二分 \(rk\) 或者下標

\(JSOI2015\) 串分割中,觀察到相關的性質,減少列舉量,然後二分得解

\(HEOI2016\) 字串中,二分長度結合 \(rmq\) 及對 \(rk\)\(rmq\) 或者主席樹的維護實現最大 \(lcp\) 的查詢

\(2.\) 差分

股市的預測 和 Sandy 的卡片 等題目中,題目涉及到趨勢的詢問,這時候差分再結合相應做法會比較巧妙

\(3.\) 兩串的 \(\cdots\)

考慮把第二個串複製一倍放到地一個後面,中間加入一個字典序大的字元進行維護


總的來看就是說字尾陣列更多的是一個工具,具體還是要靠分析性質和結合二分,資料結構等手段對題目進行維護