← 全部文章

后缀自动机 学习笔记

本文目录(20 节)
  1. 确定有限状态自动机 (DFA)
  2. 定义
  3. 作用
  4. 后缀自动机 (SAM)
  5. 构造
  6. rightpos⁡\operatorname{rightpos} 等价类
  7. rightpos⁡\operatorname{rightpos} 性质
  8. 后缀链接 parent⁡\operatorname{parent}
  9. parent⁡\operatorname{parent} 性质
  10. 构造 SAM
  11. 复杂度
  12. 状态数和转移数上界
  13. 性质
  14. 应用
  15. 【模板】P3804 【模板】后缀自动机 (SAM)
  16. 【例题】P4070 [SDOI2016]生成魔咒
  17. 【例题】SP7258 SUBLEX - Lexicographical Substring Search
  18. 【例题】SP1811 LCS - Longest Common Substring
  19. 【例题】SP10570 LONGCS - Longest Common Substring
  20. 参考资料

因为 SA 就很有用了,所以一直不想学 SAM。突然不知道为什么就想学了,可能是为了吊打兔兔吧。
后缀自动机 (SAM) 是一个能解决许多字符串相关问题的有力的数据结构。
直观上,SAM 可以理解为将字符串的所有子串高度压缩至理论复杂度下界 O(n)\mathcal O(n) 的形式。

确定有限状态自动机 (DFA)

我们先要了解,什么是确定有限状态自动机 (DFA)。

定义

一个 DFA 可以看成一张有向图,由五个部分构成:

  1. 状态集合 QQ:图中的点。
  2. 字符集 Σ\Sigma:所有合法字符的集合。
  3. 转移函数 δ(v,c)\delta(v,c):图中的边,其中 vv 和返回值都是一个状态,分别对应一条边的起点和终点,c∈Σc \in \Sigma,表示这条边上的字符。
  4. 起始状态 SS:S∈QS \in Q,是一个特殊的状态。
  5. 接受状态集合 FF:F⊆QF \subseteq Q,是一组特殊的状态。

作用

DFA 可以识别字符串。

当 DFA 读入字符串 ss 时,从 SS 开始按照 δ(v,c)\delta(v,c) 一个一个字符地转移。

特别地,如果某个状态 vv 没有字符 cc 的转移,则令 δ(v,c)=N\delta(v,c) = N,NN 只能转移到 NN,且 N∉FN \notin F。

如果读入完 ss 后处在 FF 内,就称这个 DFA 接受 ss。

扩展定义转移函数 δ(v,s)\delta(v,s),其中 ss 是一个由 Σ\Sigma 内的字符构成的字符串,则 δ(v,s)=δ(δ(v,s[1]),s[2:∣s∣])\delta(v,s) = \delta(\delta(v,s[1]),s[2:|s|])。

一个 DFA 是否接受 ss,等价于求 [δ(S,s)∈F][\delta(S, s) \in F] 的值。

后缀自动机 (SAM)

后缀自动机 (SAM) 是接受且仅接受字符串 ss 的所有后缀的最小 DFA。

构造

rightpos⁡\operatorname{rightpos} 等价类

在一些教程中,rightpos⁡\operatorname{rightpos} 也被称作 endpos⁡\operatorname{endpos}。本文选择 rightpos⁡\operatorname{rightpos},是因为同样存在定义类似的 leftpos⁡\operatorname{leftpos},而不存在 beginpos⁡\operatorname{beginpos} 的叫法。

对于 ss 的子串 tt,记 rightpos⁡(t)\operatorname{rightpos}(t) 为 ss 中 tt 的所有结束(即最右边的字符)位置。如对于字符串 abcbc\texttt{abcbc},rightpos⁡(bc)=3,5\operatorname{rightpos}(\texttt{bc}) = 3,5。特别地,空串的 rightpos⁡\operatorname{rightpos} 定义为 0,1,⋯ ,∣s∣0,1,\cdots,|s|。

若两个非空子串 t1,t2t_1,t_2,满足 rightpos⁡(t1)=rightpos⁡(t2)\operatorname{rightpos}(t_1) = \operatorname{rightpos}(t_2),则将他们划分到一个 rightpos⁡\operatorname{rightpos} 等价类中。

SAM 中的每个状态都对应着一个 rightpos⁡\operatorname{rightpos} 等价类,其中 SS 对应着空串所在的等价类。

rightpos⁡\operatorname{rightpos} 性质

  • 考虑一个子串 tt,我们每次去掉 tt 的首字母,则它所在的 rightpos⁡\operatorname{rightpos} 要么不变,要么会增加若干个新位置。
    形式化地,对于两个子串 t1,t2t_1,t_2,不妨设 ∣t1∣≤∣t2∣|t_1| \le |t_2|:
    若 t1t_1 为 t2t_2 的后缀,则 rightpos⁡(t2)⊆rightpos⁡(t1)\operatorname{rightpos}(t_2) \subseteq \operatorname{rightpos}(t_1);
    否则,说明 t2t_2 所在的位置 t1t_1 都不在,即 rightpos⁡(t1)∩rightpos⁡(t2)=∅\operatorname{rightpos}(t_1) \cap \operatorname{rightpos}(t_2) = \varnothing。
  • 反过来考虑更强一点的性质,如果若干个子串的 rightpos⁡\operatorname{rightpos} 一样,意味着它们在 ss 中的出现位置一样,且它们一定是由一个子串 tt 不断去掉首字母,但还未能增加新位置时的所有子串构成的。
    因此,在一个 rightpos⁡\operatorname{rightpos} 等价类中,较短的子串为较长的子串的后缀,且所有子串的长度恰好覆盖一个区间。

后缀链接 parent⁡\operatorname{parent}

对于一个不是 SS 的状态 vv,设它对应的等价类中最短的子串为 tt。

后缀链接 parent⁡(v)=rightpos⁡(t[2:∣t∣])\operatorname{parent}(v) = \operatorname{rightpos}(t[2:|t|])。

另外,对于一个状态 vv,记 len⁡(v)\operatorname{len}(v) 表示它对应的等价类中最长的子串的长度。特别的,len⁡(S)=0\operatorname{len}(S) = 0。

parent⁡\operatorname{parent} 性质

  • 依然考虑不断去掉一个子串 tt 的首字母:
    如果还未能增加新位置,则还在同一个 rightpos⁡\operatorname{rightpos} 等价类中;
    否则,会跳到其 parent⁡\operatorname{parent} 等价类中。
    因此,对于一个不是 SS 的状态 vv,将其视作 rightpos⁡\operatorname{rightpos} 集合,有 v⊆parent⁡(v)v \subseteq \operatorname{parent}(v)。
  • 根据上条性质,对于一个不是 SS 的状态 vv,它对应的 rightpos⁡\operatorname{rightpos} 等价类中,子串的长度区间为 (len⁡(parent⁡(v)),len⁡(v)](\operatorname{len}(\operatorname{parent}(v)), \operatorname{len}(v)],即 vv 对应的子串数量为 len⁡(v)−(len⁡(parent⁡(v))\operatorname{len}(v) - (\operatorname{len}(\operatorname{parent}(v))。
  • 所有 parent⁡\operatorname{parent} 构成一棵以 SS 为根的树,我们称其为 parent⁡\operatorname{parent} 树。

构造 SAM

一开始 SAM 只包含一个状态 SS,编号为 00。为了方便,不妨令 parent⁡(S)=−1\operatorname{parent}(S) = -1。

考虑依次添加字符,同时维护每个状态的 len⁡\operatorname{len},parent⁡\operatorname{parent} 和转移。

令 ll 表示任意时刻整个字符串对应的状态,每次添加一个字符 cc 的过程如下:

  1. 创建一个新的状态 ww,并将 len⁡(w)\operatorname{len}(w) 设为 len⁡(l)+1\operatorname{len}(l) + 1。
  2. 从 ll 开始遍历 parent⁡\operatorname{parent}:
    • 如果当前状态不存在字符 cc 的转移,则添加一个到 ww 的转移。
    • 如果当前状态存在字符 cc 的转移,则将这个状态标记为 pp,转移到的状态标记为 qq,然后跳出遍历。
  3. 接下来有三种情况:
    1. 如果始终没有状态存在字符 cc 的转移,则将 parent⁡(w)\operatorname{parent}(w) 设为 00,然后退出。
    2. 否则,若 len⁡(p)+1=len⁡(q)\operatorname{len}(p) + 1 = \operatorname{len}(q),则将 parent⁡(w)\operatorname{parent}(w) 设为 qq,然后退出。
    3. 否则,再创建一个新的状态 kk,将 qq 的 parent⁡\operatorname{parent} 和转移复制到 kk 上,将 len⁡(k)\operatorname{len}(k) 设为 len⁡(p)+1\operatorname{len}(p) + 1,将 parent⁡(w)\operatorname{parent}(w) 和 parent⁡(q)\operatorname{parent}(q) 都设为 kk。然后从 pp 往回遍历 parent⁡\operatorname{parent},若存在到 qq 的转移,则将转移重定向到 kk 上。
  4. 更新 ll,即将 ll 设为 ww。

最后,我们再从 ll 遍历一次 parent⁡\operatorname{parent},则 FF 即由遍历到的状态构成。

复杂度

如果转移用 map 存储,则时间复杂度为 O(nlog⁡∣Σ∣)\mathcal O(n \log |\Sigma|),空间复杂度为 O(n)\mathcal O(n)。

如果转移用数组存储,同时借助链表,则时间复杂度为 O(n)\mathcal O(n),空间复杂度为 O(n∣Σ∣)\mathcal O(n |\Sigma|)。如果不借助链表,时间复杂度为 O(n∣Σ∣)\mathcal O(n |\Sigma|),空间复杂度为 O(n∣Σ∣)\mathcal O(n |\Sigma|)。

状态数和转移数上界

对于 ∣s∣≥2|s| \ge 2 的字符串,状态数 ≤2∣s∣−1\le 2 |s| - 1,上界可以在 abb…b\texttt{abb}\dots\texttt{b} 达到。

对于 ∣s∣≥3|s| \ge 3 的字符串,转移数 ≤3∣s∣−4\le 3 |s| - 4,上界可以在 abb…bc\texttt{abb}\dots\texttt{bc} 达到。

性质

  • ss 的子串和 SAM 中从 SS 开始的路径一一对应。
    对于一个非 SS 状态 vv,vv 对应的 rightpos⁡\operatorname{rightpos} 等价类大小 == vv 对应的子串数量 == 从 SS 开始到 vv 的路径数量 == len⁡(v)−(len⁡(parent⁡(v))\operatorname{len}(v) - (\operatorname{len}(\operatorname{parent}(v))。
  • 称 ss 的前缀在 SAM 上对应的节点为终点节点。
    对于一个非 SS 状态 vv,vv 对应的 rightpos⁡\operatorname{rightpos} 集合 == 其 parent⁡\operatorname{parent} 树的子树内的终点节点所对应前缀的位置 == vv 对应的子串在 ss 中出现的位置。
    根据此条性质,我们可以方便地采用线段树合并求出 rightpos⁡\operatorname{rightpos} 集合。
  • 说白了,SAM 中的概念,包括 rightpos⁡\operatorname{rightpos}、parent⁡\operatorname{parent}、终点节点、前缀、后缀、子串、路径,全都能串起来。
    串起来之后,随便组合就有各种各样的性质。
    例如,ss 的任意两个前缀对应的终点节点在 parent⁡\operatorname{parent} 树上的 LCA 所对应的最长子串就是这两个前缀的最长公共后缀。

应用

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

根据:

  • 对于一个非 SS 状态 vv,vv 对应的 rightpos⁡\operatorname{rightpos} 集合 == 其 parent⁡\operatorname{parent} 树的子树内的终点节点所对应前缀的位置 == vv 对应的子串在 ss 中出现的位置。

每个前缀所在的状态有贡献 11。

那么,对每个状态求出其 parent⁡\operatorname{parent} 树上的子树贡献和,然后乘上这个状态的 len⁡\operatorname{len},最后取 max⁡\max 即可。

const int N = 1e6 + 7;
struct SAM {
	int n, l, ch[N<<1][26], len[N<<1], pa[N<<1], t;
	char s[N];
	inline void init() { pa[0] = -1; }
	inline void add(int c) {
		int w = ++t, p = l;
		len[w] = len[l] + 1;
		while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
		if (!~p) return pa[l=w] = 0, void();
		int q = ch[p][c];
		if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
		int k = ++t;
		pa[k] = pa[q], memcpy(ch[k], ch[q], sizeof(ch[k]));
		len[k] = len[p] + 1, pa[w] = pa[q] = k;
		while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
		l = w;
	}
	inline void build() {
		init();
		for (int i = 1; i <= n; i++) add(s[i] - 'a');
	}

	int cnt[N<<1];
	ll ans;
	vi e[N<<1];
	void dfs(int x) {
		for (int y : e[x]) dfs(y), cnt[x] += cnt[y];
		if (cnt[x] > 1) ans = max(ans, 1ll * cnt[x] * len[x]);
	}
	inline void calc() {
		for (int i = 1, p = 0; i <= n; i++) p = ch[p][s[i]-'a'], ++cnt[p];
		for (int i = 1; i <= t; i++) e[pa[i]].pb(i);
		dfs(0), print(ans);
	}
} sam;

int main() {
	rds(sam.s, sam.n);
	sam.build(), sam.calc();
	return 0;
}

【例题】P4070 [SDOI2016]生成魔咒

动态子串数。

每加入一个字符,本质不同的子串数就会加 len⁡(l)−len⁡(parent⁡(k))\operatorname{len}(l) - \operatorname{len}(\operatorname{parent}(k)) 个。

【例题】SP7258 SUBLEX - Lexicographical Substring Search

第 kk 小子串。

由于 SAM 是个 DAG,因此设 cnticnt_i 表示从 ii 开始的路径条数,拓扑排序后再 DAG 上 dp。

这里拓扑排序可以利用基数排序,借助转移一定是从 len⁡\operatorname{len} 小的往大的即可。

找答案时,从根开始贪心地找。

const int N = 9e4 + 7;
struct SAM {
	int n, l, len[N<<1], pa[N<<1], t;
	char s[N];
	map<char, int> ch[N<<1];
	inline void init() { pa[0] = -1; }
	inline void add(char c) {
		int w = ++t, p = l;
		len[w] = len[l] + 1;
		while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
		if (!~p) return pa[l=w] = 0, void();
		int q = ch[p][c];
		if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
		int k = ++t;
		pa[k] = pa[q], ch[k] = ch[q];
		len[k] = len[p] + 1, pa[w] = pa[q] = k;
		while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
		l = w;
	}
	inline void build() {
		init();
		for (int i = 1; i <= n; i++) add(s[i]);
	}

	int b[N<<1], c[N];
	ll cnt[N<<1];
	inline void sort() {
		for (int i = 1; i <= t; i++) ++c[len[i]];
		for (int i = 1; i <= n; i++) c[i] += c[i-1];
		for (int i = 1; i <= t; i++) b[c[len[i]]--] = i;
		for (int i = t; ~i; i--) {
			int x = b[i];
			cnt[x] = 1;
			for (auto o : ch[x]) cnt[x] += cnt[o.se];
		}
	}
	inline void ask(int x) {
		int p = 0;
		while (x)
			for (auto o : ch[p])
				if (x - cnt[o.se] > 0) x -= cnt[o.se];
				else {
					putchar(o.fi), p = o.se, --x;
					break;
				}
		putchar('\n');
	}
} sam;

int main() {
	rds(sam.s, sam.n), sam.build(), sam.sort();
	int q, x;
	rd(q);
	while (q--) rd(x), sam.ask(x);
	return 0;
}

【例题】SP1811 LCS - Longest Common Substring

两个串的最大公共子串。

对第一个串建 SAM,然后依次加入第二个串的字符,求当前第二个串的是第一个串的子串的最长后缀。

用一个指针记录当前解在 SAM 上的位置,加入一个字符时先看能不能匹配,如果能就直接匹配,不能就暴力跳 parent⁡\operatorname{parent},直到可以匹配为止。

每次的解取 max⁡\max 即为最优解。

const int N = 2.5e5 + 7;
struct SAM {
	int n, l, len[N<<1], pa[N<<1], t;
	char s[N];
	map<char, int> ch[N<<1];
	inline void init() { pa[0] = -1; }
	inline void add(char c) {
		int w = ++t, p = l;
		len[w] = len[l] + 1;
		while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
		if (!~p) return pa[l=w] = 0, void();
		int q = ch[p][c];
		if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
		int k = ++t;
		pa[k] = pa[q], ch[k] = ch[q];
		len[k] = len[p] + 1, pa[w] = pa[q] = k;
		while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
		l = w;
	}
	inline void build() {
		init();
		for (int i = 1; i <= n; i++) add(s[i]);
	}

	int op, ol, ans;
	inline void calc(char c) {
		while (op && !ch[op][c]) op = pa[op], ol = len[op];
		if (ch[op][c]) op = ch[op][c], ++ol;
		ans = max(ans, ol);
	}
} sam;

char s[N];
int n;

int main() {
	rds(sam.s, sam.n), sam.build();
	rds(s, n);
	for (int i = 1; i <= n; i++) sam.calc(s[i]);
	print(sam.ans);
	return 0;
}

【例题】SP10570 LONGCS - Longest Common Substring

多个串的最大公共子串。

依然对第一个串建 SAM,类似两个串的情况,将其他串放到 SAM 上匹配。

维护 SAM 每个状态的最长匹配长度,每次的解取 min⁡\min,所有状态取 max⁡\max 即可。

const int N = 1e4 + 7;
char s[N];
int n, ans[N<<1];
struct SAM {
	int n, l, ch[N<<1][26], len[N<<1], pa[N<<1], t;
	char s[N];
	inline void init() {
		for (int i = 0; i <= t; i++)
			memset(ch[i], 0, sizeof(ch[i]));
		t = l = len[0] = 0, pa[0] = -1;
	}
	inline void add(int c) {
		int w = ++t, p = l;
		len[w] = len[l] + 1;
		while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
		if (!~p) return pa[l=w] = 0, void();
		int q = ch[p][c];
		if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
		int k = ++t;
		pa[k] = pa[q], memcpy(ch[k], ch[q], sizeof(ch[k]));
		len[k] = len[p] + 1, pa[w] = pa[q] = k;
		while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
		l = w;
	}
	inline void build() {
		init();
		for (int i = 1; i <= n; i++) add(s[i] - 'a');
	}

	int b[N<<1], c[N];
	inline void sort() {
		for (int i = 1; i <= n; i++) c[i] = 0;
		for (int i = 1; i <= t; i++) ++c[len[i]];
		for (int i = 1; i <= n; i++) c[i] += c[i-1];
		for (int i = 1; i <= t; i++) b[c[len[i]]--] = i;
	}
	int op, ol, ans[N<<1];
	inline void calc(int c) {
		while (op && !ch[op][c]) op = pa[op], ol = len[op];
		if (ch[op][c]) op = ch[op][c], ++ol, ans[op] = max(ans[op], ol);
	}
	inline void update() {
		for (int i = t; i; i--) {
			int x = b[i];
			ans[pa[x]] = max(ans[pa[x]], min(ans[x], len[pa[x]]));
			::ans[x] = min(::ans[x], ans[x]), ans[x] = 0;
		}
	}
} sam;

inline void solve() {
	int m;
	rd(m), rds(sam.s, sam.n), sam.build(), sam.sort();
	for (int i = 1; i <= sam.t; i++) ans[i] = 1e9;
	while (--m) {
		rds(s, n), sam.op = sam.ol = 0;
		for (int i = 1; i <= n; i++) sam.calc(s[i] - 'a');
		sam.update();
	}
	print(*max_element(ans + 1, ans + sam.t + 1));
}

int main() {
	int T;
	rd(T);
	while (T--) solve();
	return 0;
}

参考资料

评论

    填写昵称即可留言。评论经站主审核后公开,请勿提交私人信息。

    正在连接评论服务…