后缀自动机(SAM)学习笔记&做题记录
后缀自动机瞎扯
后缀自动机(SAM)是一种字符串算法,它可以有效解决有关子串的问题。
例如:
- 本质不同子串数量
- 字典序第 \(k\) 大的子串
前置知识:
- AC 自动机
- 后缀数组(也可以不学)
想想我们处理多个字符串有什么算法?
一个很基本的算法是 trie 树,可以把所有串放到一棵树上,然后可以轻松查询任何一个串了。
如果是匹配呢?运用 KMP 的思想,我们可以在 trie 树上建 fail 树,然后就可以有效解决字符串匹配问题,这就是 AC 自动机。
但它的劣势也很明显:所有放在 trie 数上的串都是以前缀的形式放在树上的,那么如果查询一个串,问是否这个串以子串的形式出现呢?
把所有子串建一个 AC 自动机就好了,但这显然会爆炸。
由于 AC 自动机是把所有前缀放入,那么我们把每个后缀都加入就好了,但这样复杂度 \(O(n^2)\)。
考虑对于一个串 abaab
,我们可以得到这样的一个图:
但会发现这样一棵树的形态对点来说非常浪费,我们需要使用另外的方法。
下面进入正题,SAM 是啥,咋建 SAM。
假设现在有一个字符串 \(S\),有一个子串 \(T\),记录 \(\text{endpos}(T)\) 表示在 \(S\) 中 \(T\) 所有出现位置的右端点的集合,例如 \(S=abaab\)
,那么 \(\text{endpos}(ab)=\{2,5\}\)。
下面来几个重要性质。
- 如果两个子串 \(A,B\) 满足 \(\text{endpos}(A)=\text{endpos}(B)\),如果 \(|A|\le |B|\),那么 \(A\) 一定是 \(B\) 的子串,且 \(A\) 一定是 \(B\) 的后缀。
证明比较简单,考虑对于每个出现位置,有 \(A\) 就一定有 \(B\) ,那么 \(A\) 显然就是 \(B\) 的后缀。
- \(\text{endpos}(A)\) 和 \(\text{endpos}(B)\) 要么有包含关系,要么没有交集,不可能相交。
证明也很简单,如果有交集,那么说明一个是另一个后缀,一定是包含关系了。
接下来就可以愉快的开始建后缀自动机了。
考虑把所有 \(\text{endpos}(A)\) 相同的 \(A\) 放在一起,当做一个节点,一个节点中的串称为一个等价类,然后这样来建关系。
-
一个等价类中最长的串长度为 \(len\),最短的为 \(minlen\),那么长度在 \([minlen,len]\) 中的串一定也在这个等价类中,并且以最长串的后缀形式出现。
-
如果 \(\text{endpos}(B)\in \text{endpos}(A)\),那么 \(B\) 所在等价类就和 \(A\) 所在等价类连边,这样会形成一棵树。
这个也比较简单,因为一个大集合向它的子集连边,而它的子集之间不可能有交,所以最后会形成一棵树,且一个节点的所有儿子节点并起来就等于它自己。
例如对于 abaab
可以建出下面的树:
这样就有效地把所有的子串放在了一棵树上。
- 这棵树的大小规模为 \(O(n)\)。
显然,可以想象是 \(n\) 个点合并,最多合并 \(n-1\) 次。
下面开始讲建后缀自动机方法。
首先,这个算法是在线的,每次插入一个字符,可以在 \(O(1)\) 的时间支持插入。
后缀自动机需要在一个节点上维护以下信息:
- \(fa\),也就是父亲节点,也叫后缀链接。
- \(len\),在这个等价类中最长串长度。
- \(ch[n]\),和 trie 树类似,这里 \(ch[i]\) 表示在该节点后面最后一个后缀为 \(i\) 的节点。
显然,\(minlen\) 就是 \(fa\) 节点的 \(len\) 加一。
然后考虑加入一个点时,相当于把新产生的后缀全部放入自动机里面,此时会有整个串的 \(\text{endpos}(S)=\{n\}\),自动机中没有这样一个集合,所以需要新开一个节点,它的 \(len\) 也可以直接得到。
然后考虑去找它的父亲节点,也就是找一个节点使得它在新串中的 \(\text{endpos}(A)\) 含有 \(n\)。
这个节点一定满足是一个包含原串的后缀的节点后面加上一个本次加入的字符 \(c\)。
那么本质上就是在上一次插入操作后得到的节点一直向上跳,直到找到一个点的 \(ch[c]\) 不为空。
如果没有这样的点,那么父亲就是初始点 \(1\)。
int p=las,now=las=++tot;tr[now].len=tr[p].len+1;//las 就是上一次插入的最后一个节点,tot 是总节点数,先初始化 len。
for(;p&&!tr[p].ch[x];p=tr[p].fa)tr[p].ch[x]=now;//一直向上跳,这些点的 ch[x] 为空,所以直接更新。
if(!p)tr[now].fa=1;
否则考虑又一种情况,记录那个满足条件的节点为 \(q\),如果满足 \(len_q=len_p+1\),那么这个 \(p\) 点恰好满足条件,所以父亲节点就是 \(p\)。
int q=tr[p].ch[x];
if(tr[q].len==tr[p].len+1)tr[now].fa=q;
模拟一下上面的情况,如果字符串是 aaba
,可以得到下面的图(贺的)。
然后考虑不相等的情况。
为什么不能用上面的做?比方说在上面的图中加入一个 b
,那么可以找到的 \(p=2,q=4\),此时 \(len_q=3,len_p=1\),此时可以发现由于在后面不仅仅加了一个 b
,所以前缀会多出一个 a
,所以产生的 \(4\) 中的 \(\text{endpos}\) 中不含有 \(n\)。
那么就新开一个点用来表示父亲节点,那么实际上就表示前面都没有的一个后缀,在上图中就表示 ab
,然后此时它的 \(len\) 就是 \(len_q+1\),因为是在 \(q\) 后面直接接一个 \(c\),这样用来满足第一个条件,需要注意的一点是 \(q\) 的父亲也变成了这个新建的点,因为此时原来无法匹配的 \(q\) 可以去找 \(p\) 进行匹配。
然后把所有原来 \(ch[c]\) 指向 \(q\) 的点指向新建的点即可。
最后得到完整代码:
void add(int x){
int p=las,now=las=++tot;tr[now].len=tr[p].len+1;siz[tot]=1;
for(;p&&!tr[p].ch[x];p=tr[p].fa)tr[p].ch[x]=now;
if(!p)tr[now].fa=1;
else {
int q=tr[p].ch[x];
if(tr[q].len==tr[p].len+1)tr[now].fa=q;
else{
int t=++tot;tr[t]=tr[q];
tr[t].len=tr[p].len+1;
tr[now].fa=tr[q].fa=t;
for(;p&&tr[p].ch[x]==q;p=tr[p].fa)tr[p].ch[x]=t;
}
}
}
此时 aabab
的图为
至此,后缀自动机建立完成,现在来一些简单的应用。
给定一个只包含小写字母的字符串 \(S\)。
请你求出 \(S\) 的所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。
\(|S|\le 10^6\)。
直接建立后缀自动机,然后可以发现一个等价类的答案就是集合大小乘上 \(len\),集合大小类似于求子树大小,直接加就好了。