• 「算法笔记」后缀自动机


    一、引入

    顾名思义,后缀自动机(Suffix Automaton,简称 SAM)是一个 自动机。这里的自动机指的是确定有限状态自动机(DFA)。

    DFA?DFA 的作用就是识别字符串。可以把一个 DFA 看成一个 边上带有字符 的有向图。

    图中的节点就是 DFA 中的状态,边就是状态间的转移(DFA 的转移函数)。

    DFA 存在一个指定的 起始状态(对应图的起始节点),以及多个 接受状态

    • 一个 DFA 读入字符串 (S) 后,会从起始节点开始,第 (i) 次沿着字符 (S_i) 的转移边走。

    • 读入完成后,若 (S) 最后位于一个接受状态,则称 DFA 接受 (S),否则称 DFA 不接受 (S)(转移过程中没有出边,也称 DFA 不接受 (S)

    其实就是,从起始节点出发,每次沿着与当前字符对应的边走,走完了,并且最后位于可接受的节点上,那就 ok,否则就是不 ok。

    二、定义

    后缀自动机 是可以且仅可以接受一个母串 (S) 的后缀的 DFA。栗子

    SAM 的结构包含两部分:有向单词无环图(DAWG)以及一棵树(parent 树),它们的节点集合相同。

    目标:最小化节点集合大小(SAM 是满足是可以接受 (S) 所有后缀的 最小的 DFA)。

    1. Endpos 集合

    先引入一个概念:子串的结束位置集合 Endpos。在下文写作 ( ext{end})

    (S) 的一个子串 (T)(S) 中出现的 结束位置 集合为 ( ext{end}(T))

    举个栗子,比如 (S= ext{banana}),则 ( ext{end}( ext{ana})={4,6})

    对于两个子串 (t_1,t_2),若 ( ext{end}(t_1)= ext{end}(t_2)),则 (t_1,t_2) 属于一个 ( ext{end}) 等价类

    Endpos 集合的性质:对于非空子串 (t_1,t_2 (|t_1|leq|t_2|))

    • ( ext{end}(t_1)= ext{end}(t_2)),则 (t_1)(S) 中每次出现,都是以 (t_2) 的后缀形式存在。

    • (t_1)(t_2) 的后缀,则 ( ext{end}(t_2)subseteq ext{end}(t_1));否则 ( ext{end}(t_2)cap ext{end}(t_1)=varnothing)

    • 一个 ( ext{end}) 等价类中的串为 某个前缀长度连续的后缀

    • ( ext{end}) 等价类的个数为 (mathcal{O}(n)) 级别。

    根据合并等价类的思想,我们将 Endpos 集合完全相同的子串合并到同一个节点。

    ( ext{end}) 的等价类构成了 SAM 的状态集合。

    2. DAWG

    DAWG 是 DAG,其中每个 节点 表示一个或多个 (S) 的子串。特别地,起始节点对应空串 (varnothing)

    每条转移边上有且仅有一个字符。从起始节点出发,沿着转移边移动,则每条 路径 都会唯一对应 (S) 的一个子串。

    到达某节点的路径可能不止一条。一个节点对应一些字符串的集合,集合的元素对应这些路径。

    不存在可代表同一子串的两个不同状态,因为每个子串唯一地对应一条路径。

    规定:除起始节点外,每个节点都是 不同的 ( ext{end}) 等价类,对应该等价类内子串的集合。

    (u) 的长度最小、最大的子串为 (min(u)) 以及 (max(u))

    根据 Endpos 集合的性质,每个节点所代表的字符串是 (S) 某个前缀长度连续的后缀,则状态 (u) 中所有的字符串都是 (max(u)) 的不同后缀,且字符串长度覆盖区间 ([|min(u)|,|max(u)|])

    3. parent 树

    定义:定义 (u) 的 parent 指针指向 (v),当且仅当 (|min(u)|=|max(v)|+1),且 (v) 代表的子串均为 (u) 子串的后缀,记作 ( ext{next}(u)=v)

    显然,所有节点沿着 parent 指针向前走,都会走到 DAWG 的起始节点(即代表空串的节点)。因此以 parent 指针为边,所有节点组成了一棵树,称为 parent 树。栗子

    parent 指针的性质:

    • (|min(u)|=1),则 ( ext{next}(u)) 为起始节点。

    • ( ext{next}(u)) 所对应的字符串长度严格小于 (u) 所表示的字符串。

    • ( ext{end}(u)subsetneq ext{end}( ext{next}(u)))。(注意这里是 (subsetneq) 不是 (subseteq),因为若两者相同,那么 (u)( ext{next}(u)) 应该被合并为一个节点)

    • (max( ext{next}(u)))(min(u)) 的次长后缀(最长为其本身)。

    parent 树的性质:

    • 在 parent 树中,子节点的 ( ext{end}) 集合一定是父亲的真子集,即 ( ext{end}(u)subsetneq ext{end}( ext{next}(u)))

    • 从节点 (v_0) 沿着 parent 指针遍历,总会到达起始节点。设经过的节点为 (v_1,v_2,cdots,v_k)。可以得到一个互不相交的区间 ([|min(v_i)|,|max(v_i)|]),它们的并集形成了连续的区间 ([0,|max(v_0)|]),代表 (S) 长度为 (|max(v_0)|) 的前缀的所有后缀。

    parent 树本质上是 Endpos 集合构成的一棵树,体现了 Endpos 的包含关系。

    注:节点 (u) 对应着具有相同 Endpos 的等价类,( ext{end}(u)) 指的是节点 (u) 对应的等价类的 Endpos 集合。

    4. 小结

    1. (S) 的子串可根据结束位置 Endpos 划分为若干个 ( ext{end}) 等价类。

    2. DAWG 中,每个节点表示一个或多个 (S) 的子串。除起始节点外,每个节点都是 不同的 ( ext{end}) 等价类,对应该等价类内子串的集合。

    3. 每个节点所代表的字符串是 (S) 某个前缀 的 长度连续的后缀
    4. 对于节点 (u),设 (u) 的长度最小、最大的子串为 (min(u)) 以及 (max(u))

    5. 对于两个节点 (u,v)( ext{next}(u)=v),当且仅当 (|min(u)|=|max(v)|+1),且 (v) 代表的子串均为 (u) 子串的后缀。

    6. 以 parent 指针为边,所有节点组成了一棵树(根节点为 DAWG 的起始节点),称为 parent 树。

    三、构建 SAM

    SAM 的构建使用 增量法:通过 (S) 的 SAM 求出 (S+c) 的 SAM((c) 为一个字符)。

    加入字符 (c) 后,子串只增加了 (S+c) 的后缀,已有的子串不受影响。

    (S+c) 的某些后缀可能在 (S) 出现过,在 SAM 中有其对应的节点。

    SAM 中一个串只能对应一个节点,需考虑将它们对应到相应节点上。

    1. 初始化与判断

    设此前表示 (S) 的节点为 (p)

    (S+c) 不可能出现在 (S) 中,它一定被对应到新节点上。设新节点为 (u),那么 (|max(u)|=|S+c|=|max(p)|+1)

    考虑如何判断 (S+c) 的后缀是否在 (S) 出现过。(S+c) 的后缀 (=) (S) 的后缀 (+) (c),判断 (S+c) 的后缀是否在 (S) 的后缀出现过,等价于判断 (S) 的后缀 有无转移边 (c)

    (S) 的某后缀有转移边 (c),那么它一定是新串的后缀,且说明 (S+c) 的该后缀在 (S) 中出现过。

    根据 parent 树的性质,从节点 (p) 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 (S) 的所有后缀。

    从节点 (p) 沿着 parent 指针遍历,找到第一个有转移边 (c) 的节点 (p')

    只需找到 (p') 即可,因为 parent 树上 (p') 的祖先代表的串,均为 (p') 的后缀。它们对应的串的长度小于 (p') 所表示的串,一定也有转移 (c)

    int p=lst,x=lst=++tot;    //新建一个节点 x。此前表示 S 的节点为 p。 
    sz[x]=1,len[x]=len[p]+1;    //sz(i) 表示节点 i 所代表的 Endpos 集合的大小。len(i) 表示 |max(i)|,即节点 i 长度最大的子串的长度。 
    //ch[p][c]=q 表示 p 经过转移边 c 后到 q 
    while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];    //这里的 fa(u)=v 即上文中的 next(u)=v(fa(u) 其实就是 u 在 parent 树上的父亲)。从节点 p 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 S 的所有后缀。从节点 p 沿着 parent 指针遍历,找到第一个有转移边 c 的节点 p′。 

    2. 分类讨论

    接下来对 (S+c) 的后缀在 (S) 中有无出现进行讨论。(u)(p') 的定义同上文。

    (1)(S+c) 的所有后缀在 (S) 中 均未出现

    直接将 (u) 的 parent 指针指向起始状态。此时 (u) 表示 (S+c) 的所有后缀 ([1,|S+c|])

    if(!p){fa[x]=1;return ;}    //S 中不存在子串 为 S+c 的后缀,直接将新节点的 parent 指针指向起始节点(起始节点标号为 1)。 

    (2)(S+c) 的某后缀在 (S) 中 出现过。设 (p') 经过转移边 (c) 后到达节点 (q)(下同)。有 (|max(q)|=|max(p')|+1)

    (q) 代表的所有串,以及 parent 树上它的祖先代表的串,均为 (S+c) 的后缀。

    (max(q))(S+c) 的后缀,应有 ( ext{next}(u)=q)

    此时 (u) 表示 (S+c) 的后缀 ([|max(q)|+1,|S+c|])

    //S 中存在子串 为 S+c 的后缀,且 p' 经过转移边 c 后到达节点 q,有 |max(q)|=|max(p')|+1。 
    int q=ch[p][c],Q;    //q 表示 p' 经过转移边 c 后到达的节点,Q 在下文会解释。 
    if(len[q]==len[p]+1){fa[x]=q;return ;}    //q 代表的所有串,以及 parent 树上它的祖先代表的串,均为 S+c 的后缀。应有 next(u)=q,此时 u 表示 S+c 的后缀 [|max(q)|+1,|S+c|]。 

    (3)(S+c) 的某后缀在 (S) 中 出现过。有 (|max(q)| eq|max(p')|+1)

    首先有 (|max(q)|>|max(p')|+1)(p') 经过转移边 (c) 可转移到 (q)(|max(q)|<|max(p')|+1) 不成立)。

    (q) 中长度 小于等于 (|max(p')|+1) 的串,及 parent 树上它的祖先代表的串,为 (S+c) 的后缀。

    考虑将 (q) 拆成 (S+c) 的后缀部分,和非 (S+c) 的部分。

    设将 (q)(S+c) 的后缀部分放入节点 (q') 中,其余的保留在 (q) 中。

    (q') 应继承 (q) 的转移,因为 (q') 中的串与 新的 (q) 的串 为后缀关系(新的 (q) 指原来的 (q) 中非 (S+c) 的部分。为后缀关系是因为,上文中说过每个节点所代表的串是某个前缀长度连续的后缀),转移同样字符后也为后缀关系。

    显然 (|max(q')|=|max(p')|+1)

    (q') 代表的子串均为 (q) 的后缀。有 ( ext{next}(q')= ext{next}(q))

    又因为 (|min(q)|=|max(q')|+1),则 ( ext{next}(q)=q')

    (q') 代表的所有串,及 parent 树上它的祖先代表的串,均为 (S+c) 的后缀。应有 ( ext{next}(u)=q'),此时 (u) 代表 (S+c) 的后缀 ([|max(q')|+1,|S+c|])

    最后枚举所有 可以转移到 原来的 (q) 的比 (p') 还短的 (S) 的后缀,将其指向 (q')。((p') 应转移到 (q'),则比 (p') 的串还短的后缀也应转移到 (q')

    应转移到 新的 (q) 的后缀 的转移不会被修改。

    //S 中存在子串 为 S+c 的后缀,但 |max(q)|!=|max(p')|+1。 
    Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));    //将 q 拆成 S+c 的后缀部分,和非 S+c 的部分。将 q 的 S+c 的后缀部分放入节点 Q 中,其余的保留在 q 中。Q 应继承 q 的转移,因为 Q 中的串与 新的 q 的串 为后缀关系,转移同样字符后也为后缀关系。
    fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;    //Q 代表的串均为 q 的后缀。显然有 next(Q)=next(q),next(q)=Q。同时应有 next(u)=Q,此时 u 代表 S+c 的后缀 [|max(Q)|+1,|S+c|]。 
    while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];    //最后枚举所有 可以转移到 原来的 q 的比 p' 还短的 S 的后缀,将其指向 Q。从 p' 开始沿着 parent 指针遍历,等价于按长度递减遍历比 p' 长度更短的 S 的所有后缀。 

    3. 代码

    void insert(int c){    //通过 S 的 SAM 求出 S+c 的 SAM 
        int p=lst,x=lst=++tot;    //新建一个节点 x(上文中的 u)。此前表示 S 的节点为 p。 
        sz[x]=1,len[x]=len[p]+1;    //sz(i) 表示节点 i 所代表的 Endpos 集合的大小。len(i) 表示 |max(i)|,即节点 i 长度最大的子串的长度。 
        //ch[p][c]=q 表示 p 经过转移边 c 后到 q 
        while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];    //这里的 fa(u)=v 即上文中的 next(u)=v(fa(u) 其实就是 u 在 parent 树上的父亲)。从节点 p 沿着 parent 指针遍历到达起始节点,等价于按长度递减遍历 S 的所有后缀。从节点 p 沿着 parent 指针遍历,找到第一个有转移边 c 的节点 p′。 
        if(!p){fa[x]=1;return ;}    //S 中不存在子串为 S+c 的后缀,直接将新节点的 parent 指针指向起始节点(起始节点标号为 1)。 
        //S 中存在子串 为 S+c 的后缀,且 p' 经过转移边 c 后到达节点 q,有 |max(q)|=|max(p')|+1。 
        int q=ch[p][c],Q;    //q 表示 p' 经过转移边 c 后到达的节点,Q 在下文会解释。 
        if(len[q]==len[p]+1){fa[x]=q;return ;}    //q 代表的所有串,以及 parent 树上它的祖先代表的串,均为 S+c 的后缀。应有 next(u)=q,此时 u 表示 S+c 的后缀 [|max(q)|+1,|S+c|]。 
        //S 中存在子串 为 S+c 的后缀,但 |max(q)|!=|max(p')|+1。 
        Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));    //将 q 拆成 S+c 的后缀部分,和非 S+c 的部分。将 q 的 S+c 的后缀部分放入节点 Q 中,其余的保留在 q 中。Q 应继承 q 的转移,因为 Q 中的串与 新的 q 的串 为后缀关系,转移同样字符后也为后缀关系。
        fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;    //Q 代表的串均为 q 的后缀。显然有 next(Q)=next(q),next(q)=Q。同时应有 next(u)=Q,此时 u 代表 S+c 的后缀 [|max(Q)|+1,|S+c|]。 
        while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];    //最后枚举所有 可以转移到 原来的 q 的比 p' 还短的 S 的后缀,将其指向 Q。从 p' 开始沿着 parent 指针遍历,等价于按长度递减遍历比 p' 长度更短的 S 的所有后缀。 
    } 

    四、复杂度

    可以证明:

    • 对于一个长度为 (n (ngeq 2)) 的字符串 (S),它的 SAM 的状态数 (leq 2n−1)

    • 对于一个长度为 (n (ngeq 3)) 的字符串 (S),它的 SAM 的转移数 (leq 3n−4)

    SAM 的 空间复杂度:

    • 写成 int ch[N<<1][M](其中 (N) 为状态数,(M) 为字符集大小):空间 (mathcal{O}(n|sum|)),查询时间 (mathcal{O}(1))

    • 字符集较大时,可写成 map<int,int>ch[N<<1],空间 (mathcal{O}(n)),查询时间 (mathcal{O}(log|sum|))

    构建 SAM 的 时间复杂度:均摊 (mathcal{O}(n))

    五、模板

    Luogu P3804 【模板】后缀自动机 (SAM)。

    题目大意:给定一个只包含小写字母的字符串 (S),求 (S) 的所有出现次数不为 (1) 的子串的出现次数乘上该子串长度的最大值。(|S|leq 10^6)

    Solution:建出 SAM 后在 parent 树上 DP 即可。

    #include<bits/stdc++.h>
    using namespace std;
    const int N=2e6+5,M=30;
    int n,lst=1,tot=1,cnt,hd[N],to[N],nxt[N],ch[N][M],len[N],fa[N],sz[N];    //注意 1 为起始节点编号,所以这里 lst 和 tot 初值为 1 
    long long ans;
    char s[N];
    void add(int x,int y){
        to[++cnt]=y,nxt[cnt]=hd[x],hd[x]=cnt;
    }
    void insert(int c){
        int p=lst,x=lst=++tot;
        sz[x]=1,len[x]=len[p]+1;    //sz(i) 表示节点 i 所代表的 Endpos 集合的大小,即所对应的字符串集出现的次数 
        while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
        if(!p){fa[x]=1;return ;}
        int q=ch[p][c],Q;
        if(len[q]==len[p]+1){fa[x]=q;return ;}
        Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
        fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
        while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
    } 
    void dfs(int x,int fa){
        for(int i=hd[x];i;i=nxt[i]){
            int y=to[i];
            if(y==fa) continue;
            dfs(y,x),sz[x]+=sz[y];
        }
        if(sz[x]!=1) ans=max(ans,1ll*sz[x]*len[x]); 
    }
    signed main(){
        scanf("%s",s+1),n=strlen(s+1);
        for(int i=1;i<=n;i++) insert(s[i]-'a');
        for(int i=2;i<=tot;i++) add(fa[i],i);    //建出 parent 树 
        dfs(1,0),printf("%lld
    ",ans);
        return 0;
    }

    六、应用

    可参考 后缀自动机 (SAM) - OI Wiki 和 这个。有些就不写了(其实这里可以算是搬运 233)。

    一些套路:SAM 的实质为 DAG,可以尝试是否能利用 DP 求解。

    有些题目是基于 SAM 的性质的。比如“子串相关”的问题,不妨想想“从起始节点出发,每条路径唯一对应 (S) 的一个子串”,或许会有所帮助。

    1. 子串相关

    求不同子串个数:Problem(2) 种方法)

    1. 不同子串个数等于从起始节点开始的不同路径条数。令 (d_i) 表示从节点 (i) 开始的路径数量,(E) 表示 DAWG 的边集,则 (d_i=1+sum_{(i,j)in E} d_j)

    2. parent 树中,每个节点对应的子串数量是 (len(i)-len( ext{next}(i))),对所有节点求和即可。

    所有不同子串的总长度:

    1. 考虑不同子串数量 (d_i) 和总长度 (ans_i),同样 DP 求解。

    2. 每个节点对应的后缀长度为 (frac{len(i) imes (len(i)+1)}{2}),减去其 ( ext{next}) 节点的对应值就是改节点的贡献,对所有节点求和即可。

    2. 字典序相关

    字典序第 k 小子串:Problem

    字典序第 (k) 小的子串对应 SAM 中字典序第 (k) 小的路径。计算出每个节点的路径数后,可以从 SAM 的根找到第 (k) 小的路径。

    字典序最小的循环移位:

    (S+S) 包含 (S) 的所有循环移位作为子串。

    问题转化为在 (S+S) 对应的 SAM 上找最小的长度为 (|S|) 的路径。从起始节点出发,贪心地访问最小的字符即可。

    3. 最长公共子串

    两个串的最长公共子串:Problem

    先对 (S_1) 构造 SAM,对于 (S_2) 的每个位置,找到这个位置结束的 (S_1)(S_2) 的最长公共子串长度。

    (p) 为当前节点,(l) 为当前长度。从起始节点开始匹配,对于每一个字符 (S_2[i])

    • (p) 存在转移边 (S_2[i]),那么就转移并使 (l)(1)

    • 否则 (p= ext{next}(p)),直到找到有转移边 (S_2[i]) 的节点,(l=len(p))(经过 ( ext{next}(p)) 后到达的节点对应的最长字符串是一个子串)。

    • 若仍没有找到有转移边 (S_2[i]) 的节点,从起始节点开始重新匹配。

    最大的 (l) 即为答案。

    n=strlen(s1+1),m=strlen(s2+1),p=1;    //起始节点编号为 1 
    for(int i=1;i<=n;i++) insert(s1[i]-'a');
    for(int i=1;i<=m;i++){
        int c=s2[i]-'a';
        while(p&&!ch[p][c]) p=fa[p],l=len[p];
        if(ch[p][c]) p=ch[p][c],l++;
        else p=1,l=0;    //从起始节点重新匹配 
        ans=max(ans,l);
    }

    多个串的最长公共子串:Problem

    对其中一个串构造 SAM,其他的串跟之前仅有两个串的方法一样跑一遍。对于每个串,记 (mx(p)) 表示以节点 (p) 为结尾的最长匹配长度。

    由于是多个串,记 (mn(p)=min{mx(p)})(mn(p)) 才是所有串以 (p) 为结尾的最长匹配长度。

    答案即为 (max{mn(p)})

    注意一个节点能被匹配,它在 parent 树上的所有祖先都能被匹配。所以对于每一个节点 (u)(mx(u)) 还要与 (maxlimits_{vin son(u)}{min(mx(v),len(u))}) 取最大值。

    每一个串操作过后记得清空 (mx)

    #include<bits/stdc++.h>
    #define int long long
    using namespace std;
    const int N=1e6+5,M=30;
    int t,k,n,lst=1,tot=1,cnt,hd[N],to[N<<1],nxt[N<<1],ch[N][M],len[N],fa[N],mx[N],mn[N],p,l,ans;
    char s[N];
    void add(int x,int y){
        to[++cnt]=y,nxt[cnt]=hd[x],hd[x]=cnt;
    }
    void insert(int c){
        int p=lst,x=lst=++tot;
        len[x]=len[p]+1;
        while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
        if(!p){fa[x]=1;return ;}
        int q=ch[p][c],Q;
        if(len[q]==len[p]+1){fa[x]=q;return ;}
        Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
        fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
        while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
    } 
    void dfs(int x,int fa){
        for(int i=hd[x];i;i=nxt[i]){
            int y=to[i];
            if(y==fa) continue;
            dfs(y,x),mx[x]=max(mx[x],min(mx[y],len[x]));
        }
    }
    signed main(){
        scanf("%lld",&t);
        while(t--){ 
            scanf("%lld%s",&k,s+1),n=strlen(s+1),k--;
            for(int i=1;i<=tot;i++){
                fa[i]=len[i]=hd[i]=0;
                for(int j=0;j<26;j++) ch[i][j]=0;
            }
            ans=cnt=0,tot=1,lst=1,memset(mn,0x3f,sizeof(mn));
            for(int i=1;i<=n;i++) insert(s[i]-'a');
            for(int i=2;i<=tot;i++) add(fa[i],i);
            for(int i=1;i<=k;i++){ 
                scanf("%s",s+1),n=strlen(s+1),p=1,l=0;
                for(int i=1;i<=n;i++){
                    int c=s[i]-'a';
                    while(p&&!ch[p][c]) p=fa[p],l=len[p];
                    if(ch[p][c]) p=ch[p][c],l++;
                    else p=1,l=0;
                    mx[p]=max(mx[p],l);
                }
                dfs(1,0);
                for(int i=1;i<=tot;i++) mn[i]=min(mn[i],mx[i]),mx[i]=0;
            } 
            for(int i=1;i<=tot;i++) ans=max(ans,mn[i]);
            printf("%lld
    ",ans);
        } 
        return 0;
    }

    七、广义 SAM

    广义 SAM:Trie 树上 SAM,一般用来处理多个串的问题。可参考 这里

    1. 离线做法

    离线做法,即将所有串离线插入到 Trie 树中,依据 Trie 树构造广义 SAM。

    具体操作:

    1. 将所有字符串插入到 Trie 树中。

    2. 对 Trie 进行 BFS 遍历,记录下顺序以及每个节点的父亲。

    3. 将得到的 BFS 序列按照顺序,把 Trie 树上的每个节点插入到 SAM 中。(last) 为它在 Trie 树上的父亲对应的 SAM 上的节点(其中 (last) 表示插入字符之前的节点)。也就是每次找到插入节点的父亲作为 (last) 往后接即可。

    用 BFS 而不是 DFS 是因为 DFS 可能会被卡。

    (insert) 部分和普通 SAM 一样。加上返回值方便记录 (last)

    //Luogu P6139
    #include<bits/stdc++.h>
    using namespace std;
    const int N=3e6+5,M=27;
    int n,ch[N][M],pos[N],fa[N],len[N],tot=1;
    long long ans;
    char s[N];
    queue<int>q;
    struct Trie{ 
        int ch[N][M],fa[N],c[N],tot;    //分别为 Trie 上的转移数组、父节点、节点对应的字符、节点总数 
    }T; 
    void insert_(char* s){
        int len=strlen(s+1),p=1;
        for(int i=1;i<=len;i++){
            int k=s[i]-'a';
            if(!T.ch[p][k]) T.ch[p][k]=++tot,T.fa[tot]=p,T.c[tot]=k;
            p=T.ch[p][k];
        }
    }
    int insert(int c,int lst){    //将 c 接到 lst 后面。返回值为 c 插入到 SAM 中的节点编号 
        int p=lst,x=++tot;
        len[x]=len[p]+1;
        while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
        if(!p){fa[x]=1;return x;}
        int q=ch[p][c],Q;
        if(len[q]==len[p]+1){fa[x]=q;return x;}
        Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
        fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
        while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
        return x;
    } 
    signed main(){
        scanf("%d",&n),T.tot=1;    //根初始化为 1
        for(int i=1;i<=n;i++)
            scanf("%s",s+1),insert_(s);
        for(int i=0;i<26;i++)
            if(T.ch[1][i]) q.push(T.ch[1][i]);    //插入第一层字符
        pos[1]=1;    //Tire 树上的编号为 1 的节点(根节点)在 SAM 上的位置为 1(根节点) 
        while(q.size()){
            int x=q.front();q.pop();
            pos[x]=insert(T.c[x],pos[T.fa[x]]);    //pos[x]: Trie 上节点 x 的前缀字符串(路径 根到 x 所表示的字符串)在 SAM 中的对应节点编号
            for(int i=0;i<26;i++)
                if(T.ch[x][i]) q.push(T.ch[x][i]);
        }
        for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]];
        printf("%lld
    ",ans);
        return 0;
    }

    2. 在线做法

    在线做法,即不建立 Trie,直接把给出的串插入到广义 SAM 中。

    这里 SAM 的 (insert) 部分和普通 SAM 存在差别。

    //Luogu P6139
    #include<bits/stdc++.h>
    #define int long long
    using namespace std;
    const int N=3e6+5,M=27;
    int n,m,ch[N][M],pos[N],fa[N],len[N],lst,tot=1,ans;
    char s[N];int insert(int c,int lst){    //返回值为 c 插入到 SAM 中的节点编号
        int p=lst,x=0; 
        if(!ch[p][c]){    //如果这个节点已存在就不需要新建了
            x=++tot,len[x]=len[p]+1;
            while(p&&!ch[p][c]) ch[p][c]=x,p=fa[p];
        } 
        if(!p){fa[x]=1;return x;}     //1 
        int q=ch[p][c],Q=0;
        if(len[q]==len[p]+1){fa[x]=q;return x?x:q;}    //2
        Q=++tot,memcpy(ch[Q],ch[q],sizeof(ch[q]));
        fa[Q]=fa[q],len[Q]=len[p]+1,fa[q]=fa[x]=Q;
        while(p&&ch[p][c]==q) ch[p][c]=Q,p=fa[p];
        return x?x:Q;    //3
    } 
    signed main(){
        scanf("%lld",&n);
        for(int i=1;i<=n;i++){ 
            scanf("%s",s+1),m=strlen(s+1),lst=1;
            for(int j=1;j<=m;j++) lst=insert(s[j]-'a',lst);
        }
        for(int i=2;i<=tot;i++) ans+=len[i]-len[fa[i]];
        printf("%lld
    ",ans);
        return 0;
    }

    可以证明最坏复杂度为线性。

    八、参考资料

  • 相关阅读:
    lintcode197- Permutation Index- easy
    lintcode10- String Permutation II- medium
    lintcode211- String Permutation- easy
    lintcode51- Previous Permutation- medium
    lintcode52- Next Permutation- medium
    lintcode108- Palindrome Partitioning II- medium
    lintcode136- Palindrome Partitioning- medium
    lintcode153- Combination Sum II- medium
    lintcode521- Remove Duplicate Numbers in Array- easy
    lintcode135- Combination Sum- medium
  • 原文地址:https://www.cnblogs.com/maoyiting/p/14207304.html
Copyright © 2020-2023  润新知