后缀自动机的一些应用
未学习后缀自动机的话,可以去这里看一下
由于字符串变化多端,这里介绍一些\(SAM\)的简单应用,以增加一些理解;
后缀自动机的应用
本质不同子串的个数
我们有\(SA\)数组的做法,在\(SAM\)上也一样可做;
发现其实\(SAM\)上是没有重复子串的,我们只需要统计出\(SAM\)上所有的子串就可以了;
即$$ans=\sum maxlen[i]-minlen[i]+1$$
统计子串出现次数
求出\(SAM\)上每个每个字串的出现次数;
其实就是求每个状态的\(endpos\)集合大小,怎么求呢?
首先每次新加的一个状态\(endpos\)至少有一;
另外的,对于一个状态,每一个\(link\)入边,都会对此状态产生贡献;
即\(num[i]+=\sum_{link[j]=i}num[j]\)
这样就可以在\(link\)所构成的\(DAG\)上跑拓扑;
两个串的最长公共子串
\(eg.\) SP1811
\(SA\)过不了的那种,可以先在第一个串上建\(SAM\),因为所有子串都会在\(SAM\)上体现,我们可以考虑将第二个串在这个\(SAM\)进行匹配;
算法流程:
当前状态\(p\)(初始为\(1\)),待匹配的字符是\(S[i]\),已匹配的长度为\(len\)
- 如果\(trans[p][S[i]]!=0\),意味着有这个子串,将\(len++\),转移到下一个状态;
- 否则沿着\(link\)向前寻找:
- 如果能找到一个状态\(p\),满足\(trans[p][S[i]]!=0\),则可以将\(len=maxlen[p]+1\),转移到\(trans[p][S[i]]\);
- 否则\(len=0,p=1\);
- 更新答案;
时间复杂度是\(O(n)\),\(n\)是两个字符串的总长;
部分代码
void search(int n)
{
int p=1;
for(int i=1;i<=n;i++)
{
int c=s[i]-'a';
if(trans[p][c]) len++,p=trans[p][c];
else
{
for(;p&&!trans[p][c];p=link[p]);
if(p) len=maxlen[p]+1,p=trans[p][c];
else len=0,p=1;
}
ans=max(ans,len);
}
}
多个串的最长公共子串
\(eg.\) SP1812
假设有\(k\)个长度为\(n\)的字符串;
介绍两种方法
1 O(nk^2)
类似于统计子串出现次数,对于加进去的第\(i\)个字符串,对每个状态的第\(i\)维打上标记,即\(num[x][i]=1\);
再用拓扑或深搜统计出每个状态在每个字符串里出现的次数;
如果存在一个状态\(x\),它的每一位都有值(即在每个字符串中都出现过),可以用它更新答案;
时间复杂度是\(O(nk^2)\),过不了那个例题,但很好理解;
深搜时的代码
inline void dfs(int x)
{
for(int i=head[x];i;i=a[i].nxt)
{
dfs(a[i].to);
for(int j=0;j<t;j++)//t是个数
T.num[x][j]+=T.num[a[i].to][j];
}
bool fl=0;
for(int j=0;j<t;j++)
if(!T.num[x][j])
{
fl=1;
break;
}
if(!fl) ans=max(ans,T.ml[x]);
}
2 O(nk)
另一种
由求两个串的情况拓展而来;
考虑在第一个串上建\(SAM\),把剩余的每个串都拿到\(SAM\)上去匹配一下;
每次匹配时,得到当前匹配,每个状态的最长匹配成功长度;
按照理想的情况,最后在每次匹配的最长长度中找一个最小值,就是某一状态的所有匹配的最长公共匹配成功长度;
但是会存在一个问题,如果状态\(x\)匹配成功了,其\(link[x]\)也一定存在\(x\)的匹配,我们没有到达过\(link[x]\),这一部分值就无从存在了,所以我们必须要更新\(link[x]\);
但由于\(link[x]\)自身长度的限制,我们应当把\(maxn[link[x]]=max(maxn[link[x]],min(maxn[x],maxlen[link[x]]))\);
(\(maxn[x]\)就是状态\(x\)的当前匹配的最长匹配成功长度)
为了保证这个过程更新完全,我们需要按拓扑序来更新,即先更新了\(x\)后才能更新\(link[x]\);
我们发现\(maxlen[x]>maxlen[link[x]]\),所以按照\(maxlen\)的排名更新就可以了,可以省去一次拓扑排序;
这样时间是\(O(nk)\)
有关代码
//这都在SAM结构体中
void topu()
{
for(int i=1;i<=sz;i++) tub[ml[i]]++;
for(int i=1;i<=sz;i++) tub[i]+=tub[i-1];
for(int i=sz;i>=1;i--) b[tub[ml[i]]--]=i;
}
//类似于后缀数组,用一次基数排序得到拓扑序
void work()
{
int l=0,p=1;
for(int i=1;i<=n;i++)
{
int x=c[i]-'a';
if(trans[p][x]) l++,p=trans[p][x];
else
{
for(;p&&!trans[p][x];p=link[p]);
if(p) l=ml[p]+1,p=trans[p][x];
else l=0,p=1;
}
mas[p]=max(mas[p],l);
}
for(int i=sz;i>=1;i--)
{
int x=b[i],li=link[x];
mas[li]=max(mas[li],min(mas[x],ml[li]));
mis[x]=min(mis[x],mas[x]);
mas[x]=0;
}
}
字典序第k大子串
\(eg.\) P3675
有两种情况
当不合并本质相同的串时:
还是基于一个思想,\(SAM\)上的路径对应了一个一个的子串;
求第\(k\)大子串就是求第\(k\)大路径;
类似于二叉查找树的方法,如果我们能知道每个状态后连接了多少条路径的话,我们就可以把这个问题当成多叉查找树来做;
另外还需要考虑一个问题;
每个状态有多个\(endpos\),这相当与一个状态的副本数;
我们仍然记\(|endpos[i]|\)为\(num[i]\);
将第\(i\)个状态的路径总数,扩展为经过这个状态的子串数,记为\(sum[i]\);
因为,状态自身就存在\(num[i]\)个子串,且排在后面继续匹配的得到的子串的前面;
可以得到
另一种情况:
合并本质相同的串;
其实就是把每个状态(除了起始状态)的\(num\)都置为\(1\);
\(code\)
#include<bits/stdc++.h>
#define ll long long
#define mp make_pair
using namespace std;
const int N=500010;
char c[N];
int n,ty,k;
struct SAM
{
int sz,last;
int tub[N<<1],b[N<<1];
int num[N<<1],sum[N<<1];
int link[N<<1],trans[N<<1][26],ml[N<<1];
SAM(){sz=last=1;}
void add(int id)
{
int x=++sz,p;
ml[x]=ml[last]+1;
num[x]=1;
for(p=last;p&&!trans[p][id];p=link[p]) trans[p][id]=x;
if(!p) link[x]=1;
else
{
int q=trans[p][id];
if(ml[q]==ml[p]+1) link[x]=q;
else
{
int y=++sz;
ml[y]=ml[p]+1;
memcpy(trans[y],trans[q],sizeof trans[y]);
link[y]=link[q];
for(;p&&trans[p][id]==q;p=link[p]) trans[p][id]=y;
link[x]=link[q]=y;
}
}
last=x;
}
void topu()
{
for(int i=1;i<=sz;i++) tub[ml[i]]++;
for(int i=1;i<=sz;i++) tub[i]+=tub[i-1];
for(int i=sz;i>=1;i--) b[tub[ml[i]]--]=i;
}
void getsz()
{
for(int i=sz;i>=1;i--)
{
int x=b[i];
sum[x]+=num[x];
num[link[x]]+=num[x];
}
if(ty==0) for(int i=1;i<=sz;i++) sum[i]=num[i]=1;
}
void getsum()
{
num[1]=sum[1]=0;
for(int i=sz;i>=1;i--)
{
int x=b[i];
for(int j=0;j<26;j++)
if(trans[x][j]) sum[x]+=sum[trans[x][j]];
}
}
}T;
inline void sol(int x,int k)
{
if(k<=T.num[x]) return ;
k-=T.num[x];
for(int i=0;i<26;i++)
{
int y=T.trans[x][i];
if(!y) continue;
if(k>T.sum[y])
{
k-=T.sum[y];
}
else
{
printf("%c",'a'+i);
sol(y,k);
break;
}
}
}
int main()
{
scanf("%s",c+1);
scanf("%d%d",&ty,&k);
n=strlen(c+1);
for(int i=1;i<=n;i++)
T.add(c[i]-'a');
T.topu();
T.getsz();
T.getsum();
if(T.sum[1]<k) printf("-1");
else sol(1,k);
return 0;
}
长度为k的子串的最大出现次数
\(eg.\) SP8222
如果只有一组询问,可以直接遍历\(SAM\),如果一个状态的\(maxlen>=k>=minlen\),就可以用这个状态的\(|endpos|\)更新;
如果有多组询问,可以将每个状态的\(|endpos|\),来对应更新\(ans[maxlen[i]]\);
再用\(ans[i]=max(ans[i],ans[i+1])\)来补全答案;
因为\(maxlen\)的后缀的出现次数一定大于\(maxlen\)的出现次数;
所有前缀的最长公共后缀和
\(eg.\) P4248
先说求所有前缀的公共后缀:
先把我们得到的\(link\)建成一棵树;
两个前缀所在的状态在这棵树上的\(lca\)的\(maxlen\)就是这两个前缀的最长公共后缀;(因为这两个前缀共有同样的后缀连接,它们后缀“断掉”的情况是相同的);
如何求所有呢?是不是求每两个前缀的最长公共后缀再乘上,它们出现次数的积就可以了呢?
不只是这样的,对于那些从初始状态出发,不是前缀的状态(一定是分裂出的状态),它们的出现次数是从它们被\(link\)的前缀得来的,也就是说,它们的出现也伴随着前缀的出现,它们的最长公共后缀就等于它们所对应的前缀的最长公共后缀;
对于这部分的状态,也应该像上面那样处理;
所以我们必须求出
\(num\)就是一个状态的\(|endpos|\);
这样时间复杂度是\(O(n^2)\)
我们考虑算每个状态的贡献,计算它是多少点对的\(lca\);
拓扑之后
可以得到这样的代码
ll sol()
{
ll res=0;
for(int i=sz;i>=1;i--)
{
int x=b[i];//拓扑序对应的节点
res+=(ll)num[link[x]]*num[x]*ml[link[x]];
num[link[x]]+=num[x];
}
return res;
}
循环中的\(num[link[x]]\)是未加入\(num[x]\)的值,相当不包含当前子树的其他子树的大小,可以与当前子树中节点一一构成一个节点对;
这样就可以\(O(n)\)得到答案;
但例题中好像要求所有后缀的最长公共前缀;
其实把最初的字符串翻转,这两者就是相等的了;
拓展:广义后缀自动机简介
上面有些题目是对一组字符串进行处理;
我们有的是对一个串建机,其他跑匹配,有的用特殊字符隔开在复合串上建机;
但这都有局限性;
而广义\(SAM\)是专门来解决多字符串的;
这个\(SAM\)上有所有字符串的子串,同样不重复;
构造的代码如下
void add(int id)
{
if(trans[last][id]&&ml[trans[last][id]]==ml[last]+1)//changed
{
last=trans[last][id];
return ;
}
int x=++sz,p,y;
bool fl=0;
ml[x]=ml[last]+1;
for(p=last;p&&!trans[p][id];p=link[p]) trans[p][id]=x;
if(!p) link[x]=1;
else
{
int q=trans[p][id];
if(ml[q]==ml[p]+1) link[x]=q;
else
{
if(ml[p]+1==ml[x]) fl=1;//changed
y=++sz;
ml[y]=ml[p]+1;
memcpy(trans[y],trans[q],sizeof trans[y]);
link[y]=link[q];
for(;p&&trans[p][id]==q;p=link[p]) trans[p][id]=y;
link[x]=link[q]=y;
}
}
if(fl==1) last=y;
else last=x;
}
需要注意的是,每次新加进一个串时,要把\(last=1\);
与普通\(SAM\)只多了两个特判;
两个地方都是为了不构建一些不用的节点,第一个地方是利用已经建好了的点,第二个地方是跳过了空节点\(x\)