后缀自动机题解

版权声明:转载注明出处,部分带(坑)文章谢绝转载。 https://blog.csdn.net/linjiayang2016/article/details/82756528

前言

后缀自动机(SAM)是一种优秀的数据结构,它作为一种自动机,不仅能接受一个字符串所有的后缀,还可以接受所有子串。然而这里并不打算写它的原理,这里仅提供题表和题解。
有关教程,固然,网上有很多详解,但都没有CLJ的正规,尽管CLJ的论文有些难懂,因此在学完网上的内容后,千万不要忘记回去看CLJ的论文。
进阶:Links

约定

  1. 状态:SAM上的点。
  2. 转移:SAM上的边。
  3. l o n g e s t longest :状态 i i l o n g e s t longest 表示从初始状态转移到状态 i i 的最多步数。
  4. s h o r t e s t shortest :同 l o n g e s t longest ,只是变成了最少步数。
  5. r i g h t right 集合:节点 i i r i g h t right 集合表示初始状态转移到节点 i i 所表示的字符串在原串中出现的右端点集合。
    如果你说初始状态转移到节点 i i 可能有多条路径,那说明你还没有弄懂SAM,至少“相同 r i g h t right 集合的状态被合并到一个状态”这一点你是不知道的。
  6. f a i l fail 指针:某个状态代表的是若干个 r i g h t right 集合相同的串,那么随着后缀长度的减小,从某一个后缀开始,就可能出现在了更多的位置
    而且这个后缀以及比它更短的后缀的 r i g h t right 集合一定会变大,因此就不得不分离到另一个节点上,成为那个节点的 l o n g e s t longest ,而当前状态的 f a i l fail 指针就会指向那个状态。
  7. f a i l fail 树:把 f a i l fail 指针翻转,得到的树就是 f a i l fail 树。

题表

题号(网站) 名称(题解) 题意 提示
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);

构建函数 Θ ( n k ) \Theta(nk)

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拓扑序 Θ ( n ) \Theta(n)

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树拓扑序 Θ ( n + k ) \Theta(n+k)

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当然可以,但这里有一种更简单的方法。
既然每个状态表示的是若干个 r i g h t right 相等的字符串,那么不难得出一点,对于一堆 r i g h t right 集合相同的子串,它们一定互为后缀,并且他们长度连续。因此只需要考虑每个节点对答案的贡献即可,即不同的子串的个数为: l o n g e s t s h o r t e s t + 1 = l o n g e s t f a i l . l o n g e s t longest-shortest+1=longest-fail.longest
注意两道题目其中一道是大写字母,另一道是小写字母。
代码如下:

#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 后缀自动机

题意:求出字符串 S S 的所有在 S S 中出现次数不为 1 1 的子串的出现次数乘上该子串长度的最大值。
题解:
构建出 S A M SAM 后,可以发现每个字符串的出现次数就是对应状态的 r i g h t right 集合大小。可以发现,在 f a i l fail 树上,某个状态所表示的字符串一定是其儿子所表示的字符串的后缀,因此某个状态所表示的 r i g h t right 一定是其所有儿子的 r i g h t right 集合的并集。
又因为子串可以表示成某个后缀的前缀,因此只需要把原字符串的所有前缀的次数标记出来,然后在 f a i l fail 树上合并即可,即:
s i z e u = v u s i z e v size_u=\sum _{v是u的儿子}size_v
代码如下:

#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系统下通过了而已,因为仔细想想可能会爆栈。
于是我们可能需要一遍拓扑排序。
其实不需要拓扑排序。我们知道节点 v v r i g h t right 集合一定是 v v 的父亲 f a fa r i g h t right 集合的子集,因此 v . l o n g e s t v.longest 必然大于 f a . l o n g e s t fa.longest 。因此一个状态的 l o n g e s t longest 越长,它一定是更底层的状态。因此只需要对每个节点按照 l o n g e s t longest 排序即可,为了不提高时间复杂度,这里采用了基数排序(可参考后缀数组的倍增算法),和构建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

题意:定义 f i f_i 为字符串 S S 的所有长度为 i i 的子串的出现次数的最大值。求 f 1 f l e n g t h ( S ) f_{1}\cdots f_{length(S)} 值。
题解:
有了上一题的基础,这一题应该不难解决。先对 S S 建立SAM,对于节点 v v ,可以发现,其对 f v . s h o r t e s t , &ThinSpace; , f v . l o n g e s t f_{v.shortest},\cdots,f_{v.longest} 均有贡献,如果这样维护那时间复杂度就是 O ( n 2 ) O(n^2) 的了。
其实可以发现,长度为 i i 的子串一定是长度为 i + 1 i+1 的子串的子串。
也就是说,我们可以只考虑 f v . l o n g e s t f_{v.longest} ,然后用 f i = m a x ( f i + 1 , f i ) f_i=max(f_{i+1},f_i) 来更新更短的子串。
代码如下:

#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,这里有个小问题,不知道每个元素的大小,导致 c h ch 数组不好开,这里其实可以用 m a p map
建立好SAM之后,可以直接从初始状态出发,贪心地沿着最小的边(ch[p].begin())走,走 n n 步即可。
代码如下:

#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)解决,方法类似,倍长后第一个长度大于等于 n n 的后缀即为答案,时间复杂度比SAM略高。
可这不是重点,重点是这题有绝对的 Θ ( n ) \Theta(n) 的时间复杂度。算法名称是:最小表示法。

Spoj7258 Lexicographical Substring Search

题意:给出一个字符串,若相同子串算一次,且排名相同,询问其字典序第 k k 小的子串。
题解:由SAM的性质得,所有 r i g h t right 集合相同的子串会被合并到一个状态中,那完全相同的子串就更加会被合并到一个状态中了。定义 f u f_u 表示从状态 u u 出发,能到达的的串的个数(且这些串一定是原字符串的子串),则有:
f u = 1 + v u f v f_u=1+\sum_{v是u的儿子}f_v

注意这个方程需要的计算顺序。按照SAM的拓扑序固然可以,但其实也可以按照fail树的拓扑序。为什么?其实本来是不可以的,但是因为我们做fail树的拓扑排序是根据其len的大小排序的,因而更长的串一定先被处理了。
得到 f f 后就很容易了,从初始状态 s s 出发,带上 k k ,扫一遍 s s 的儿子,设当前访问到的儿子为 v v ,若 f v &gt; k f_v&gt;k ,则答案必然不在 v v 子树中,令 k = k f v k=k-f_v ,然后继续遍历。若 f v &lt; = k f_v&lt;=k ,则令 s = v s=v 进入该子树找。
代码如下:

#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;
}

猜你喜欢

转载自blog.csdn.net/linjiayang2016/article/details/82756528
今日推荐