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(i)=\sum_{link(j)=i}siz(j) \]

然后考虑损失的一个。显然向前扩展导致长度到达上限的只有一个,且必然是原串的一个前缀,那么只要令 siz[q]=1 。做完之后,再对 Parent Tree 做一次求子树和即可。

  • 求具体 \(endpos\) 集合

可以用动态开点线段树,做线段树合并。少数情况下用平衡树启发式合并或者 高科技 .

经典套路

求本质不同子串个数

先构造自动机。每个 \(S\) 的子串都相当于自动机中从根开始的路径,因此不同的子串个数等于以 \(1\) 为起点的不同路径的条数。由于 SAM 是个 DAG,可以 DP 计算,最后去掉空串。时间复杂度 \(\mathcal{O}(|S|)\) .

这样的方法有很大的局限性,大多数时候采用下述方法:

一个字符串唯一对应一个节点,而一个节点表示的是长度为 \(len_u\) 的前缀中长度在 \((len_{fa},len_u]\) 的后缀。所以有:

\[ans=\sum (len_u-len_{fa_u}) \]

P2408 不同子串个数

ll Query()
{
	ll ans=0;
	for ( int i=1; i<=cnt; i++ )
		ans=ans+len[i]-len[fa[i]];
	return ans;
}

第 k 小子串

求出从一个点出发的子串数量,从 az 依次查看,找到第一个满足子串数量前缀和 \(\ge k\) 的转移边,转移即可。

LOJ2102 弦论 提交记录

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\) 的过程而已。

实现

广义 SAM模板

离线

//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 - 广义后缀自动机

刘汝佳《算法竞赛入门经典(第二版)》

posted @ 2021-01-15 18:47  MontesquieuE  阅读(322)  评论(1编辑  收藏  举报