后缀自动机(知识整理+板子总结)

思路来源

史上最通俗的后缀自动机详解 - KesdiaelKen 的博客 - 洛谷博客 首推这一篇

后缀自动机(SAM)学习笔记 - zjp_shadow - 博客园 也看了这一篇,也不错

hihoCoder hihocoder板子题

登录 - 洛谷 洛谷板子题

前置知识

自动机的知识(编译原理那一套,比如有限状态自动机云云,知道能在其上沿边转移感觉就可)

AC自动机、Trie树、后缀数组

死记硬背环节

证明和心得(后附)感觉用处不大,直接计入死记硬背和抄板子做题环节

后缀自动机(SAM),是一个最小的确定有限状态自动机(DFA),接受且只接受S的后缀

parent树和自动机节点共用,时间复杂度O(n),空间复杂度O(n)

parent(也称fa,后缀链接link):当一个节点的串,在变为其后缀,且endpos发生了扩大时,最长的那个后缀对应的节点

endpos(也称right):每个节点对应的串的endpos集合是一致的,每个子串唯一出现在某个节点里

len:一个节点内代表的串的最大长度

minlen:一个节点内代表的串的最小长度,由于a回跳到父亲fa(a)的时候len(fa)+1=minlen(a),故一般不存

ch[0-26](后缀转移transfer):自动机上对应字母的转移

配合两张图食用,便于记忆这些概念

图1:abcd的后缀自动机,

红色括号是endpos集合,字母是自动机转移

图二:aaba的后缀自动机,

红色数字是节点对应的最长子串的长度len,蓝边是其parent树上的父亲fa

板子

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
struct SAM{
    static const int N=1e5+10;
    struct NODE{
        int ch[26]; // 每个节点添加一个字符后到达的节点
        int len;  // 每个节点代表的状态中的最大串长
        int fa; // 每个节点在parent树上的父亲节点, 即该节点link边指向的节点
        int sz; // 每个节点对应的endpos集合的大小(即串的出现次数),等于所有parent树上儿子的大小
        NODE(){memset(ch,0,sizeof(ch));len=0;}
    }dian[N<<1]; // 节点数开串长的两倍
    int n; // 串长 
    char s[N]; // 串
    int las=1; // las: 上一个用到的节点编号
    int tot=1; // tot: 当前用到的节点编号
    // 向SAM中插入一个字符c 
    void add(int c){
        int p=las; // 上一个状态的节点
        int np=las=++tot; // 要加入的状态的节点
        dian[np].sz=1; // 叶子节点endpos大小为1
        dian[np].len=dian[p].len+1; // 新状态比上一个装填多一个首字符
        for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np; // 指针p沿link边回跳,直至找到一个节点包含字符c的出边,无字符c的出边则将出边指向新状态的节点
        if(!p)dian[np].fa=1;// 以上为case 1,指针p到SAM的起点的路径上的节点都无字符c的出边,将新节点作为SAM的起点的一个儿子节点
        else{ // 节点p包含字符c的出边
            int q=dian[p].ch[c]; // 节点p的字符c的出边指向的节点
            if(dian[q].len==dian[p].len+1)dian[np].fa=q;// 以上为case 2,节点p和q代表的状态的最大串长相差1
            else{ // 节点p和q代表的状态的最大串长相差>1
                int nq=++tot; // 新建一个节点nq
                dian[nq]=dian[q]; // 节点nq克隆节点q的信息
                dian[nq].sz=0; // nq产生时,是一个分支节点,需要从后续儿子节点里更新获取sz
                dian[nq].len=dian[p].len+1; // 保证节点p和nq代表的状态的最大串长相差1
                dian[q].fa=dian[np].fa=nq; 
                for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;// 以上为case 3,将节点p到SAM的起点的路径上的所有节点的字符c的出边指向的节点替换为nq
            }
        }
    }
    void init(){
        scanf("%s",s);
        n=strlen(s);
        for(int i=0;i<n;i++)add(s[i]-'a');
    }
    int b[N<<1],a[N<<1]; // b: 用于基数排序 a: 用于记录点号 
    // 按长度基数排序,短的在前长的在后
    // 另一种方法是用vector直接建出parent树,对parent树直接dfs
    void base_sort(){
        for(int i=1;i<=tot;++i)b[dian[i].len]++;
        for(int i=1;i<=tot;++i)b[i]+=b[i-1];
        for(int i=1;i<=tot;++i)a[b[dian[i].len]--]=i;
    }
    // 用于逆拓扑序遍历求出sz
    void get_sz(){
        for(int i=tot;i>=1;--i){
            int u=a[i],fa=dian[u].fa;
            dian[fa].sz+=dian[u].sz;
        }
    }
    void solve(){
        init();
        base_sort();
        get_sz();
    }
}sam;
int main(){
    sam.solve();
    return 0;
}

性质

①endpos:一个子串在原串中所有出现的地方,其末位置下标的集合

如串abcab,endpos(ab)={2,5}

②endpos相同,一个必为另一个后缀,

考虑abcdbcd,当abcd变成后缀bcd的时候,endpos增加,bcd变成cd的时候,endpos不变

③根据②,任意两个子串的endpos集合,要么一个是另一个子集,要么二者相交为空,

只要有一个相交的位置,说明是后缀,后缀就是含于的情况

④根据②,在子串缩短为其后缀的过程中,要么endpos不变,要么增加,把endpos相同的视为一个等价类节点

在SAM中,一个子串只属于一个节点,这个节点内的子串的endpos都相同

⑤endpos等价类(节点)个数为O(n),

这个没看懂曾经看懂过,可以参考原博客

⑥parent树、自动机

后缀自动机的parent树和自动机的节点是共用的,

parent树往祖先跳的时候,endpos会变大,实际上是当前串变为其后缀串,在节点中用fa定义

自动机就是读进这个字符转移到哪个点,定义了一种转移关系,在节点中用ch[0-26]定义,类似trie

⑦由②,不难发现,endpos不变的一段子串是连续的,

分别记minlen和len为这个点对应的子串的最小长度和最大长度

记点a覆盖的串的长度为[minlen(a),len(a)],则从minlen(a)再缩短一个长度的时候,endpos扩充,跳到父亲

所以有len(fa(a))+1=minlen(a),fa(a)是a在parent树上的父亲

⑧后缀自动机的边数为O(n)

这个没看懂曾经看懂过,可以参考原博客

⑨SAM的构造过程

设当前字母为c,从上一个点las转移过来,当前新开一个点np,endpos多出一个{n}来

先考虑转移,为了最大程度的利用节点,

只对las的祖先节点中,计当前祖先节点为p,

若不存在字母c的转移的点,对其进行转移到np的操作,

并令p回跳到p的父亲,最终p停留在了某个节点

以下分三种情况,实际上是(1)(2)两种情况,其中(2)分为两种子情况

(1)c是没出现过的字符,这样回跳的时候一定会回到根,这里计根为1号节点

根代表了空串,其endpos集合为{1,2,...,n},

因此,c所在节点np构成了一个新的endpos集合{n},直接令fa(np)=1

(2)停留在了中途某个点,此时点p存在字母c的转移,所以停下了,

这其中分成两种子情况,计q为p通过字母c能转移到的点,len[q]是q中最长串的长度

①若len[q]=len[p]+1,由于p是las的祖先,p的串一定是las串的后缀,

np比las多了一个字母c,q比p多了一个字母c,且q最长的串是p最长的串的长度+1,

这表明,在p的串后面补一个字母c,其仍然是 在las串后面补一个字母c 的后缀,

则q的串也是np串的后缀,q的endpos一定是np的endpos的超集,令fa(np)=q即可

②len[q]>len[p]+1,由于len[q]>=len[p]+1(是由p的串补了一个字母c而来),既然不等于,就一定大于

说明q中的串分成了两部分,长度=len[p]+1的那些串x,由①,是np串的后缀,其endpos多出了{n}

长度>len[p]+1的那些串y,不是np串的后缀,其endpos没有变化,

而q是np在往上回跳的过程中,第一个包含了np的所有endpos的集合的点,

既然不能表示,就考虑将q拆成两部分,

一部分是x串集合构成的点,是新开的点,记为nq,有len[nq]=len[p]+1

另一部分是y串集合构成的点,保持原来的q不变,

起先,令nq的所有自动机转移关系(所有ch节点)等于原先的q,这样做可行的原因是:

nq是q的后缀,后续在加入新的字符z时,设其跳到状态nr,

由于nq和q只有碰到字母c时有区别,而z不等于c(设z=c,则nr是第一个包含了np的所有endpos的点,与nq是第一个包含了np的所有endpos集合的点矛盾),

故没有受到新字母c的影响,所以二者唯一的区别endpos{n},没有给后续的nr的状态带来影响

然后考虑fa的关系,nq是旧q的一部分,fa(nq)=fa(q),新q比nq长,可以令fa(q)=nq

构建这个nq节点的意义在于表示np,所以令fa(np)=nq

旧q的儿子(转移关系ch)原封不动保留到了新q和nq中,考虑fa的改变

原先有一些节点指向q,但现在nq成为了q和np的fa,这些点应该指向nq,

所以应从p往其祖先回跳,若其碰到字母c的转移为q,就应将其改为nq,

对于没有通过c转移到q的那些祖先节点,其一定指向了nq的祖先,故不用修改

心得

感觉自己死抠知识点的证明,不做题,没啥大的意义,

有些知识点感性理解一下就好了……

但总之思路来源的博主写的很好,

满足了我这个zz对于原理的一切幻想……

去年8月、11月自学过SAM的原理,但是后来没做题,就渐渐忘了,

现在想想,SA前年当时花了四五天抠原理,但现在也只记得rank、sa、height数组是干啥的了,

实际上,SAM的情况就分三种,代码也很短,掌握了性质就可以了……

证明什么的,除了O(n)的点数和边数的证明略复杂以外,剩下的还好……

没必要每次做题前都复习一下证明,更何况这玩意比SA做题直接很多……

  • 6
    点赞
  • 45
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Code92007

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值