前言
后缀自动机(SAM)是一种优秀的数据结构,它作为一种自动机,不仅能接受一个字符串所有的后缀,还可以接受所有子串。然而这里并不打算写它的原理,这里仅提供题表和题解。
有关教程,固然,网上有很多详解,但都没有CLJ的正规,尽管CLJ的论文有些难懂,因此在学完网上的内容后,千万不要忘记回去看CLJ的论文。
进阶:Links
约定
- 状态:SAM上的点。
- 转移:SAM上的边。
- :状态 的 表示从初始状态转移到状态 的最多步数。
- :同 ,只是变成了最少步数。
-
集合:节点
的
集合表示初始状态转移到节点
所表示的字符串在原串中出现的右端点集合。
如果你说初始状态转移到节点 可能有多条路径,那说明你还没有弄懂SAM,至少“相同 集合的状态被合并到一个状态”这一点你是不知道的。 -
指针:某个状态代表的是若干个
集合相同的串,那么随着后缀长度的减小,从某一个后缀开始,就可能出现在了更多的位置
而且这个后缀以及比它更短的后缀的 集合一定会变大,因此就不得不分离到另一个节点上,成为那个节点的 ,而当前状态的 指针就会指向那个状态。 - 树:把 指针翻转,得到的树就是 树。
题表
题号(网站) | 名称(题解) | 题意 | 提示 |
---|---|---|---|
Spoj1811 | Longest Common Substring | 最长公共子串 | 匹配 |
Spoj1812/bzoj2946 | Longest Common Substring II | 多个串最长公共子串 | 取最小匹配 |
Spoj705/Spoj694 | Distinct Substrings | 子串个数 | DAG上的DP |
Luogu P3804 | 后缀自动机 | 统计字符串 | fail树上DP |
Spoj 8222 | Substrings | 统计字符串 | right集合大小 |
Luogu P1368 | 工艺 | 最小表示法 | 可以不用SAM或SA |
Spoj7258 | Lexicographical Substring Search | 第k小子串 | SAM分治 |
模板
初始化
last=1,tot=1,ans=0;
memset(ch,0,sizeof ch);
memset(len,0,sizeof len);
memset(fails,0,sizeof fails);
构建函数
void ins(int n){
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x];p=fail[p])
ch[p][x]=np;
if(p==0)
fail[np]=1;
else{
int q=ch[p][x];
if(len[q]==len[p]+1)
fail[np]=q;
else{
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof ch[q]);
fail[nq]=fail[q];
fail[q]=fail[np]=nq;
for(;ch[p][x]==q;p=fail[p])
ch[p][x]=nq;
}
}
}
SAM拓扑序
int in[maxn],t[maxn];
void tsort(){
int st=1,ed=1;
for(int i=1;i<=tot;i++)
for(int k=0;k<26;k++)
++in[ch[i][k]];
for(int i=1;i<=tot;i++)
if(!in[i])
t[ed++]=i;
while(st!=ed){
int &x=t[st++];
for(int k=0;k<26;k++)
if(ch[x][k]&&!--in[ch[x][k]])
t[ed++]=ch[x][k];
}
}
Fail树拓扑序
int ft[maxn],rs[maxn];
void build(){
const int n=strlen(s);
for(int i=1;i<=tot;i++)
rs[len[i]]++;
for(int i=n;i>=0;i--)
rs[i]+=rs[i+1];
for(int i=1;i<=tot;i++)
ft[rs[len[i]]--]=i;
}
题解
Spoj1811 Longest Common Substring
题意:求两个字符串的最长公共子串的长度。
题解:
对第一个串建立后缀自动机,然后让第二个在上面跑即可。
代码如下:
#include<bits/stdc++.h>
using namespace std;
#define maxn 1000000 //2n-1
int root=1,tot=1;
char s[maxn];
int fails[maxn],ch[maxn][30];
int last=1,len[maxn];
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
int runs(const char *s){
int cur=1,lens=0,ret=0;
for(int i=0;s[i];i++){
int x=s[i]-'a';
if(ch[cur][x]){
++lens;
cur=ch[cur][x];
}else{
while(cur&&!ch[cur][x])
cur=fails[cur];
if(!cur){
cur=1,lens=0;
}else{
lens=len[cur]+1;
cur=ch[cur][x];
}
}
ret=max(ret,lens);
}
return ret;
}
int main(void)
{
scanf("%s",s);
for(int i=0;s[i];i++)
ins(s[i]-'a');
scanf("%s",s);
printf("%d\n",runs(s));
return 0;
}
Spoj1812/bzoj2946 Longest Common Substring II
题意:求多个字符串最长公共子串的长度。
题解:
对第一个字符串建立SAM,然后把每个串在上面跑,其中,答案记录在SAM的节点上,取所有匹配的最短匹配(公共),答案则为所有节点的最大匹配(最长)。
其实这样有个小小的问题,当到达了状态’aba’时,我们可能没有更新状态’ba’和状态’a’的答案。因此在每次加入一个串之后需要重新更新其fail树上的所有祖先的答案。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn=500010;
int len[maxn],ch[maxn][26],fails[maxn];
int tot=1,last=1,maxs[maxn],ans[maxn];
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
int sum[maxn],tmp[maxn];
void Tsort(int n){//基数排序计算拓扑序
memset(sum,0,sizeof sum);
for(int i=1;i<=tot;i++)
sum[len[i]]++;
for(int i=1;i<=n;i++)
sum[i]+=sum[i-1];
for(int i=1;i<=tot;i++)
tmp[sum[len[i]]--]=i;
}
void work(const char *s){//匹配
memset(maxs,0,sizeof(maxs));
int lens=0,p=1;
for(int i=0;s[i];i++){
int x=s[i]-'a';
if(ch[p][x])//匹配成功
lens++,p=ch[p][x];//往下走
else{
for(;p&&!ch[p][x];p=fails[p]);//失配跳转
if(!p)
p=1,lens=0;
else
lens=len[p]+1,p=ch[p][x];
}
maxs[p]=max(maxs[p],lens);
}
for(int i=tot;i;i--){//更新fail树
int x=tmp[i];
ans[x]=min(ans[x],maxs[x]);
if(maxs[x]&&fails[x])
maxs[fails[x]]=len[fails[x]];
}
}
char s[maxn];
int main(){
scanf("%s",s);
for(int i=0;s[i];i++)
ins(s[i]-'a');
memset(ans,63,sizeof ans);
Tsort(strlen(s));
while(~scanf("%s",s))
work(s);
int res=0;
for(int i=1;i<=tot;i++)
res=max(res,ans[i]);
printf("%d\n",res);
return 0;
}
Spoj694/Spoj705 Distinct Substrings
题意:求一个字符串的不同的子串个数。
题解:
对该字符串建立SAM。DP当然可以,但这里有一种更简单的方法。
既然每个状态表示的是若干个
相等的字符串,那么不难得出一点,对于一堆
集合相同的子串,它们一定互为后缀,并且他们长度连续。因此只需要考虑每个节点对答案的贡献即可,即不同的子串的个数为:
注意两道题目其中一道是大写字母,另一道是小写字母。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn=500010;
int len[maxn],ch[maxn][26],fails[maxn];
int tot,last,ans;
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
char s[maxn];
int main(){
int n;
scanf("%d",&n);
while(n--&&~scanf("%s",s)){
last=1,tot=1,ans=0;
memset(ch,0,sizeof ch);
memset(len,0,sizeof len);
memset(fails,0,sizeof fails);
for(int i=0;s[i];i++)
ins(s[i]-'A');//705要变成 ins(s[i]-'a')
for(int i=1;i<=tot;i++)
ans+=len[i]-len[fails[i]];//直接统计答案
printf("%d\n",ans);
}
return 0;
}
Luogu P3804 后缀自动机
题意:求出字符串
的所有在
中出现次数不为
的子串的出现次数乘上该子串长度的最大值。
题解:
构建出
后,可以发现每个字符串的出现次数就是对应状态的
集合大小。可以发现,在
树上,某个状态所表示的字符串一定是其儿子所表示的字符串的后缀,因此某个状态所表示的
一定是其所有儿子的
集合的并集。
又因为子串可以表示成某个后缀的前缀,因此只需要把原字符串的所有前缀的次数标记出来,然后在
树上合并即可,即:
代码如下:
#include<bits/stdc++.h>
using namespace std;
#define maxn 2000010
char s[maxn];
int last=1,tot=1;
int len[maxn],ch[maxn][30],fails[maxn];
int size[maxn];
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
struct edge{
int v,next;
}edges[maxn];
int head[maxn];
void ins(int u,int v){
static int len=0;
edges[++len]=(edge){v,head[u]};
head[u]=len;
}
long long ans=0;
void dfs(int x){
for(int i=head[x];i;i=edges[i].next)
dfs(edges[i].v);
size[fails[x]]+=size[x];
if(size[x]>1)
ans=max(ans,(long long)size[x]*len[x]);
}
int main(void){
scanf("%s",s);
for(int i=0;s[i];i++)
ins(s[i]-'a');
for(int i=1;i<=tot;i++)
ins(fails[i],i);
for(int i=1,p=1;s[i];i++)
p=ch[p][s[i]-'a'],size[p]=1;
dfs(1);
printf("%lld\n",ans);
return 0;
}
这份代码只是幸运地在Linux系统下通过了而已,因为仔细想想可能会爆栈。
于是我们可能需要一遍拓扑排序。
其实不需要拓扑排序。我们知道节点
的
集合一定是
的父亲
的
集合的子集,因此
必然大于
。因此一个状态的
越长,它一定是更底层的状态。因此只需要对每个节点按照
排序即可,为了不提高时间复杂度,这里采用了基数排序(可参考后缀数组的倍增算法),和构建SAM的时间复杂度一致。
代码如下:
#include<bits/stdc++.h>
using namespace std;
#define maxn 2000010
char s[maxn];
int last=1,tot=1;
int len[maxn],ch[maxn][30],fails[maxn];
int size[maxn];
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
int t[maxn],rs[maxn];
void build(){
const int n=strlen(s);
for(int i=0;i<n;i++)
ins(s[i]-'a');
for(int i=1;i<=tot;i++)
rs[len[i]]++;
for(int i=n;i>=0;i--)
rs[i]+=rs[i+1];
for(int i=1;i<=tot;i++)
t[rs[len[i]]--]=i;
}
long long ans=0;
void solve(){
for(int i=1,p=1;s[i];i++)
p=ch[p][s[i]-'a'],size[p]=1;
for(int i=1;i<=tot;i++){
int now=t[i];
size[fails[now]]+=size[now];
if(size[now]>1)
ans=max(ans,(long long)size[now]*len[now]);
}
}
int main(void){
scanf("%s",s);
build();
solve();
printf("%lld\n",ans);
return 0;
}
Spoj 8222 Substrings
题意:定义
为字符串
的所有长度为
的子串的出现次数的最大值。求
值。
题解:
有了上一题的基础,这一题应该不难解决。先对
建立SAM,对于节点
,可以发现,其对
均有贡献,如果这样维护那时间复杂度就是
的了。
其实可以发现,长度为
的子串一定是长度为
的子串的子串。
也就是说,我们可以只考虑
,然后用
来更新更短的子串。
代码如下:
#include<bits/stdc++.h>
using namespace std;
#define maxn 2000010
char s[maxn];
int last=1,tot=1;
int len[maxn],ch[maxn][30],fails[maxn];
int size[maxn];
int t[maxn],rs[maxn];
void ins(int x) {
int p=last,np=++tot;
last=np,len[np]=len[p]+1;
for(; p&&!ch[p][x]; p=fails[p])
ch[p][x]=np;
if(p==0)
fails[np]=1;
else {
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else {
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[q]));
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q; p=fails[p])
ch[p][x]=nq;
}
}
}
void build(){
const int n=strlen(s);
for(int i=0;i<n;i++)
ins(s[i]-'a');
for(int i=1;i<=tot;i++)
rs[len[i]]++;
for(int i=n;i>=0;i--)
rs[i]+=rs[i+1];
for(int i=1;i<=tot;i++)
t[rs[len[i]]--]=i;
}
long long f[maxn];
int main(void){
scanf("%s",s);
build();
const int n=strlen(s);
for(int i=0,p=1;s[i];i++)
p=ch[p][s[i]-'a'],size[p]=1;
for(int i=1;i<=tot;i++)
size[fails[t[i]]]+=size[t[i]];
for(int i=1;i<=tot;i++)
f[len[i]]=max(f[len[i]],(long long)size[i]);
for(int i=n;i;i--)//似乎数据水,不加也能过
f[i]=max(f[i],f[i+1]);
for(int i=1;i<=n;i++)
printf("%lld\n",f[i]);
return 0;
}
Luogu P1368 工艺
题意:给定一个循环序列,从某处断开,输出所有可能得到的序列中,字典序最小的那一个。
题解:
对于循环类的问题,先把序列复制一遍,然后构建SAM,这里有个小问题,不知道每个元素的大小,导致
数组不好开,这里其实可以用
。
建立好SAM之后,可以直接从初始状态出发,贪心地沿着最小的边(ch[p].begin()
)走,走
步即可。
代码如下:
#include<bits/stdc++.h>
using namespace std;
#define maxn 1000010
char s[maxn];
int last=1,tot=1;
int len[maxn],fails[maxn];
map<int,int> ch[maxn];
int size[maxn];
void ins(int x){
int p=last,np=++tot;
last=np;len[np]=len[p]+1;
for(;p&&!ch[p].count(x);p=fails[p])
ch[p][x]=np;
if(!p)
fails[np]=1;
else{
int q=ch[p][x];
if(len[q]==len[p]+1)
fails[np]=q;
else{
int nq=++tot;
len[nq]=len[p]+1;
ch[nq]=ch[q];
fails[nq]=fails[q];
fails[q]=fails[np]=nq;
for(;ch[p][x]==q;p=fails[p])
ch[p][x]=nq;
}
}
}
int n,tt[maxn];
int main(void)
{
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%d",&tt[i]);
ins(tt[i]);
}
for(int i=1;i<=n;i++)
ins(tt[i]);
for(int p=1,i=1;i<=n;i++){
map<int,int>::iterator pos=ch[p].begin();
printf("%d ",pos->first);
p=pos->second;
}
printf("\n");
return 0;
}
顺便说一下,这道题还可以用后缀数组(SA)解决,方法类似,倍长后第一个长度大于等于
的后缀即为答案,时间复杂度比SAM略高。
可这不是重点,重点是这题有绝对的
的时间复杂度。算法名称是:最小表示法。
Spoj7258 Lexicographical Substring Search
题意:给出一个字符串,若相同子串算一次,且排名相同,询问其字典序第
小的子串。
题解:由SAM的性质得,所有
集合相同的子串会被合并到一个状态中,那完全相同的子串就更加会被合并到一个状态中了。定义
表示从状态
出发,能到达的的串的个数(且这些串一定是原字符串的子串),则有:
注意这个方程需要的计算顺序。按照SAM的拓扑序固然可以,但其实也可以按照fail树的拓扑序。为什么?其实本来是不可以的,但是因为我们做fail树的拓扑排序是根据其len的大小排序的,因而更长的串一定先被处理了。
得到
后就很容易了,从初始状态
出发,带上
,扫一遍
的儿子,设当前访问到的儿子为
,若
,则答案必然不在
子树中,令
,然后继续遍历。若
,则令
进入该子树找。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int maxn=2e5+5;
int ch[maxn][26],fail[maxn],len[maxn],sum[maxn];
int n,last=1,tot=1;
char s[maxn];
void extend(int x){
int p=last,np=++tot;
last=np;len[np]=len[p]+1;
for(;p&&!ch[p][x];p=fail[p])
ch[p][x]=np;
if (p==0)
fail[np]=1;
else{
int q=ch[p][x];
if(len[q]==len[p]+1)
fail[np]=q;
else{
int nq=++tot;
len[nq]=len[p]+1;
memcpy(ch[nq],ch[q],sizeof(ch[nq]));
fail[nq]=fail[q];
fail[q]=fail[np]=nq;
for(;p&&ch[p][x]==q;p=fail[p])
ch[p][x]=nq;
}
}
}
int ft[maxn],rs[maxn];
void build(){
const int n=strlen(s);
for(int i=1;i<=tot;i++)
rs[len[i]]++;
for(int i=n;i>=0;i--)
rs[i]+=rs[i+1];
for(int i=1;i<=tot;i++)
ft[rs[len[i]]--]=i;
}
void solve(int k){
int p=1;
while(k>0){
for(int j=0;j<26;++j){
if(!ch[p][j])
continue;
if(sum[ch[p][j]]>=k){
putchar(j+'a');
--k;
p=ch[p][j];
break;
}else
k-=sum[ch[p][j]];
}
}
putchar('\n');
}
int main(){
scanf("%s",s);
for(int i=0;s[i];++i)
extend(s[i]-'a');
build();//同样可以用拓扑排序tsort
for(int i=1;i<=tot;i++){
sum[ft[i]]=1;
for(int j=0;j<26;++j)
sum[ft[i]]+=sum[ch[ft[i]][j]];
}
int Q,k;
scanf("%d",&Q);
while (Q--&&~scanf("%d",&k))
solve(k);
return 0;
}