广义后缀自动机学习笔记

广义后缀自动机学习笔记

前言

为了方便,下文有如下约定:

  1. 在下文中,广义后缀自动机简称广义 \(\text{SAM}\)

  2. \(|S|\) 为字符串 \(S\) 的长度。

  3. \(\sum\) 为字符集,\(|\sum|\)​​ 为字符集大小。

  4. 在针对时间复杂度的分析时,\(n\)\(\text{Trie}\) 树的结点数,\(m\) 指字符串总长。

前置知识

  • 后缀自动机
  • \(\text{Trie}\)

概述

相比于 \(\text{SAM}\),广义 \(\text{SAM}\) 用于解决多模式串上的子串相关问题。它对于多个字符串建立出一个 \(\text{SAM}\),或者说本质上是对多个字符串形成的一棵 \(\text{Trie}\) 建立 \(\text{SAM}\)

大部分能够使用普通 \(\text{SAM}\) 解决的字符串问题都可以扩展到 \(\text{Trie}\),并使用广义 \(\text{SAM}\) 解决。

在有些题目中,会直接给出一个结点数为 \(n\)\(\text{Trie}\) 树。和给出多个字符串不同的,后者给出的 \(\text{Trie}\) 树结点数并不意味着字符串的总长,相反在这种情况下,字符串的总长可以达到 \(n^2\)。而一些广义 \(\text{SAM}\) 的复杂度基于字符串总长而并非 \(\text{Trie}\) 树的结点个数,因此这种写法不适用于少部分直接给出 \(\text{Trie}\) 树的题目。

定义与概念的拓展

因为将单串的 \(\text{SAM}\) 挪到了 \(\text{Trie}\) 上,因此需要先将普通 \(\text{SAM}\) 的定义改为适用于广义 \(\text{SAM}\) 的定义。

后缀

首先定义 \(\text{Trie}\) 树为 \(T\)\(T_{x\to y}\) 表示在 \(\text{Trie}\) 树上从结点 \(x\) 到结点 \(y\) 的路径组成的字符串,当然 \(y\)\(x\) 的子树内。那么 \(T\) 的所有后缀就可以表示为 \(\{T_{x\to y}|y\text{ is leaf}\}\)。广义 \(\text{SAM}\) 压缩的子串信息即为这一集合内的后缀。

结束位置 \(\text{endpos}\)

在新的后缀的定义下,一个字符串 \(s\)\(\text{endpos}\) 也随之改变,应表示为 \(\text{endpos}(s)=\{y|T_{x\to y}=s\}\)

后缀链接 \(\text{link}\)

在新的 \(\text{endpos}\) 的定义下,可以不改变 \(\text{link}\) 的定义。

根据这些新的定义对 \(\text{Trie}\) 构建出的 \(\text{SAM}\) 结构,就称为广义 \(\text{SAM}\)

伪广义 \(\text{SAM}\)

  1. 将所有字符串用不同的特殊字符分开后,对于合并后的整串构建 \(\text{SAM}\)
  2. 每次添加新串前将 \(\text{last}\) 改为 \(P=0\),在原有 \(\text{SAM}\) 的基础上继续构建。

这两种都是常见的伪广义 \(\text{SAM}\),下面对它们分别进行分析。

合并法

这种写法不能保证线性的时间复杂度。

对于字符串个数无法保证的题目时,由于字符串之间的特殊符号的不重复,导致 \(|\sum|\) 不再是常数级别的,因而为了使空间复杂度可以承受而将时间复杂度提升至 \(O(n\log n)\) 的错误复杂度。

归零法

这种写法有正确的时间复杂度,但会出现空结点问题。

\(\text{last}\) 已有一条标记为 \(c\) 的出边,再次加入字符 \(c\) 时,就会出现 \(\text{DAG}\) 中没有边指向新结点的问题(因为连边的循环在第一次判断后就结束了)。此时,新结点就是一个只有 \(\text{link}\) 标记没有入边的空结点。

这种问题在匹配子串时不会出现问题,但是它违反了 \(\text{SAM}\) 的最小性,在统计后缀树的大小时会出现问题。

综上所述,伪广义 \(\text{SAM}\) 各有各的问题,学习标准的广义 \(\text{SAM}\) 是必要的。

构建方法

离线构建

离线构建指的是先读入所有的字符串并建立出 \(\text{Trie}\) 树,再基于 \(\text{Trie}\) 树构建 \(\text{SAM}\) 的写法,可以用 \(\text{Bfs},\text{Dfs}\) 实现。

构建 \(\text{Trie}\)

完成一棵 \(\text{Trie}\) 树是肯定没有问题的。下面给出代码。

struct TRIE{
    static const int N=1e6+5,S=1e6+5;
    int n,tot;
    char s[N];
    struct Node{
        int faz,ch;//父亲结点和对应字符
        int son[26];
    }p[S];
    void Init(){//初始化
        for(int i=0;i<=tot;i++){
            p[i].faz=p[i].ch=0;
            memset(p[i].son,0,sizeof p[i].son);
        }
        tot=0;
        return ;
    }
    void Insert(char *s){//插入字符串
        int now=0;
        for(int i=0;s[i];i++){
            int ch=s[i]-'a';
            if(p[now].son[ch]==0){
                p[now].son[ch]=++tot;
                p[tot].faz=now;
                p[tot].ch=ch;
            }
            now=p[now].son[ch];
        }
        return ;
    }
    void Build(){
        scanf("%d",&n);
        Init();
        for(int i=1;i<=n;i++){
            scanf("%s",s);
            Insert(s);
        }
        return ;
    }
}trie;

\(\text{Bfs}\) 的构建

考虑在 \(\text{Trie}\) 树上进行 \(\text{Bfs}\),对于一个非根的结点 \(x\),考虑按照正常的插入顺序,在其前一个插入的点一定是它在 \(\text{Trie}\) 树上的父亲结点 \(\text{faz}(x)\)。因为在插入时需要上一个插入的结点对应在 \(\text{SAM}\) 上的结点编号,所以记录 \(\text{pos}(x)\) 为结点 \(x\)\(\text{SAM}\) 上的对应编号。接着正常插入即可。

首先考虑为什么这样子做对答案没有影响。在 \(\text{Trie}\) 树不同分支上的结点显然不会有重复的部分,因为它们的后缀定然不同。所以在插入字符 \(c\) 的时候,需要考虑的所有边都只包括自己 \(\text{Trie}\) 树上的祖先在 \(\text{SAM}\) 上的标记为 \(c\) 的边,而因为在 \(\text{Trie}\) 树上一个结点不会存在多个儿子 \(c\),因此在插入结点 \(x\) 的时候,\(\text{pos}(\text{faz}(x))\) 不存在标记为 \(c\) 的边,因而不会出现空结点问题,而结点 \(x\) 所在字符串的所有前置字符也都加入进了 \(\text{SAM}\)​ 中,因此正确性显然。

时间复杂度是 \(O(n)\) 的。

struct SAM{
    static const int N=1e6+5;
    int tot;
    int pos[N<<1];//pos 数组
    long long ans;
    struct Node{
        int len,link;
        int son[26];
    }p[N<<1];
    void Init(){//初始化
        for(int i=0;i<tot;i++){
            p[i].len=p[i].link=pos[i]=0;
            memset(p[i].son,0,sizeof p[i].son);
        }
        tot=0;
        p[0].len=0;
        p[0].link=-1;
        return ;
    }
    int Insert(int ch,int lst){//插入结点
        p[++tot].len=p[lst].len+1;
        int pos=lst;
        while(pos!=-1&&p[pos].son[ch]==0){
            p[pos].son[ch]=tot;
            pos=p[pos].link;
        }
        lst=tot;
        if(pos==-1)p[tot].link=0;
        else{
            int u=pos,v=p[pos].son[ch];
            if(p[u].len+1==p[v].len)p[tot].link=v;
            else{
                p[++tot]=p[v];
                p[tot].len=p[u].len+1;
                p[v].link=p[tot-1].link=tot;
                while(pos!=-1&&p[pos].son[ch]==v){
                    p[pos].son[ch]=tot;
                    pos=p[pos].link;
                }
            }
        }
        ans+=p[lst].len-p[p[lst].link].len;
        return lst;
    }
    void Bfs(){
        queue<int>q;
        for(int i=0;i<26;i++){
            if(trie.p[0].son[i]!=0)q.push(trie.p[0].son[i]);
        }
        pos[0]=0;
        while(!q.empty()){
            int now=q.front();
            q.pop();
            pos[now]=Insert(trie.p[now].ch,pos[trie.p[now].faz]);//存储对应的 pos 位置
            for(int i=0;i<26;i++){
                if(trie.p[now].son[i])q.push(trie.p[now].son[i]);
            }
        }
        return ;
    }
    void Build(){
        trie.Build();
        Init();
        Bfs();
        printf("%lld\n%d",ans,tot+1);
        return ;
    }
}sam;

\(\text{Dfs}\) 的构建

\(\text{Dfs}\) 的构建便不能像 \(\text{Bfs}\) 一样随意,因为它不具备 \(\text{Bfs}\) 所拥有的在插入结点 \(x\) 的时候,\(\text{pos}(\text{faz}(x))\) 不存在标记为 \(c\) 的边的性质。这意味着,如果进行朴素的 \(\text{Dfs}\),本质上和 \(\text{last}\)\(0\) 没有区别,因此需要在插入时特判一些情况。

struct SAM{
    static const int N=1e6+5;
    int tot;
    int pos[N<<1];
    long long ans;
    struct Node{
        int len,link;
        int son[26];
    }p[N<<1];
    void Init(){
        for(int i=0;i<=tot;i++){
            p[i].len=p[i].link=pos[i]=0;
            memset(p[i].son,0,sizeof p[i].son);
        }
        tot=0;
        p[0].len=0;
        p[0].link=-1;
        return ;
    }
    int Insert(int ch,int lst){
        if(p[lst].son[ch]!=0&&p[p[lst].son[ch]].len==p[lst].len+1)return p[lst].son[ch];
        //如果 lst 已经有标记为 c 的边,并且新插入的结点的 link 应该指向这条边对应的结点,那么新结点可以认为就是对应变指向的结点。
        p[++tot].len=p[lst].len+1;
        int pos=lst;
        while(pos!=-1&&p[pos].son[ch]==0){
            p[pos].son[ch]=tot;
            pos=p[pos].link;
        }
        int Lst=lst,u,v;
        lst=tot;
        if(pos==-1)p[tot].link=0;
        else{
            u=pos,v=p[pos].son[ch];
            if(u==Lst)lst=tot--;
            //如果 lst 已经有标记为 c 的边,但是新插入的结点的 link 应该是分裂后的其中的一个结点。那么就说明需要分裂,新结点可以认为是复制出的结点。
            if(p[u].len+1==p[v].len)p[tot].link=v;
            else{
                p[++tot]=p[v];
                p[tot].len=p[u].len+1;
                p[v].link=tot;
                if(tot!=lst)p[tot-1].link=tot;
                while(pos!=-1&&p[pos].son[ch]==v){
                    p[pos].son[ch]=tot;
                    pos=p[pos].link;
                }
            }
        }
        if(u!=Lst)ans+=p[lst].len-p[p[lst].link].len;
        //如果结点不是新建的,那么之前的贡献已经统计过了,就不需要再统计了。
        return lst;
    }
    void Dfs(int now){
        for(int ch=0;ch<26;ch++){
            if(trie.p[now].son[ch]!=0){
                pos[trie.p[now].son[ch]]=Insert(ch,pos[now]);
                Dfs(trie.p[now].son[ch]);
            }
        }
        return ;
    }
    void Build(){
        trie.Build();
        Init();
        Dfs(0);
        printf("%lld\n%d",ans,tot+1);
        return ;
    }
}sam;

事实上,这样子的写法的复杂度相当于每一次将 \(\text{last}\)\(0\),只不过加入了特判。而将 \(\text{last}\)\(0\) 加入特判也可以做到一样的效果,复杂度是 \(O(m)\) 的。

在线构建

在线构建就是不预先建出 \(\text{Trie}\) 树,每一次先将 \(\text{last}\)\(0\),并直接加入字符串。事实上,因为有了插入字符时的特判,这样子不会有空结点问题,复杂度和 \(\text{Dfs}\) 一样是 \(O(m)\) 的。

struct SAM{
    static const int N=1e6+5;
    int n,tot,lst;
    int pos[N<<1];
    long long ans;
    char s[N];
    struct Node{
        int len,link;
        int son[26];
    }p[N<<1];
    void Init(){
        for(int i=0;i<=tot;i++){
            p[i].len=p[i].link=pos[i]=0;
            memset(p[i].son,0,sizeof p[i].son);
        }
        tot=0;
        p[0].len=0;
        p[0].link=-1;
        return ;
    }
    void Insert(int ch){
        if(p[lst].son[ch]!=0&&p[p[lst].son[ch]].len==p[lst].len+1){
            lst=p[lst].son[ch];
            return ;
        }
        int pos=lst;
        p[++tot].len=p[lst].len+1;
        while(pos!=-1&&p[pos].son[ch]==0){
            p[pos].son[ch]=tot;
            pos=p[pos].link;
        }
        int Lst=lst,u,v;
        lst=tot;
        if(pos==-1)p[tot].link=0;
        else{
            u=pos,v=p[pos].son[ch];
            if(u==Lst)lst=tot--;
            if(p[u].len+1==p[v].len)p[tot].link=v;
            else{
                p[++tot]=p[v];
                p[tot].len=p[u].len+1;
                p[v].link=tot;
                if(tot!=lst)p[tot-1].link=tot;
                while(pos!=-1&&p[pos].son[ch]==v){
                    p[pos].son[ch]=tot;
                    pos=p[pos].link;
                }
            }
        }
        if(u!=Lst)ans+=p[lst].len-p[p[lst].link].len;
        return ;
    }
    void Build(){
        Init();
        scanf("%d",&n);
        for(int i=1;i<=n;i++){
            scanf("%s",s);
            lst=0;
            for(int j=0;s[j];j++)Insert(s[j]-'a');
        }
        printf("%lld\n%d",ans,tot+1);
        return ;
    }
}sam;

特殊应用

子串出现次数

同样存储一个计数数组。

只需要在每一个字符串的每一个前缀所在的结点上将计数数组的值加 \(1\),最后在后缀树上从下向上以此更新即可。

最长公共子串

考虑和求解子串出现次数相同的思路,通过计数数组存储每一个结点的字符串在哪些字符串中出现过。然后在后缀树上不断利用子结点更新父结点的计数数组。如果一个结点的计数数组中存储了所有的 \(n\) 个字符串,那么记录答案并退出枚举即可。

posted @ 2024-03-20 21:22  DycIsMyName  阅读(24)  评论(0编辑  收藏  举报