后缀数组 SA 学习笔记

后缀数组 SA 学习笔记

后缀数组处理字符串后缀排名,公共子串类问题十分优秀,可以在部分情况下替代后缀自动机(SAM),本文主要讲解后缀数组的实现过程和部分例题。

算法

定义

后缀:从 \(i\) 开始到字符串结束的一个特殊子串,本文用 \(suf(i)\) 表示从 \(i\) 开始的后缀。

后缀数组 SA:SA 是一维数组,\(SA_i\) 表示所有后缀按字典序排序之后,第 \(i\) 名的后缀的开始位置,即 \(suf(SA_i)\) 在所有后缀中字典序排序是第 \(i\) 名。

名次数组 rk:rk 是一维数组,\(rk_i\) 表示后缀 \(suf(i)\) 和所有后缀按字典序排序后的排名。

倍增算法

前置知识:基数排序。

使用倍增方法,对字符开始的 \(2^k\) 长度的子字符串进行排序,求出其 rk 值。(这里 rk 允许相同)

\(2^k\) 大于 \(n\) 以后我们的后缀数组 SA 已经求出。

在求 \(2^k\) 长度的排序时,\(2^{k-1}\) 的排序已经求出,一个长度为 \(2^k\) 的段可以由两个长度为 \(2^{k-1}\) 的段合并得到。

那么把从 \(i\) 开始的前 \(2^{k-1}\) 位之前的排序结果的 rk,看做第一关键字,把后 \(2^{k-1}\) 的排序结果看做第二关键字,对关键字排序从而求出整个排序结果。

附一张 2009 年集训队论文的图:

这里的 \(x\) 为第一关键字,\(y\) 为第二关键字。

在排序时使用基数排序,排序未完成时 \(rk[i]\) 表示 \(s[i,\min(i+2^{k-1}-1,n)]\) 的排位。可以利用上次的排序结果,直接排序好第二关键字。

#include<bits/stdc++.h>
using namespace std;

const int maxn=2e6+5;

int n,m=128;
int sa[maxn],rk[maxn],b[maxn],tmp[maxn];

char s[maxn];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    for(int i=1;i<=n;i++) ++b[rk[i]=s[i]];
    for(int i=1;i<=m;i++) b[i]+=b[i-1];
    for(int i=n;i;i--) sa[b[rk[i]]--]=i;
    for(int i=1;i<=n;i++) tmp[i]=rk[i];
    int t=0;
    for(int i=1;i<=n;i++)
    {
        if(tmp[sa[i]]==tmp[sa[i-1]]) rk[sa[i]]=t;
        else rk[sa[i]]=++t;
    }
    m=t;

    for(int l=1;l<n;l=l<<1)
    {
        //此时排序的长度为 2*l
        int t=0;
        for(int i=n-l+1;i<=n;i++) tmp[++t]=i;//长度不足 l 第二关键字最小
        for(int i=1;i<=n;i++) if(sa[i]>l) tmp[++t]=sa[i]-l;
        //sa[i] 向后 l 位排第 i 个,按顺序加入 tmp
        //由于是 s[sa[i],sa[i]+l] 第二关键字,所以第一关键字为 s[sa[i]-l,sa[i]-1
        //此时 tmp 已经按照第二关键字排序
        for(int i=1;i<=m;i++) b[i]=0;
        for(int i=1;i<=n;i++) b[rk[tmp[i]]]++;//第一关键字排序
        for(int i=1;i<=m;i++) b[i]+=b[i-1];
        for(int i=n;i;i--) sa[b[rk[tmp[i]]]--]=tmp[i];//基数排序

        for(int i=1;i<=n;i++) tmp[i]=rk[i];//读取上次排名,辅助判断关键字相同,辅助修改本次rk
        t=0;
        for(int i=1;i<=n;i++)
        {
            if(tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+l]==tmp[sa[i-1]+l]) rk[sa[i]]=t;//过程中允许排名相同,判断条件为第一,第二关键字都相同
            else rk[sa[i]]=++t;
        }
        m=t;
    }
    for(int i=1;i<=n;i++) printf("%d ",sa[i]);
}

当基数排序排出 \(n\) 个数字时,排序已经结束,可以直接退出。

随机数据情况下,可以大幅度节省时间。

#include<bits/stdc++.h>
using namespace std;

const int maxn=2e6+5;

int n,m=128;
int sa[maxn],rk[maxn],b[maxn],tmp[maxn];

char s[maxn];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    for(int i=1;i<=n;i++) ++b[rk[i]=s[i]];
    for(int i=1;i<=m;i++) b[i]+=b[i-1];
    for(int i=n;i;i--) sa[b[rk[i]]--]=i;
    for(int i=1;i<=n;i++) tmp[i]=rk[i];
    int t=0;
    for(int i=1;i<=n;i++)
    {
        if(tmp[sa[i]]==tmp[sa[i-1]]) rk[sa[i]]=t;
        else rk[sa[i]]=++t;
    }
    m=t;

    for(int l=1;l<n;l=l<<1)
    {
        int t=0;
        for(int i=n-l+1;i<=n;i++) tmp[++t]=i;
        for(int i=1;i<=n;i++) if(sa[i]>l) tmp[++t]=sa[i]-l;
        for(int i=1;i<=m;i++) b[i]=0;
        for(int i=1;i<=n;i++) b[rk[tmp[i]]]++;
        for(int i=1;i<=m;i++) b[i]+=b[i-1];
        for(int i=n;i;i--) sa[b[rk[tmp[i]]]--]=tmp[i];//基数排序

        for(int i=1;i<=n;i++) tmp[i]=rk[i];//读取上次排名,修改本次rk
        t=0;
        for(int i=1;i<=n;i++)
        {
            if(tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+l]==tmp[sa[i-1]+l]) rk[sa[i]]=t;
            else rk[sa[i]]=++t;//过程中允许排名相同
        }
        m=t;
        if(m==n) break;
    }
    for(int i=1;i<=n;i++) printf("%d ",sa[i]);
}

SA-IS

先留个坑

关于后缀数组的应用——height 数组

定义

height 数组:\(height_i=LCP(suf(SA_i),suf(SA_{i-1}))\)

求 height 数组

如果直接去求 height 数组是 \(O(n^2)\) 的,并没有利用 SA 的优秀性质。

但这里有一个妙不可言的证明,可以把两者联系起来。

排序后,越接近的两个后缀,他们的 \(LCP\) 肯定越大。数学语言就是若 \(|rk_i-rk_j|<|rk_i-rk_k|\),则有 \(LCP(suf(i),suf(j))\ge LCP(suf(i),suf(k))\)

其实有一个比这个结论更强的结论,设 \(h_i=height_{rk_i}\),我们有:

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

人话就是,\(suf(i)\) 的最长 \(lcp\) 长度至少为 \(suf(i-1)\) 的最长 \(lcp\) 长度减一。

感性证明是容易的,下面是写成书面语言的证明:

画一张图。

其中 \(s_{i-1}\)\(s_j\)\(suf(i-1)\) 的最长 \(lcp\),长度为 \(j-(i-1)+1\),满足 \(h_{i-1}=height_{rk_{i-1}}=j-(i-1)+1\)

对于 \(suf(i)\) 而言,由于 \(s_i\)\(s_j\)\(suf(i-1)\) 的最长 \(lcp\) 的一部分,那么 \(s_i\)\(s_j\) 可以借用 \(suf(i-1)\) 的最长 \(lcp\) 找到一段相同字串,成为 \(suf(i-1)\)\(lcp\),所以 \(h_i=height_{rk_i}\) 至少为 \(j-i+1\),即 \(h_{i-1}-1\)

得证。

上述关于 \(h_i\) 的结论加上 \(SA_{i-1}\)\(SA_i\) 最长 \(lcp\) 可以在 \(O(n)\) 的时间内求出 \(height\) 数组。

int k=0;
for(int i=1;i<=n;i++)
{
    if(rk[i]==1) continue;
    if(k) k--;//k 即为 h[i-1]
    int j=sa[rk[i]-1];
    while(i+k<=n&&j+k<=n&&s[j+k]==s[i+k]) ++k;
    height[rk[i]]=k;
}

height 数组的实际运用

height 数组的实际运用有很多,这里先提出一个运用,后面例题再分析:

\(LCP(suf(i),suf(j))\ (i\neq j)\)

不妨设 \(rk_i < rk_j\)

理解一下,有

\[LCP(suf(i),suf(j))=\min_{k=rk_i+1}^{rk_j} height_k \]

上图,来自集训队论文 2009 年:

不难证明上述结论,留作习题供读者自己思考。

例题

例1 P4051 JSOI2007 字符加密

长度为 \(n\) 的字符串,位移若干位(可以是 \(0\) 位)形成的 \(n\) 个字符串,按字典序排序后的输出每一项的最后一位。

把原字符串复制一次(去除最后一位),后缀排序,然后按顺序输出每个后缀的第 \(n\) 位即可。

可以这么做的原因是,对排序影响最大的肯定是前 \(n\) 位,后面的存在对于排序造成的影响可以看做没有。

#include<bits/stdc++.h>
using namespace std;

const int maxn=2e5+5;

int n,m;
int sa[maxn],rk[maxn],tmp[maxn],b[maxn];

char s[maxn];

int main()
{
    scanf("%s",s+1);
    n=strlen(s+1);
    for(int i=1;i<n;i++) s[i+n]=s[i];
    n=n+n-1;

    m=2000;
    for(int i=1;i<=n;i++) b[rk[i]=s[i]]++;
    for(int i=1;i<=m;i++) b[i]+=b[i-1];
    for(int i=n;i;i--) sa[b[rk[i]]--]=i;
    for(int i=1;i<=n;i++) tmp[i]=rk[i];
    int t=0;
    for(int i=1;i<=n;i++)
    {
        if(tmp[sa[i]]==tmp[sa[i-1]]) rk[sa[i]]=t;
        else rk[sa[i]]=++t;
    }
    m=t;

    for(int l=1;l<n;l<<=1)
    {
        int t=0;
        for(int i=n-l+1;i<=n;i++) tmp[++t]=i;
        for(int i=1;i<=n;i++) if(sa[i]>l) tmp[++t]=sa[i]-l;
        for(int i=1;i<=m;i++) b[i]=0;
        for(int i=1;i<=n;i++) b[rk[tmp[i]]]++;
        for(int i=1;i<=m;i++) b[i]+=b[i-1];
        for(int i=n;i;i--) sa[b[rk[tmp[i]]]--]=tmp[i];
        for(int i=1;i<=n;i++) tmp[i]=rk[i];
        t=0;
        for(int i=1;i<=n;i++)
        {
            if(tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+l]==tmp[sa[i-1]+l]) rk[sa[i]]=t;
            else rk[sa[i]]=++t;
        }
        m=t;
        if(m==n) break;
    }

    for(int i=1;i<=n;i++)
    {
        if(sa[i]>(n+1)/2) continue;
        putchar(s[sa[i]+(n+1)/2-1]);
    }
}

例2 P5546 POI2000 公共串

把所有的字符串接在一起,中间用不同的特殊字符分开。

求出 height 数组,然后使用双指针。先使得 \([l,r]\) 区间内满足出现了 \(n\) 个字符串内所有的后缀,接着推动 \(l\) 指针,使得区间内刚刚好出现了 \(n\) 个字符串的所有后缀,此时 height 数组在区间 \([l,r]\) 上的最小值为一个可行长度。

#include<bits/stdc++.h>
using namespace std;

const int maxn=2e6+5;

int n,m=128,_;
int sa[maxn],rk[maxn],b[maxn],tmp[maxn],height[maxn],L[10],R[10];

char s[maxn];

int ok;
int vis[10],col[maxn];
void add(int x)
{
    if(col[x]==0) return ;
    vis[col[x]]++;
    if(vis[col[x]]==1) ok++;
}
void del(int x)
{
    if(col[x]==0) return ;
    vis[col[x]]--;
    if(vis[col[x]]==0) ok--;
}

int main()
{
    scanf("%d",&_);
    for(int i=1;i<=_;i++)
    {
        L[i]=n+1;
        scanf("%s",s+n+1);
        n+=strlen(s+n+1);
        R[i]=n;
        s[++n]=i+'0';
    }

    for(int i=1;i<=n;i++) b[rk[i]=s[i]]++;
    for(int i=1;i<=m;i++) b[i]+=b[i-1];
    for(int i=n;i;i--) sa[b[rk[i]]--]=i;
    for(int i=1;i<=n;i++) tmp[i]=rk[i];
    int t=0;
    for(int i=1;i<=n;i++)
    {
        if(tmp[sa[i]]==tmp[sa[i-1]]) rk[sa[i]]=t;
        else rk[sa[i]]=++t;
    }
    m=t;

    for(int l=1;l<n;l=l<<1)
    {
        int t=0;
        for(int i=n-l+1;i<=n;i++) tmp[++t]=i;
        for(int i=1;i<=n;i++) if(sa[i]>l) tmp[++t]=sa[i]-l;
        for(int i=1;i<=m;i++) b[i]=0;
        for(int i=1;i<=n;i++) b[rk[tmp[i]]]++;
        for(int i=1;i<=m;i++) b[i]+=b[i-1];
        for(int i=n;i;i--) sa[b[rk[tmp[i]]]--]=tmp[i];

        for(int i=1;i<=n;i++) tmp[i]=rk[i];
        t=0;
        for(int i=1;i<=n;i++)
        {
            if(tmp[sa[i]]==tmp[sa[i-1]]&&tmp[sa[i]+l]==tmp[sa[i-1]+l]) rk[sa[i]]=t;
            else rk[sa[i]]=++t;
        }
        m=t;
        if(m==n) break;
    }
    for(int i=1;i<=n;i++) rk[sa[i]]=i;

    int k=0;
    for(int i=1;i<=n;i++)
    {
        if(rk[i]==1) continue;
        if(k) k--;
        int j=sa[rk[i]-1];
        while(i+k<=n&&j+k<=n&&s[j+k]==s[i+k]) ++k;
        height[rk[i]]=k;
    }
    for(int i=1;i<=_;i++)
        for(int j=L[i];j<=R[i];j++) col[rk[j]]=i;

    deque<int>q;
    int l=1,ans=0;
    add(1);
    for(int r=2;r<=n;r++)
    {
        while(!q.empty()&&height[q.back()]>=height[r]) q.pop_back();
        q.push_back(r);
        add(r);
        if(ok==_)
        {
            while(ok==_&&l<r) del(l),l++;
            add(l-1),l--;
        }
        while(!q.empty()&&q.front()<=l) q.pop_front();
        if(ok==_) ans=max(ans,height[q.front()]);
    }
    printf("%d",ans);
}

例3 P2743 USACO5.1 乐曲主题Musical Themes

“转调”可以用差分数组替代,这样就是求这个差分数组的 height,然后二分答案,对 height 进行分组,相邻的大于 mid 的分为一组。

如果有一组最靠前的后缀的起点和最靠后的后缀的起点,之间的距离大于等于 mid。那么这个 mid 是可行的。

#include<bits/stdc++.h>
using namespace std;

const int maxn=1e5+6;

int n,m;
int sa[maxn],tmp[maxn],rk[maxn],b[maxn],s[maxn],height[maxn];

bool check(int mid)
{
    int mx=sa[1],mi=sa[1];
    for(int i=2;i<=n;i++)
    {
        if(height[i]<mid) mx=mi=sa[i];
        else
        {
            mi=min(mi,sa[i]),mx=max(mx,sa[i]);
            if(mx-mi>mid) return 1;
        }
    }
    return 0;
}

int main()
{
    scanf("%d",&n);
    for(int i=1;i<=n;i++) scanf("%d",&s[i]);
    for(int i=1;i<n;i++) s[i]=s[i+1]-s[i]+90;
    n--;

    m=250;
    for(int i=1;i<=n;i++) b[rk[i]=s[i]]++;
    for(int i=1;i<=m;i++) b[i]+=b[i-1];
    for(int i=n;i;i--) sa[b[rk[i]]--]=i;
    for(int i=1;i<=n;i++) tmp[i]=rk[i];
    int t=0;
    for(int i=1;i<=n;i++)
    {
        if(tmp[sa[i]]==tmp[sa[i-1]]) rk[sa[i]]=t;
        else rk[sa[i]]=++t;
    }
    m=t;

    for(int l=1;l<n;l=l<<1)
    {
        int t=0;
        for(int i=n-l+1;i<=n;i++) tmp[++t]=i;
        for(int i=1;i<=n;i++) if(sa[i]>l) tmp[++t]=sa[i]-l;
        for(int i=1;i<=m;i++) b[i]=0;
        for(int i=1;i<=n;i++) b[rk[tmp[i]]]++;
        for(int i=1;i<=m;i++) b[i]+=b[i-1];
        for(int i=n;i;i--) sa[b[rk[tmp[i]]]--]=tmp[i];

        for(int i=1;i<=n;i++) tmp[i]=rk[i];
        t=0;
        for(int i=1;i<=n;i++)
        {
            if(tmp[sa[i-1]]==tmp[sa[i]]&&tmp[sa[i-1]+l]==tmp[sa[i]+l]) rk[sa[i]]=t;
            else rk[sa[i]]=++t;
        }
        m=t;
        if(m==n) break;
    }
    for(int i=1;i<=n;i++) rk[sa[i]]=i;
    int k=0;
    for(int i=1;i<=n;i++)
    {
        if(rk[i]==1) continue;
        if(k) k--;
        int j=sa[rk[i]-1];
        while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++;
        height[rk[i]]=k;
    }

    int l=0,r=n,ans=0;
    while(l<=r)
    {
        int mid=(l+r)>>1;
        if(check(mid)) ans=mid,l=mid+1;
        else r=mid-1;
    }
    if(ans<4) printf("0");
    else printf("%d",ans+1);
}

习题

P2178 NOI2015 品酒大会

P1117 NOI2016 优秀的拆分

这两题都运用了后缀数组求 \(LCP\) 或者 \(LCS\) 的思想,值得一做。

参考资料

2009 年国家集训队论文 《后缀数组——处理字符串的有力工具》——罗穗骞

posted @ 2024-01-14 23:07  彬彬冰激凌  阅读(48)  评论(0)    收藏  举报