SAM 学习笔记
SAM 简介
一个长度为 \(n\) 的字符串,子串数目为 \(\mathcal{O}(n^2)\) ,很大程度上限制了对子串相关问题的研究。于是 DAWG 就出现了。它可以用 \(\mathcal{O}(n)\) 的状态数表示出所有子串,并在线性时间内增量构造。后缀自动机(SAM) 是 DAWG 的一种,其接受状态为 \(s\) 的所有后缀。
可以直观理解为,SAM 是给定字符串的 所有子串 的压缩形式,一个 SAM 最多有 \(2n-1\) 个节点和 \(3n-4\) 条转移边。
自动机相关内容参见文末“关于自动机”部分。
前置:endpos 集合
定义
定义字符串 \(t\) 在 \(s\) 中出现的 所有终止位置的集合 为 \(endpos(t)\) . 例如,\(s\) 为 abbab
,\(t\) 为 ab
,那么 \(endpos(t)=\{2,5\}\) 。
对于两个 \(s\) 的子串 \(t_1,t_2\) ,如果有 \(endpos(t_1)=endpos(t_2)\) ,称 \(t_1,t_2\) 在同一个 \(endpos\) 等价类 (简称类)中。这些等价类最终将构成 SAM 的状态集合。
性质
-
对于同一 \(endpos\) 等价类中的两个子串 \(t_1,t_2\) (不妨设 \(|t_1|\leq |t_2|\) ),\(t_1\) 是 \(t_2\) 的后缀。
显然,如果不是后缀,必然不在同一个等价类中。
-
对于任意两个子串 \(t_1,t_2(|t_1|\leq |t_2|)\) ,有 \(endpos(t_1)\supseteq endpos(t_2)\) 或者 \(endpos(t_1)\cap endpos(t_2)=\varnothing\) 成立。
前者代表 \(t_1\) 是 \(t_2\) 的一个后缀,后者表示不是后缀,那么 \(t_1\) 出现位置 \(t_2\) 必然不会出现。
-
对于一个 \(endpos\) 等价类,其中所有子串的长度连续。
如果只有一个子串,显然成立。后面的推理可以通过第一条的后缀关系得到。
-
\(endpos\) 等价类个数为 \(\mathcal{O}(n)\) .
非常重要的一条性质。
对于一个类的一个子串,在前面增加一个字符,所属的等价类可能会改变。如果改变,那么这个子串(在不同的 实际 的 \(endpos\) 上)会变成若干个子串。对于这些子串中的两个不同的,必然是开头加上的字符不同,显然它们的 \(endpos\) 没有交集。那么这个过程可以视为 \(endpos\) 集合的分割。(如果有 \(endpos\) 无法再往前拓展了,会丢失一部分信息,但至多 \(1\) 个)
注意到分割出来的子集互不相交,那么所有分割过程可以看成一个树形结构。由于最大的 \(endpos\) 集合只会有 \(n\) 个元素,显然树的节点数也只有 \(\mathcal{O}(n)\) .
前置定义和 Parent Tree
引入记号:
- \(longest(i)\) :等价类 \(i\) 中最长的子串
- \(len(i)\) :\(longest(i)\) 的长度
- \(shortest(i)\) :等价类 \(i\) 中最短的子串
- \(minlen(i)\) :\(shortest(i)\) 的长度
上文中,\(endpos\) 的分割关系是一棵树,我们称之为 Parent Tree ,树上的一个节点 \(i\) 代表一个等价类,节点 \(i\) 的父亲记为 \(link(i)\) ,即 后缀链接 。它的实际意义是:\(link(u)=v\) 为最长的 \(len_v\) 使得 \(endpos(u)\subseteq endpos(v)\) 。
那么有结论:\(minlen(i)=len(link(i))+1\) .
SAM
终于进入正题了
状态 & 转移
作为一个合格的自动机,显然要有明确的状态含义。SAM 把一个等价类作为一个状态 。显然,这样状态数就是 \(\mathcal{O}(n)\) 级别的。
转移:定义转移函数 \(\delta(x,c)\) 表示从一个状态 \(x\) 进行转移,在当前字符串的 末尾追加一个字符 \(c\) .
构造
先上代码:
void Insert( int c )
{
int p=las,q=las=++cnt;
len[q]=len[p]+1;
for ( ; p && !ch[p][c]; p=fa[p] )
ch[p][c]=q;
if ( !p ) { fa[q]=1; return; }
int np=ch[p][c];
if ( len[np]==len[p]+1 ) { fa[q]=np; return; }
int nq=++cnt; memcpy( ch[nq],ch[np],sizeof(ch[nq]) );
fa[nq]=fa[np]; fa[np]=fa[q]=nq;
len[nq]=len[p]+1;
for ( ; p && ch[p][c]==np; p=fa[p] )
ch[p][c]=nq;
}
其中 ch[p][c]
表示 \(\delta(p,c)\) ,las
是上一次添加的位置,fa
就是 \(link\) 。我们只需要维护 \(\delta,link,len\) ,不维护额外信息是因为要么会影响复杂度( \(endpos\) ),要么可以直接计算( \(minlen\) )。
下面来逐个解剖这段代码在干嘛。
int p=las,q=las=++cnt;
len[q]=len[p]+1;
for ( ; p && !ch[p][c]; p=fa[p] )
ch[p][c]=q;
if ( !p ) { fa[q]=1; return; }
\(las\) 是上一次插入的节点,\(q\) 是当前要加入的节点。记 \(s_u\) 表示节点 \(u\) 能表示的最长子串,那么 \(s_q=s_p+c\) ,所以 len[q]=len[p]+1
。
现在我们知道 \(s_{fa[p]}\) 一定是 \(s_p\) 的一个后缀,我们希望找到一个已有的状态,它存在一个 \(c\) 边的扩展。
如果当前 \(p\) 不存在 \(c\) 边扩展,那么显然 \(q\) 本身就是一个新的扩展,直接赋值 ch[p][c]=q
,然后继续向上找。如果找到根节点了还找不到,就说明当前串没有前驱,直接 fa[q]=1
,插入完成。
int np=ch[p][c];
if ( len[np]==len[p]+1 ) { fa[q]=np; return; }
现在我们找到了一个 \(c\) 边的扩展,显然这是存在 \(c\) 边扩展中最长的子串。但这个扩展不一定是直接的,也有可能是 \(s_{np}=s_p+c...\) 。
如果这个扩展是直接的,也就是 \(s_{np}=s_p+c\) ,那么显然有 \(endpos(q)\subseteq endpos(np)\) ,直接赋值,完成插入。
int nq=++cnt; memcpy( ch[nq],ch[np],sizeof(ch[nq]) );
fa[nq]=fa[np]; fa[np]=fa[q]=nq;
len[nq]=len[p]+1;
for ( ; p && ch[p][c]==np; p=fa[p] )
ch[p][c]=nq;
现在只剩下一种情况:\(np\) 表示的某个串是 \(q\) 的前缀。
对于这种情况,我们需要分裂出一个点 \(nq\) ,令 \(s_{nq}=s_q+c\) ,显然有 fa[nq]=fa[np]
,fa[np]=fa[q]=nq
.
现在 \(nq\) 代替了 \(np\) ,成为了 \(len\) 更短的点,那么所有原本连向 \(np\) 的点都应该连向 \(nq\) .
重要性质及模板
- 令 \(n=|S|\) ,那么 SAM 中状态数不会超过 \(2n-1\) ,转移数不会超过 \(3n-4\) . 证明参见 OI-Wiki
- 给定一个字符串,对于每个字符在 SAM 上暴力跳 \(fa\) 打标记是 \(\mathcal{O}(n\sqrt n)\) 的。证明及构造 应用
洛谷模板题 换个读入就能卡进最优解第二页,没想到吧
//Author:RingweEH
const int N=1e6+1,M=26;
int n;
struct Suffix_Automaton
{
int len[N<<1],tr[N<<1][M],fa[N<<1],siz[N<<1],las=1,cnt=1;
void Insert( int c )
{
n++; int p=las,q=las=++cnt; len[q]=len[p]+1; siz[q]=1;
for ( ; p && !tr[p][c]; p=fa[p] )
tr[p][c]=q;
if ( !p ) { fa[q]=1; return; }
int np=tr[p][c];
if ( len[np]==len[p]+1 ) { fa[q]=np; return; }
int nq=++cnt; memcpy( tr[nq],tr[np],sizeof(tr[nq]) );
fa[nq]=fa[np]; fa[np]=fa[q]=nq;
len[nq]=len[p]+1;
for ( ; p && tr[p][c]==np; p=fa[p] )
tr[p][c]=nq;
}
int buc[N],ord[N<<1];
ll Query()
{
ll ans=0;
for ( int i=1; i<=cnt; i++ ) buc[len[i]]++;
for ( int i=1; i<=n; i++ ) buc[i]+=buc[i-1];
for ( int i=1; i<=cnt; i++ ) ord[buc[len[i]]--]=i;
for ( int i=cnt; i; i-- )
{
int p=ord[i]; siz[fa[p]]+=siz[p];
if ( siz[p]>1 ) ans=max( ans,1ll*siz[p]*len[p] );
}
return ans;
}
}sam;
int main()
{
char ch=getchar();
while ( ch>='a' && ch<='z' ) sam.Insert(ch-'a'),ch=getchar();
printf( "%lld\n",sam.Query() );
return 0;
}
常用处理手段
- 字符集过大
很简单,直接 map
即可,复杂度 \(\mathcal{O}(n\log |\sum|)\) .
- 拓扑序
由于 SAM 是个 DAG ,所以会出现有题目让你在 SAM 上记忆化搜索,或是在 Parent Tree 上 DFS,我们可以用拓扑序来替代。
具体地,有 \(len(p)>len(link(p))\) 和 \(len(p)<len(\delta(p,c))\) ,这意味着我们可以对状态按照 \(len\) 排序,开个桶即可。
for ( int i=1; i<=cnt; i++ ) ++buc[len[i]];
for ( int i=1; i<=cnt; i++ ) buc[i]+=buc[i-1];
for ( int i=1; i<=cnt; i++ ) ord[buc[len[i]]--]=i;
- 计算 \(endpos\) 集合大小
上文中提到,分割关系构成一棵 Parent Tree ,且分割中至多损失一个元素。记 \(siz(i)=|endpos(i)|\) ,那么对于没有丢失的情况,有
然后考虑损失的一个。显然向前扩展导致长度到达上限的只有一个,且必然是原串的一个前缀,那么只要令 siz[q]=1
。做完之后,再对 Parent Tree 做一次求子树和即可。
- 求具体 \(endpos\) 集合
可以用动态开点线段树,做线段树合并。少数情况下用平衡树启发式合并或者 高科技 .
经典套路
求本质不同子串个数
先构造自动机。每个 \(S\) 的子串都相当于自动机中从根开始的路径,因此不同的子串个数等于以 \(1\) 为起点的不同路径的条数。由于 SAM 是个 DAG,可以 DP 计算,最后去掉空串。时间复杂度 \(\mathcal{O}(|S|)\) .
这样的方法有很大的局限性,大多数时候采用下述方法:
一个字符串唯一对应一个节点,而一个节点表示的是长度为 \(len_u\) 的前缀中长度在 \((len_{fa},len_u]\) 的后缀。所以有:
ll Query()
{
ll ans=0;
for ( int i=1; i<=cnt; i++ )
ans=ans+len[i]-len[fa[i]];
return ans;
}
第 k 小子串
求出从一个点出发的子串数量,从 a
到 z
依次查看,找到第一个满足子串数量前缀和 \(\ge k\) 的转移边,转移即可。
void Query()
{
int i,c,p;
for ( i=1; i<=cnt; i++ ) buc[len[i]]++;
for ( i=1; i<=n; i++ ) buc[i]+=buc[i-1];
for ( i=1; i<=cnt; i++ ) ord[buc[len[i]]--]=i;
if ( typ==0 ) for ( int i=cnt; i; i-- ) siz[i]=1;
else
for ( i=cnt; i; i-- )
p=ord[i],siz[fa[p]]+=siz[p];
siz[1]=0;
for ( i=cnt; i; i-- )
{
p=ord[i]; sum[p]=siz[p];
for ( c=0; c<26; c++ )
if ( tr[p][c] ) sum[p]+=sum[tr[p][c]];
}
if ( k>sum[1] ) { printf( "-1" ); return; }
k-=siz[1]; p=1;
while ( k>0 )
{
c=0;
while ( k>sum[tr[p][c]] ) k-=sum[tr[p][c]],c++;
p=tr[p][c]; printf( "%c",'a'+c ); k-=siz[p];
}
}
动态求出现次数
给定文本串 \(S\) 和若干询问串 \(T_i\) ,动态询问 \(T_i\) 的出现次数。
SAM 本质上是压缩 Trie 的结果,所以可以直接跳转移边得到,最后的结果的状态数就是答案,可以 DFS 预处理状态数,复杂度 \(\mathcal{O}(|S|+|T|)\) 。
求前缀的 LCS
正常情况下直接 SA 。但是如果要动态加字符呢?
注意到 Parent Tree 保留的就是一棵虚树,所以可以直接找 LCA 。
广义 SAM
SAM 仅仅是对单串建立的,如果是多串,就要用到 广义 SAM 。
写法有三种:
- 多个串用分隔符拼接成一个,然后跑 SAM(显然非常的鸡肋)
- 建 Trie ,然后 BFS 建 SAM (离线)
- 对每个串从根建 SAM ,为了防止无用节点要特判(不特判的写法是错的)(在线)
BFS 离线建广义 SAM
这里 的例子指出,DFS 会被卡成 \(n^2\) ,而 BFS 不会。
//Trie.fa[u] 是 Trie 上的父节点
//Trie.c[u] 是 Trie 上节点 u 的字符
//pos[u] 是 Trie 上 u 节点的前缀字符串(根到 u 的路径)在 SAM 上对应的节点标号
void Build()
{
queue<int> q;
for ( int i=0; i<26; i++ ) //插入第一层字符
if ( Trie.tr[1][i] ) q.push( Trie.tr[1][i] );
pos[1]=1; //Trie的根1在SAM上的位置为1
while ( !q.empty() )
{
int u=q.front(); q.pop();
pos[u]=Insert( Trie.c[u],pos[Trie.fa[u]] ); //一个是插入字符,一个是las
for ( int i=0; i<26; i++ )
if ( Trie.tr[u][i] ) q.push( Trie.tr[u][i] );
}
}
在线构造
BFS 虽好,离线却丧失了 SAM 极其重要的性质之一。所以出现了第三种写法。(但是如果不加特判,相同的字符会插入两遍)
在 Insert
开头加入这样一段代码:
if ( tr[las][c] )
{
int p=las,np=tr[p][c];
if ( len[p]+1==len[np] ) return np;
else
{
int nq=++cnt; len[nq]=len[p]+1;
memcpy( tr[nq],tr[np],sizeof(tr[np]) );
fa[nq]=fa[np]; fa[np]=nq;
for ( ; p && tr[p][c]==np; p=fa[p] ) tr[p][c]=nq;
return nq;
}
}
普通 SAM 中,\(las\) 是新插入的最后一个节点,是没有转移的,但是广义 SAM 中可能会有。那么,如果有了转移边,说明当前插入的字符串已经出现过了,直接返回已经建立的节点。后面部分的处理和普通 SAM 完全一致,不过是舍去了建立新节点 \(q\) 的过程而已。
实现
离线
//Author:RingweEH
const int N=1e6+1,C=26;
int n;
char s[N];
struct Trie_Tree
{
int tot=1,c[N],fa[N],tr[N][C];
void Insert( char s[] )
{
int p=1,ch;
for ( int i=1; s[i]; i++ )
{
ch=s[i]-'a';
if ( !tr[p][ch] ) tr[p][ch]=++tot,fa[tot]=p,c[tot]=ch;
p=tr[p][ch];
}
}
}Trie;
struct Suffix_Automaton
{
int len[N<<1],tr[N<<1][C],fa[N<<1],cnt=1,pos[N<<1];
queue<int> que;
int Insert( int c,int las )
{
int p=las,q=++cnt; len[q]=len[p]+1;
for ( ; p && !tr[p][c]; p=fa[p] )
tr[p][c]=q;
if ( !p ) fa[q]=1;
else
{
int np=tr[p][c];
if ( len[np]==len[p]+1 ) fa[q]=np;
else
{
int nq=++cnt; memcpy( tr[nq],tr[np],sizeof(tr[nq]) );
fa[nq]=fa[np]; fa[np]=fa[q]=nq;
len[nq]=len[p]+1;
for ( ; p && tr[p][c]==np; p=fa[p] )
tr[p][c]=nq;
}
}
return q;
}
void Build()
{
for ( int i=0; i<C; i++ ) //插入第一层字符
if ( Trie.tr[1][i] ) que.push( Trie.tr[1][i] );
pos[1]=1; //Trie的根1在SAM上的位置为1
while ( !que.empty() )
{
int u=que.front(); que.pop();
pos[u]=Insert( Trie.c[u],pos[Trie.fa[u]] ); //一个是插入字符,一个是las
for ( int i=0; i<C; i++ )
if ( Trie.tr[u][i] ) que.push( Trie.tr[u][i] );
}
}
ll Query()
{
ll ans=0;
for ( int i=2; i<=cnt; i++ )
ans+=len[i]-len[fa[i]];
return ans;
}
}sam;
在线
struct Suffix_Automaton
{
int len[N<<1],tr[N<<1][C],fa[N<<1],cnt=1;
int Insert( int c,int las )
{
if ( tr[las][c] )
{
int p=las,np=tr[p][c];
if ( len[p]+1==len[np] ) return np;
else
{
int nq=++cnt; len[nq]=len[p]+1;
memcpy( tr[nq],tr[np],sizeof(tr[np]) );
fa[nq]=fa[np]; fa[np]=nq;
for ( ; p && tr[p][c]==np; p=fa[p] ) tr[p][c]=nq;
return nq;
}
}
int p=las,q=++cnt; len[q]=len[p]+1;
for ( ; p && !tr[p][c]; p=fa[p] )
tr[p][c]=q;
if ( !p ) fa[q]=1;
else
{
int np=tr[p][c];
if ( len[np]==len[p]+1 ) fa[q]=np;
else
{
int nq=++cnt; memcpy( tr[nq],tr[np],sizeof(tr[nq]) );
fa[nq]=fa[np]; fa[np]=fa[q]=nq;
len[nq]=len[p]+1;
for ( ; p && tr[p][c]==np; p=fa[p] )
tr[p][c]=nq;
}
}
return q;
}
ll Query()
{
ll ans=0;
for ( int i=2; i<=cnt; i++ )
ans+=len[i]-len[fa[i]];
return ans;
}
}sam;
关于自动机
以下内容摘自《算法竞赛入门经典(第二版)》12.1.1 节。
DFA( Deterministic Finite Automaton
,确定有限状态自动机)可以用一个 \(5\) 元组\((Q,\sum,\delta,q_0,F)\) 表示,\(Q\) 为状态集,\(\sum\) 为字母表,\(\delta\) 为转移函数,\(q_0\) 为起始状态,\(F\) 为终态集。
DFA 代表了一个字符串集合。判断一个字符串是否属于这个集合(即“被这个DFA接受”),边读入边进行状态转移。一开始,自动机在起始状态 \(q_0\) ,每次读入一个字符串 \(c\) ,转移到 \(\delta(q,c)\) ,其中 \(q\) 为当前状态。整个字符串转移完之后,当且仅当 \(q\) 在终态集 \(F\) 中时,DFA接受这个字符串。
NFA( Nondeterministic Finite Automata
,非确定自动机)和 DFA 唯一的区别是,转移函数 \(\delta\) 返回一个集合(可能为空)而不是一个状态,实际转移到集合中任意一个状态。
NFA 的变种:\(\varepsilon\) -NFA, 可以有标记为 \(\varepsilon\) 的转移弧,表示不需要输入任何一个字符就能完成转移。一般的处理方式是,转化为等价的 NFA 。先求出每个状态的 \(\varepsilon\) 闭包(即只经过 \(\varepsilon\) 到达的状态集),然后把每个状态转移 \(\delta(q,c)=S\) 改成 \(\delta(q,c)=S'\) ,\(S'\) 为 \(S\) 中所有状态的闭包并集。注意,这样的 NFA 的起始状态有多个。
DAWG( Directed Acyclic Word Graph
),记为 \(D_w\) ,可以接受一个字符串 \(w\) 的所有子串,而且状态只有 \(\mathcal{O}(n)\) 个,\(n=|w|\) 。一般介绍 SAM 的构造算法就是 DAWG 的构造算法。
参考资料
Wallace - 浅析后缀自动机及基础应用
Flying2018 - 如何建一个 SAM SAM 学习笔记
OI-Wiki - SAM & 广义SAM
Xing_Ling - 广义后缀自动机
刘汝佳《算法竞赛入门经典(第二版)》