Loading

后缀自动机

sto 辰星凌 command_block orz

概念

后缀自动机是一种用于处理子串相关问题的字符串算法。

通常可以做到线性时间内处理一些复杂的子串问题。

可以和 线段树合并/LCT 等等的数据结构一起出。

有时候可以用 SA/后缀树 一类的东西代替。

概述

对于一个字符串 \(S\) 的子串 \(s\)\(s\) 的 endpos 集合表示: \(s\) 每次在 \(S\) 中出现时最后一个字符的下标构成的整数集。

约定 endpos 相同的子串为一个 endpos 等价类。

关于 endpos 集合有一些有用的性质:

  1. 两个子串的 endpos 集合要么交集为空,要么其中长串的 endpos 是短串 endpos 的子集。

  2. 如果两个子串的 endpos 相同,那么其中短串必定是长串的后缀。

  3. 对于一个 endpos 等价类,其中的子串长度构成一个连续的区间,并且其中长度为 \(x\) 的子串必定是长度为 \(x + 1\) 的子串(如果有)的后缀。

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

事实上 endpos 集合的包含关系可以构成一棵树,称之为 parent tree。

注意 parent tree 的结点表示的是一个 endpos 等价类而非字符串。

\(fa(S)\) 表示 parent tree 中等价类 S 的父结点,\(minlen(S)\) 表示 endpos 等价类 S 中最短的字符串的长度,\(len(S)\) 表示 endpos 等价类 S 中最长的字符串的长度。

有性质 5:\(minlen(S) = len(fa(S)) + 1\)

存在构造方式使得 SAM 的结点和 parent tree 的结点相同,于是只需要考虑如何构造边集使得其满足 SAM 的性质:

  1. 父结点的 endpos 包含子结点的 endpos

  2. 父结点等价类中的所有子串都是当前等价类中任意子串的后缀

  3. SAM 是一张 DAG

  4. 原串的所有子串都可以被 SAM 上的一条路径表示

事实上可以证明存在边数不超过 \(O(n)\) 的构造方法。

构造

实际上 SAM 的构造和前面的推导没太大关系(笑

采用增量构造。

SAM 可以表示原串的所有子串,等价于可以表示每个前缀的任意后缀。因此可以考虑把每个长度的前缀的任意后缀都插入 SAM,实际上从 \(i - 1\) 长度到 \(i\) 长度的增量就是若干 \(i - 1\) 长度前缀的后缀加上第 \(i\) 个字符形成的新后缀。

对 parent tree 结点的定义:

struct node
{
    int len, fa, son[26];

    node() { memset(son, 0, sizeof(son)); fa = len = 0; }
} nd[sz];

其中 len 表示当前结点代表的等价类中最长字符串的长度。

假设要插入的字符为 c,定义增量函数为:

void insert(int c)

对于 SAM 中的结点,其中存在一些 endpos 中含有已插入最长前缀长度的结点,不妨称这些结点为终止结点。

考虑在增量的同时维护变量 lst 表示上一次插入的字符所处的终止结点。

int p = lst, np = lst = ++cur;

首先字符串总长增加 \(1\)

nd[np].len = nd[p].len + 1;

对于此前 SAM 中 np 的祖先结点,它们会代表一些长度为 \(i - 1\) 的前缀的后缀。假设其中有一字符串 \(s\),如果 \(s + c\) 不能被 SAM 中的结点表示,那么那么就从该结点到插入的结点连一条边,代表插入这个新后缀:

for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;

判断 p != 0 是防止跳出起始结点。

情况 1:如果没有其他结点可以表示产生的新后缀,根据定义有:

if (!p) nd[np].fa = 1;

情况 2:存在其他结点可以表示产生的新后缀

如果这个结点的等价类里最长的串是新后缀:

int q = nd[p].son[c];
if (nd[q].len == nd[p].len + 1)

那么这个结点的等价类里所有的字符串都是产生的新后缀,发现此时 \(endpos(np) \subseteq endpos(q)\),于是:

nd[np].fa = q;

反之,进入情况 3。此时 q 的等价类里有更长的串,因此它的 endpos 不包含 n,所以此时直接连边不满足 parent tree 的定义。

那么考虑将 q 拆分成两个结点,一个结点表示原等价类中长度在 \([minlen, len(p) + 1]\) 的字符串,另一个表示原等价类中剩下的子串。注意此时两个结点表示的 endpos 不相等。

int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;

那么根据定义有 \(endpos(q) \subseteq endpos(nq), endpos(p) \subseteq(nq)\),直接连边:

nd[q].fa = nd[np].fa = nq;

然后相应地把祖先结点的连边也一起修改:

for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;

具体正确性我不会证,之后再填坑吧。

void insert(int c)
{
    int p = lst, np = lst = ++cur;
    cnt[cur] = 1, nd[np].len = nd[p].len + 1;
    for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;
    if (!p) nd[np].fa = 1;
    else
    {
        int q = nd[p].son[c];
        if (nd[q].len == nd[p].len + 1) nd[np].fa = q;
        else
        {
            int nq = ++cur;
            nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
            nd[q].fa = nd[np].fa = nq;
            for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
        }
    }
}

采用这种方法构造的 SAM 点数不超过 \(2n - 1\),边数不超过 \(3n - 4\)\(n \geq 3\)

广义 SAM

普通 SAM 维护的是字符串,广义 SAM 维护的是 Trie。

也可以理解成是维护多个字符串。

网上有大量盗版广义 SAM,常见的是每次插入后直接令 lst = 1 或者用奇怪符号处理原串。这些在大部分情况下是对的,但是可以被卡。@辰星凌 的博客详细解释过这些问题。

定义增量函数为:

int insert(int c, int lst)

实际上正确的在线写法只需要添加两个特判:

  1. 如果该结点已经存在,则不用新建结点。
if (nd[lst].son[c])
    int p = lst, q = nd[lst].son[c];
    if (nd[p].len + 1 == nd[q].len) return q;
  1. 如果 nd[p].son[c] 已经存在且不为上面的情况,那么如果新建结点,则该结点为空(不保存任何信息)。此时也按照不新建结点的情况处理。
else
{
    int nq = ++cur;
    nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
    nd[q].fa = nq;
    for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
    return nq;
}

证明略,见参考博客。

于是得到广义 SAM 的增量函数。

int insert(int c, int lst)
{
    if (nd[lst].son[c])
    {
        int p = lst, q = nd[lst].son[c];
        if (nd[p].len + 1 == nd[q].len) return q;
        else
        {
            int nq = ++cur;
            nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
            nd[q].fa = nq;
            for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
            return nq;
        }
    }
    int p = lst, np = ++cur;
    nd[np].len = nd[p].len + 1;
    for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;
    if (!p) nd[np].fa = 1;
    else
    {
        int q = nd[p].son[c];
        if (nd[q].len == nd[p].len + 1) nd[np].fa = q;
        else
        {
            int nq = ++cur;
            nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
            nd[q].fa = nd[np].fa = nq;
            for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
        }
    }
    return np;
}

注意插入新串时要重新将 lst 设为 \(1\)

如果要对多个字符串维护信息,需要对每个字符串分别存储信息,例如求出现次数不能共用一个变量而是要开数组。

用结构体实现可能会被 卡常,写数组会快很多,但数组写法先咕了

套路

子串出现次数

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

结论:SAM 中结点的出现次数 等价于 子树大小。

考虑 parent tree,对于子结点出现过的位置,其父结点(后缀)一定也出现过。因此每次插入字符时将其出现次数设为 \(1\),然后在 parent tree 上求子树和即可。

本质不同子串个数

  • P2408 不同子串个数

    做法一:等价于求 \(\sum\limits maxlen(p) - minlen(p) = maxlen(p) - maxlen(fa(p))\)

    做法二:注意到 SAM 是 DAG,所以等价于路径计数。

  • P4070 [SDOI2016]生成魔咒

    动态求不同子串个数。

    每次新建 SAM 结点的时候加入其贡献就行,注意 nq 没有贡献。

最长公共子串

SP1811 LCS - Longest Common Substring

考虑字符串所有的子串都可以表示成:原串某个前缀的后缀。

于是考虑对其中一个串建立 SAM,在另一个串上跑匹配。

枚举匹配串的前缀,如果 SAM 中有当前字符的转移则转移,反之一直向上跳,直到存在转移为止。得到的是此前缀的某一个后缀,也就是这个前缀和另一个串的 LCS.

第 k 小子串

P3975 [TJOI2015]弦论

跑一遍路径计数,然后类似于平衡树地递归求即可。

求等价类的 endpos

CF700E Cool Slogans

考虑线段树合并。

考虑遍历原串的每一个前缀所在的等价类,将这些结点作为初始条件,暂时令其 endpos 为自身长度。

然后将所有等价类按照长度排序,然后倒序线段树合并。

具体的以后再补,咕咕咕。

parent tree 相关

多串匹配

SP8093 JZPGYZ - Sevenk Love Oimaster

考虑对所有模板串建立广义 SAM,插入每个前缀时在相应结点打上标记。

查询时找到该串对应的 SAM 结点,转化成在该结点的子树内数不同的标记数量。线段树合并维护。

动态维护出现次数

P5212 SubString

每次给定一个字符串,将其 接在已有串的末尾 或者 询问其在已有串的出现次数。

考虑到某个串的出现次数可以转化成 parent tree 上的子树和,于是考虑转化成动态加点,维护子树和。

注意到增加一个结点的贡献等价于:其到根的路径上每个结点的子树和加一。用 LCT 维护动态链加,单点查权值就行。

parent tree 上倍增

CF666E Forensic Examination

类似求 LCS,考虑维护 \(S\) 的每个前缀 \([1, r]\)\(T\) 匹配的最长长度 \(mch(r)\) 和此时对应的 SAM 结点 \(nd(r)\)

对于询问 \([pl, pr]\),考虑从 \(nd(r)\) 向上倍增,直到找到等价类中包含长度 \([pr - pl + 1]\) 的结点。

此时的问题是该结点对应的字符串在 \(T_l, \cdots, T_r\) 中出现次数最多的串。

考虑用线段树合并维护,插入时对于前缀结点 \(p\),令其对应的字符串编号为 \(x\),在线段树上 \(x\) 处加一。

后缀树

反串的 parent tree.

性质:\(lcp(a, b)\) 等于 \(a, b\) 在后缀树上 LCA 的深度.

资料

史上最通俗的后缀自动机详解 - KesdiaelKen 的博客 - 洛谷博客

【学习笔记】字符串—广义后缀自动机

后缀自动机学习笔记(干货篇)- command_block 的博客 - 洛谷博客

后缀自动机学习笔记(应用篇) - command_block 的博客 - 洛谷博客

posted @ 2022-12-06 22:21  kymru  阅读(210)  评论(0编辑  收藏  举报