后缀自动机 (SAM) 学习笔记

后缀自动机 (SAM) 学习笔记

一、定义

字符串 s 的 SAM 是一个接受 s 的所有后缀的最小 DFA (确定性有限自动机或确定性有限状态自动机),也就是说:

  • SAM 是一张有向无环图。它的结点是图中的状态,边是状态之间的转移。
  • SAM 有源点 t0,且其它各结点均可从 t0 出发到达。
  • SAM 中每个转移都标有一个字母,且从一个结点出发的所有转移都是不同的。
  • SAM 存在一些终止状态。特殊地,到达一个终止状态时从 t0 到该状态的路径连接起来是字符串 s 的后缀。反之,s 的每个后缀同样可从一条由 t0 到某个终止状态的路径构成。
  • 满足这样条件的自动机有多个,而 SAM 的结点数是最少的。

让我们举一个例子来描述一个对于字符串 abcbc 的 SAM:picture1

需要注意的是,SAM 的结点个数和边数都是 O(n) 的。具体地,一个 SAM 最多会有 2×n1 个结点和 3×n4 条转移边。

二、endpos 等价类及其性质

这一部分的内容,似乎和 SAM 没有直接关系,但却是 SAM 中很重要的一部分。我们需要证明关于它的一些性质,并得出一些结论,这是我们构建 SAM 的基础。

1. 定义

对于字符串 s 的一个子串,它在原串中会出现若干次。一个子串 ps 中出现的右端点位置的集合,就称为 endpos(p)。对于上文中串 abcbc,对于字符串 bc,其 endpos 集合为 {2,4}。需要说明的是,每一个 endpos 等价类对应着 SAM 上的一个结点。根据定义我们显然可以这样做,这样 SAM 中一个状态就会对应这个 endpos 等价类中所有的字符串。

2. 性质及其证明

  1. 若串 s1,s2 满足 endpos(s1)=endpos(s2),且 s1s2,则 len(s1)len(s2)

证明:若存在 s1,s2 满足 endpos(s1)=endpos(s2)=R,s1s2,len(s1)=len(s2)=l,则对于任意 posR,由 s1s2s[posl+1,pos]s[posl+1,pos],显然矛盾。

  1. 对于 s 的任意后缀 ts,有 endpos(s)endpos(ts)

证明:由 endpos 的定义知道每个 s 能匹配到的右端点 ts 必能匹配。

  1. 若两个不同的串 s1,s2 满足 endpos(s1)=endpos(s2)=R,则对于 len(s1)llen(s2),一定存在 s3 满足 len(s3)=lendpos(s3)=R

证明:由 1,len(s1)len(s2),不妨设 len(s1)<len(s2)。令 s3=s2[l2l+1,l2],由 2,Rendpos(s3),endpos(s3)R。因此 endpos(s3)=R

  1. endpos 集合相等的字符串的长度必然是连续的。

证明:设其中两个不同串为 s1,s2,由 1,可不妨设 len(s1)<len(s2)。由 3,必然有 len(s1)llen(s2) 满足 endpos=R

  1. 对于两个 endpos 集合 Ra,Rb,要么 RaRb,要么 RaRb=

证明:设 RaRb=r,那么设从 t0Ra,Rb 结点路径表示的字符串的集合为 Sa,Sb。记 maxs, 表示集合 Sa 中最长的串的长度,mina 同理,则由 4,[mina,maxa][minb,maxb]=。不妨设 maxb<mina,则对于任意 saSa,有 len(a)>len(b)。又因为 RaRb=r,由 2,RaRb。考虑 Ra=Rb 的情形,则 RaRb

三、 parent 树及 SAM 的复杂度

根据上面的性质,任意两个 endpos 集合或是不相交,或是其中一个是另一个的子集。那么对于任意一个不为初始状态的状态 a,一定恰好存在一个状态 b 满足 endpos(a)endpos(b)maxb=maxa1。这种关系可以抽象成一个树形结构,记非根状态 x 在 parent 树上的父亲为 link(x)。那么容易发现的性质是一个结点在 parent 树上的子结点至少有两个,否则其 endpos 集合应当相同。且子结点代表的 endpos 集合互不相交。那么仍然以串 abcbc 举例,我们可以用绿色的线表示 parent 树上的边。

image

让我们进一步发现 parent 树上的一些奇妙性质:

  1. parent 树有 len(s) 个儿子。

证明:显然会存在的叶子结点 endpos 集合为 {1},{2},,{len(s)}

  1. parent 树的状态不会超过 O(n) 级别。

证明:由于一个点至少有两个子结点,那么新增一个结点必然会删去两个结点,因此最多新增 n1 个非叶子结点。于是总的状态级别是 O(n)

那么我们已经证明了 SAM 状态数是 O(n) 的。需要知道的是 SAM 的转移边数同样是 O(n) 的,不过这个性质没有状态数那么重要,且较难证明,因此略去。

四、SAM 的构造

1. 构造流程

初始情况是只有状态 t0=1,其 len=0,link=0,现在将字符 c 加入 SAM 中,加入之前 SAM 的最终状态为 p

我们创建新的状态 np,令 len(np)=len(p)+1。从 p 开始跳 link,若没有 c 的转移,添加到 np 为字符 c 的转移,直到找到一个有该转移的状态,改这个状态为 p。若没有找到 p,那么其 endpos 是一个全新的 endpos,直接令 link(np)=1 即可,否则我们记 p 关于 c 的转移为 q

len(q)=len(p)+1,那么显然令 link(np)=q 是正确的。考虑 len(q)len(p)+1 的情形:我们将状态 q 复制至 nq,但将 len(nq) 置为 len(p)+1,并将 q,nplink 信息指向 nq

最后,我们从 p 开始跳 link,若 pc 的转移且转移到了 q,将这个转移改到 nq 即可,直到找不到或是回到源点停止。

需要知道的是,建完这个 SAM 后对应的终止状态就是 np

2. 正确性证明

需要证明的部分只有 len(q)len(p)+1 的部分。此时显然 len(q)>len(p)+1,也就是状态 q 在对应长度为 len(p)+1 后缀的同时也对应了更长的子串。于是将状态 q 拆一个状态 nq 出来,且将其 len 设为 len(p)+1。这样一来,nq 继承 q 的其它信息是理所当然的。同时需要留意的是,要将状态 p 原有到 q 的转移改到 nq,于是跳 link 的后缀直到找不到转移 (c,q) 为止。

需要知晓的是,这样构建 SAM 的时间复杂度是 O(n)。这是建立在字符集大小为常数的前提下。否则一般使用 std::map 来存边,此时时间复杂度为 O(nlog||) 而空间复杂度为 O(n)

这里给出 SAM 的一般实现:

struct SAM {
	int len, fa;
	int s[M];
} sam[N];
int tot = 1, lst = 1;
void insert(int c) {
	int p = lst, np = lst = ++tot;
	sam[np].len = sam[p].len + 1;
	for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;
	if (!p) sam[np].fa = 1;
	else {
		int q = sam[p].s[c];
		if (sam[q].len == sam[p].len + 1) sam[np].fa = q;
		else {
			int nq = ++tot;
			sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
			sam[q].fa = sam[np].fa = nq;
			for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;
		}
	}
}

五、SAM 的基础应用

1. 求本质不同子串个数

一般的方法是求每个状态内子串的个数。也就是 len(i)link(len(i))

2. 求第 k 小子串

考虑到每个子串唯一对应着 SAM 上一条路径,于是转化为求 SAM 上字典序第 k 小的路径。于是简单 dp 可以处理。

3.求两个字符串的最长公共子串

对于两个字符串 s,t,对于 s 建出后缀自动机,对 t 进行匹配处理。我们使用两个变量进行匹配:当前状态 p 和当前长度 l。初始时 p=t0,l=0

p 存在字符 ti 的转移时,我们转移长度并让 l 加一即可。若不存在 p 的转移,需要将 plink 数组知道满足当前字符的转移。对于时间复杂度,显然每次最多使 l 加一,或是将 l 减小一些,调整加减顺序不难得到总的时间复杂度为 O(|s|+|t|)

给出代码实现:

int fnd(char *s) {
	int p = 1, ans = 0, l = strlen(s), res = 0;
	for (int i = 0; i < l; i++) {
		int c = s[i] - '0';
		while (p > 1 && !sam[p].s[c]) {
			p = sam[p].fa;
			ans = sam[p].len;
		}
		if (sam[p].s[c]) {
			p = sam[p].s[c];
			++ans;
		}
        res = max(res, ans);
	}
    return res;
}

4. 线段树合并维护 endpos 集合

考虑在 parent 树上对 endpos 集合进行线段树合并,这样通常可以维护出每个点 endpos 的一些信息,在一些题目中会用到。

5. SAM 求后缀的最长公共前缀

考虑将字符串反向后插入 SAM 中,这样问题转化为了前缀的最长公共后缀,那么 parent 树上所有父亲串一定是儿子串的前缀。于是找到两个字符串对应的结点,求它们的 LCA 即可。

六、广义 SAM

广义 SAM,就是对多个字符串建出的 SAM。如果暴力将它们连接,往往会出现各种各样的问题,因此需要掌握建立正确的广义 SAM 的方法。

我们先针对这些字符串建出 Trie,在此基础上将 Trie 的每条边建到广义 SAM 中即可。我们通常适用的方法是离线 BFS。下面给出代码实现:

struct Trie {
	int fa, ch;
	int s[M];
} tr[N];
int cnt = 1;
void ins(char *s) {
	int l = strlen(s), p = 1;
	for (int i = 0; i < l; i++) {
		int ch = s[i] - '0';
		if (!tr[p].s[ch]) {
			tr[p].s[ch] = ++cnt;
			tr[cnt].fa = p;
			tr[cnt].ch = ch;
		}
		p = tr[p].s[ch];
	}
}
struct SAM {
	int len, fa;
	int s[M];
} sam[N];
int tot = 1;
int insert(int c, int lst) {
	int p = lst, np = lst = ++tot;
	sam[np].len = sam[p].len + 1;
	for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;
	if (!p) sam[np].fa = 1;
	else {
		int q = sam[p].s[c];
		if (sam[q].len == sam[p].len + 1) sam[np].fa = q;
		else {
			int nq = ++tot;
			sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
			sam[q].fa = sam[np].fa = nq;
			for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;
		}
	}
	return lst;
}

queue<int>q;
int pos[N];
void build() {
	for (int i = 0; i < M; i++)
		if (tr[1].s[i]) q.push(tr[1].s[i]);
	pos[1] = 1;
	while (!q.empty()) {
		int p = q.front();
		q.pop();
		pos[p] = insert(tr[p].ch, pos[tr[p].fa]);
		for (int i = 0; i < M; i++)
			if (tr[p].s[i]) q.push(tr[p].s[i]);
	}
}

posted @   长安19路  阅读(29)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示