后缀自动机SAM

一些记号:

  • \(|s|=len(s)\)
  • \(t_0\):初始状态
  • \(endpos(t)\):字符串\(s\)中子串\(t\) 的结束位置的集合,集合大小就是\(t\)\(s\)中出现的次数
  • \(link(v)\): 状态\(v\)的后缀链接
  • \(len(v)\):状态\(v\)对应的最长子串的长度
  • \(minlen(v)\):状态\(v\)对应的最短子串的长度

一些名词:

  • 后缀链接树:\(v\to link(v)\)形成的树
  • 前缀节点:一个节点\(v_i\)的所代表的最长字符串是原字符串的一个前缀,则称\(v_i\)前缀节点

可以解决的问题:

  • 在另一个字符串中搜索一个字符串的所有出现位置
  • 计算给定的字符串中有多少个不同的子串
  • 在另一个字符串中判断一个字符串是否出现过
  • 计算给定的字符串中所有不同子串的总长度
  • 字典序第 k 大子串
  • 字符串的最小循环移位
  • \(\cdots \cdots\)

endpos:

两个子串\(t_1\)\(t_2\)的结束位置可能完全相同:endpos(t1)=endpos(t2),把endpos相同的非空子串叫做一个等价类。

性质1:如果有两个非空子串\(v,w\)\(|v|\le |w|\))的endpos相同,则\(v\)\(w\)的后缀

性质2:endpos相同的子串的长度是连续的

\(v\)的后缀链接\(link(v)\)连接到的状态,对应于\(w\)的后缀中与它的endpow集合不同且最长的那个

性质1:所有后缀链接构成一棵根节点为\(t_0\)的树。(每一个点的后缀链接会连接到\(len\)严格小于该节点的点,最终一定会到达\(t_0\)

性质2:后缀链接树上的父节点的endpos集合一定包含其儿子节点。

性质3:后缀链接树每一个节点包含的子串出现的次数,就是该

构造SAM:

假设现在已经构造了字符串\(s\)的SAM,最后插入的字符所对应的状态为\(last\),现在要插入一个字符\(c\)(设在后缀链接的编号为\(cur\)),那么从\(last\)开始,判断该节点是否有字符\(c\)的出边,如果没有,就跳该节点的后缀链接,继续判断,如果有,则要分类讨论:(设当前跳到了\(parent\)树上的点\(p\), 点\(p\)经由字符串\(c\)转移到了点\(q\)

  • 情况一:$len(p)+1=len(q) \(,此时点\)q\(代表的字符串就是点\)p\(代表的字符串加上\)c\(,直接把\)cur\(连接到\)q$即可
  • 情况二:$len(p)+1\ne len(q) \(,此时点\)q\(代表的字符串集合大于点\)p\(代表的字符串加上\)c\(的集合,所以不能直接把\)cur\(连接到\)q\(即可。此时只能把点\)q\(克隆一个出来,分成两个,一个是\)clone$ , $len(clone)=len(p)+1 \(,\)clone\(除了\)len\(的值其他都是\)q\(的, 另一个就是点\)q'\(,和\)q\(代表的字符串是一样的,接下来操作是把\)p\(到\)t_0\(的路径上的点所有从字符\)c\(转移到\)q\(的边都变成转移到\)clone\(边,最后把\)cur\(和\)q\(的后缀链接连接到\)clone$上

做完这些以后把\(last\)设为\(cur\)即可。

具体过程核心代码:

void insert(char c) {
    int cur = ++ sz;
    int p = last;
    sam[cur].len = sam[p].len + 1;
    while(p != -1 && !sam[p].nxt.count(c)) {
        sam[p].nxt[c] = cur;
        p = sam[p].link;
    }
    if(p == -1) {
        sam[cur].link = 0;
    }else {
        int q = sam[p].nxt[c];
        if(sam[q].len == sam[p].len + 1) {//情况一
            sam[cur].link = q;
        }else {//情况二
            int clone = ++ sz;//克隆出一个点
            sam[clone].len = sam[p].len + 1;
            sam[clone].nxt = sam[q].nxt;//把clone的其他值都设为q的
            sam[clone].link = sam[q].link;//与上同理
            while(p != -1 && sam[p].nxt[c] == q) {// 改边
                sam[p].nxt[c] = clone;
                p = sam[p].link;
            }
            sam[q].link = sam[cur].link = clone;
        }
    }
    last = cur;
    val[cur] = 1;
}

注意:

  1. \(clone\)\(q\)的出边一样,是因为\(clone\)\(q\)合起来是原来的\(q\),所以\(clone\)\(q\)的出边和原来的\(q\)是完全一样的,所以在代码上就是直接把\(clone\)的边复制成\(q\)的就行。
  2. \(p\)\(parent\)树上的父节点的经过\(c\)的转移边都改到\(clone\)上,是因为将原来的点分裂,实际上是分成了一个所有字符串长度\(\le len(p) + 1\)(就是\(clone\))的和一个所有字符串长度\(>len(p)+1\)的两个点,而\(p\)\(parent\)树上的父节点的\(len< len(p)\),所以经由\(c\) 转移后的\(len + 1 < len(p) + 1\),所以应该连向\(clone\)
  3. 因为分裂后的\(q\)\(clone\)刚好能够拼起来,所以他们所包含的字符串的长度是连续的,也就是说$minlen(p)+1=len(p) \(,根据后缀链接树的定义,\)q\(因该连接到\)clone\(,而\)cur$是 \(last+c\)的得到的,又因为\(p\)\(last\)的合法后缀,\(clone\)\(p+c\)得到的,所以\(clone\)\(cur\)的后缀,所以\(cur\)应该连接到\(clone\)

由此能够解释构造过程中的具体操作了。

应用:

不同字串个数:建出来\(parent\)树后计算每个节点\(len(p) - len(link(p))\)之和即可
不同字串的长度总和:计算\(parent\)树上每个节点\(\sum_{i=minlen(p)}^{len(p)}i\)
文本串出现次数:设这个字串在\(parent\)树上的节点为\(v\),则这个子串的出现次数\(=endpos(v)\)集合大小\(=v\)子树内前缀节点的数量
\(\cdots\cdots\cdots\cdots\cdots\cdots\cdots\)

空间复杂度:

因为最多分裂\(n-1\) 次,一共有\(n\)个字符,所以在\(sam\)\(parent\)树上一共有\(2n-1\)个点

例题:

P3804 【模板】后缀自动机(SAM)

posted @ 2025-06-29 22:11  lghjl  阅读(19)  评论(0)    收藏  举报