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的节点。

题目一会再补吧。

上一篇:Luogu P4248 [AHOI2013]差异


下一篇:拓扑排序