Loading

后缀数组 + 后缀自动机小记

后缀数组 & 后缀自动机小记

基本介绍

后缀数组,英文SA,可以用于解决不少与字符串相关的题目。后缀数组本质上就是将其所有后缀进行排序得到的数组。

后缀自动机同样是一个解决字符串的强力工具,可能稍微需要一些理解,但是很板子。

后缀自动机需要较为熟练的掌握,后缀数组也最好能够掌握。

后缀自动机和后缀数组只有前缀相同 (SA,SAM)

后缀数组

定义在上文已经讲述,简单明了。大概讲一下如何求后缀数组,思想是倍增方法

朴素

暴力的方法明显是 O(n2logn) 的,利用倍增思想可以优化到 O(nlog2n)

性质+倍增

设两个数组,rkw(i)saw(i) 分别表示长度为 w 的字符串,从第 i 位开始在所有长度为 w 字串中的排名,和长度为 w 的字串中字典序第 i 小的开始位置(若相同则按照先后位置排序)。

这里有个小的预处理,我们将字符串后面填上与之等长的0,以此解决长度不统一的问题

rk1,sa1是很容易求出来的。

我们考虑倍增,假设我们知道现在的 rkw,saw 会发现我们能够很容易地求出来 sa2w,rk2w

我们思考对于两个相等长度的字符串 s,t,其中 s 字典序小于 t。我们将这两个串劈成两半,那么要么 s 前半部分比 t 小,要么前面部分相等,后面的部分 st 小。

把这个过程反过来,我们可以直接用两个参数 rkw(saw(i))rkw(saw(i)+w) 分别作为第一关键字和第二关键字排序,这样就可以得到 sa2w 的值,接着反退出来 wk2w 即可。

附oiwiki的效果图

那我们就可以很轻易地做到 O(nlog2n) 的做法了

简单优化

这个复杂度的限制因素在于排序上面。有没有更快的排序方法?是有的。因为在 rk 中每个数都在 [1,n] 范围内的,因此我们可以采用基数排序,再优化掉一个 log ,可以做到O(nlogn) 的优秀复杂度。

板子

/*强烈建议不要贺OIWiki的代码,因为跑得相当慢*/
#include<iostream>
#include<cstdio>
#include<cstring>
using namespace std;
char s[1000005];
int rk[1000005],cnt[1000005],tmp[1000005];
int sa[1000005],n,M=1000,tmp2[1000005];
int main(){
    scanf("%s",s+1);
    int n=strlen(s+1);
    //presets
    for(int i=1;i<=n;i++){
        rk[i]=s[i];
        cnt[rk[i]]++;
    }
    for(int i=2;i<=M;i++)cnt[i]+=cnt[i-1];
    for(int i=n;i>=1;i--)sa[cnt[rk[i]]--]=i;
    //opertation,every time *=2
    for(int T=1;T<=n;T<<=1){
        int po=0;
        for(int i=n-T+1;i<=n;++i)tmp[++po]=i;
        for(int i=1;i<=n;i++)
            if(sa[i]>T)tmp[++po]=sa[i]-T;
        //基数排序基本操作
        for(int i=1;i<=M;i++)cnt[i]=0;
        for(int i=1;i<=n;i++)cnt[rk[i]]++;
        for(int i=2;i<=M;i++)cnt[i]+=cnt[i-1];
        for(int i=n;i>=1;i--){
            sa[cnt[rk[tmp[i]]]--]=tmp[i];
            tmp[i]=0;
        }
        for(int i=1;i<=n;i++)tmp2[i]=rk[i];
        po=1;rk[sa[1]]=1;
        //倒回到rk上
        for(int i=2;i<=n;i++){	
            if(tmp2[sa[i]]==tmp2[sa[i-1]] && tmp2[sa[i]+T]==tmp2[sa[i-1]+T])rk[sa[i]]=po;
            else rk[sa[i]]=++po;
        }
        if(po==n)break;
        M=po;
    }
    for(int i=1;i<=n;i++)cout<<sa[i]<<" ";
    return 0;
}

可以详细讲一下这里面每一步的作用。

因为每一次是两个关键字的排序,采用基数排序可以大大加快排序的过程。基数排序的思路为,记录第二关键字排名第 i 的对应第一关键字的位置。之后对于第二关键字从后往前扫,考察其对应的第一关键字大小关系即可,第一关键字用计数排序统计。

落实到代码中,其中 rk,sa 代表意义如之前所说,cnt 代表的是桶。tmpi 是代表第二关键字排行为 i 的位置。每一次倍增的前面几行是处理 tmpi,在 nT+1 这一部分是没有第二关键字的,因此排在最前面。从小往大考虑 sa 数组,如果 sai 即排行第 i 大的前缀的位置 >T,那么就会有 saiT 的位置用其作为第二关键字,插入到序列中。

之后就是基数排序的基本操作,排序之后会得到新的 sa。注意到因为还没有考虑整个字符串,因此 sai,sai1 之间两个位置的目前考虑的后缀可能是相等的,因此在利用 rksai=i 的性质的时候,需要提前判断是否和前一个相等。判断方式很简单,复制一份之前的 rk 数组,然后检查 (rksai,rksai+w),(rksai1,rksai1+w) 毕竟就是根据这个排序的。

这下应该就能够完全看懂 SA 的代码实现了。

当然后缀数组还有一些其他的东西可以操作1

后缀树

定义

后缀树就是所有后缀数组组成的 Trie 树。

小小优化

首先我们考虑简单建树,但是由于本质不同的字串的数量可能做到 O(n2) 级别1 ,看起来非常不合理。

但是我们不要忘记一个很优秀的性质,就是所有叶子节点一定不会超过 O(n) 个!这样意味着,有很多只有一个儿子的几点。我把这样的“棍子”合并起来,这样就可以做到了合适的后缀树。

构建后缀树

此处我们先放下不讲,为什么呢?因为后缀树用可以直接用SAM来进行构造更方便。

关于hight数组

1 这里我们讲一下 hight 数组这个奇妙的东西

定义

hight[i]=lcp(sa[i],sa[i1])

其中 lcp 表示两个数组的最长前缀

作用

  • 求本质不同的子串个数。

​ 很显然,我们对于每个后缀,跳过 higth[i] 个(因为很明显前面有过一样的),数一数有多少个。

​ 我们换过来想,我们每次要跳过 hight[i] 个,一个字符串总共的子串个数是 n(n1)2 个,我们减去 hight[i] 即可。

  • 求两个字串的LCP

​ 假设两个字串 s1,s2 它们的起始点分别是 l1,l2 。不难发现,他们的LCP就是 min(|s1|,|s2|,lcp(sa[l1],sa[l2])) ,即要么是整个串,要么是这个串开头的后缀。

​ 接着我们求 lcp(sa[l1],sa[l2]) 会发现这恰恰是 minl1xl2hight[x] 这里用各种方法维护一下即可(如线段树)。这样求 LCP 就是 log 级别的了。

构造方法

结论:hight[rk[i]]hight[rk[i1]]1

通过此结论,可以 O(n) 求出来 hight

int k=0;
for(int i=1,k=0;i<=n;i++){
    if(k>0)k--;
    while(s[i+k]==s[sa[rk[i]-1]+k])k++;
    ht[rk[i]]=k;
}

虽然这个性质确实感觉有点无中生有,我也没有搞明白是怎么想到的。但是是能够证明的。证明比较无聊而且没有什么意思,大概就是强行分析两者的关系。

后缀自动机(SAM)

后缀自动机和很多其他自动机一样,后缀自动机每个节点接受对于原字符串 S 的所有后缀,其交集也就是 S 的所有子串。在这个基础下,我们肯定要尽量满足节点数量少。

不多介绍,我们从头开始讲起。

Endpos

我们定义 endpos(T) 表示一个串 T 在原串所有的结束位置的集合。

如图,对于原串,endpos(ab)={1,3,6}

我们将利用它来进行自动机的构造

parent tree

首先思考一下关于 endpos 的一些性质:

  1. 如果 xy 的一个后缀,那么 endpos(y) 一定是 endpos(x) 的一个子集
  2. 对于所有相等的 endpos(我们称之为 endpos 等价类),那么其中所有的串一定是最长的那个串的后缀。

第一个性质是显然的,因为 endpos 记录的就是在原串所有结束位置的集合,所以如果一个串是另一个串的后缀,那么更大的串出现的位置其一定出现过。

第二个性质根据 endpos 的定义也很容易得到。

当然这两个性质都可以更为严谨地证明。

根据这两个我们会发现这是一个类似树形的结构,尝试根据包含关系构造出来树。

这棵树就叫做 parent tree,其节点和后缀自动机状态一一对应(注意 parent tree 并不是 SAM 真正的转移边,这个概念是不同的,只是他们的状态,即节点对应的内容是相同的)。

接下来考虑如何构造 parent tree

构造

采用动态构造的方法,对于每次新添加一个字符,考虑其所有的后缀的位置。

需要维护若干个变量:

  1. tr[u][c] 表示 u 节点接受了 c 之后转移到的点
  2. fa[u] 表示 parent tree 上 u 的父亲
  3. len[u] 表示对于一个 endpos 集的最长串长度
  4. lst 表示当前原串所在的节点

每次加入一个新的字符 c,我们先新创一个节点为 x。我们会从 lst 开始往上跳,每次经过一个节点,就会多在 SAM 上连一条边从经过的节点到 x。一直这样网上爬,直到找到一个点 p 能够通过 c 的转移到 q

  • Case1

    如果一直找不到,那么表示这是一个全新的后缀,直接 fa[x]=0

  • Case2

    如果 len[p]+1=len[q] 那么表示这是一个连续的转移,也就是说对于所有 p 里面的后缀,只要往后面加一个 c 就能够全部丢到 q。此时直接 fa[x]=tr[p][c] 即可。

  • Case3

    如果 len[p]+1len[q] 也就说明这不是一个连续的转移,即有一部分的 p 的后缀通过加一个 c 并不是都能够丢到 q 里面。此时要考虑分裂。

    我们复制一个 q 出来,设 nq。对于 nq,它首先要继承所有 q 的信息,唯一不同的是,我们要设 len[nq]=len[p]+1 即强制让其能够转移。之后从 p 开始往上爬,每一个点只要有连向 q 都让其连向 nq。这样就劈开了两个部分,最后让 fa[nq]=fa[x]=nq。就成功进行了劈开。

记得最后 lst=x

void ins(int c){
    int x=++cnode,p=lst;
    lst=cnode;
    w[cnode]=1;
    len[x]=len[p]+1;
    for(;p&&!tr[p][c];p=fa[p])tr[p][c]=x;
    if(!p)fa[x]=1;//Case 1
    else{
        int q=tr[p][c];
        if(len[q]==len[p]+1)fa[x]=q;//Case 2
        else{
            int newn=++cnode;//Case 3:clone
            for(int i=0;i<=25;i++)tr[newn][i]=tr[q][i];
            fa[newn]=fa[q];
            len[newn]=len[p]+1;
            fa[q]=fa[x]=newn;
            for(;p&&tr[p][c]==q;p=fa[p])tr[p][c]=newn;//reset
        }
    }
}

后缀自动机的主要部分也就讲完了,但是没有讲性质,因为需要结合具体的题目。

posted @   Jryno1  阅读(15)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下
点击右上角即可分享
微信分享提示