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))\) 的贡献即可。

例题 【模板】后缀自动机 (SAM)

求最大值:出现次数大于 \(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。我也会写一篇较短的题解,但不会放在这篇文章中。

posted @ 2021-03-29 18:04  zkdxl  阅读(114)  评论(0编辑  收藏  举报