后缀自动机学习笔记

本文抄写自 OIwiki 是对 OIwiki 的注解。



用途

以合并部分子串的方式,储存字符串所有的子串。



根本思想

有一种数据结构叫作后缀树。它的思想是:将所有后缀插入字典树。如下。

容易发现它有非常多的部分是重复的。

于是,为了消除此类冗余,将字典树的结构修改为图,便有了 SAM。

引自知乎



概念阐释

  • endpos:这是一个集合。endpos(x) 代表子串 xs 中所有结束位置的集合。

  • 等价类:这也是一个集合。一个等价类中包含所有 endpos(i) 完全相等的子串 i

有如下引理(其实很显然,看了理解了就可以了):

  • 同一等价类中,子串绝对存在后缀包含关系。

  • 若子串 u 为子串 v 的后缀,endpos(u) 被包含于 endpos(v);否则二者的 endpos 无交。

  • 同一等价类中,串的长度绝对连续,且没有重复。

接下来是有关 SAM 构建的概念。

  • SAM 本身:和其他自动机一样,以字典树结构为主体——或者更应该说是字典图,是一个 DAG。它的更为严谨的定义是:一个接受 s 的所有后缀的最小 DFA。

  • SAM 中的节点:代表一个等价类。而它在 SAM 图中距离原点最长的路径代表着 “该等价类中的最长子串”,下文将该字串记为 w

  • 原点:代表空串的节点 0。

  • 终止状态:由于 SAM 是一个 DFA,它的终止状态即为字符串 s 的所有后缀。在算法的最后我们会介绍怎么设置终止状态。

  • len:每个节点上储存的数据。代表 w 的长度,也代表 SAM 图上原点到该节点的最长距离

  • 后缀链接(link):每个节点上储存的数据。类似其它自动机的 fail。它指向 w 最长的一个后缀,which 满足不在该等价类中。容易发现,后缀链接可构成一棵以 0 为根的树。

  • 转移边:每个节点上储存的数据。就是普通字典树边,储存字符信息。

引自 OIwiki | 左图为 SAM 图,右图为 link 形成的树



算法过程

同其它自动机一样,也是在线地一个一个插入字符。

设插入前的字符串为 s。对于当前字符 c,算法流程如下:

  • last 为添加字符 c 之前,s 所在等价类对应的节点。

  • 创建新节点 cur,代表串 s+c 所在等价类,将 len(cur) 赋值为 len(last)+1

  • last 开始,循环跳 link。记当前遍历到的节点为 p,每次执行如下:

    • 如果 p 不存在字符 c 的转移,则将 c 转移指向 cur,并继续循环。(将新子串添加到 cur 等价类内。)

    • 否则,现在及之前的字符 c 转移都已经赋值完成。设 pc 转移指向节点 q

      此时显然无法再将任何子串加入 cur 等价类了。现在的首要任务是:

      1. 通过分析,找出 link(cur) 所对应的值。

      2. 分析 p 所代表的等价类在加上了字符 c 之后发生的变化:可能,有的子串因为新增的 c,加入了新的等价类;而有的保持在原本的等价类。

      因此,需要再分两种情况:

      • 如果 len(p)+1=len(q),这说明原点到 q 的所有路径中(也就是等价类 q 所包含的所有子串中),经过 p 的这一条路径刚好是最长的那一条(最长子串)。此时,即使加入了新字符 c,也不会产生新的等价类。因此直接将 link(cur) 指向 q 即可。

      • 否则则一定产生了一个新的等价类,它的最长子串就是经过转移边 (p,q) 的路径。
        于是就创建一个新的状态 clone,复制 qlen 以外的所有信息(后缀链接和转移),并将 len(clone) 赋值为 len(p)+1
        复制之后,将 link(cur) 指向 clone,也将 link(q) 指向 clone
        最终需要做的是修改一些原本指向 q 的转移边。具体地,对于一个点 x,如果 len(x)+1<len(clone) 则将转移边指向 clone,否则则保持它指向 q(利用等价类长度连续的引理)。容易发现,只要继续从 p 开始跳 link,就能够找到每一个满足 len(x)+1<len(clone)x(跳 link 时,len 单调递减,故 len(x)+1<len(p)+1=len(clone))。

  • 在跳到 link(0)=1 这个虚拟节点时,停止循环。将 last 的值更新为 cur

最后提一下怎么设置“终止状态”(即代表字符串所有后缀的节点):从代表整个字符串的节点开始,往上跳后缀链接 link,遇到的所有节点设为终止状态。一般来说,可以忽略这一个操作。



复杂度证明

如果我们考虑算法的各个部分,算法中有两处时间复杂度不明显是线性的:

  • 第一处是遍历所有状态 last 的后缀链接,添加字符 c 的转移。

  • 第二处是修改指向 q 的转移,将它们重定向到 clone 的过程。

第一处显然可以用均摊证明整体的 O(n) 复杂度。

第二处的复杂度需要用到一个结论:总转移数的上界为 3n。(证明 没看懂,咕了。。。)

回过来看第二处的复杂度证明。明显复杂度等价于指向 clone 的转移数,而 clone 不会重复遍历,因此第二处的均摊复杂度等价于总转移数,为 O(n)



应用

1. 每个子串出现次数

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

点击查看代码
#include<bits/stdc++.h>
#define ll long long
using namespace std;

const int MAXN = 1e6+5;
int n, tot;
char s[MAXN];
vector<int> ord[MAXN];

struct Trie{
	int link, len, sz, ch[26];
	#define sz(x)		tree[x].sz
	#define len(x)		tree[x].len
	#define link(x)		tree[x].link
	#define ch(x, y)	tree[x].ch[y]
} tree[MAXN<<1];//注意因为有复制操作,要两倍大小 

int main(){
	scanf("%s", s+1);
	n = strlen(s+1);
	link(0) = -1;
	int p = 0;
	for(int i = 1; i <= n; i++){
		int c = s[i]-'a', cur = ++tot;
		len(cur) = len(p)+1, sz(cur) = 1;
		for(; p != -1 and !ch(p, c); p = link(p))	ch(p, c) = cur;
		if(p != -1){
			int q = ch(p, c);
			if(len(p)+1 == len(q))	link(cur) = q;
			else{
				int clone = ++tot; tree[clone] = tree[q];
				len(clone) = len(p)+1, sz(clone) = 0;
				link(cur) = link(q) = clone;
				for(; p != -1 and ch(p, c) == q; p = link(p))	ch(p, c) = clone;
				//				每个不同的 ch(p, c) 在树链上是连续的颜色段 
			}
		}
		p = cur;
	}
	ll ans = 0;
	for(int i = 1; i <= tot; i++)	ord[len(i)].push_back(i);
	for(int i = n; i >= 1; i--)
		for(int j = 0; j < ord[i].size(); j++){
			int x = ord[i][j];
			sz(link(x)) += sz(x);
			if(sz(x) > 1)	ans = max(ans, 1ll*len(x)*sz(x));
		}
	cout<<ans;
	
	return 0;
}
/*
利用“前缀的后缀就是所有子串”(或者说,一个新增点肯定是与 link 链上的所有点形成新子串的 ) 
将所有表示前缀的节点(非复制节点,即正常插入的节点)的 size 设为 1
然后按照 link 形成的后缀链接树累加起来 
*/

2. 不同子串个数

法一:DP 求 DAG 不同路径条数。(每个节点存储的 dp[i],实际表示从这个点开始的子串个数。)

法二:总数量 - 重复数量(上一题)。

法三:在线做法。观察可知,每插入一个新字符,它只与新增的转移边形成新的“不同子串”。于是每次连接转移 (p,cur) 时,使 ans 加上 len(p)len(link(p)) 即可。

P4070 [SDOI2016] 生成魔咒:用 map 对转移边储存进行优化。


3. 第 k 大的子串

运用上一题中的 dp 数组,预处理完成后进行扫描即可。

P3975 [TJOI2015] 弦论


4. 最长公共子串

【待补】

SPOJ Longest Common Substring



对比

  • 与后缀数组对比:

  • 与 AC 自动机对比:



广义后缀自动机

广义后缀自动机是用来解决多模式串匹配的一个工具。以下题为例:

P6139 【模板】广义后缀自动机(广义 SAM)

给定 n 个由小写字母组成的字符串 s1,s2,,sn,求它们的所有本质不同子串的个数。

网上流传的主流写法有三种:

  1. 通过用特殊符号将多个串直接连接后,再建立 SAM。

  2. 对每个串,重复在同一个 SAM 上进行建立,每次建立前,将 last 指针置零。

  3. (正解)用所有模式串建出一棵 Trie 树,对其进行 bfs 遍历构建 SAM,insert(x) 时以 x 在 Trie 上的父亲为 last,其余和普通 SAM 一样。

【弄不懂为什么第一种有错。复杂度难道不是线性的吗?抑或是说是插入特殊符号的特判出了问题?】

第二种其实是可以的,但需要加上一些特判。 这篇博客 中给出了对于不加特判的情况的卡掉的方案。

在讨论第三种之前先研究一下第二种法案建出来的 SAM 图。这时图中的每个节点已不再是一个等价类了,而是 等价类的集合。如下图,每个节点旁有两个花括号,每个花括号内都是一个不同的模式串的等价类。这样问题就被扩展了。

【感觉我这里还没有理解清楚……但是得先咕掉了……】

引自知乎

第三种本质上是对第二种的改进:第二种可能会重复建立一些节点,而使用 Trie 结构则去除了这些重复。这样建立的 DFA 可保证节点数量最小。(听说其实也可以用 dfs 而非 bfs,但是特判太多容易写挂,故不在此讨论。)

bfs 写法代码:

点击查看代码
inline void Insert_SAM(int p, int cur, int c){
	//不用为 cur 新建节点。因为我们直接利用已经建好的那棵 Trie 树建立 SAM。
	len(cur) = len(p)+1;
	ans += p ? len(p)-len(link(p)) : 1;
	//为了 ch(p, c) 能够进行,要从 link(p) 开始 
	for(p = link(p); p != -1 and !ch(p, c); p = link(p)){
		ch(p, c) = cur;
		ans += p ? len(p)-len(link(p)) : 1;
	}
	if(p == -1)	return;
	int q = ch(p, c);
	if(len(q) == len(p)+1)	{link(cur) = q; return;}
	int clone = ++tot;
	link(clone) = link(q);
	for(int i = 0; i < 26; i++)
		if(len(ch(q, i))) ch(clone, i) = ch(q, i);
		//len 在这里实际用来判断一个节点是否已经被插入 SAM 
	len(clone) = len(p)+1;
	link(cur) = link(q) = clone;
	for(; p != -1 and ch(p, c) == q; p = link(p))	ch(p, c) = clone;
	return;
}

inline void bfs(){
	queue<int> que;
	que.push(0); link(0) = -1;
	while(!que.empty()){
		int cur = que.front(); que.pop();
		for(int i = 0; i < 26; i++){
			if(!ch(cur, i)) continue;
			Insert_SAM(cur, ch(cur, i), i);
			que.push(ch(cur, i));
		}
	}
	return;
}

还有一种写法,支持在线插入模式串。(其实就是上述的第二种加上了一些特判。)代码如下:

点击查看代码
inline void Insert(){
	for(int i = 1, p = 0; i <= n; i++){
		int c = s[i]-'a';
		if(ch(p, c)){//如果想插入的位置已经存在节点了,不用新建 
			int q = ch(p, c);
			//按照“等价类的集合”是否变动来决定是否拆分该节点
			//(和普通 SAM 的判断操作其实是一样的) 
			if(len(p)+1 == len(q))	p = q;//last = q
			else{ 
				int clone = ++tot; tree[clone] = tree[q];
				len(clone) = len(p)+1;
				link(q) = clone;
				for(; p != -1 and ch(p, c) == q; p = link(p))	ch(p, c) = clone;
				p = clone;//last = clone 
			}
		}
		else{//剩下同普通 SAM 
			int cur = ++tot; len(cur) = len(p)+1;
			for(; p != -1 and !ch(p, c); p = link(p)){
				ch(p, c) = cur;
				ans += p ? len(p)-len(link(p)) : 1;
			}
			if(p != -1){
				int q = ch(p, c);
				if(len(p)+1 == len(q))	link(cur) = q;
				else{
					int clone = ++tot; tree[clone] = tree[q];
					len(clone) = len(p)+1;
					link(q) = link(cur) = clone;
					for(; p != -1 and ch(p, c) == q; p = link(p))	ch(p, c) = clone;
				}
			}
			p = cur;
		}
	}
	return;
}
posted @   David_Mercury  阅读(16)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示