后缀自动机学习笔记

定义与性质#

后缀自动机(Suffix Automaton,简称 SAM)是用于解决字符串问题的强有力的工具。它是一个能够接受字符串所有后缀的最小 DFA。

SAM 是一张有向无环图,每个节点叫做状态,每条边被称作转移

SAM 存在一个源点 t,称为初始状态。任何状态均可从初始状态到达。

每一个转移都对应于一个字母,每个状态的转移都各不相同。

存在若干个终止状态。从 t 出发走到一个终止状态,则这条路径对应原串的一个后缀。同样的,任意一个后缀都可以通过 SAM 上从初始状态到终止状态的一条路径来表示。

SAM 上每一条路径对应原串的一个子串。换句话说, SAM 上的路径和原串上的子串一一对应。

到达某个状态的路径不止一条,所以一个状态表示的实际上是一个集合。

构造 SAM#

在构造 SAM 之前,我们需要了解一些前置知识。

结束位置集合 endpos#

对于串 s 的任意一个非空的子串 t,我们定义 endpos(t) 为字符串 ts 中出现的末尾位置的集合。

例如,有 s="abcaabcabca"t="abc"endpos(t)={3,7,10}(下标从 1 开始)。

两个非空子串 t1,t2endpos 可能相等,我们称这两个 endpos等价的,它们同属于同一个等价类。原串 s 的所有子串被这样划分为若干个等价类

引理1:同属于同一个等价类的串之间必然有后缀关系。如果两个字符串 t1,t2 (|t1||t2|)endpos 相同,当且仅当 t1s 中每次出现,都是 t2 的后缀。

由定义,引理显然成立。

引理2:考虑两个 s 的子串 u,v (|u||v|),有

{endpos(v)endpos(u)if u is a suffix of vendpos(u)endpos(v)=otherwise

如果 uv 的后缀,那么每一次 v 出现 u 都会出现,所以 endpos(v)endpos(u)。否则,两者绝对不会有相同的 endpos

引理3:每一个 endpos 类都是由若干个长度连续,并且具有后缀关系的子串构成的。

上面引理的推论

引理4:同一个 endpos 类中的子串同时加上同一个字符后,它们仍然属于同一个 endpos 类。

同时加上一个字符,仍然满足后缀关系。

后缀链接和 parent 树#

对于一个不是初始状态的状态 v,我们将其中的串按照长度从大到小排序,由引理3,除去最大的以外,其余每个子串都是最大串的后缀。记这个最大串为 longest(v),其长度为 len(v)

我们找到第一个 longest(v) 的后缀 t 使得 vendpos(t)=u。我们就将 fail(v)u

引理5:所有后缀链接构成了一棵树。

每个状态的出度和入度都为 1。我们称这棵树为 parent 树。

引理6:由后缀链接构成的 parent 树与后缀树相同。

不知道。

构造方法#

我们定义在插入某个字符 c 前的状态为 last,新插入的状态为 now

我们沿着 lastfail 边向上跳,设每次跳到状态 p。如果 p 没有连向 c 的边,那么就讲它直接连向 now

如果最后跳到了 t 就将 fail(now)t

否则分成两种情况:

我们定义状态 qp 转移为 c 的状态。

  1. len(q)=len(p)+1

    说明 qp 之间只差了一个字符。此时可以直接 fail(now)q

  2. 否则,我们需要新建一个节点来辅助建边。

    我们新建状态 clone=q,复制其除了 len 的所有信息。

    len(clone)=len(p)+1

    此时我们让 fail(q)clone,fail(now)clone

正确性证明#

不会。

代码实现#

class Suffix_Automaton {
    struct node {
        int len, fail;
        int ch[26];
    } tr[N << 1];
    int tot = 1, last = 1;

  public:
    void ins(int x) {
        int p = last, q, now = ++tot;
        siz[now] = 1;
        tr[now].len = tr[p].len + 1;
        for (; p && !tr[p].ch[x]; p = tr[p].fail)
            tr[p].ch[x] = now;
        if (!p) {
            tr[now].fail = 1;
        } else {
            q = tr[p].ch[x];
            if (tr[q].len == tr[p].len + 1) {
                tr[now].fail = q;
            } else {
                int cl = ++tot;
                tr[cl] = tr[q];
                tr[cl].len = tr[p].len + 1;
                tr[q].fail = tr[now].fail = cl;
                for (; p && tr[p].ch[x] == q; p = tr[p].fail)
                    tr[p].ch[x] = cl;
            }
        }
        last = now;
    }
} SAM;

应用#

自动机#

SAM 接收了关于一个字符串的所有信息。每一条从原点出发的路径都代表着原串的一个子串,并且不是原串子串的一定不是从原点出发的路径。

和一般的自动机一样,我们可以在上面进行字符串的匹配与查找。

parent树#

本质上是被压缩过的后缀树。后缀自动机的独特之处在于 lenfail

上面已经说过,同一个状态中的串连续并满足后缀关系,是它的 fail 的真子集。

我们可以在 parent 树上进行差分,len(i)len(fail(i)) 即为同等价类的串的个数。

不同子串个数#

统计自动机上路径条数即可。

或者统计每个等价类包含的串的个数。

如果要统计总长度,改一下求和就行了。

子串出现次数#

利用树形结构,我们定义 siz(t) 为状态 tendpos 集合大小。

由于 fail 满足后缀关系。所以,如果一个串在某个地方被匹配到了,那么它将在它的所有子节点(parent 树上)的同样位置出现。

所以对子树的 siz 求和即可。

void dfs(int x) {
    // siz 在构建的时候为1
    for (int i = head[x]; i; i = e[i].next) {
        const int y = e[i].to;
        dfs(y);
        siz[x] += siz[y];
    }
}

本质不同子串个数#

siz 设为 1 就无了。

区间本质不同子串个数#

好玩 SAM + LCT + 线段树。码量就 150 行。

posted @   LewisLi  阅读(108)  评论(0编辑  收藏  举报
编辑推荐:
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示
主题色彩