后缀自动机学习笔记
一些定义
一个节点的状态由以下参数确定:
-
结束位置集合,记为 endpos()。endpos(M) 表示一个字符串 S 的子串 M 在 S 中出现的结束位置集合。每个子串都有一个 endpos 集合,可能有一些子串的 endpos 集合相同,就合并,构成后缀自动机节点。也即后缀自动机的一个节点代表一个 endpos 集合的等价类,里边包含一些长度可能不同但结束位置集合相同的串。这些串是连续的,且互为包含关系。一个点的 endpos 集合大小即为当前点对应所有串在 S 中出现的次数。
-
转移边。每个点有一些转移边,含义为在当前节点所能表示的所有串后面添加一个字符,会转移到哪个等价类。这样的转移关系构成一个拓扑图。
-
后缀链接,记为 link。含义为删除当前串的最小前缀,且能使得 endpos 集合发生改变的转移边。这样的转移关系构成一棵树。
-
每个点有一个该点表示的所有串中长度最大的串的长度,记为 len。那么该点表示的所有串的长度介于 \((link.len,len]\)。
板子
void ins(int c){
int tmp=la,X=la=++tot;
endpos[X]=1,t[X].len=t[tmp].len+1;
for(;tmp&&!t[tmp].w[c];tmp=t[tmp].par) t[tmp].w[c]=X;
if(!tmp) t[X].par=1;
else{
int to=t[tmp].w[c];
if(t[to].len==t[tmp].len+1) t[X].par=to;
else{
int Nto=++tot;
t[Nto]=t[to],t[Nto].len=t[tmp].len+1;
t[to].par=t[X].par=Nto;
for(;tmp&&t[tmp].w[c]==to;tmp=t[tmp].par) t[tmp].w[c]=Nto;
}
}
}
不同子串个数
给你一个长为 \(N\) 的字符串,求本质不同的子串的个数。
SOL: 拓扑图上 dp,\(S_{u}=\sum S_v+1\) ;或者直接是 \(\sum_{}\)
【模板】后缀自动机
求出 \(S\) 的所有出现次数不为 \(1\) 的子串的出现次数乘上该子串长度的最大值。
SOL: 出现次数为 endpos 集合大小,可以先在树上子树求和。长度即为该点的 len。
[SDOI2016]生成魔咒
共进行 \(n\) 次操作,每次操作是在 \(S\) 的结尾加入一个数。每次操作后都需要求出,当前的串 \(S\) 共有多少种本质不同的串。
SOL: 字符集是 1e9,考虑用 map。根据增量构造,S 的后缀自动机会有两种加点方式,第一种是新建的 X,另一种是 Nto。而容易知道 Nto 是没有贡献的,因为它只是将原有节点拆开了。贡献只来自 X,为 \(len-par.len\)。
LCS - Longest Common Substring
求两个串 S 和 T 的最长公共子串。
SOL: 对 S 建后缀自动机,用 T 在自动机上跑匹配。如果有转移边,就转移,并使匹配长度加一,否则跳后缀链接(类似 AC 自动机的性质,后缀链接一定指向的是一个已经匹配了的后缀,而且是长度最长且 endpos 不同的串),直到有转移边为止,然后领匹配长度为当前节点的最长串长度。最后对过程中所有匹配的长度取最大值。
int now=1,l=0,ans=0;
for(int i=0;i<n;i++){
int c=s[i]-'a';
if(!t[now].w[c]){
while(now!=1&&!t[now].w[c]) now=t[now].par;
l=t[now].len;
}
if(t[now].w[c]) now=t[now].w[c],l++;
ans=max(ans,l);
}
LCS2 - Longest Common Substring II
求多个串的最长公共子串。
SOL: 对除第一个串的每个串重复建后缀自动机。然后用第一个串在上面跑匹配,定义 \(p_i\) 表示第一个串以第 \(i\) 位结尾的后缀能匹配到的最大长度。匹配的时候对所有 \(p_i\) 取最小值来更新,最后答案是所有 \(p_i\) 的最大值。
清空:
void Clear(){
for(int i=1;i<=tot;i++){
t[i].par=t[i].len=0;
for(int j=0;j<26;j++) t[i].w[j]=0;
}
tot=la=1;
}
[TJOI2015]弦论
求字典序第 \(k\) 小的子串,分不同位置算一个和不同位置算多个。
SOL: 先统计出从当前节点能都到多少字符串。然后从小到大枚举转移边,如果 \(k\) 大于能走到的串个数 \(num\),就减去 \(num\),然后枚举下一个转移边。直到 \(k\) 不够,然后输出当前转移,继续递归。同时在进入函数时减去 endpos 集合大小,表示判断答案是不是就是当前串,或者后面还要接字符。如果只算本质不同的串就把 endpos 设成 1。
void print(int u,long long k){
if(k<=right[u]) return;
k-=right[u];
for(int i=0;i<26;i++)
if(nd[u].c[i]){
int v=nd[u].c[i];
if(k>sum[v]){
k-=sum[v];
continue;
}
printf("%c",i+'a');
print(v,k);
return;
}
}
[BJOI2020] 封印
给出只包含小写字母 \(a,b\) 的两个字符串 \(s,t,q\) 次询问,每次询问 \(s[l \dots r]\) 和 \(t\) 的最长公共子串长度。
SOL: 求出上述的 \(p_i\) 后(对于这一类问题似乎都可以考虑这样做?),实际上就是要算 \(\max\limits_{l\leq i \leq r}\{\min\{p_i,i-l+1\}\}\)。然后有一个很神奇的二分做法。二分能不能取到 \(mid\),显然必然只考虑 \(i\geq l+mid-1\) 的,因为如果 \(i\) 小了,那么一定取不到。也就是在 \([l+mid-1,r]\) 查询有没有大于 \(mid\) 的 \(p_i\)。可以用 ST 表。那么就 \(O(n\log n)\) 了。
Cyclical Quest
给定一个主串 \(S\) 和 \(n\) 个询问串,求每个询问串的所有循环同构在主串中出现的次数总和。
SOL: 如果不考虑循环同构,那么直接在 \(S\) 的后缀自动机上匹配。循环同构实际上是先在前面删去一个字符,然后在当前串后接一个字符。删字符其实就对应了跳后缀链接。要注意如果上一个同构都没有匹配那么根本不需要删。加字符直接走转移边即可。然后注意相同的节点是不能重复贡献的,考虑走的时候打上 tag。
[AHOI2013]差异
给定一个长度为 \(n\) 的字符串 \(S\),令 \(Ti\) 表示它从第 \(i\) 个字符开始的后缀。求
SOL: 前面两项很简单,关键是求公共前缀长度。我们知道 SAM 可以求出两个串最长后缀的长度,考虑把串反过来,原串的前缀就是反串的后缀,所以考虑构建反串的 SAM。利用后缀链接性质可知,两个串的公共后缀长度就是两个对应节点的 LCA 的长度。那么只需要统计有多少对 \((i,j)\) 的 LCA 是当前节点,树形 dp 即可。
[APIO2014]回文串
给定串 S。一个串得存在值为其在 S 中的出现次数乘上其长度。求所有回文串的存在值的最大值。
SOL: 不考虑回文串的话就是板子。首先需要知道那些是回文串,想到跑 manacher。那么就有一个暴力的做法,每次扩展到一个新的回文串就在 SAM 上查询,由于本质不同的回文串共有 \(O(n)\) 个,那么复杂度就是 \(O(n^2)\)。这个算法慢在我们每次需要暴力重新匹配。再次观察,发现是自身和自身的匹配,想到优化。记串 \(S(l,r)\) 为回文串,S 的前缀串 \(T_r\) 对应的节点为 \(p\)。那么 \(S(l,r)\) 对应的节点一定在 \(p\) 的根缀上。我们要求的是长度最小的包含该回文串的节点,而 SAM 上一条根缀上的节点的 len 是有单调性的,所以想到倍增。复杂度 \(O(n \log n)\)。
CF427D Match & Catch
给定两个字符串 S 和 T,求最短的满足各只出现一次的连续公共字串
SOL: 对 S 建 SAM,用 T 在 SAM 上跑匹配,求出 \(p_i\) 表示以 \(i\) 结尾的串的最长匹配长度。那么对应节点的根缀都是能匹配的串,考虑打上差分标记。最后 dfs 子树求和,记为 s,那么必须 s 和 \(|endpos|\) 都为一才能算进答案,长度更新为当前节点能表示的最短的长度。
[HAOI2016]找相同字符
给定两个字符串,求出在两个字符串中各取出一个子串使得这两个子串相同的方案数。两个方案不同当且仅当这两个子串中有一个位置不同。
SOL: 和上一题差不多,也是树上求和,但是有一个特殊情况要考虑。假设当前匹配到节点 \(p\),然而可能并不完全匹配,只是匹配了该节点所表示的部分节点。所以算答案的时候要单独提出来算。
Match:
ans+=1ll*(l-t[t[p].par].len)*R[p];
S[t[p].par]++;
dfs: if(u!=1) ans+=1ll*(t[u].len-t[t[u].par].len)*S[u]*R[u];
CF1037H Security
给定串 \(S\),每次给出串 \(T\),询问串子串 \(S(l,r)\) 中字典序最小的且严格大于串 \(T\) 的子串。输出串,没有就输出 -1。
SOL: 先考虑对全局串询问。有一个贪心的做法,每次在后面补最小的能补的字符一定是最优的。那么最先匹配到的一定是一个前缀,如果可以完全匹配那么再在后面补一个字符就是最小的。但是可能并不能完全匹配,假设当前匹配到第 \(p\) 个字符,但是没有 \(S[p+1]\) 的转移边,那么选择一个大于 \(S[p+1]\) 的转移就得到了答案。如果不存在大于等于 \(S[p+1]\) 的转移,那么只能回溯。复杂度 \(O(26\times\sum|T|)\)。
现在考虑加入区间限制,也就是说在 SAM 上有些转移边不能走了,因为对应节点的 endpos 里不存在元素属于这个区间。那其实我们只需要知道 endpos 与该区间的交是不是空的就行了。考虑线段树合并,维护每个点的 endpos 集合,直接查询即可。注意合并时,儿子节点不能销毁,而要新建一个节点。因为是在树上维护有哪些节点,所以复杂度有保证。还要注意,endpos 只是结束位置,我们只保证了结束为止存在于区间内,而没有保证左端点也在区间内。解决这个问题,只需要查询最大的属于该区间的结束点,然后判断其左端点是不是在区间内。修改一下 query 即可。
// 'l' start from 0
bool Dfs(int l,int u){
int c=s[l]-'a';
if(l==m){
for(int i=0;i<26;i++)
if(t[u].w[i]&&query(rt[t[u].w[i]])-l>=L)
return sta[++top]=i,1;
}else if(t[u].w[c]&&query(rt[t[u].w[c]])-l>=L&&Dfs(l+1,t[u].w[c]))
return sta[++top]=c,1;
else{
for(int i=c+1;i<26;i++)
if(t[u].w[i]&&query(rt[t[u].w[i]])-l>=L)
return sta[++top]=i,1;
}
return 0;
}
[HEOI2016/TJOI2016]字符串
\(Q\) 次询问。每次询问 \(S(l,r)\) 的所有子串和串 \(S(l',r')\) 的最长公共前缀。
SOL: 显然答案有单调性,可以二分答案,然后转换为 \(S(l',mid)\) 在不在 \(S(l,r)\) 出现过。可以先从 \(S\) 的前缀串 \(T_mid\) 对应的节点向上跳 parent,跳到 \(S(l',r')\) 对应节点,然后判断该点的 endpos 集合是否存在元素出现在区间 \([l+|S(l',mid)|-1,r]\) 以判定串是否严格属于该区间。然后 endpos 集合可以沿用上题做法用线段树合并求出。
[NOI2018] 你的名字
给定串 \(S\) 。\(Q\) 次询问,每次给出串 \(T\) 和整数 \(l,r\),问串 \(T\) 的所有本质不同的子串中有多少个串不是 \(S(l,r)\) 的子串。
SOL: 容易想到对 \(S\) 建 SAM,并用线段树合并预处理出每个点的 endpos 集合。然后用 \(T\) 在 SAM 上匹配,求出后缀匹配长度。注意线段树判断一下转移边能不能走。如果失配了,不能直接跳 parent 边,因为我们只是提取了一个区间的 SAM,并不满足一个节点所表示的串的 endpos 都一样,所以每次只能将匹配长度减一。这样的复杂度显然是正确的,因为匹配至多使长度加一,所以均摊下来能接受。最后对 \(T\) 建 SAM,本质不同的串的个数就是每个节点的 \(len-par.len\) 求和。再减去每个节点对应后缀串的最大匹配长度就是不能匹配的长度,即答案。