后缀数组 SA 学习笔记

后缀数组 SA 学习笔记

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

算法

定义

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

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

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

倍增算法

前置知识:基数排序。

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

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

在求 2k 长度的排序时,2k1 的排序已经求出,一个长度为 2k 的段可以由两个长度为 2k1 的段合并得到。

那么把从 i 开始的前 2k1 位之前的排序结果的 rk,看做第一关键字,把后 2k1 的排序结果看做第二关键字,对关键字排序从而求出整个排序结果。

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

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

在排序时使用基数排序,排序未完成时 rk[i] 表示 s[i,min(i+2k11,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 数组:heighti=LCP(suf(SAi),suf(SAi1))

求 height 数组

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

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

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

其实有一个比这个结论更强的结论,设 hi=heightrki,我们有:

hihi11

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

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

画一张图。

其中 si1sjsuf(i1) 的最长 lcp,长度为 j(i1)+1,满足 hi1=heightrki1=j(i1)+1

对于 suf(i) 而言,由于 sisjsuf(i1) 的最长 lcp 的一部分,那么 sisj 可以借用 suf(i1) 的最长 lcp 找到一段相同字串,成为 suf(i1)lcp,所以 hi=heightrki 至少为 ji+1,即 hi11

得证。

上述关于 hi 的结论加上 SAi1SAi 最长 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)) (ij)

不妨设 rki<rkj

理解一下,有

LCP(suf(i),suf(j))=mink=rki+1rkjheightk

上图,来自集训队论文 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 @   彬彬冰激凌  阅读(22)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示