后缀自动机学习笔记(SAM)

定义:构造一个节点数、边数尽量少的DAG满足其(有向无环图)上表示出一个字符串的所有子串


SAM两大要素

一、Parent tree(类比ACAM失配指针fail)

组成:

1.endpos及endpos等价类

定义:有一源串Str,对于其中可能出现的子串p,它出现的位置的右端点的编号组成的集合叫做endpos(p).

如ababa中的ab的endpos(ab)为{2,4}.对于endpos()相同的的子串将它们归为一个endpos等价类。

性质:(4、5结论需要用到parenttree的构造)

   1.如果两个子串的endpos相同,则其中子串一个必然为另一个的后缀

   2.对于任意两个子串t和p(lent≤lenp),要么endpos(p)∈endpos(t),要么endpos(t)∩endpos(p)=∅

   3.一个endpos等价类内的串的长度连续,若将类中子串按长度降序排序,则一定依次减1。

   4.endpos等价类个数的级别为O(n)

   5.对于父类和子类(不同等价类),父类中最长子串的长度加1一定是子类中最短子串的长度(子类还可能有其他更长的等价的子串,但一定不会有长度不超过父类的子串)

   6.后缀自动机的边数为O(n)(此处不与证明)

2. 构造与证明

  证明:(1,2,3):结论1易得若类中长度小的子串不为另一同类子串的后缀,则源串存在一位字符位同时出现两种不同的字符,这和字符串的定义相矛盾,得证。结论2为结论1的逆否命题;结论3根据结论1可知,对于某个endpos类中最长的子串w,所有的w的后缀从次最大后缀开始递减,到某一个后缀之后不再是该类的,可证3。

  构造:首先,对每一个endpos等价类,在其最长的子串前w(左边)添一个字符,有性质2可得新形成的字符串nw一定不属于该类(易证),同时endpos(nw)为endpos(w)的子集。那么对同一子串w前添加两不同字符,这新形成的两个字符串也一定属于两个不同的endpos类(也由结论二易得),所以对于前述操作可视为将endpos(w)切割分为两个不相交的子集。这样的话可以构造父类和子类,父类就是子串w代表的类,子类就是w前添加了不同不同字符形成的字符串代表的类。由于最终的endpos类一定只含有一个元素且由空串代表的endpos类分割而来,即将一个含有源串S长度n个元素的集合分割为只含有一个元素的集合并保留原集合,那么最多会有2n个不同类(也叫节点),结论4得证。至于结论5则很容易看出——根据定义子类是父类最长串添加一个字符形成的,那么一定有len(w)+1=minlen(nw)(minlen代表nw类中长度最短的字串长度)。

定义终止节点为endpos含有n的节点,n即源串长度,也即字符串可作为源串后缀的节点。

完成图如下:每个节点用该类最长字符串代表(以字符串nmnmnnm为例)方框中为endpos该类字符的集合。

  • 标红的节点即为终止节点

不难理解,父子类之间endpos集合发生一定损失,是因为父类含有源串前缀

二、后缀自动DAG(类比前缀树trie)

定义:对于parenttree中每个节点另构造每个节点的边使得从根节点出发到某一个节点所经历的字符边构造的字符串是属于该节点(endpos类)的。

对于后缀自动机的边(转移边),从自动机的节点入手,因为parenttree与后缀自动机共用同一节点,根据前面所提到的性质,沿着前者的边走相当于字符串的首字符增添,沿后者的边走相当于字符串的尾部字符增添,每一个节点相当于一个endpos类,


自动机的构造

首先声明,自动机构造代码不长,只需要一个一维数组存储所有节点,每个节点只需要该类最长字符串的的长度len,和其父类fa节点的下标,以及后缀自动机的转移边ch数组(建立在字符串只含26位字母的基础上,若需要更多的字符的构造,则参考广义后缀自动机)。同时只需要一个函数add(int c)一位一位的接受源串的字符,函数的功能是创建新的节点、构造parenttree的链接边并借此跳转构造自动机的转移边(DAG)。多次执行add函数即可构造出后缀自动机。

不难发现,构造好的自动机同时具有parenttree树和trie转移的两种性质,后续应用都需依靠这两者的性质(详见下文后缀自动机的应用)

构造注释说明如下:

struct NODE
{
	int ch[26];
	int len,fa;
	NODE(){memset(ch,0,sizeof(ch));len=0;}
}dian[MAXN<<1];
int las=1,tot=1;
void add(int c)
{
	int p=las;int np=las=++tot;//p代表源串不同前缀,也可以叫不同的完整旧串
	dian[np].len=dian[p].len+1;
	for(;p&&!dian[p].ch[c];p=dian[p].fa)//从长到短遍历p的所有后缀直到到根节点或到‘已经在旧字串里出现过的后缀’,endpos不再与np不再具有fa父子关系
    dian[p].ch[c]=np;//连接后缀自动机的边,使得np节点是所有路径组合而成的字符串的同一endpos类
    //后续不再需要跳fa了,因为再往前遍历的其他q,或处理一定在之前的添加字符操作已经被完成了,且子类不再是np
	if(!p)dian[np].fa=1;//若遍历到根节点了,说明旧字符串的后缀没有尾部加新字符c仍是旧字符串的子串的除了空串类它没有父类
    //以上为case 1
	else
	{
		int q=dian[p].ch[c];
		if(dian[q].len==dian[p].len+1)//如果满足条件,q代表的字符串将是既在旧串中出现也是新串后缀的字符串
        dian[np].fa=q;//np仅代表endpos{n}的类,而q代表包含{n}的类,则满足父子关系建parenttree边
        //以上为case 2
		else//因为q是由p的c出边引出去的,则一定len(q)>len(p)+1,代表了还有至少一个比′′  p中最长字符串+c  ′′ 更长的串属于q,而这个更长的串必定不是新串的后缀
		{
			int nq=++tot;dian[nq]=dian[q];//创建新节点nq代表endpos{x,....,n}的类
            //首先nq继承原q的出边(ch[]),因为原q沿其出边形成的字符串一定属于旧串子串nq在没有添加新字符之前仍与q属于同一等价类,
            //则沿出边形成的字符串仍属于同一类。出边指向同一节点
			dian[nq].len=dian[p].len+1;//q为长度超过len(p)+1的字串代表的类
			dian[q].fa=dian[np].fa=nq; //nq相当于q的fa,而且原来q的fa类成为nq的fa类,类似于链表的插入
			for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;
            //再从当前的p节点沿fa往上跳,将每一个由p指向现在的q节点的出边改为指向nq的边,
            //因为现在的q节点只包含旧串子串,不含新串后缀,指向它的边不再符合后缀自动机的定义(因为p节点始终代表旧串的不同后缀)
            //当循环满足退出条件,便不再需要继续往上跳了,因为此时dian[p].ch[c]的指向的点肯定是q的某个祖先
            //既然,nq作为q的父类endpos都已经包含了{n}祖先们都一定包含{n}一定满足后缀自动机的定义,可以理解为之前的情况已经被处理过了
            //以上为case 3
		}
	}
}
char s[MAXN];int len;
int main()
{
	scanf("%s",s);len=strlen(s);
    for(int i=0;i<len;i++)add(s[i]-'a');
}

后缀自动简单应用

1.判断子串是否存在

例题:给定一段文本,多个字符串,分别判断每个字符串是否存在于该文本中

思路:运用后缀自动机的trie转移性质,由于自动机相当于存储了源文本的所有子串,只需要拿字符串放入自动机从根节点开始跑(转移),若节点始终不为空(0)则说明是子串,时间复杂度O(e),e为子串长度。

事实上KMP,ac自动机也能完成,效率也不低。

2.判断不同子串个数

例题:给定一个字符串,问其有几个不同的子串。

如aba有“a,b,ba,ab,aba”五个

思路:运用后缀自动机的节点的性质,每个节点代表了一类长度连续的endpos相同的子串,且每个节点元素无交集,也即每个节点代表的子串各不相同,而且因为所有节点endpos的并集为endpos所有可能情况,即所有节点包含了所有的子串,则可遍历所有节点统计节点p的maxlen(p)-minlen(p)+1,最后相加即可得ans=\sum [len(fa(p))-len(p)]

参考代码:

#include<bits/stdc++.h>
#define maxn 100005
#define INF 0x3f3f3f3f
#define ll long long
using namespace std;
struct SAM{
    ll len=0;
    int fa=0; 
    int ch[26]={0};
}pool[maxn<<1];
ll tot=1,las=1;//根节点下标设置为1
ll ans=0;
void extend(int c)
{
    int p=las;
    int np=las=++tot;
    pool[np].len=pool[p].len+1;
    for(;p&&!pool[p].ch[c];p=pool[p].fa)//遍历p后缀
    pool[p].ch[c]=np;
    if(!p)
    pool[np].fa=1;
    else
    {
        int q=pool[p].ch[c];
        if((pool[q].len)==(pool[p].len+1))
        pool[np].fa=q;
        else
        {
            int nq=++tot;
            pool[nq]=pool[q];
            pool[nq].len=pool[p].len+1;
            pool[q].fa=pool[np].fa=nq;
            for(;p&&pool[p].ch[c]==q;p=pool[p].fa)
            pool[p].ch[c]=nq;
        }
    }
    ans+=(pool[np].len-pool[pool[np].fa].len);
}
int main()
{
    ll n;
    char S[maxn];
    scanf("%d",&n);
    scanf("%s",S);
    for(ll i=0;i<n;i++)
    {
        extend(S[i]-'a');
    }
    printf("%lld\n",ans);
    system("pause");
}

后缀自动机的适用性广泛,能解决大部分字符串问题

声明:本篇仅篇幅有限,未作详尽的解释,请多谅解

推荐相关补充:史上最通俗的后缀自动机详解-洛谷

  • 32
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值