SAM 学习笔记
详见 \(\text{OI-Wiki}\)。
一般适用范围:子串相关。
SAM 定义相关
字符串 \(s\) 的 \(\text{SAM}\) 是一个接受 \(s\) 所有后缀的最小 DFA(确定性有限自动机或确定性有限状态自动机)。其中,\(s\) 每个后缀均可用一条从初始状态 \(t_{0}\) 到某个终止状态的路径构成。
包含 \(s\) 的所有子串:从 \(t_{0}\) 开始的任意路径都构成一个子串,每个子串也对应某条路径。但是到某个状态的路径可能不止一条,即每个状态都对应一些字符串组成的集合。
SAM 构造相关
重要概念:结束位置 endpos
对于 \(s\) 任意非空子串 \(t\),\(\text{endpos}(t)\) 为 \(s\) 中 \(t\) 的所有结束位置。根据 \(\text{endpos}\) 集合将 \(t\) 分为若干个等价类,每个等价类在 \(\text{SAM}\) 上对应一个状态,即 \(\text{SAM}\) 状态数为 \(\text{endpos}\) 等价类个数加 \(1\)(还有初始状态)。下面是由 \(\text{endpos}\) 得到的一些性质。
-
\(s\) 的两个非空子串 \(u\) 和 \(v\),满足 \(\lvert u\rvert \leq \lvert v\rvert\) 且 \(\text{endpos}(u)=\text{endpos}(v)\),当且仅当 \(u\) 在 \(s\) 中每次出现都是以 \(v\) 后缀的形式存在的。
-
\(s\) 的两个非空子串 \(u\) 和 \(v\),满足 \(\lvert u\rvert\leq \lvert v\rvert\),如果 \(u\) 是 \(v\) 的一个后缀,有 \(\text{endpos}(v)\subseteq \text{endpos}(u)\);否则,\(\text{endpos}(u)\cap \text{endpos}(v)=\varnothing\)。
-
对于同一等价类的任意两个子串,较短者为较长者的后缀。记 \(u\) 为等价类中最短的字符串,\(v\) 为等价类中最长的字符串,则该等价类中的子串长度恰好覆盖整个区间 \([\lvert u\rvert,\lvert v \rvert]\)。
证明都比较显然。
重要概念:后缀链接 link
设 \(w\) 为状态 \(v\) 对应的 \(\text{endpos}\) 等价类中最短的字符串,\(t\) 是 \(w\) 的后缀且 \(\lvert t\rvert = \lvert w\rvert -1\),则 \(\text{link}(v)\) 连接到 \(t\) 所属的 \(\text{endpos}\) 等价类对应的状态。\(\text{link}\) 构成一棵根节点为 \(t_{0}\) 的树。
有 \(\text{endpos}(u)\subsetneq \text{endpos(link(}u))\),否则 \(u\) 和 \(\text{link}(u)\) 应该合并,那么 \(\text{endpos}\) 集合构成的树和 \(\text{link}\) 是等价的,这就是 \(\text{parent}\) 树,\(\text{link}(u)\) 对应 \(u\) 在树上的父亲。从 \(u\) 开始向上跳父亲直到根节点 \(t_{0}\),所有状态的字符串长度区间互不相交但连续。
构造算法:在线
此处再定义 \(\text{len}(u)\) 表示状态 \(u\) 中最长字符串的长度。
此部分内容暂时咕咕咕。
struct SAM
{
int link[N],len[N],ch[N][26];
int tot,lst;
inline SAM() { tot=lst=1; link[1]=len[1]=0; memset(ch[1],0,sizeof(ch[1])); }
inline void Extend(int c)
{
int now=++tot;
int p=lst;
while(p && !ch[p][c]) ch[p][c]=now, p=link[p]; //找到有转移 c 的 p
len[now]=len[lst]+1; //更新 now 的 len
if(!p) { lst=now; link[now]=1; return; } //没找到 p 就把 now 连到根节点上
int q=ch[p][c];
//x 为 s 的后缀,现在加入 x+c,且 x+c 已经出现过了,那么 link(now) 应该连接到最长字符串恰好是 x+c 的状态,即 len(q)=len(p)+1,如果不存在这样的 q,再分类讨论
if(len[q]==len[p]+1) link[now]=q;
else
{
//新加一个 nq 状态,满足 len(nq)=len(p)+1,此时 len(nq)<len(q)。所以将 q 的转移和 link 都给 nq,并且 link(q)=link(now)=nq
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
link[nq]=link[q];
link[q]=link[now]=nq;
// 到 q 的一些转移要改到 nq 上。v 是 p 的最长字符串,将 v+c 转移到 q 全部修改为转移到 nq,直到不存在转移到 q 的状态为止
while(p && ch[p][c]==q) ch[p][c]=nq, p=link[p];
}
lst=now;
}
}A;
一些性质:
状态数上限 \(2n-1\),转移数上限 \(3n-4\)。
对于节点 \(x\),长度为 \(\text{len}(x)\) 的字符串 \(A\);对于节点 \(y\),长度为 \(\text{len}(y)\) 的字符串 \(B\)。如果 \(x\) 是 \(y\) 的祖先,那么 \(A\) 是 \(B\) 的后缀。
两个前缀 \([1,x]\) 和 \([1,y]\) 的最长公共后缀对应的字符串是 \(v_{x}\) 和 \(v_{y}\) 在 \(\text{parent}\) 树上的 \(\text{LCA}\) 对应的字符串。
本质不同子串总数:\(\sum\limits_{i} \text{len}(i)-\text{len(link}(i))\),例题 生成魔咒,每次增加 \(\text{len}(now)-\text{len(link}(now))\) 的贡献即可。
求最大值:出现次数大于 \(1\) 某个子串的出现次数乘上长度
建出 \(\text{SAM}\),\(\text{parent}\) 树上非复制的点都对应了一个字符串。
由于 \(\text{endpos}\) 集合的性质,点 \(x\) 的子树内的节点对应的 \(\text{endpos}\) 集合无交集但都是包含于 \(x\)。所以求一遍子树和就可以得到点 \(x\) 的 \(size\)。那么对 \(size\not=1\) 的点 \(x\) 对应的 \(len\times size\) 取一个最大值即可。
void DFS(int x)
{
for(ri int i=head[x];i;i=e[i].nxt)
{
int v=e[i].to;
DFS(v);
siz[x]+=siz[v];
}
if(book[x]) siz[x]++;
}
例题 LCS - Longest Common Substring
\(n\) 个串的最长公共子串。
首先考虑两个串的情况。对于第一个串建出 \(\text{SAM}\),第二个串在上面匹配。子串可以表示成一段前缀的后缀,所以考虑每加入一个字符,计算新的答案。设以 \(i\) 结尾的最长公共子串长度为 \(now\),当前状态为 \(v\),新加入字符 \(c_{i}\):
- 存在 \(v\) 到 \(c\) 的转移,\(now\) 加 \(1\) 即可。
- 不存在 \(v\) 到 \(c\) 的转移,那么 \(v\rightarrow \text{link}(v)\),\(now\rightarrow \text{len}(v)\)。如果到根节点仍不存在到 \(c\) 的转移,则显然 \(now=0\);否则回到上一种情况即可。
对于多个串也是类似。记 \(tag[i][j]\) 表示第 \(i\) 个串(\(2\leq i \leq n\))在状态 \(j\) 匹配的最长字符串,再对每个状态的所有 \(tag\) 取个 \(\min\) 得到每个状态上的答案,最后对所有状态取 \(\max\) 即可。
注意 \(v\rightarrow \text{link}(v)\) 时,也要更新 \(tag\),这是因为一个节点 \(x\) 能匹配,那么 \(x\) 在 \(\text{parent}\) 树上所有祖先都能被匹配。
while(scanf("%s",s[++n]+1)!=EOF) len[n]=strlen(s[n]+1); n--;
for(ri int i=1;i<=len[1];i++) A.Extend(s[1][i]-'a');
for(ri int i=2;i<=n;i++)
{
int now=0,sta=1;
for(ri int j=1;j<=len[i];j++)
{
int p=s[i][j]-'a';
while(sta && !A.ch[sta][p]) sta=A.link[sta], now=A.len[sta], tag[i][sta]=max(tag[i][sta],now);
if(!sta) sta=1;
if(A.ch[sta][p]) sta=A.ch[sta][p], now++;
tag[i][sta]=max(tag[i][sta],now);
}
}
for(ri int i=2;i<=A.tot;i++)
{
int gg=1e9;
for(ri int j=2;j<=n;j++) gg=min(gg,tag[j][i]);
if(gg<1e9) Ans=max(Ans,gg);
}
printf("%d\n",Ans);
例题 SUBLEX - Lexicographical Substring Search
本质不同排名第 \(k\) 小子串。
即 \(\text{SAM}\) 上字典序第 \(k\) 小路径。一个想法是求出每个状态对应的总路径数,这样就可以从根节点暴力找到第 \(k\) 小路径。
对 \(\text{SAM}\) 上状态构成的 \(\text{DAG}\) 进行拓扑排序:
int id[N],cs[N];
//基数排序
inline void Topo()
{
for(ri int i=1;i<=A.tot;i++) cs[A.len[i]]++;
for(ri int i=1;i<=A.tot;i++) cs[i]+=cs[i-1];
for(ri int i=A.tot;i;i--) id[cs[A.len[i]]--]=i;
}
然后根据拓扑序加入状态,很方便地得到每个状态的总路径数。
string ot="";
while(1)
{
int flg=0;
for(ri int i=0;i<26;i++)
{
if(!A.ch[now][i]) continue;
if(K>siz[A.ch[now][i]]) K-=siz[A.ch[now][i]];
else
{
flg=1;
now=A.ch[now][i], K--;
ot+=(char)(i+'a');
break;
}
}
if(!flg||!K) break;
}
if(K) puts("");
else cout<<ot<<endl;
例题 [AHOI2013]差异
两两后缀的最长公共前缀之和,即两两前缀的最长公共后缀之和。
在 \(\text{parent}\) 树上这有很好的性质,因为两个前缀分别对应的状态的 \(\text{LCA}\) 就是它们的最长公共后缀。
所以考虑每个状态作为 \(\text{LCA}\) 时的贡献即可。
for(ri int i=head[x];i;i=e[i].nxt)
{
int v=e[i].to;
DFS(v);
siz[x]+=siz[v];
Ans-=2ll*siz[v]*(siz[x]-siz[v])*A.len[x];
}
例题 2015 集训队论文集 《后缀自动机及其应用》 例题一 字符串
给定 \(n\) 个字符串,询问每个字符串有多少非空子串是所有 \(n\) 个字符串中至少 \(k\) 个的子串。
将 \(n\) 个串用 #
相拼接之后建出 \(\text{SAM}\),每个串在上面匹配,则只需要考虑每个状态出现在多少个字符串中即可。这里可以用暴跳 \(parent\) 树的 \(O(n\sqrt n)\) 做法,或者用数据结构维护做到 \(O(n\text{ poly}(\log n))\)。具体实现可以参考 \(\text{darkbzoj}\) 以及论文中的题解。
例题 2015 集训队论文集 《后缀自动机及其应用》 例题二 [APIO2014]回文串
求字符串 \(S\) 的所有回文子串中,出现次数乘长度的最大值。
\(\text{PAM}\) 板题,但是 \(\text{SAM}\) 也可以做,详见 Link。我也会写一篇较短的题解,但不会放在这篇文章中。