SAM 学习笔记

发现自己根本没有 SAM 基础,所以想补一篇学习笔记。

SAM

SAM 是一个可以接受字符串 \(s\) 的所有后缀的最小 \(DFA\)(确定性有限状态自动机)。不过他最大的用处和后缀数组一样,都是用来处理子串信息的。既然他是 \(DFA\),那他就是 \(DAG\),下文的 \(DAG\) 都代指这张图。同时这个 \(DAG\) 有且只有一个出发点 \(P\),满足没有点可以到它但是所有点都可以从它抵达。

endpos 等价类

对于一个字符串 \(S\) 的一个子串 \(s\),定义 \(endpos(s)\) 表示 \(s\)\(S\) 中每个出现位置的右端点所形成的集合。

例如,\(S=ababbabaab,s=ab\) 时,\(endpos(s)=\{2,4,7,10\}\)

我们容易发现在上面的字符串中,\(aa\)\(baa\)\(endpos\) 相等。同样的情况还有很多组。我们将所有 \(endpos\) 相等的子串归为一个等价类。我们定义 \(endpos(E)\) 代表等价类 \(E\)(是一个字符串集合)对应的 \(endpos\) 集合。

\(DAG\) 的每个点都会代表一个 \(endpos\) 等价类,且两两不同。

各种引理

下文中,\(|s_1|\ge|s_2|\)

  1. \(endpos(s_1)=endpos(s_2)\)” 是 “\(s_2\)\(s_1\) 的后缀,且在 \(S\) 中每次都会以后缀形态出现。”的充要条件。
  2. \(s_2\)\(s_1\) 后缀,则 \(endpos(s_1)\subseteq endpos(s_2)\),否则 \(endpos(s_1)\cap endpos(s_2)=\emptyset\)
  3. 一个 \(endpos\) 等价类中不会包含两个长度相同但本质不同的字符串。
  4. 一个 \(endpos\) 等价类中的字符串长度一定是连续出现的。

对于某个不是 \(P\) 的节点 \(v\),定义 \(s\) 为节点 \(v\) 代表的的字符串中最长的一个。记字符串 \(t\) 表示最长的不和 \(s\) 在同一等价类的 \(s\) 的后缀,我们认为 \(t\) 所在的等价类为 \(E'\)\(s\) 所在等价类为 \(E\)。我们定义 \(link(E)=E'\)。若我们从 \(E\)\(E'\) 连一条边,可以证明我们将会得到一棵内向树,称之为后缀树。显然,若我们从 \(E\) 开始一直跳到 \(P\),我们就可以遍历 \(s\) 的所有后缀。

各种引理

  1. \(link(E)\) 中最长的字符串 \(s\)\(E\) 中最短的字符串 \(t\) 长度为 \(|t|-1\) 的后缀。
  2. \(endpos(E)\subsetneq endpos(link(E))\)
  3. 后缀树的形态是一棵树(我们刚才并没有证明他的形态)。

实现

现在我们大概能想到,SAM 是由 DAG 和后缀树组成的。两部分相互关联而又独立,造就了 \(SAM\) 的毒瘤。

现在,我们定义 DAG 中节点 \(v\) 所对应的等价类为 \(E_v\)\(E_v\) 中最长的字符串为 \(r(v)\),最短的为 \(l(v)\)

那么我们就可以开始讲解流程了。

算法流程

最开始时,整个 SAM 只有一个点 \(P\)。我们规定 \(|r(P)|=0,link(P)=-1\)。现在我们添加一个字符 \(c\),流程如下:

  1. \(last\) 为添加之前整个字符串所对应的节点。初始时 \(last=0\)
  2. 创建一个新节点 \(cur\),显然 \(|r(cur)|=|r(last)|+1\)
  3. 在后缀树上,从 \(last\) 开始遍历,如果当前节点 \(p\) 在 DAG 上没有标记为 \(c\) 的出边,我们就在 DAG 上创建一条有向边 \(p\to cur\),标记为 \(c\)
  4. 如果当前节点 \(p\) 在 DAG 上有标记为 \(c\) 的出边,我们就停止遍历,并记通过 DAG 上标记为 \(c\) 的边到达的节点为 \(q\)
    1. \(|r(p)|+1=|r(q)|\),令 \(link(cur)=q\)(显然 \(r(p)\)\(r(last)\) 的后缀,\(|r(p)|+1=|r(q)|\) 根据引理3,相当于 \(r(q)\) 也是 \(r(cur)\) 的后缀,那么 \(q\) 中的所有字符串 \(endpos\) 仍然相等)。
    2. 否则再建立一个点 \(cpy\),继承 \(q\) 除了名字以外的所有信息(包括 \(link\) 和 DAG 上的所有出边),同时令 \(|r(cpy)|=|r(p)|+1,link(q)=link(cur)=cpy\),再从 \(p\) 开始遍历(实际上此时 \(q\) 中的所有字符串的 \(endpos\) 已经不等了,而且显然分为了两个部分。这一步相当于将 \(q\) 拆成 \(r(p)+c\) 的后缀 \(cpy\) 和其他字符串 \(q\) 两个部分):
      1. 如果当前节点 \(v\) 在 DAG 上有标记为 \(c\) 的出边 \(v\to q\),则将之删除,并改为 \(v\to cpy\)
      2. 否则停止遍历。
  5. 如果遍历到 \(P\) 了,\(link(cur)=0\)(相当于之前不存在字符 \(c\))。
  6. \(last=cur\),并且结束这次插入。

时空复杂度

首先需要声明,一般来说,DAG 的连边方式和 \(Trie\) 类似,所以假如我们设字符串中字符数量为 \(m\),则 SAM 空间复杂度为 \(O(nm)\)。若使用映射可以将空间复杂度降至 \(O(n)\),但时间复杂度也会相应上升,这要看你使用的是平衡树还是哈希表。

节点数

显然不超过 \(2n-1\)。能达到上限的字符串有 \(S=abb\cdots bb\)

边数

整个 SAM 的边数可以证明不超过 \(3n-4\)。能达到上限的字符串有 \(S=abb\cdots bbc\)

代码

//Luogu P3804
#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5;
string s;long long ans;
namespace SAM{
    int tot,link[N],len[N];
    int lst,sz[N],tr[N][26];
    vector<int>g[N];
    void insert(int c){
        int cur=++tot,p=lst;
        len[cur]=len[lst]+1,sz[lst=cur]++;
        while(!tr[p][c]&&~p)
            tr[p][c]=cur,p=link[p];
        if(p<0) return;
        int q=tr[p][c];
        if(len[q]==len[p]+1)
            return link[cur]=q,void();
        int cpy=++tot;link[cpy]=link[q];
        for(int i=0;i<26;i++)
            tr[cpy][i]=tr[q][i];
        len[cpy]=len[p]+1;
        link[q]=link[cur]=cpy;
        while(tr[p][c]==q)
            tr[p][c]=cpy,p=link[p];
    }void build(){
        for(int i=1;i<=tot;i++)
            g[link[i]].push_back(i);
    }void dfs(int x){
        for(auto y:g[x]) dfs(y),sz[x]+=sz[y];
        if(sz[x]>1) ans=max(ans,1ll*sz[x]*len[x]);
    }
}int zh(char c){return c-'a';}
int main(){
    ios::sync_with_stdio(0);
    cin.tie(0),cout.tie(0);
    cin>>s,SAM::link[0]=-1;
    for(int i=0;s[i];i++)
        SAM::insert(zh(s[i]));
    SAM::build(),SAM::dfs(0);
    cout<<ans;
    return 0;
}

exSAM

SAM 虽好,但仍有局限性:只能针对一个字符串建立。假如有多个字符串,固然可以在字符串间加入特殊字符的方式解决,但每个特殊字符也必须不同。这样就犯了字符数量过大的忌讳,导致时空复杂度增大。

我们能否像 ACAM 一样,通过 \(Trie\) 树建立 exSAM 呢?

修改定义

在 SAM 中,我们有三大定义:后缀、\(endpos\)\(link\)

对于后缀,设 \(Trie\)\(T\) 上从 \(x\)\(y\) 的路径为 \(T_{x\to y}\),当然,\(x\) 必须为 \(y\) 的祖先。那么 \(T\) 的后缀集合就可以表示为 \(\{T_{x\to y}|r_y=1\}\)。其中 \(r_x\) 表示 \(x\) 的度数。

对于 \(endpos\) 的新定义也顺水推舟,即 \(endpos(s)=\{y|T_{x\to y}=s\}\)。那 \(link\) 就可以不变了。

离线方法

由于 \(dfs\) 死法多多,所以我只说 \(bfs\)

考虑在 \(Trie\)\(bfs\)。对于非根节点 \(x\),若要将其插入,\(last\) 应该是其父亲在 exSAM 上的编号,所以要对于每一个 \(Trie\) 树上的节点记录其在 exSAM 中对应的编号,其他操作和普通 SAM 相同。可以证明是有正确性的(dfs 就没这么好了)。

\(Trie\) 树点数为 \(n\)\(Trie\) 树所代表的所有字符串长度总和为 \(k\),那么 \(bfs\) 的时间复杂度是 \(O(nm)\) 的,远优于 dfs 和在线的 \(O(km)\)

在线方法

考虑每新加入一个字符串,就把 \(last\) 归一次 \(0\),再加入少许特判,就可以避免空节点问题。实际上,\(dfs\) 和在线的 \(insert\) 相同的。

posted @   长安一片月_22  阅读(2)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示