ACM算法总结 字符串(二)



后缀数组

后缀数组(suffix-array)的前置知识是基数排序,而基数排序基于稳定的计数排序,基数排序的本质是:按照关键字的反优先顺序,对相应关键字进行计数排序,然后更新排名函数 r k rk r k [ i ] rk[i] 记录了当前排名为 i i 的数据在原数组中的下标。

接下来关于后缀数组的所有算法的下标都从1开始

后缀数组 s a [ i ] sa[i] 表示按字典序排名为 i i 的后缀的下标,其中后缀的下标代表以该下标开始的文本串的后缀。比如说对于字符串banana,我们称后缀3为nana,后缀6为a ,该字符串的后缀数组为6 4 2 1 5 3

构造后缀数组的过程可以用下面一张图形象地解释:

在这里插入图片描述

其中红色箭头指向的数组就是下面代码中每次倍增需要的 x x 数组,也就是每个后缀的排名,而 y y s a sa 数组用于中间的双关键字基数排序(代码中 y y 数组记录了第二个关键字排序后的排名),其中 s a sa 数组的意义就如同一开始介绍的基数排序中的排名函数 r k rk ,可以看出它就是我们需要的后缀数组。

const int maxn=1e6+5;
char s[maxn];
int n,sa[maxn],c[maxn],t1[maxn],t2[maxn];	//一定注意n要赋值为s的长度,s从1开始

void get_sa(int m)
{
    int *x=t1,*y=t2;
    REP(i,0,m) c[i]=0;
    REP(i,1,n) c[x[i]=s[i]]++;
    REP(i,1,m) c[i]+=c[i-1];
    REP_(i,n,1) sa[c[x[i]]--]=i;
    for(int k=1;k<=n;k<<=1)
    {
        int p=0;
        REP(i,n-k+1,n) y[++p]=i;
        REP(i,1,n) if(sa[i]>k) y[++p]=sa[i]-k;
        REP(i,0,m) c[i]=0;
        REP(i,1,n) c[x[y[i]]]++;
        REP(i,1,m) c[i]+=c[i-1];
        REP_(i,n,1) sa[c[x[y[i]]]--]=y[i];
        swap(x,y);
        p=1,x[sa[1]]=p;
        REP(i,2,n) x[sa[i]]=(max(sa[i],sa[i-1])+k<=n && y[sa[i]]==y[sa[i-1]] && y[sa[i]+k]==y[sa[i-1]+k])?p:++p;
        m=p;
        if(p>=n) break;
    }
}

代码中判断 max(sa[i],sa[i-1])+k<=n 是为了防止多组数据互相影响(HDU4691)。




LCP(最长公共前缀)

定义两个新数组:

r k [ i ] rk[i] :表示后缀 i i 的排名;

h e i g h t [ i ] height[i] :表示后缀 s a [ i 1 ] sa[i-1] 和后缀 s a [ i ] sa[i] 的LCP,

那么我们求任意两个后缀的LCP相当于只用维护height数组的RMQ就可以了,比如说假设 r k [ i ] < r k [ j ] rk[i]<rk[j] ,那么 L C P ( i , j ) = m i n { h e i g h t [ r k [ i ] + 1 ] ,   h e i g h t [ r k [ i ] + 2 ] ,   h e i g h t [ r k [ j ] ] } LCP(i,j)=min\{height[rk[i]+1],\ height[rk[i]+2],\ height[rk[j]] \}

r k rk 数组就是 s a sa 的逆,而 h e i g h t height 数组的求解首先需要一个结论 h e i g h t [ r k [ i ] ] h e i g h t [ r k [ i 1 ] ] 1 height[rk[i]]\geq height[rk[i-1]]-1 ,然后就可以从小到大在 O(n) 时间内求解。

这个结论的证明见下图所示:

在这里插入图片描述

构造完 height 数组之后就可以用 st 表维护 height 的 RMQ,从而方便地计算出任意两个后缀的最长公共前缀。代码如下:

int rk[maxn],height[maxn],st[maxn][22],lg2[maxn];

void get_height()
{
    REP(i,1,n) rk[sa[i]]=i;
    int k=0;
    REP(i,1,n)
    {
        if(k) k--;
        int j=sa[rk[i]-1];
        while(s[i+k]==s[j+k]) k++;
        height[rk[i]]=k;
    }
}

void get_st_height()
{
    REP(i,2,maxn-1) lg2[i]=lg2[i-1]+(1<<(lg2[i-1]+1)==i);
    REP(i,1,n) st[i][0]=height[i];
    REP(j,1,lg2[n]) REP(i,1,n+1-(1<<j))
        st[i][j]=min(st[i][j-1],st[i+(1<<(j-1))][j-1]);
}

int RMQ(int l,int r)
{
    int k=lg2[r-l+1];
    return min(st[l][k],st[r-(1<<k)+1][k]);
}

int LCP(int x,int y)
{
    if(x==y) return n-x+1;
    if(rk[x]>rk[y]) swap(x,y);
    return RMQ(rk[x]+1,rk[y]);
}
  • height分组:这是一种思想,也就是说我们求出height数组之后,可以得出,对于一个后缀sa[i],排名离它越远的后缀与它的LCP一定越小。所以如果我们指定一个常数M,那么可以根据 height[i] \geq M 来将height数组分组,这有利于一些其他处理(比如二分)。




后缀自动机

后缀自动机(suffix-automaton, SAM)用一种高压缩的方式存储了文本串的所有子串信息,其中最重要的信息是状态结点转移后缀连接。下图表示字符串banana的后缀自动机,其中红色虚线代表后缀连接。

在这里插入图片描述

关于后缀自动机的一些性质可以从上面的实例中得以体现:

  • 每条从 S 开始到任意结点结束的路径都构成了文本串的一个子串,所有从 S 开始的路径构成了文本串的所有子串集。

  • 每条从 S 开始到终点结点(图中蓝色结点)的路径构成了文本串的一个后缀,同样的,所有这种路径构成了文本串的所有后缀集。

  • 每个结点代表了唯一的一种 endpos等价类,其意义是:该结点包含的所有子串(也即从 S 开始到该结点的路径组成的所有子串)在原文本串中有相同的结束位置集。比如图中对于 8 号结点,endpos(“an”) = endpos(“n”) = {3,5}。

  • 每个结点包含的子串构成了一个连续的长度区间。我们令 maxlen(u) 表示结点 u 的子串中最长的长度,longest(u) 表示最长的子串,则 u 中所有的子串都是 longest(u) 的后缀。

  • 后缀连接的意义如下:结点 u 的后缀连接为不在 u 中的 longest(u) 的最长的后缀所在的结点。也就是说设 minlen(u) 为结点 u 的子串中最短的长度,那么 longest(u) 的长度为 minlen(u)-1 的后缀就不在 u 中了,假设在 v 中,那么 u 的后缀连接 link[u]=v 。显然有 minlen(u) = maxlen(link[u]) +1 (这个非常重要)。

  • 沿着一个结点 u 的后缀路径遍历所有该路径上的结点,可以遍历到 longest(u) 的所有后缀。

  • 沿着最后一个建立的结点的后缀路径遍历所有该路径上的结点,可以得到所有的终点结点。


接下来说一下后缀自动机的构造方法。

假设当前已经构造好了 S[1…n] 的后缀自动机,当我们新增添 c=S[n+1] 的时候,需要考虑 n+1 个新的后缀。设上一次新增字符增添的结点为 last,这次肯定要新增加一个新的结点 cur,否则字符串 S[1…n+1] 就无法表示。令 p 沿着 last 的后缀路径遍历,如果 trans[p][c](转移)不存在,那么令 trans[p][c]=cur,否则跳出。如果一直到 S 仍然转移不存在,最后令 link[cur]=S,否则假设第一个存在 trans[p][c] 的结点是 u,trans[u][c]=v,就要分两种情况讨论了:

  1. 如果 maxlen(u) + 1 = maxlen(v),说明 v 中所有子串都是 S[1…n+1] 的后缀,故直接令 link[cur] = v;
  2. 否则,新增添一个结点 clone,令 trans[clone] = trans[v],然后把从 u 开始的后缀路径上所有 trans[p][c] == v 的转移都改成 trans[p][c] = clone(保证 clone 的所有子串都是 S[1…n+1] 的后缀),clone 继承 v 的后缀连接,link[v] = link[cur] =clone。

构造代码如下(构造仅仅是 extend 那一部分,所有数组从1开始):

// 1号是S结点
namespace suffix_automaton
{
    const int maxn=1e6+5;
    int maxlen[maxn<<1],trans[maxn<<1][26],link[maxn<<1],tot=1,last=1;
    int endnum[maxn<<1],c[maxn<<1],a[maxn<<1];
    int e[maxn<<1];

    void extend(int c)
    {
        int cur=++tot,p; endnum[cur]=1;
        maxlen[cur]=maxlen[last]+1;
        for(p=last;p && !trans[p][c];p=link[p]) trans[p][c]=cur;
        if(!p) link[cur]=1;
        else
        {
            int q=trans[p][c];
            if(maxlen[q]==maxlen[p]+1) link[cur]=q;
            else
            {
                int clone=++tot;
                maxlen[clone]=maxlen[p]+1;
                memcpy(trans[clone],trans[q],sizeof(trans[q]));
                for(;p && trans[p][c]==q;p=link[p]) trans[p][c]=clone;
                link[clone]=link[q];
                link[q]=link[cur]=clone;
            }
        }
        last=cur;
    }

    void get_endpos_num()	// count the end-position number of every node(state)
    {
        REP(i,1,tot) c[maxlen[i]]++;
        REP(i,1,tot) c[i]+=c[i-1];
        REP(i,1,tot) a[c[maxlen[i]]--]=i;
        REP_(i,tot,1) endnum[link[a[i]]]+=endnum[a[i]];
    }

    void get_end()	// get the end node(state)
    {
        for(int i=last;i!=1;i=link[i]) e[i]=1;
    }

    void build(char *s,char minc)
    {
        for(int i=0;s[i];i++) extend(s[i]-minc);
        get_endpos_num();
        //get_end();
    }
}

后缀自动机的时间复杂度和空间复杂度都是 O(n) 。

关于后缀自动机的一些常见应用

  • 完美取代AC自动机,多模式匹配问题直接对文本串建后缀自动机,然后每个模式串从S开始跑一遍就知道是不是子串了。
  • 子串出现次数。我们需要计算每个结点对应结束位置的个数 endnum,每一个长的子串出现时,都对其更短的后缀有贡献,故(一定要忽略clone的结点)对每次新建结点出现次数赋值为1,然后在后缀连接图上拓扑序累加即可,或者可以按照每个结点的 maxlen 排个序,然后从大到小累加也可(可以说明这样子满足拓扑序)。
  • 本质不同子串个数 = i = 2 t o t ( m a x l e n [ i ] m a x l e n [ l i n k [ i ] ] ) \sum_{i=2}^{tot}(maxlen[i]-maxlen[link[i]])




回文树

回文树又称回文自动机,也是一种有限状态机,它的每个状态(结点)代表了一个本质不同的回文串。

下图即为字符串 banana 的回文树:

在这里插入图片描述

回文树有两个根节点(0,1),分别为偶回文串和奇回文串。其中蓝色实现的转移表示将当前回文串两边同时加上一个字符,红色虚线(这里同样称之为后缀连接)指向当前回文串的最长回文后缀。

构建回文树的过程非常简单。和后缀自动机类似,每次添加新的字符时,我们找到上一个结点last的最长回文后缀,并且这个回文后缀的前一个字符和当前字符一样(这个过程就是下面代码中的get_suffix),这样就可以从这个状态进行扩展;而新的结点的后缀连接,又可以从其父节点的后缀连接开始get_suffix。

在构建过程中令0结点的后缀连接为1结点,1的初始长度为-1,s[0]=-1(或者任意一个没出现的字符)。

代码如下:

// s为扩展的所有字符,n为s的长度,p为结点个数(p-2为本质不同回文串个数,1,2,...,p-2号结点都表示回文串)
// link为后缀连接,len为回文串长度,cnt为相同回文串个数,last为上一次扩展的结点。

namespace palindromic_tree
{
    const int maxn=3e5+5;
    int t[maxn][26],link[maxn],cnt[maxn],len[maxn];
    int s[maxn],last,n,p;

    int new_node(int L) {len[p]=L; return p++;}
    int get_suffix(int x) {while(s[n-len[x]-1]!=s[n]) x=link[x]; return x;}

    void extend(int c)
    {
        s[++n]=c;
        int u=get_suffix(last);
        if(!t[u][c])
        {
            int v=new_node(len[u]+2);
            link[v]=t[get_suffix(link[u])][c];
            t[u][c]=v;
        }
        last=t[u][c]; cnt[last]++;
    }

    void build(char *ss)
    {
        s[0]=-1, link[0]=1;
        new_node(0); new_node(-1);
        for(int i=0;ss[i];i++) extend(ss[i]-'a');
        REP_(i,p-1,0) cnt[link[i]]+=cnt[i];
    }
}

注意cnt统计的时候,我们一开始实际上只算了每个结点的出现次数,但是这个结点的所有回文后缀实际上也出现了,所以要拓扑序累加。

发布了12 篇原创文章 · 获赞 5 · 访问量 525

猜你喜欢

转载自blog.csdn.net/dragonylee/article/details/103837745
今日推荐