Loading

SAM学习笔记

后缀自动机——处理子串问题的强大工具

如何用一个自动机接受一个字符串的所有子串?

(以下以字符串\(\textbf{ababc}\)为例)

我会\(\operatorname{Trie}\)树!

You are too naive!

\(\Omicron(n^2)\)的时空复杂度,让人难以忍受!如何优化?

注意到\(\operatorname{Trie}\)树中每个状态都代表了一个本质不同子串,而光本质不同子串子串的理论复杂度就高达\(\Omicron(n^2)\)

考虑如何降低状态数,我们使用自动机问题中常见的最小化操作,即节省无用的状态数。

但首先要引出一个概念:

\(\operatorname{endpos}\)集合:

定义子串\(t\)\(s\)中出现的位置为\(\operatorname{endpos}(t)\),比如子串\(\textbf{ab}\)\(\textbf{ababc}\)中的\(\operatorname{endpos}\)集合为\(\{2,4\}\).

对于两个子串\(t_1,t_2\),如果\(\operatorname{endpos}(t_1)=\operatorname{endpos}(t_2)\),那么称\(t_1,t_2\)在同一个\(\operatorname{endpos}\)等价类中。

一些性质

(1).在一个后缀\(\textbf{Trie}\)中,同一个\(\operatorname{endpos}\)等价类中的点,他们能接受的子串相同(也就是转移边相同).

  • 很显然,任何一条转移边都来自于\(\operatorname{endpos}\)集合中的点之后的那个位置,既然你的\(\operatorname{endpos}\)相同,那么可能的转移边必然相同。

(2).对于两个子串\(t_1,t_2(|t_1|<|t_2|)\),满足\(\operatorname{endpos}(t_1)\subseteq\operatorname{endpos}\|\operatorname{endpos}(t_1)\cap\operatorname{endpos}(t_2)=\varnothing\).

  • 因为任意两个\(\operatorname{endpos}\)有交集的串必定一个是另一个的子串,而子串的\(\operatorname{endpos}\)必定包括它的母串的所有\(\operatorname{endpos}\)

(3).对于任意\(\operatorname{endpos}\)等价类,满足包含的子串长度连续。

(4).一个字符串所有子串的\(\operatorname{endpos}\)集合中,本质不同的集合只有\(2len\)个。

  • 对一个\(\operatorname{endpos}\)等价类中长度最长的所有位置,再向前添加一个字符,那么由于性质(2),这个等价类必定会被分割成若干个新的\(\operatorname{endpos}\)等价类,一开始有\(1\)个点,每次划分最多只能产生\(2\)个新节点,由于初始\(\operatorname{endpos}\)集合大小才\(n\),最多划分\(n-1\)次,所以自然只会有\(2n-1\)个状态。

\(\textbf{parent}\)树:

不难想象,\(\operatorname{endpos}\)等价类的分割关系形成了一个树状结构,我们称这个结构为\(\textbf{parent}\)树(其实就是反串的后缀树)。

虽然\(\textbf{sam}\)的本体是接下来要讲到的\(\operatorname{DAG}\),但是这棵\(\textbf{parent}\)树的功能其实更加强大。

\(\textbf{SAM}\)

有了\(\operatorname{endpos}\)之后我们能做什么呢?把\(\textbf{Trie}\)上每个状态的\(\operatorname{endpos}\)画出来:

考虑到\(\textbf{endpos}\)总个数最多才\(2n\)个,又通过性质(1)注意到对于两个\(\textbf{endpos}\)相同的状态所有转移边也相同,于是自然想到把每个\(\textbf{endpos}\)等价类缩成一个点,形成了一个\(\operatorname{DAG}\)

而这个简练而优雅并且画起来格外省力\(\operatorname{DAG}\),正是本文的主角——后缀自动机(Suffix Automation,简称\(\operatorname{SAM}\)),它以\(\Omicron(n)\)的状态数接受了一个字符串的所有子串。

一些定义

  • \(len(p)\):状态\(p\)所能表示的最长的子串
  • \(minlen(p)\):状态\(p\)所能表示的最短的子串
  • \(link(u)\):节点\(u\)\(\textbf{parent}\)树上的父亲
  • \(ch(p,c)\)\(\textbf{sam}\)上从\(p\)出发,字符为\(c\)的转移边的终点

\(\textbf{sam}\)\(\textbf{parent}\)树有以下性质:

(1) \(\textbf{sam}\)上一条从起点出发的路径对应一个子串.

(2) 表示两个子串的公共后缀的节点是两个节点在\(\textbf{parent}\)树上的公共祖先.

  • 这是因为祖先都是后代的后缀。

(3) 对于任意状态\(p\)\(minlen(p)=len(link(p))+1\).

  • 从根到叶子代表了一个后缀的所有子串,并且这些子串都是连续的,不会凭空消失。

(4) \(\textbf{sam}\)\(\textbf{parent}\)树上,如果某个点有字符\(c\)转移边,那么他的所有祖先必然都有字符\(c\)转移边.

  • 通过后文提到的\(\textbf{sam}\)的构造法可以很直观的得到。

(5) 沿\(\textbf{sam}\)的转移边行进,\(\operatorname{endpos}\)大小单调不增。

  • 和跳\(\textbf{parent}\)树的过程类似(一个是在前面加字符,一个是在后面加),沿\(\textbf{sam}\)行进时,到达的状态永远是原状态的母串,\(\operatorname{endpos}\)集合当然不可能变大。

(6) \(\textbf{sam}\)边数为\(\Omicron(n)\)

  • 不会证/kk,咕咕咕~

下图为\(\textbf{sam}\)+\(\textbf{parent}\)树:

增量法构造\(\textbf{sam}\)

强行背板子部分

具体过程:每次插入一个字符\(c\)的时候,新增节点\(o\),从上一次新增的状态\(p\),不断跳\(link\),沿途所有状态全部链向\(o\),直到某个状态\(u\)有转移边\(ch(q,c)\)指向点\(v\),直接将\(link(o)\)链向\(v\),如果不满足性质\(len(v)=len(u)+1\),那就再新建一个节点\(q\),使得\(len(v)=len(u)+1\)\(link(q)=link(v)+1\),然后把\(o\)\(v\)连到\(q\)上然后往上跳,过程中把全部指向\(v\)的转移边全指向\(q\)

结合代码理解:

int fa[maxn<<1],ch[maxn<<1][28],psiz[maxn<<1],len[maxn<<1];
int lstp=1,cnt=1;
inline void extend(int c){
	int p=lstp,np=lstp=++cnt;
	len[np]=len[p]+1,psiz[np]=1;
	for (;p&&!ch[p][c];p=fa[p]) ch[p][c]=np;
	if(!p){
		fa[np]=1;
		return;
	}
	int q=ch[p][c];
	if(len[q]==len[p]+1){
		fa[np]=q;
		return;
	}
	int nq=++cnt;
	memcpy(ch[nq],ch[q],sizeof(ch[q])),len[nq]=len[p]+1;
	fa[nq]=fa[q],fa[q]=fa[np]=nq;
	for (;p&&ch[p][c]==q;p=fa[p]) ch[p][c]=nq;
}
posted @ 2021-03-29 22:54  SmilingKnight  阅读(145)  评论(0)    收藏  举报