SAAAAAAAAM学习笔记。

不知道啥时候会弃坑。。。

本来想着用后缀数组能把后缀自动机专题的水个遍呢,结果还是无法做到,于是还是来学SAM了。。。然后发现SAM比SA难懂多了,被迫记个笔记。。。

写的仅代表当时的想法。

像trie树一样,后缀自动机可以用\(O(n)\)的时空来表示出一个字符串的所有子串,便于理解,用到了一些定义:

  • \(endpos\):对于一个子串来说,它的\(endpos\)就是它最后一个字母的位置。如果一个子串在整个字符串中多次出现,那么\(endpos\)就是所有这种位置的集合。

  • \(endpos\)等价类:对于多个子串,如果它们的\(endpos\)完全相同,那么它们就组成了一个\(endpos\)等价类。

到这里有一些结论:

  1. 如果两个子串的\(endpos\)相同,那么一个必定为另一个后缀。

  2. 对于两个子串\(a\)\(b\),钦定\(len_a<=len_b\),那么\(a\)\(b\)\(endpos\)一定是包含或者完全不相交关系。

  3. 对于一个\(endpos\)等价类,若按照串长从大到小排序,则每个串的长度均为上一个串-1,且为上一个串的后缀。

  4. \(endpos\)等价类的个数是\(O(n)\)的。

    对于第4点的证明,对于一个等价类的最长串\(s\),若在其之前加上一个字符,则形成的新字符\(p\)\(endpos\)一定是\(s\)的子集,并且处于新的类中。如果分别加不同的字符,它们的\(endpos\)一定是互不相交的关系,因此对于添加一个字符,可以看成把原来的\(endpos\)分割成不同的部分并且保留原来的\(endpos\),添加n次就分割n次,因此是\(O(n)\)的。

    再考虑分割关系,就形成了一颗\(endpos\)的集合组成的树,\(endpos\)之间有了包含关系。这棵树就叫做\(parent tree\)

可以理解成\(parent\ tree\)上的边是在串前面加一个字符,而SAM则是在后面。

然后再有几个定义:

  • \(len(a)\):对于一个等价类\(a\),其中最长的那个串的长度。

  • \(minlen(a)\):对于一个等价类\(a\),其中最短的那个串的长度。

  • \(fa(a)\):等价类\(a\)\(parent tree\)上的父亲。

  • \(longest(a)\):对于一个等价类\(a\),其中最长的那个串。

  • \(shortest(a)\):对于一个等价类\(a\),其中最短的那个串。

那么有:

  1. \[len(fa(a))+1=minlen(a) \]

    还是通过上面的证明,\(a\)中的串是来自于\(fa(a)\)的最长串前面加一个字符产生的,那么也就是上面那个柿子了。

  2. 后缀自动机的边数为O(n)

    由于菜所以直接粘过来了:


	/*
	考虑对于一个后缀自动机:先求出其任意一个生成树,舍弃其其它边。我们知道,后缀自动机上有若干个终止节点。于是我们便从每个终止节点往回跑所有属于它(这个类)的子串(从终止节点跑其实就是跑原串的后缀)。注意这里的往回跑是指:对于一个子串,按照原本后缀自动机到达其的唯一路径经过的边往回跑,而非只考虑边的字符,因为可能有多条代表相同字符的边指向同一个节点。

	对于每个终止节点:我们按一定顺序跑遍属于它的子串。如果能顺利跑回源点,则跑下一个子串。否则,连上本应该跑回的边,沿它跑回下一个节点。此时,从这个节点出发,一定有一条或多条路径(经过现存的边)回到源点(因为有树的基础结构),则我们往回跑其中任意一条路径。这样,实际走的路径形成的字符串不一定是原本希望跑的子串,但是因为加了一条新边,且路径是从同样的终止节点开始的,所以得到的字符串必然属于此类,且未被跑过。我们只需要将这个字符串在将来要跑的子串中划掉即可。之后重跑我们原本希望跑的子串,直到真正顺利跑完这个子串,再按顺序跑下一个子串。可以发现,我们在此过程中增加的边数不会超过此节点endpos的大小。

	这样,当跑完所有终止节点时,在原本的生成树上增加的边不会超过后缀的个数,即n个。而此时,增加了边的后缀自动机已经完整了。于是,生成树的边数加n,数量级为O(n)。
	*/

那么这样后缀自动机就有了几个性质:

  1. 每一条边都相当于在字符串后面加上一个字符。

  2. 到达一个点的任意路径形成的字符串的\(endpos\)一定是这个点所代表的。

  3. 对于点之间的父子关系,要满足到达一个点的所有字符串的长度都大于到父亲的所有字符串的长度,且到达父亲节点形成的任意字符串一定都是到达这个点的字符串的后缀。

然后是构造,打了一下注释,代码是粘的。

struct NODE
{
    int ch[26];//儿子
    int len,fa;//和上文说的一样
    NODE(){memset(ch,0,sizeof(ch));len=0;}
}dian[MAXN<<1];
int las=1,tot=1;
//las:当前要插入的字符插入之前的整个串所属的点的编号。tot就是简单计数器。
void add(int c)
{
    int p=las;int np=las=++tot;
	//np当前要插入的字符的节点编号
    dian[np].len=dian[p].len+1;
	//长度加一,好像很显然。
    for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;
	//一直跳到父亲节点有边权为c的边
	/*
		跳父亲节点的含义是:由于定义,父亲longest是儿子longest的后缀,
		插入一个字符相当于在所有后缀后面加了一个字符,
		也就是满足能在后面加字符的一定都在父亲节点中,
		那么遍历父亲节点就相当于遍历所有后缀。
	*/
    if(!p)dian[np].fa=1;//以上为case 1(所有都没有,说明c是第一次出现在字符串中,直接源点是父亲)
    else
    {
        int q=dian[p].ch[c];
        if(dian[q].len==dian[p].len+1)dian[np].fa=q;//以上为case 2(这里q是p的长度加一说明longest(q)=longest(p)+'c',和np要满足的1、2、3条件都匹配上了,于是就可以把q直接当做np的父亲)
        else
        {
			/*
				dian[q].len!=dian[p].len+1
				也就是dian[q].len>dian[p].len+1
				这代表了至少有一个比longest(p)+'c'更长的串在q中,
				而且这个串一定不会是新串的后缀,因为如果是的话,
				去掉这个'c'就是旧串的后缀,而且长度更长,应该比p更先被跳到才对。
				于是不能满足父子的后缀关系,要拆点。
			*/
            int nq=++tot;dian[nq]=dian[q];
            dian[nq].len=dian[p].len+1;
            dian[q].fa=dian[np].fa=nq; 
			/*
				不满足就造一个满足的,先继承q的再创别的新东西。
				并且这个新建的nq一定同时满足是np和q的父亲。
			*/
            for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;
			/*
				然后把向q连的边都改成向nq连的边,
				这样就满足了真正继承q和其它边的关系。
			*/
			//以上为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');
	//一个字符一个字符插入
}

这样SAM的构建就没了,好像记笔记确实有点用哈。

然后就到实战篇了。
虽然我还没开始实战,但是可以先把基本东西搞一搞:

  1. 判断子串。可以把母串SAM建出来,从源点跑一遍看能不能跑完。
  2. 不同子串个数
    1. 可以dp,\(f_i\)表示从i出发的子串个数(不记重),\(f_i=\sum\limits_{(i,j)\in edge}(f_j+1)\),1结点的f就是答案。
    2. \(\sum(len(i)-len(fa_i))\)
  3. 求字典序第\(k\)大的是哪个串(不去重)。
    处理出每个结点的endpos大小,即每个结点的任意串在整个串中出现的次数。
    \(f_i\)表示\(i\)的endpos大小。如果i不包括前缀,则:
    \(f_i=(\sum\limits_{(i,j)\in parent\ tree\ edge}f_j)\)
    否则:
    \(f_i=(\sum\limits_{(i,j)\in parent\ tree\ edge}f_j)+1\)
    最后加一是因为i的endpos集合中的字符串里必有一个是前缀,而前缀前面不可能加字符,这个位置的endpos就丢失了(在case1中加一个\(f[np]=1\))。
    \(g_i\)表示i出发的子串个数(记重),则:
    \(g_i=\sum\limits_{(i,j)\in SAM\ edge}(f_j+g_j)\)
    最后按边的字典序dfs SAM,具体操作和权值线段树求全局第k大差不多。
  4. 求多个串的最长公共子串。
    把这几个串拼起来建SAM,跑出一个子串在拼起来的串在各个串出现的次数。然后遍历节点,找len最大的在各个串中出现次数都不为0的节点。

题目一会再补吧。

诸神眷顾的幻想乡

注意到叶子节点最多只有20个,另外以所有叶子节点为根能够得到所有路径的伸展式(就是不跨过LCA的直接一条链),所以对于每个叶子节点都\(dfs\)一遍建出SAM,然后就是求本质不同的字符串个数了。
也就是:

\[ans=\sum len_i-len_{fa_i} \]

熟悉的文章

先把模板串建出SAM。显然答案具有单调性,于是可以二分答案,check可以通过一个dp实现:
设当前二分的长度为L,则:

\[f_i=\max\limits_{j\in [i-match_i,i-L]}{\{f_j+i-j\}} \]

\[f_i=i+\max\limits_{j\in [i-match_i,i-L]}{\{f_j-j\}} \]

于是可以通过单调队列\(O(n)\)check,然后就没了。

噢,还有个求LCS的小方法,在SAM上要是当前结点(一开始是1)能匹配上就匹配,len++,否则一直往上跳父亲,要是跳到0就len是0,当前结点重置成1,否则就按上面一样操作。

inline void prework(int n){
	int now=1,len=0;
	F(i,1,n){
		int v=s[i]-'0';
		if(t[now].son[v])now=t[now].son[v],len++;
		else{
			for(;now&&!t[now].son[v];now=t[now].fa);
			if(now)len=t[now].len+1,now=t[now].son[v];
			else now=1,len=0;
		}match[i]=len;
	}
}

CF235C Cyclical Quest

本来想用SA水过呢,结果暴T不止,交了一页之后放弃了,转成SAM,
求一个串的循环同构串在母串里出现几次。
先把母串SAM建出来,求出SAM上的每个节点在整个串中出现了几次。设其为\(siz_i\)并发现在parent tree上满足简单的拓扑关系,然后求出之后,把子串在SAM上按照上面的方式跑,
一直跑到父亲的len<子串长度,这个时候自己的len>=子串长度,于是说明len正好等于子串长度的就在本节点,然后就累加到答案中。

CF700E Cool Slogans

我写了两种做法。
第一种是后缀数组,设\(f_i\)表示以i开头的后缀的所有前缀中最大的k值,与之伴随的还有一个\(len_i\)表示这个前缀的长度,显然k值相等时越短越好。然后倒着转移,用线段树每次查询自己的\(f_i\),在另一颗线段树上计算自己的\(len_i\),然后求出可以转移的区间之后再用线段树转移即可。
第二种就是后缀自动机。设\(f_i\)表示i节点表示的子串最大的k值,由父亲转移给儿子,但转移条件要满足父亲再儿子中出现至少两次,于是可以用线段树合并记录下父亲的endpos集,如果在\([pos_{son}\text{(随便一个)}-len_{son}+len_{fa},pos_{son}-len_{son}]\)中有父亲代表的串出现过,那么就可以转移,对每个点的\(f\)取个max即可。

SubString

首先把询问串放到SAM上跑,最后跑到节点的val就是答案。
考虑新加进来一个字符对答案的贡献,它会让1-新节点的权值都+1。
所以要实现动态连边并加权值。
于是对SAM建立LCT,通过LCT实现上述操作。
函数和原来大致一样,就是把fa直接赋值改成了把fa赋值并在LCT上进行cut或者link。

posted @ 2021-12-08 10:24  letitdown  阅读(212)  评论(0编辑  收藏  举报