zoukankan      html  css  js  c++  java
  • 后缀数组

    参考了:victorique - 后缀数组 最详细讲解

    比较基础的知识点,虽然有不少玩法

    快速过掉后认真学SAM

    (mrfz周年池沉了,难受)

    (UPD:我出货了,我又好了)


    ~ 后缀数组(SA)的定义 ~

    给定一个长度为$n$的字符串$s$,将其所有后缀$s[i...n]$按照字典序大小进行排序

    我们的目标是求两个数组$sa,rk$

    $sa[i]$表示,字典序从小到大第$i$个字符串的起点(那么该字符串为$s[sa[i]...n]$)

    $rk[i]$表示,以$i$为起点的字符串$s[i...n]$在所有后缀中的排名(rank)

    显然若已知$rk[i]$,那么$sa[i]$就能直接得到,故真正进行求解的是$rk$数组


    ~ 求rk数组 ~

    显然不能使用常规的字符串排序来求$sa$,否则时间复杂度为$O(n^2logn)$

    所以需要用到两个trick来将复杂度降到$O(nlogn)$

    Part 1 - 倍增

    对于所有子串暴力比较是不可取的,我们需要利用一下特殊性质:后缀

    这有什么用处呢?我们可以像Manacher、EXKMP一样,利用【已经求得】的信息来简化计算

    在对两个字符串进行字典序比较的过程中,我们需要从前向后依次比较;而在这里也是一样的,我们需要先对所有后缀的第一个字符进行比较

    例如,令$s$=abacab,那么可以给所有$s[i]$进行排名(大小相同则取同排名)

    i

    1 2 3 4 5 6
    s[i] a b a c a b
    排名 1 2 1 3 1 2

    但是一遍排名过后依然有排名相同的元素;而$s$的所有后缀均不相同(毕竟长度不同),显然排名应该互不相同;所以需要将所有子串的第二个字符纳入考虑,再次进行排名

    这一次排名,我们可以利用上一次求得的排名进行计算

    比如,$i=1,s[1]=a$的排名是$1$,$i=2,s[2]=b$的排名是$2$,那么我们可以用一个二元组$1/2$来表示$s[1,2]$的大小;对二元组进行比较时,先比较第一维度,再比较第二维度

    对于$i=6,s[6]=b$,它的第二维度应当设定为$0$,因为长度小的子串字典序更小

    i 1 2 3 4 5 6
    s[i] a b a c a b
    二元组 1/2 2/1 1/3 3/1 1/2 2/0
    排名 1 4 2 5 1 3

    进行一遍排名后,可以看出排名相同的元素减少了

    我们在下一次求排名的时候,就不是将所有子串的第三个字符纳入考虑了,而是将第$3,4$位;因为在第二轮排名结束后,求得的排名是以$i$为起点的、长度为$2$的子串的排名,所以我们在下一轮利用这个排名的时候,相当于直接获得了两个字符的大小信息

    相应地,这次构造二元组的时候,并不是将下一个位置的排名作为第二维度,而是将下下个位置

    比如$i=1,s[1,2]=ab$的排名是$1$,$i=3,s[3,4]=ac$的排名是$2$,那么构造的二元组就是$1/2$;这是将$s[1,2]$与$s[3,4]$拼起来、以表示$s[1...4]=abac$的大小

    对于$i=5,i=6$,其第二维度为$0$

    i 1 2 3 4 5 6
    s[i] a b a c a b
    二元组 1/2 4/5 2/1 5/3 1/0 3/0
    排名 2 5 3 6 1 4

    发现排名互不相同,于是可以结束了

    如果字符串的长度更大,就是不断地将倍增进行下去

    第$i$轮是将从每个位置开始、长度为$2^i$的子串进行排名;而通过我们的倍增转化,其实只需要对$n$个二元组进行比较

    总复杂度为$O(ncdot (logn)^2)$,因为需要比较$logn$轮,而每轮需要对$n$个二元组进行排序

    Part 2 - 桶排序优化

    在上面的进行排名的过程中,我们仅需要比较$n$个二元组

    那么就可以利用桶排序降低时间复杂度;共有$n+1$个桶表示排名

    接着就是标准的桶排序操作了:先按照第二维度将二元组放到桶中,再从小到大地从桶中取出二元组得到一个顺序,最后按顺序依照第一维度将二元组放到桶中,即可完成排序

    于是二元组排序的时间复杂度变成了$O(n)$,那么总的时间复杂度就降到了$O(nlogn)$


    ~ 代码实现 ~

    虽然上面说的逻辑比较清晰,但代码实现会混乱一些(为了简短)

    在具体实现的过程中,我们并不是单独求$rk$数组(否则需要开数组表示二元组及对应关系),而是在$rk,sa$之间反复横跳

    先介绍一下需要用的数组

    int sa[N],rk[N];
    int tmp[N<<1],top[N];

    $rk,sa$:和定义中的意义相同

    $top$:桶排时用到的桶(更准确地说是用来占位的,下面会具体说明)

    $tmp$:表示二元组的第二维度中 从小到大第$i$个元素在字符串中的位置(可以理解成对于第二维度的$sa$);开成两倍是为了防止在后面的代码中数组越界(为了简化一个判断条件,可能需要访问$2N$以内的$tmp[i]$)

    先康康桶排是如何实现的:

    //n: 待排元素数  m: 桶数
    void quicksort(int n,int m)
    {
        for(int i=1;i<=m;i++)
            top[i]=0;
        for(int i=1;i<=n;i++)
            top[rk[i]]++;
        for(int i=1;i<=m;i++)
            top[i]=top[i-1]+top[i];
        for(int i=n;i>=1;i--)
            sa[top[rk[tmp[i]]]--]=tmp[i];
    }

    这个实现和桶排的一般实现不太一样,但思想是相同的

    先看看$top$数组做了什么事:先统计第一维度($rk[i]$)出现了多少次,然后做了一次前缀和;这样相当于第一维度为$i$的元素占据了 排序后$n$个位置中 的一段连续区间,区间的右端点是$top[i]$

    这样一来,如果我们对于第一维度相同的元素,将第二维度按从大到小的次序依次加入,并让$top[i]--$,就能恰好将二元组排好序

    具体看最后一个for循环:$tmp[i]$是第二维度的$sa$,那么从$n$循环到$1$就是 按照第二维度从大到小 依次获得二元组的下标;$top[rk[tmp[i]]]$就是这个二元组 第一维度所在区间 的右端点

    剩下的部分也需要慢慢想一下:

    void getsa(char *s,int n)
    {
        for(int i=1;i<=n;i++)
            rk[i]=s[i],tmp[i]=i;
        quicksort(n,200);
        
        //开始倍增
        for(int i=1;i<n;i<<=1)
        {
            int cnt=0;
            for(int j=n-i+1;j<=n;j++)
                tmp[++cnt]=j;
            for(int j=1;j<=n;j++)
                if(sa[j]>i)
                    tmp[++cnt]=sa[j]-i;
            
            quicksort(n,max(cnt,200));
            
            for(int j=1;j<=n;j++)
                swap(rk[j],tmp[j]);
            
            rk[sa[1]]=cnt=1;
            for(int j=2;j<=n;j++)
            {
                if(tmp[sa[j]]!=tmp[sa[j-1]] || tmp[sa[j]+i]!=tmp[sa[j-1]+i])
                    cnt++;
                rk[sa[j]]=cnt;
            }
            
            if(cnt==n)
                break;
        }
    }

    一开始对$rk,tmp$做初始化:由于我们在第一遍排序只关注每个子串首字符的大小,所以$tmp$取任意一个$1$到$n$的排列即可

    排完序以后,我们得到了正确的$rk,sa$(仅需要关注$rk$之间的相对大小,并不需要在意其具体值;在这里$rk$只是不从$1$开始,而相对大小是正确的),可以开始倍增了

    在每一轮倍增中,我们都是利用上一轮的$rk,sa$构造二元组;每个二元组的第一维度是$rk[j]$,第二维度是$rk[j+i]$:

    若$j+i>n$,那么该二元组所对应字符串长度小于$i$(即不存在后半部分),在第二维度的排序中就具有最高优先度,应放在$tmp$数组中的前面;否则就顺着$sa$数组从小到大放,此时需要注意放在$tmp$中的是$sa[j]-i$,即二元组所对应字符串的起点(枚举的$j$表示字符串后半部分的起点)

    这样已经足够我们进行排序了,桶排一波得到正确的$sa$;而此时的$rk$仍然是上一轮的,所以我们需要参照$sa$重新计算一次$rk$

    $tmp$已经完成了它的使命,所以不妨将上一轮的$rk$放到$tmp$中,并利用它计算当前轮的$rk$

    我们求得的$sa$虽然是正确的,但是对于相等的二元组 相当于强行给它们定了一个顺序,而它们的$rk$应该是相等的;于是依次判断是否相等即可(这里在判断第二维度是否相等时,下标可能会超出$n$,故$tmp$需要开成两倍空间)

    倍增过程中的$cnt$表示桶的数量,即第一维度的数量;当$cnt=n$时,$rk$已经互不相同了,即可结束倍增

    模板题:Luogu P3809 (【模板】后缀排序)

    若不让$cnt=n$时break,就会超时

    #include <cstdio>
    #include <cstring>
    #include <algorithm>
    using namespace std;
    
    const int N=1000005;
    
    int sa[N],rk[N];
    int tmp[N<<1],top[N];
    
    void quicksort(int n,int m)
    {
        for(int i=1;i<=m;i++)
            top[i]=0;
        for(int i=1;i<=n;i++)
            top[rk[i]]++;
        for(int i=1;i<=m;i++)
            top[i]=top[i-1]+top[i];
        for(int i=n;i>=1;i--)
            sa[top[rk[tmp[i]]]--]=tmp[i];
    }
    
    void getsa(char *s,int n)
    {
        for(int i=1;i<=n;i++)
            rk[i]=s[i],tmp[i]=i;
        quicksort(n,200);
        
        for(int i=1;i<n;i<<=1)
        {
            int cnt=0;
            for(int j=n-i+1;j<=n;j++)
                tmp[++cnt]=j;
            for(int j=1;j<=n;j++)
                if(sa[j]>i)
                    tmp[++cnt]=sa[j]-i;
            
            quicksort(n,max(cnt,200));
            
            for(int j=1;j<=n;j++)
                swap(rk[j],tmp[j]);
            
            rk[sa[1]]=cnt=1;
            for(int j=2;j<=n;j++)
            {
                if(tmp[sa[j]]!=tmp[sa[j-1]] || tmp[sa[j]+i]!=tmp[sa[j-1]+i])
                    cnt++;
                rk[sa[j]]=cnt;
            }
            
            if(cnt==n)
                break;
        }
    }
    
    int n;
    char s[N];
    
    int main()
    {
        scanf("%s",s+1);
        n=strlen(s+1);
        
        getsa(s,n);
        for(int i=1;i<=n;i++)
            printf("%d ",sa[i]);
        return 0;
    }
    View Code

    ~ 拓展:求LCP ~

    $LCP(i,j)$指的是两个后缀$s[sa[i]...n],s[sa[j]...n]$的最长公共前缀长度

    性质1:$LCP(i,k)=min{LCP(j,j+1)}$,其中$ileq j<k$

    继续以之前的字符串$s=$abacab为例

    i sa[i] s[sa[i]...n]
    1 5 a
    2 1 abacab
    3 3 acab
    4 6 b
    5 2 bacab
    6 4 cab

    可以通过观察发现,$LCP(i,k)$会随着$k$的增大而单调不增(推论1

    那么最小的$LCP(j,j+1)$相当于一个分界点:在它之前的字符串与$s[sa[i]...n]$拥有更多的公共前缀,而在它之后字符串的至少在第$LCP(j,j+1)+1$位与$s[sa[i]...n]$不同,那么$LCP(i,k)$显然不会超过$LCP(j,j+1)$;而$LCP(i,k)$不小于$min{LCP(j,j+1)}$是显然的

    性质1把求$LCP(i,k)$转化成了求$min{LCP(j,j+1)}$,那么我们显然需要提前将$LCP(i,i+1),1leq i<n$预处理出来

    记$height[i]=LCP(i,i+1)$,$h[i]=height[rk[i]]$

    可以这样理解这两个数组:$height[i]$对应的是子串$s[sa[i]...n]$与$s[sa[i+1]...n]$的最长公共前缀长度,而$h[i]$对应的是子串$s[i...n]$与$s[sa[rk[i]+1]...n]$的公共前缀长度;即前者的下标指的是在排序后第$i$小的子串,而后者的下标指的就是子串$s[i...n]$

    性质2:$h[i+1]geq h[i]-1$(证明有点绕,但最好还是要搞懂)

    可以这样证明:假设将所有后缀进行排序后,$s[i...n]$的后面是$s[j...n]$,那么有$h[i]=height[rk[i]]=LCP(s[i...n],s[j...n])=L$;而考虑到$s[i+1...n]$在排序后的子串中应该也很接近$s[j+1...n]$,因为前$L-1$位是相等的;那么若在排序后的后缀中$s[i+1...n]$的后面是$s[j+1...n]$,则$h[i+1]=height[rk[i+1]]=LCP(s[i+1...n],s[j+1...n])=L-1$

    根据推论1,$LCP(i,k)$随着$k$的增大而单调不减;那么若在排序后的后缀中$s[i+1...n]$的后面不是$s[j+1...n]$,即$rk[i+1]+1<rk[j+1]$,就有$h[i+1]=LCP(s[i+1...n],s[sa[rk[i+1]+1]...n)geq LCP(s[i+1...n],s[j+1...n])=L-1$

    综上,有$h[i+1]geq h[i]-1$

    有了强力的性质2,我们可以$O(n)$地预处理出$height$数组($h$数组除了推出一个性质2,实际上用不到)

    考虑从$1$到$n$依次算出$height[rk[i]]$(即$h[i]$)

    根据$height[rk[i+1]]geq height[rk[i]]-1$(即$h[i+1]geq h[i]-1$),能够发现$sum_{i=1}^{n} (height[rk[i+1]]-height[rk[i]])leq n+n$,那么每次均暴力进行子串比较就行了

    int height[N];
    
    void getheight(char *s,int n)
    {
        int h=0;
        for(int i=1;i<=n;i++)
        {
            if(rk[i]==n)//height[n]=0
                continue;
            h=(h>0?h-1:h);
            
            int j=sa[rk[i]+1];
            while(j+h<=n && i+h<=n && s[i+h]==s[j+h])
                h++;
            height[rk[i]]=h;
        }
    }

    ~ 后缀数组的应用 ~

    上面写了那么多其实都是模板,想要图一乐还得看具体题目

    Luogu SP705  ($New Distinct Substrings$)

    经典应用——求一个字符串中本质不同的子串的数量

    字符串中的每一个子串$s[l...r]$都是某个后缀$s[l...n]$的前缀,按照这个思路尝试考虑

    $s[l...n]$这个后缀共有$n-l+1$个前缀,在这些前缀中,若有一些之前被统计过了,那么它们对答案的贡献就是$0$;如何判断是否被统计过呢?可以利用$LCP(i,i+1)$

    我们顺着$sa[i]$进行考虑,这样一来所有的后缀都是按照字典序从小到大排列;假设$LCP(i,i+1)=x$,那么$s[sa[i+1]...n]$的前$x$个前缀都在之前被统计过了(至少在$s[sa[i]...n]$中已被统计),而其余的前缀都未被统计过(因为均与之前其他后缀的前$x+1$个字符不同)

    于是,答案就是$frac{n(n+1)}{2}-sum_{i=1}^{n} height[i]$

    #include <cstdio>
    #include <cstring>
    #include <algorithm>
    using namespace std;
    
    typedef long long ll;
    const int N=50005;
    
    int sa[N],rk[N];
    int tmp[N<<1],top[N];
    
    void quicksort(int n,int m)
    {
        for(int i=1;i<=m;i++)
            top[i]=0;
        for(int i=1;i<=n;i++)
            top[rk[i]]++;
        for(int i=1;i<=m;i++)
            top[i]=top[i-1]+top[i];
        for(int i=n;i>=1;i--)
            sa[top[rk[tmp[i]]]--]=tmp[i];
    }
    
    void getsa(char *s,int n)
    {
        for(int i=1;i<=n;i++)
            rk[i]=s[i],tmp[i]=i;
        quicksort(n,200);//可能需要改成最大字符值 
        
        for(int i=1;i<n;i<<=1)
        {
            int cnt=0;
            for(int j=n-i+1;j<=n;j++)
                tmp[++cnt]=j;
            for(int j=1;j<=n;j++)
                if(sa[j]>i)
                    tmp[++cnt]=sa[j]-i;
            
            quicksort(n,max(cnt,200));
            
            for(int j=1;j<=n;j++)
                swap(rk[j],tmp[j]);
            
            rk[sa[1]]=cnt=1;
            for(int j=2;j<=n;j++)
            {
                if(tmp[sa[j]]!=tmp[sa[j-1]] || tmp[sa[j]+i]!=tmp[sa[j-1]+i])
                    cnt++;
                rk[sa[j]]=cnt;
            }
            
            if(cnt==n)
                break;
        }
    }
    
    int height[N];
    
    void getheight(char *s,int n)
    {
        int h=0;
        for(int i=1;i<=n;i++)
        {
            if(rk[i]==n)//height[n]=0
                continue;
            h=(h>0?h-1:h);
            
            int j=sa[rk[i]+1];
            while(j+h<=n && i+h<=n && s[i+h]==s[j+h])
                h++;
            height[rk[i]]=h;
        }
    }
    
    int n;
    char s[N];
    
    int main()
    {
        int T;
        scanf("%d",&T);
        while(T--)
        {
            scanf("%s",s+1);
            n=strlen(s+1);
            
            getsa(s,n);
            getheight(s,n);
            
            ll ans=1LL*n*(n+1)/2;
            for(int i=1;i<n;i++)
                ans-=height[i];
            printf("%lld
    ",ans);
        }
        return 0;
    }
    View Code

    (待续)

  • 相关阅读:
    USB设备驱动之设备初始化(设备枚举)
    clCreateCommandQueue&#39;: was declared deprecated
    Struts2 Result Type
    IOS屏幕旋转
    VMware Workstation 集群仲裁磁盘和数据共享磁盘的创建
    UNIX环境高级编程之第3章:文件I/O
    poj 1068 Parencodings(模拟)
    使用oracle数据库和MySQL数据库时hibernate的映射文件.hbm.xml的不同
    线程池的实现
    zoj 1648 Circuit Board
  • 原文地址:https://www.cnblogs.com/LiuRunky/p/Suffix_Array.html
Copyright © 2011-2022 走看看