后缀自动机(SAM) 学习笔记
很久以前学过SAM,现在又忘了。
学习资料
SAM
如果我们把一个长度为
n
n
n 的串
S
S
S 的所有后缀放入同一个 trie 中,并标记结束位置end
,可以得到一个时间、空间均为
O
(
n
2
)
\mathcal O(n^2)
O(n2) 的(假)后缀树。
它具有以下几个性质:
- 从根到任意
end
是一个后缀,从根到任意节点是一个子串。 - 本质不同的子串个数就是状态(节点)个数。
但是它的时空太大,不满足我们的需求,所以我们采用它的强压缩版本——SAM。
字符串 的 SAM 是一个接受 字符串的所有后缀的最小 DFA (确定性有限自动机或确定性有限状态自动机)。它是一个DAG。
SAM的构造
结束位置 endpos
对于一个 S S S 的子串 t t t,我们用 e n d p o s ( t ) \mathrm{endpos}(t) endpos(t) 表示 t t t 在 S S S 中的所有 结束位置。
如果 两个子串 t 1 , t 2 t_1,t_2 t1,t2 满足 e n d p o s ( t 1 ) = e n d p o s ( t 2 ) \mathrm{endpos}(t_1)=\mathrm{endpos}(t_2) endpos(t1)=endpos(t2),则称它们为 等价类。
除初始状态外,每一个等价类对应SAM上的一个状态(节点)。当然可以把初始状态看成空串。
endpos 有以下性质:
- 如果 t 1 , t 2 ( ∣ t 1 ∣ ≤ ∣ t 2 ∣ ) t_1,t_2(|t_1|\le|t_2|) t1,t2(∣t1∣≤∣t2∣) 的 endpos 相同,那么 t 1 t_1 t1 是 t 2 t_2 t2 的后缀,且不可能以其它形式出现。
- 对于任意两个子串
t
1
,
t
2
(
∣
t
1
∣
≤
∣
t
2
∣
)
t_1,t_2(|t_1|\le |t2|)
t1,t2(∣t1∣≤∣t2∣):
- 若 t 1 t_1 t1 是 t 2 t_2 t2 的后缀,则 e n d p o s ( t 2 ) ⊆ e n d p o s ( t 1 ) \mathrm{endpos}(t_2)\subseteq\mathrm{endpos}(t_1) endpos(t2)⊆endpos(t1)
- 否则 e n d p o s ( t 1 ) ∩ e n d p o s ( t 2 ) = ∅ \mathrm{endpos}(t_1)\cap \mathrm{endpos}(t_2)=\emptyset endpos(t1)∩endpos(t2)=∅
- 在一个等价类中存的是一系列 长度连续 的串,并且它们 互为后缀。形式化地,假设这个等价类中的最长串为 S [ x … y ] S[x\dots y] S[x…y],则其所有串为 S [ i … y ] ( x ≤ i ≤ z ) S[i\dots y](x\le i\le z) S[i…y](x≤i≤z)。最长串长度为 m a x l e n = y − x + 1 \mathrm{maxlen}=y-x+1 maxlen=y−x+1,最短串长度为 m i n l e n = y − z + 1 \mathrm{minlen}=y-z+1 minlen=y−z+1。
后缀 link
设 u u u 为一个非初始状态的状态。根据性质3,它对应一个等价类,这个等价类中的串都形如 S [ i … y ] ( x ≤ i ≤ z ) S[i\dots y](x\le i\le z) S[i…y](x≤i≤z)。这相当于将长串 S [ x … y ] S[x\dots y] S[x…y] 一个个“削去”首字母得到的串。而这些串有什么特性呢?根据性质1,它们之间互相 只以 后缀形式存在。
但是,在削到最短串 S [ z … y ] S[z\dots y] S[z…y] 后,如果我继续削,得到的串的 e n d p o s \mathrm{endpos} endpos 就不同了(因为不属于一个等价类了)。根据性质2,它的 e n d p o s \mathrm{endpos} endpos 应该“扩增了”。这个新得到的串 对应的 e n d p o s \mathrm{endpos} endpos 对应的状态 v v v 叫作 u u u 的“父亲状态” (其实就是parent tree 上的父亲)。我们把 后缀 l i n k ( u ) \mathrm{link}(u) link(u) 定义为 v v v,即 u u u 的“父亲”。
于是我们发现,所有的后缀链接会构成一棵树。这个树叫 parent tree。
parent tree除了按后缀链接(自底向上)构造外,还可以理解为 endpos(自上而下)的分裂。
具体实现
以下不再区分 节点、状态 和 等价类,因为它们是一一对应的。
struct Node {
int ch[26], fa, len;
}t[MAXN << 1];
int lst = 1, tot = 1;
void add_sam(int c) {
int p = lst, np = lst = ++tot;
t[np].len = t[p].len + 1;
for(; p && !t[p].ch[c]; p = t[p].fa) t[p].ch[c] = np;
if(!p) t[np].fa = 1;
else {
int q = t[p].ch[c];
if(t[q].len == t[p].len + 1) t[np].fa = q;
else {
int nq = ++tot; t[nq] = t[q];
t[nq].len = t[p].len + 1;
t[q].fa = t[np].fa = nq;
for(; p && t[p].ch[c] == q; p = t[p].fa) t[p].ch[c] = nq;
}
}
}
随意说两点吧,可能对理解有所帮助。
一、跳后缀link的意义:跳后缀link即为“压缩地”遍历当前串的全部后缀。如果从节点 p 开始跳,不妨设 p 对应的 等价类中最长串为 S [ x … y ] S[x\dots y] S[x…y],那么跳后缀link即为 跳所有 S [ i … y ] ( x ≤ i ≤ y ) S[i\dots y](x\le i\le y) S[i…y](x≤i≤y) 对应的节点。
二、为什么要遍历 旧串(即加入 c c c 之前的串)的所有后缀:加入 c c c 后我们考虑其对所有节点的endpos的影响。会影响的只会是旧串的后缀,它们都可以+c成为新串的一个后缀。
三、for(; p && !t[p].ch[c]; p = t[p].fa) t[p].ch[c] = np;
这句话的意义:在跳后缀link遍历 ((旧串)的所有后缀)时,如果没有转移边,说明这个等价类中的后缀连上一个c都不曾出现在旧串中出现过。那么现在多接了一个c,可以到达一个新的状态 np(因为这些串肯定是旧串的后缀,加一个c就是新串的后缀)。
四、if(t[q].len == t[p].len + 1) t[np].fa = q;
这句话的意义:说明 q 中的最长串是(p中的最长串+c),所以 q中的所有串都是新串的后缀,它们的endpos没有发生变化,都加入了一个
n
n
n。所以 q 仍然保持是一个节点。np 的后缀 link 也是 q,这是因为 q 是“(最长的)(不属于np等价类的)(新串的)后缀”。
五、这一段代码:非常重要
int nq = ++tot; t[nq] = t[q];
t[nq].len = t[p].len + 1;
t[q].fa = t[np].fa = nq;
for(; p && t[p].ch[c] == q; p = t[p].fa) t[p].ch[c] = nq;
由于不满足t[q].len == t[p].len + 1
,说明 q 中分为两类串
t
1
t_1
t1 和
t
2
t_2
t2:
- t 1 t_1 t1 满足 ∣ t 1 ∣ = m a x l e n ( p ) + 1 |t_1|=\mathrm{maxlen}(p)+1 ∣t1∣=maxlen(p)+1,同 四、 所述,它是新串的后缀,也应同 四、中一般处理。我们把它分裂出去形成一个新的等价类 nq。
- t 2 t_2 t2 满足 ∣ t 2 ∣ > m a x l e n ( p ) + 1 |t_2|>\mathrm{maxlen}(p)+1 ∣t2∣>maxlen(p)+1,它 不是 新串的后缀。那么它们的 endpos 就不能加 n n n 。
然后调整后缀link和转移即可。