后缀自动机

大部分是贺 OI-Wiki 的。

目录

性质

首先需要知道 SAM 是个什么东西。

SAM,全称 Suffix Automaton,中文名后缀自动机。字符串 sSAM 是一个最小 DFA(最小性将在下方证明),接受 s 的所有后缀。如果将状态视作节点,转移看作边,其构成了一个 DAG(也称 DAWG,Directed Acyclic Word Graph)。一个状态表示了一系列字符串,一个转移表示一个字符。

SAM 存在一个初始状态,常记作 t0。由初始状态到任意节点 t 的一条路径能够表示一个 s 的子串,构造方法是按 t0t 的方向将转移顺次连接得到一个字符串。由于到达某个状态的路径不只一条,因此我们可以自然地定义路径和字符串集合间的对应关系。

SAM 存在一个或多个终止状态,由初始状态到终止状态的字符串一定是 s 的一个后缀,s 的任意后缀也定可以由形如初始状态到终止状态结束开始的一条路径表示。终止状态不一定对应无出度的节点。

概念

endpos - 子串在母串中的结束位置

考虑字符串 s 的一个非空子串 t。我们记 endpos(t) 为字符串 st 的所有结束位置构成的集合(编号从 0 开始)。对于 acbbcbcbendpos(bcb)={5,8}

对于两个子串 t1,t2endpos(t1) 可能等于 endpos(t2)。这样 s 的所有非空子串都可以通过 endpos 集合被划分为若干个等价类。若字符串 s 的两个子串 u,vendpos 集合相同,则我们称 u,v 等价。

我们构造的 SAM 需要满足以下的性质:每个状态对应一个 endpos 集合。换句话说,一个字符串能被一个状态表示,当且仅当这个字符串的 endpos 集合是这个状态对应的 endpos 集合。这样,SAM 中的状态个数是等价类的个数加初始状态。

可以发现,这样构造的 SAM 能够满足所有性质。特别的,最小性由 Myhill-Nerode 定理导出。

由构造方案,可以得到以下几个性质:

[1]. 字符串 s 的两个子串 u,v (|u||v|) 等价,当且仅当 us 中的所有出现都是 v 的一个后缀。

这显然。

[2]. 考虑字符串 s 的两个子串 u,v (|u||v|)。可以发现,endpos(u)endpos(v) 只有包含和不交两种情况,这取决于 u 是否是 v 的一个后缀。形式化地,我们有

{endpos(u)endpos(v)u  v endpos(u)endpos(v)=otherwise

感性理解。

[3]. 考虑一个等价类。我们将属于该等价类的所有子串按照长度非递增顺序排列。每个子串都应当是其前一个子串的后缀,且该等价类中子串的长度恰好覆盖一个整数区间 [x,y]

假设这个等价类中最长的子串是 u (|u|=y),最短的子串是 v (|v|=x)。由 [1].vu 的一个后缀。由 [2].,对于 u 的一个后缀 w s.t. |w|>|v|w 必定在这个等价类中。由于 u,v 是等价类中最短/长的子串,其余子串定不在这等价类中。
因此有证明。

link - 转移结束位置的后缀链接

考察一个等价类 v。我们设 w 为其中最长的子串,则不难发现这个等价类中包含的串是 w 最长的若干个后缀。还可以发现,一定存在一个 w 的后缀(至少有一个空串)不在该等价类中。我们记 t 为和 w 不在一个等价类里的 w 的最长后缀,那我们定义 link(v) 连接在 tendpos 对应状态上。有时候我们会将一条后缀链接看作边 (v,t)
这里似乎能证明 t 是对应 endpos 集合中最长的一个?没细想。

我们假设 endpos(t0)={1,0,,|s|1}。随后能得到性质:

[4]. 所有后缀链接构成了一棵根为 t0 的树。

考虑从任意节点开始沿后缀链接移动,每次移动肯定会使对应的最长子串长度减 1。由 [3].,最终一定会到达最长子串长度为 0 的状态,即 t0
为表述方便,称这棵树为后缀链接树。

[5.] 通过 endpos 构造的树(每个子节点的 endpos 都包含于父亲节点的 endpos 中)和通过后缀链接构造的树相同。

首先由 [2].,通过 endpos 肯定能构造出一棵树。下面只需要证明这两棵树等价即可。
考虑一个状态 vt0。由后缀链接性质,有 endpos(v)endpos(link(v))。结合 [2]. 可以轻易证明。

这里有一个拓展,即一个字符串 s 在翻转后对应的 SAM 的后缀链接树就是这个字符串的后缀树。

图不粘了。这放一个 Mivik 的 SAM 可视化
调代码好帮手嗯 但是别指望这玩意画很长的字符串。

总结

  • 字符串 s 的子串可以根据 endpos 被划分成数个等价类。每个等价类对应一个状态。
  • 记一个状态 v 中最短的子串为 min(v),最长的子串为 max(v),最长子串长度为 len(v)。状态 v 里字符串的长度恰好覆盖 [|min(v)|,|max(v)|] 里的每个整数。
  • 对于一个状态 v,他的后缀链接 link(v) 满足 |max(link(v))|+1=|min(v)|。后缀链接树也表示了 endpos 的包含关系。
  • 考虑从一个状态 v 开始沿后缀链接转移直到根。我们能够得到一个互不相交的区间 [|min(v)|,|max(v)|] 的序列,它们的并组成了一段连续的区间 [0,|max(v)|]

构造

我们即将讲述的算法是在线的。也就是说,我们可以支持在线向自动机中加入最后一个字符,并在这过程中维护每个节点的信息。

我们即将讲述的算法是线性的。也就是说,如果使用哈希结构,其对应的时空复杂度都是 O(n) 的。同时,我们只会保存 |max(v)|link(v) 和转移边,并不标记终止状态。终止状态可以在插入所有节点后从最终插入的节点沿后缀链接遍历 SAM,遍历到的节点都是终止节点。

初始化 SAM 只包含一个状态 t0。我习惯初始化它为 1 号节点。同时初始化一个虚拟状态 0。对于 t0len(t0)=0link(t0)=0

流程

我们只需要着眼于添加一个新字符 c 的过程。

  • 首先我们需要申请一个变量 last,表示插入该字符前整个字符串对应的状态。初始化为 1,并在每次插入完更新即可。
  • 创建一个新的状态 now,令 len(now)=len(last)+1。随后确定 link(now)
  • 随后从 last 状态开始重复以下流程:
    • 如果该状态有对应字符 c 的转移,那就停止,记这个状态为 p
    • 如果没有这样的转移,我们就添加一条到 now 的转移,沿后缀链接遍历。
    • 如果直到虚拟状态都没有停止,那确定 link(now)0,退出整个过程。
  • 到这里就是找到了状态 p。状态 p 肯定能通过加入 c 的转移到达一个状态,记为 q。分类讨论两种可能的结果。
    • len(q)=len(p)+1
      我们只需要确定 link(now)=q
    • otherwise
      有点难办。我们需要复制一个状态 q,记这个节点的编号为 kage
      我们复制的信息是除了 len 外的所有内容。需要确定 len(kage)=len(p)+1。同时确定 link(now)=kage,link(q)=kage
      最后沿后缀链接从 p 开始遍历,只要存在 pq 的转移,就将该转移重定向为 pkage。当不存在这样的转移时退出。容易发现这样的转移肯定是自 p 开始的一条子链。
  • 最后将 last 更新为 now
实现

采用了数组 son 存储转移。

void extend(int c) {
	int now = ++ mlc, p = lst;
	cnt[now] = 1, len[now] = len[lst] + 1;
	while (p and !son[p][c]) 
		son[p][c] = now, p = link[p];
	if (p == 0) link[now] = 1;
	else {
		int q = son[p][c];
		if (len[p] + 1 == len[q]) link[now] = q;
		else {
			int kage = ++ mlc;
			len[kage] = len[p] + 1, link[kage] = link[q];
			memcpy(son[kage], son[q], sizeof(son[q]));
			while (p and son[p][c] == q) 
				son[p][c] = kage, p = link[p];
			link[q] = link[now] = kage;
		}
	} 
	lst = now;
}

正确性证明

首先分类转移。对于转移 uv,如果 len(u)+1=len(v),则我们称此转移是连续的。反之是不连续的。
从过程中可以看到,连续的转移是不会被改变的。不连续的转移反之。因此我们需要分类这两种转移。

我们称插入字符 c 前的 SAM 对应的字符串为 s。插入后的字符串即为 s+c

我们创建了一个新的状态 now,对应地创建了一个新的字符和一个等价类。

last 开始遍历的原因显然:我们需要找到通过字符 c 向新等价类的转移。由于不能破坏原来 SAM 的性质,我们找到后就必须停止。随后开始分讨。

最简单的是到达了虚拟状态,也就是这个状态里只有一个长度为 1 的子串 c。这等价于我们为 s 的每个后缀添加了一个 c,同时意味着 c 从未在 s 中出现过。连向 t0 即可。

反之我们找到了这样的转移 pq。这意味着我们需要向 SAM 中添加一个已经存在的字符串 u+c,其中 us 的对应后缀。这时不需要加入额外的转移链接 nowp,因为 lastnow 是肯定存在的。我们只需要确定后缀链接。随后开始分讨。

仍然简单的是 len(q)=len(p)+1。确定 link(now)=q 即可。

否则 len(q)>len(p)+1,转移是不连续的。状态 q 不只对应 p 加入一个字符的后缀,而对应着 s 更长的子串。我们需要添加一个 u+c 的状态,但是状态 u 本身是还不存在,因此我们需要将 q 拆开。
这就是新建节点 kage 的意义。kage 对应的就是 u,同时由于不应该改变 q 的路径,仍然复制转移。这时 q 少了一个状态,因此其应该通过后缀链接连在 kage 上。我们这时可以将从 now 的后缀链接确定在 kage 上了,也就是 uu+c
最后一步是将一些到 q 的转移定向到 kage 上。可以发现,由于 kage 表示的是 p 状态对应的最长字符串加入字符 c 的子串,我们只需要重定向所有形如 w+c 的后缀即可。也就是说,我们沿后缀链接遍历,从 p 节点开始直到第一个不是转移到 q 的转移。

线性操作次数证明

首先假设字符集大小为常数 |Σ|

如果转移存储在快速查询与插入的平衡树中,则有时间复杂度为 O(nlog|Σ|),空间复杂度为 O(n)
如果转移存储在 O(1) 查询和插入的哈希结构/数组中,则有时间复杂度为 O(n),空间复杂度为 O(n|Σ|)

我们认定字符集大小为小常数,每次搜索转移/添加转移/查询下一个转移的复杂度都是 O(1)
离散化完反正也是 |Σ|=O(n),采用一些亚 log 数据结构(如 vEB 树)也可以做到类似的效果。

观察流程。其中有三部分不明显是线性的。

  1. 遍历 last 的后缀链接
    可以发现,每次遍历都是从 last 开始的。而 last 是上一次加入的状态。由于每一次加入状态,节点深度最多增加 O(1),因此这部分的总时间复杂度均摊后为 O(n)
  2. 复制 q 的转移给 kage
    由于转移数最多是 O(n) 的(证明将在下方给出),转移在复制时产生的总时间复杂度开销为 O(1)
  3. p 开始修改转移指向
    我们发现,每次迭代都会使得作为当前字符串后缀的子串对应位置单调递增。这样迭代次数不会超过 |s| 次。

因此总时间复杂度为 O(n)

性质

状态数

对于一个长度为 n>1 的字符串 s,其 SAM 中的状态数不会超过 2n1

考虑后缀链接树对应的 endpos 性质。我们回忆 SAM 的构造,假设后缀链接树上存在只有一个孩子的节点,那这个节点一定存在一个子集是未被子节点包含的。我们额外创建一个节点来表示这个子集。这就得到了一棵每个节点的子节点大于等于 2、叶子节点数不超过 n 的树。
这样的树最多有 2n1 个节点。

字符串 abbbbbb 达到了该上界。

转移数

对于一个长度为 n>1 的字符串 s,其 SAM 中的状态数不会超过 3n4

仍然分转移为连续与否来讨论。
连续的转移肯定能构成一棵生成树,因此这部分最多有 2n2 条边。
不连续的转移 (p,q) 对应所在的终止状态一定形如 u+c+v,其中 c 为该转移表示的字符,up 状态对应的一个极长字符串,v 为从 q 到任意终止状态的极长路径。首先对于任意不连续的转移,u+c+v 是彼此不同的,因为 u,v 只包含连续转移。其次 u+c+v 必定是一个后缀。这样的 u+c+v 不超过 n 个,而且由于全串对应的状态不能包含不连续转移,因此这部分最多有 n1 条边。
因此大致上有界 3n3,然而由于上界只能在形如 abbbbbb 的串中产生,因此可以构造发现更紧的上界 3n4

字符串 abbbbbbc 达到了该上界。

其他信息

其实转移没什么重要的。很多时候你会发现我们可以扔掉转移,建出后缀链接树来用。

我们设字符串 s 长度为 n

观察实现操作中 now 变量的值。每个 now 都对应着加入 c 后的当前字符串,即 s 的一个前缀。这样得到的 n 个节点对应着前缀的 n 个不同的终点。我们设第 i 个终点对应的节点为 vi,称其为一个终点节点。

考虑拉出后缀链接树来。定义一个节点的终点集合为该节点子树内所有终点节点对应终点组成的集合。

在此基础上我们给每个节点分配一个最长字符串,是其终点集合中任意一个节点向前取 len 个字符得到的字符串。可以发现每个这样的字符串都一样,且 len 恰好是满足这样性质的最大长度。
在树上,如果 AB 的祖先,则 A 分配的字符串是 B 分配的字符串的一个后缀。

这性质将字符串的前缀组成了一棵树。可以发现,S[1p]S[1q] 的 LCP 对应的字符串就是 vpvq 的 LCA 对应的字符串。

事实上,这棵树和 s 翻转后构造出的后缀树结构相同。

每个状态 p 对应的子串数量是 len(p)len(link(p))

应用

大坑,待填

模板
struct SAM {
    int n, mlc, lst; 
    int link[N], son[N][26], len[N];
    char ch[N];
    char* begin() { return ch + 1; }

    inline void init() { mlc = lst = 1; }
    SAM() { init(); }

    inline void extend(int c) {
        int now = ++ mlc, p = lst;
        len[now] = len[p] + 1;
        while (p and !son[p][c]) 
            son[p][c] = now, p = link[p];
        if (!p) link[now] = 1;
        else {
            int q = son[p][c];
            if (len[q] == len[p] + 1) link[now] = q;
            else {
                int kage = ++ mlc;
                len[kage] = len[p] + 1, link[kage] = link[q];
                link[q] = link[now] = kage;
                memcpy(son[kage], son[q], sizeof son[kage]);
                while (p and son[p][c] == q) 
                    son[p][c] = kage, p = link[p];
            }
        } lst = now;
    } 

    inline void debug() { rep(i,1,mlc) cout << i << ' ' << link[i] << ' ' << len[i] << endl; }

    inline void build() {
        n = strlen(begin()); 
        init();
        rep(i,1,n) extend(ch[i] - 'a');
        // debug();
    }

    inline int check(char* ch) {
        int ans = 0, now = 0, k = strlen(ch + 1);
        int p = 1;
        rep(i,1,k) {
            if (son[p][ch[i] - 'a']) p = son[p][ch[i] - 'a'], ++ now;
            else {
                while (p and !son[p][ch[i] - 'a']) p = link[p];
                if (!p) now = 0, p = 1;
                else now = len[p] + 1, p = son[p][ch[i] - 'a'];
            } 
            ans = max(ans, now);
        } return ans;
    }
} sam;
posted @   joke3579  阅读(195)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示