字符串……?

要爆了,串咋这么难啊。

后缀自动机(SAM)

前言:目前找到的最易懂且严谨的 SAM 介绍是 OI-Wiki,但是里面可能有些概念比较混乱(?)。大概把我会的写一写 & 修一修,复杂度证明完全不会啊,直接开摆了。


除特殊说明,对于字符串 \(s\)\(|s|\) 表示字符串 \(s\) 的长度;\(\sum\) 表示字符集,\(|\sum|\) 表示字符集的大小。

SAM 是一张图。

  • 其上的节点被称作 状态

  • 其上的边可分为两类:转移后缀链接

每个 SAM 的状态中都有且只有一个 初始状态 \(t_0\)。同时,「状态」与「转移」构成的图为一个 DAG(有向无环图),这个 DAG 的源点为 \(t_0\);「状态」与「后缀链接」构成的图为一颗树,这棵树的根 也是 \(t_0\)

现在,我们将依次介绍 SAM 的状态、转移和后缀链接,并讲解 SAM 的构建方式。

  1. 状态

    后缀自动机的原理依赖于字符串后缀的特点。具体地,对于原串 \(s\) 的一个子串 \(t\),记 \(\text{endpos}(t)\)\(t\)\(s\) 中的结束位置所构成的集合。由于个人习惯,\(s\) 的编号从 \(1\) 开始。例:对于字符串 \(\text{abcbc}\)\(\text{endpos}( \text{bc} ) = \{3, 5\}\)

    我们容易得到以下结论:

    • 对于原串 \(s\) 的两个不同子串 \(t_1\)\(t_2\),若 \(t_1\)\(t_2\)\(\text{endpos}\) 相等,则有:\(t_1\)\(t_2\) 中的一个为另一个的后缀。

    • 对于原串 \(s\) 的两个不同子串 \(t_1\)\(t_2\),两者的 \(\text{endpos}\) 之间的关系只有 相等(包含)不交 两种,具体地,若 \(|t_1|\)

      • \(t_1\)\(t_2\) 的后缀,则 \(\text{endpos}(t_2) \subseteq \text{endpos}(t_1)\)

      • 否则,\(\text{endpos}(t_1) \cap \text{endpos}(t_2) = \varnothing\)

    所有子串都可以根据它们的 \(\text{endpos}\) 被划分为若干个集合 等价类。公式化地,对于一个集合 \(A\),定义一个「等价类」为 \(\{ t \in s | \text{endpos}(t) = A \}\)

    容易发现,对于一个等价类,将它内部的字符串按照长度降序排序,则每一个字符串都是它的前一个字符串的后缀,设这些字符串中最长的一个为 \(t'\)

    现在,我们就可以解释「状态」的定义了。「状态」本质上是一个映射,每一个状态都对应了一个等价类,和它里面的字符串的 \(\text{endpos}\)。方便起见,对于一个状态,我们一般以它的 \(t'\) 来简单地代替它。

    而我们先前提到的「初始状态」,就是空串所对应的状态,它的 \(\text{endpos}\)\(\{ 0 \}\)

  2. 转移

    一个「转移」可以被通俗地理解为一条有向边,边权为一个字符。若状态 \(u\) 有一条连向状态 \(v\) 的转移,则设这个转移的边权为 \(c\),则 \(t'_{u} + c \in A_v\),其中 \(A_v\)\(v\) 所对应的等价类。例如:设点 \(u\)\(t'\)\(\text{bc}\),而点 \(v\) 对应的等价类为 \(\{ \text{abcb}, \text{bcb}, \text{cb} \}\),则因为 \(\text{bc} + \text{b} = \text{bcb}\),所以 \(u\)\(v\) 之间有一条边权为 \(\text{b}\) 的转移。

    我们定义概念「一条路径对应的字符串」。具体地,对于状态 \(u\) 到状态 \(v\) 之间的路径,将路径上每一条边的权值连起来所构成的字符串,为这条路径对应的字符串。

    一个重要的观察是:对于状态 \(u\),它的等价类,与所有「初始状态」到 \(u\) 的路径对应的字符串所构成的集合相等。进而得到:原字符串的任何一个子串,都等于一条起点是「初始状态」的路径对应的字符串。

  3. 后缀链接

    对于状态 \(u\),容易得到,状态 \(u\) 对应的等价类中的字符串的长度是 连续的。设这个长度的最大值为 \(\text{maxlen}(u)\),最小值为 \(\text{minlen}(u)\),则可以得到,若状态 \(v\) 满足 \(\text{maxlen}(v) = \text{minlen}(u) - 1\),则 \(u\)\(\text{endpos}\) 属于 \(v\)\(\text{endpos}\)。同时,还可以得到 \(t'_v\)\(t'_u\) 的所有后缀中,最长的不属于 \(u\) 的等价类的那一个。

    观察得,对于每个状态 \(u\),这样的状态 \(v\) 必定是 唯一的(若 \(t'_u\) 的所有后缀均属于 \(u\) 的等价类,则定义状态 \(v\) 为初始状态)。此时,我们定义后缀链接为一条从 \(u\) 指向 \(v\) 的边,并对于每个 \(u\),定义 \(\text{link}(u)\) 为它的 \(v\)

    以下是 \(\text{abcbc}\) 构造得到的 SAM,其中图的左半部分为「状态」与「转移」构成的图,右半部分为「状态」与「后缀链接」构成的图,标绿的状态满足它的 \(t'\) 为原串 \(s\) 的后缀。图片来自 OI-Wiki


对于状态 \(u\),我们一般将 \(\text{maxlen}(u)\) 简化表示为 \(\text{len}(u)\)

下面,我们将介绍 SAM 的构建流程。SAM 是支持不断加字符的。对于给当前字符串加字符 \(c\) 的过程,后缀自动机的算法流程如下:

  • 定义 \(\text{lst}\) 为添加字符 \(c\) 之前,整个字符串所对应的状态(初始时,另 \(\text{lst}\)\(0\))。

  • 新建一个状态 \(\text{cur}\),表示加入 \(c\) 后,整个字符串对应的状态。现在,我们将 \(\text{len}(\text{cur})\) 赋值为 \(\text{len}(\text{lst}) + 1\)

  • 现在,定义一个指针 \(p\)。初始时,\(p\) 指向 \(\text{lst}\)。使 \(p\) 遍历 \(\text{lst}\) 的后缀链接。若 \(p\) 还没有一个以它为起点的转移,满足这个转移的权值为 \(c\),则建立一条权值为 \(c\) 的,自 \(p\)\(\text{cur}\) 的转移;否则直接跳出遍历过程。

  • 若当前 \(p\) 没有一条权值为 \(c\) 的转移,则将 \(\text{link}(\text{cur})\) 赋值为初始状态并退出。

  • 否则,设 \(p\) 会通过 \(c\) 转移到状态 \(q\),则继续分类讨论:

    • \(q\) 满足 \(\text{len}(q) = \text{len}(p) + 1\),则将 \(\text{link}(\text{cur})\) 赋值为 \(q\) 并退出。

    • 否则,新建一个节点 \(k\),复制 \(q\) 的除了 \(\text{len}\) 之外的所有信息到 \(k\) 上,并将 \(\text{link}(k)\) 赋值为 \(\text{len}(q) + 1\)

      使指针 \(p\) 继续向初始状态跳 \(\text{link}\),若当前的 \(p\) 存在一条道状态 \(q\) 的转移,就将这条转移改变为到状态 \(k\)

      \(\text{link}(q)\)\(\text{link}(\text{cur})\) 赋值为 \(k\)

  • 最后,将 \(\text{lst}\) 赋值为 \(\text{cur}\) 并退出。

OI-Wiki 中证明了 SAM 的状态数为 \(2n - 1\)转移 数为 \(3n - 4\),在别的地方可能会看到比这个大一点的估计,这主要是考虑了初始状态向外连边导致的(实际实现中,我们会将初始状态指向一个空节点以方便操作)。总之,理论上 SAM 的空间复杂度为 \(O(n)\)

但是,实际实现中要考虑字符集的大小。当 \(|\sum|\) 不是常数时,对于每一个状态我们都会开一个平衡树(实现中一般为 map)来存储转移,所以此时 SAM 的时间复杂度为 \(O(n \log |\sum|)\),空间复杂度为 \(O(n)\)。而在字符集较小或为常数(例:小写英文字母)时,对于每个状态,我们可以直接定义一个数组来存储它的转移,故此时时间复杂度为 \(O(n)\),空间复杂度为 \(O(n |\sum|)\)。更简洁的写法如下:

  • 字符集大小较大,时间复杂度 \(O(n \log |\sum|)\),空间复杂度 \(O(n)\)

  • 字符集大小较小,时间复杂度 \(O(n)\),空间复杂度 \(O(n |\sum|)\)

什么,你问我复杂度怎么证?我不到啊。

放一个板的 SAM。

struct Suffix_Automaton {
	int sz, lst;
	struct Node { int len, link, nxt[SZ]; } a[M]; // M = 2 * N, SZ = $|\sum|$
	
	void build() {
		a[0].len = 0; a[0].link = -1;
	}
	
	void add(char c) {
		int cur = ++sz, p = lst; f[sz] = 1;
		a[cur].len = a[lst].len + 1;
		while (p != -1 && !a[p].nxt[c - 'a']) { a[p].nxt[c - 'a'] = cur; p = a[p].link; }
		if (p == -1) { a[cur].link = 0; }
		else {
			int q = a[p].nxt[c - 'a'];
			if (a[q].len == a[p].len + 1) { a[cur].link = q; }
			else {
				int k = ++sz; a[k] = a[q]; a[k].len = a[p].len + 1;
				while (p != -1 && a[p].nxt[c - 'a'] == q) { a[p].nxt[c - 'a'] = k; p = a[p].link; }
				a[q].link = a[cur].link = k;
			}
		}
		lst = cur;
	}
} SAM;

下面将介绍一些 SAM 的应用。

对 SAM 上的状态进行拓扑排序

众所周知,一般的拓扑排序需要不断拿出出度为 \(0\) 的点,于是它就需要在构造 SAM 时进行一系列复杂的加边 & 删边操作。不过我们可以不这么做。

一个性质是:SAM 上的转移总是从 \(\text{len}\) 小的状态连向 \(\text{len}\) 大的状态。同时,\(\text{len}\) 相同的状态间也不会有转移。

于是,我们就可以直接对 \(\text{len}\) 值建桶再前缀和,然后依次插入节点。

code:

void topo(int n) { // n is the length of string s
	for (int i = 1; i <= sz; ++i) ++buc[a[i].len];
	for (int i = 1; i <= n; ++i) buc[i] += buc[i - 1];
	for (int i = 1; i <= sz; ++i) tp[buc[a[i].len]--] = i;
}

统计子串的出现次数

真的是神了!

显然,这个问题等价于对于每个状态,都求出它的 \(\text{endpos}\) 集合的大小。

尝试考虑一个状态的 \(\text{endpos}\) 是如何得来的。首先发现如果从转移的角度考虑的话,不好定义初始状态,于是考虑从后缀链接的角度思考这件事。

首先,一个状态的 \(\text{endpos}\) 大小就是它的 \(t'\)(等价类中最长的字符串)的出现次数。这个串的出现次数可以被拆分为两种:

  1. 作为其他子串的后缀。

  2. 并不做为其他子串的后缀。

注意到第二种情况只有在 \(t'\) 是原串 \(s\) 的前缀时才会出现,于是这样的 \(t'\) 对应的状态的初值被设为 \(1\)

而对于第一种状态,则可以从 \(\text{link}\) 连向这个状态的其他状态转移过来。于是直接对 SAM 做拓扑序 dp 即可。

本质不同子串个数

这个东西有两种做法。

  • 从转移的角度考虑,由于每个子串都对应着 SAM 上的一条,起点为初始状态的路径,于是直接做拓扑序 dp 即可。

    具体地,设 \(f_i\) 表示 SAM 上以初始状态为起点,状态 \(i\) 为终点的路径数量。初始有 \(f_0 = 1\),转移为 \(f_i = \sum f_j\),其中 \(j\) 为所有的,满足存在一条 \(j\) 连向 \(i\) 的转移。最后的答案为 \(\sum\limits_{i = 1}^{\text{sz}} f_i\),其中 \(\text{sz}\) 表示 SAM 中的状态数量。

    该做法的时间复杂度为 \(O(n|\sum|)\),当然也有线性的做法但难写。

  • 从后缀链接的角度考虑,本质不同子串个数显然等于 \(\sum\limits_{i = 1}^{\text{sz}} \text{Card}(A_i)\),其中 \(\text{sz}\) 表示 SAM 中的状态数量,\(A_i\) 表示状态 \(i\) 对应的等价类。而根据前文内容,我们有 \(\text{Card}(A_i) = \text{len}_i - \text{len}_{\text{link}_i}\)

    该做法的时间复杂度为 \(O(n)\)

posted @   zyb_txdy  阅读(16)  评论(4编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示