Loading

后缀自动机学习笔记

后缀自动机学习笔记

推荐大佬的 \(blog\)

什么是后缀自动机

后缀自动机是在一个 \(DAG\) 图中, 表示出了后缀自动机的所有后缀
显然字典树可以解决,但是字典数的节点数是 \(n^2\) 的。
而后缀自动机可以让节点数量达到线性。
后缀自动机的一个节点代表一个集合,而这个会在后面的集合中提到。

后缀自动机的前置知识

对于一个字串,他在原串中出现了若干次。出现的右端点编号的集合称为 \(endpos\) 集合。\(e.g.\) 原串为 \(abcab\) 时, \(endpos(ab) = {2,5}\)

现在给出几个结论 (不难证明)

1. 如果两个不同子串的 \(endpos\) 集合中有任意一个元素相同,则其中子串一个必然为另一个的后缀

2. 对于任意两个子串 \(a\)\(b\), \(len_a \le len_b\) 要么 \(endpos(a) \in endpos(b)\) ,要么 \(endpos(a) \cap endpos(b) = \emptyset\)

3. 对于 \(endpos\) 相同的子串,我们将它们归为一个 \(endpos\) 等价类。对于任意一个 \(endpos\) 等价类,将包含在其中的所有子串依长度从大到小排序,则每一个子串的长度均为上一个子串的长度减 \(1\) ,且为上一个子串的后缀 (简单来说,一个 \(endpos\) 等价类内的串的长度连续)

4. \(endpos\) 等价类个数级别为 \(O(n)\)

这里要提到一个重要的东西叫做 \(parent\)
一个 \(endpos\)集合, 如果要把他分割成几个集合, 每一个集合对应一个 \(endpos\) 等价类 (因为 \(endpos\) 等价类如果不是包含关系, 就不能有相同元素。所以只能把一个 \(endpos\) 集合分割成多个 \(endpos\) 集合,然后在其中继续分割。)
于是这就是一个树形结构, 称其为 \(parent\)

以上图片是一个字符串为 \(aababa\) 的例子

在一个 \(endpos\) 等价类 \(a\) 中, 有最长的串, 同时也有最短的串。设该 \(endpos\) 等价类在\(parent\) 树上的节点的父亲为 \(fa(a)\), 设最长的字串长度是 \(len(a)\), 最短的是 \(minlen(a)\), 那么 \(minlen(fa(a)) + 1= len(a)\)

附上一张在 \(endpos\) 等价类中的最长字串的图片

这时, 我们的后缀自动机的节点就是这 \(parent\) 树上的节点!
但是, 后缀自动机的边不同于 \(parent\) 树上的边。

建立一个后缀自动机

先放代码

void ins(int x) {
	int p = las, now = las = ++tot;
	cnt[now] = 1, f[now].len = f[p].len + 1;
	for(; p && !f[p].ch[x]; p = f[p].fa) f[p].ch[x] = now;
	if(!p) f[now].fa = 1;
	else {
		int pto = f[p].ch[x];
		if(f[pto].len == f[p].len + 1) f[now].fa = pto;
		else {
			int sp = ++tot;
			f[sp] = f[pto], f[sp].len = f[p].len + 1;
			f[now].fa = f[pto].fa = sp;
			for(; p && f[p].ch[x] == pto; p = f[p].fa) f[p].ch[x] = sp;
		}
	}
}

如果在原来的后缀自动机中增加一个字符 \(x\), 那么原串的后缀要增加一个字符 \(x\)
他出现的次数为 \(1\), 而且他的最长字串是原来的后缀长度 \(+1\)
于是有代码

int p = las, now = las = ++tot; // p 是原来的最长后缀,now是现在的最长后缀,las是记录上次最长后缀的
cnt[now] = 1, f[now].len = f[p].len + 1; // len 同上面的描述,是最长字串长度, f是表示parent树的一个endpos等价类的集合, 一个后缀自动机的节点

然后包含之原来的 \(las\) 的节点都被其 \(parent\) 树上的父亲节点所包含, 因此如果在他们的后面再新增一个字符 \(x\), 都会跳到该节点。但是如果他的父亲已经有转移了, 那么就不用再增加这条边了。而且如果遇到一个节点已经有字符 \(x\) 的转移边了, 他的父亲一定也有字符 \(x\) 的转移边, 所以就不用继续往上跳了。
代码:

for(; p && !f[p].ch[x]; p = f[p].fa) f[p].ch[x] = now;

考虑如何在\(parent\) 树上处理 \(now\) 的父亲指针。
如果一直往上跳的过程中, 所有节点都没有向字符 \(x\) 的转移边, 就说明字符 \(x\) 是第一次出现(因为根结点也没有字符 \(x\) 的转移边), 所以除了根结点没有集合能够包含集合编号为 \(now\) 的节点了, 于是其父亲指针指向 \(1\)

if(!p) f[now].fa = 1;

否则 \(p\) 就在有转移边 \(x\) 的祖先节点上停下来了。
这时 \(p\) 是原来 \(las\) 的后缀, 都加上一个字符 \(x\) 之后, \(pto\) (假设点 \(p\) 加上一个 \(c\) 得到了 \(pto\)) 也是 \(now\)的后缀。 都是如果该节点 \(p\) 增加一个 \(x\) 得到的最长后缀恰好是原来后缀的最长后缀的长度\(+1\), 那么就说明了再这个节点代表的所有字串中增加一个 \(x\) 后到达 \(p\)\(endpos\) 一致, 而 \(p\) 又是跳父亲时第一个遇见的节点, 所以 \(pto\) 一定是 \(now\) 的最长后缀, \(now\)\(parent\) 树上的父亲就是 \(pto\)

int pto = f[p].ch[x];
if(f[pto].len == f[p].len + 1) f[now].fa = pto;

那么 \(len(p) > len(pto) + 1\) 怎么办?
\(len(p) > len(pto) + 1\) 代表了这个串不是 \(now\) 的子串。 因为如果是, 那么最后一个字符就是 \(x\), 然而去掉这个字符就变成了 \(p\) 的字串, 所以 \(len(p) = len(pto) + 1\), 矛盾, 所以这个串必定不是 \(now\) 的子串。所以我们还需要另外一个节点 \(sp\), 从 \(pto\) 复制下他的信息。其 \(len\) 值恰好等于 \(len(pto) + 1\), 代表在原来的 \(p\) 的最大长度代表的串后面增加一个 \(x\)。 这时可以和上面做类似的操作, 让 \(now\) 的父亲变成 \(sp\), 这时可以发现, \(p\)\(pto\) 的后缀, \(p\)\(sp\) 的后缀, 而 \(pto\)\(len > sp\)\(len\) 值, 所以可以让 \(pto\)\(fa\) 值也指向 \(sp\)。由于这时 \(sp\) 的长度更短, 所以需要让原先 \(parent\) 树上儿子节点指向 \(pto\) 的改指向 \(sp\)
Code:

int sp = ++tot;
f[sp] = f[pto], f[sp].len = f[p].len + 1;
f[now].fa = f[pto].fa = sp;
for(; p && f[p].ch[x] == pto; p = f[p].fa) f[p].ch[x] = sp;

upd

后缀自动机性质:

  1. \(DAG\) : ch[x][y] 可以表达 \(x\) 这个状态能不能继续往下走
  2. \(parent\) 树的 fa[x] 一定是 x 的后缀。

常见套路有用 \(SAM\) 建后缀树 (就是 \(parent \ tree\) 啊!),用线段树合并维护 \(endpos\) 等。

posted @ 2020-08-11 21:02  zhoukangyang  阅读(518)  评论(0编辑  收藏  举报