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|\)。
- “\(endpos(s_1)=endpos(s_2)\)” 是 “\(s_2\) 是 \(s_1\) 的后缀,且在 \(S\) 中每次都会以后缀形态出现。”的充要条件。
- 若 \(s_2\) 为 \(s_1\) 后缀,则 \(endpos(s_1)\subseteq endpos(s_2)\),否则 \(endpos(s_1)\cap endpos(s_2)=\emptyset\)。
- 一个 \(endpos\) 等价类中不会包含两个长度相同但本质不同的字符串。
- 一个 \(endpos\) 等价类中的字符串长度一定是连续出现的。
link 和后缀树
对于某个不是 \(P\) 的节点 \(v\),定义 \(s\) 为节点 \(v\) 代表的的字符串中最长的一个。记字符串 \(t\) 表示最长的不和 \(s\) 在同一等价类的 \(s\) 的后缀,我们认为 \(t\) 所在的等价类为 \(E'\),\(s\) 所在等价类为 \(E\)。我们定义 \(link(E)=E'\)。若我们从 \(E\) 向 \(E'\) 连一条边,可以证明我们将会得到一棵内向树,称之为后缀树。显然,若我们从 \(E\) 开始一直跳到 \(P\),我们就可以遍历 \(s\) 的所有后缀。
各种引理
- \(link(E)\) 中最长的字符串 \(s\) 是 \(E\) 中最短的字符串 \(t\) 长度为 \(|t|-1\) 的后缀。
- \(endpos(E)\subsetneq endpos(link(E))\)。
- 后缀树的形态是一棵树(我们刚才并没有证明他的形态)。
实现
现在我们大概能想到,SAM 是由 DAG 和后缀树组成的。两部分相互关联而又独立,造就了 \(SAM\) 的毒瘤。
现在,我们定义 DAG 中节点 \(v\) 所对应的等价类为 \(E_v\),\(E_v\) 中最长的字符串为 \(r(v)\),最短的为 \(l(v)\)。
那么我们就可以开始讲解流程了。
算法流程
最开始时,整个 SAM 只有一个点 \(P\)。我们规定 \(|r(P)|=0,link(P)=-1\)。现在我们添加一个字符 \(c\),流程如下:
- 令 \(last\) 为添加之前整个字符串所对应的节点。初始时 \(last=0\)。
- 创建一个新节点 \(cur\),显然 \(|r(cur)|=|r(last)|+1\)。
- 在后缀树上,从 \(last\) 开始遍历,如果当前节点 \(p\) 在 DAG 上没有标记为 \(c\) 的出边,我们就在 DAG 上创建一条有向边 \(p\to cur\),标记为 \(c\)。
- 如果当前节点 \(p\) 在 DAG 上有标记为 \(c\) 的出边,我们就停止遍历,并记通过 DAG 上标记为 \(c\) 的边到达的节点为 \(q\):
- 若 \(|r(p)|+1=|r(q)|\),令 \(link(cur)=q\)(显然 \(r(p)\) 是 \(r(last)\) 的后缀,\(|r(p)|+1=|r(q)|\) 根据引理3,相当于 \(r(q)\) 也是 \(r(cur)\) 的后缀,那么 \(q\) 中的所有字符串 \(endpos\) 仍然相等)。
- 否则再建立一个点 \(cpy\),继承 \(q\) 除了名字以外的所有信息(包括 \(link\) 和 DAG 上的所有出边),同时令 \(|r(cpy)|=|r(p)|+1,link(q)=link(cur)=cpy\),再从 \(p\) 开始遍历(实际上此时 \(q\) 中的所有字符串的 \(endpos\) 已经不等了,而且显然分为了两个部分。这一步相当于将 \(q\) 拆成 \(r(p)+c\) 的后缀 \(cpy\) 和其他字符串 \(q\) 两个部分):
- 如果当前节点 \(v\) 在 DAG 上有标记为 \(c\) 的出边 \(v\to q\),则将之删除,并改为 \(v\to cpy\)。
- 否则停止遍历。
- 如果遍历到 \(P\) 了,\(link(cur)=0\)(相当于之前不存在字符 \(c\))。
- 令 \(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\) 相同的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· .NET10 - 预览版1新功能体验(一)