最长公共子串

最长公共子串


题目描述

image-20210815213633720


核心思路

题意:求 n n n个字符串的最长公共子串

我们先来考虑暴力解法:

先将第一个字符串 A A A去构建好后缀自动机,然后对于第二个字符串 B B B,我们先枚举 B B B的左端点,从第一个字符开始,然后去SAM中查找,如果存在这个字符,那么就继续第二个字符,存在则继续,假设到第 i + 1 i+1 i+1个字符时,发现SAM中查找不到了,那么则说明字符串 B B B 1 1 1 i i i的这部分子串是与 A A A的最长公共子串了,设此时结果为 x 1 x_1 x1。然后枚举第二个字符为起点,得到结果为 x 2 x_2 x2,一直这样,最终比较 x 1 , x 2 , ⋯   , x n x_1,x_2,\cdots,x_n x1,x2,,xn的大小,取最大值即可。

我们上面的这种做法就是:当去跑SAM时,假设到了某个状态节点 p p p之后没有边了,不能继续跑下去了,那么这趟寻找最长公共子串的过程就结束了。然后我们就会继续枚举B的下一个起始字符,又去跑一遍SAM。

那么,我们有没有一种做法,可以不用让B又回到上一次枚举的位置的下一个字符呢?

如下图分析:

image-20210815221404684

由此可见,既然状态节点 p p p所表示的字符串中,最长的那个字符串都已经不能成功了, 那么比它小的那个串肯定也不能成功,那么我们就可以必要去考虑这些串了,也就是说我们不一定每次都要回到前面的某个位置又开始去匹配,而是可以考虑从某个特定位置去匹配。

那么状态节点 p p p中怎样才能可以接上 c c c呢?由于 t 2 t_2 t2 t 1 t_1 t1去掉首字符得到的,因此如果 t 1 t_1 t1不能接上 c c c,那么 t 2 t_2 t2大概率不能接上 c c c,以此类推分析可知,假设 t 3 t_3 t3是状态节点 p p p中的最短串,那么 t 3 t_3 t3去掉首字符后所得到的串,是最有可能产生质变的,这时候是可以接上 c c c的。也就是说,我们最应该考虑的就是 p p p中的最短串。

那么我们联想到后缀链接的本质可以知道,某个状态节点 x x x的后缀链接,其实就是寻找状态节点 x x x所表示的字符串中最短的那个串,然后去掉其首字符,得到一个字符串 s t r str str,然后我们看哪个状态节点所形成的字符串中是含有字符串 s t r str str的,那么状态节点 x x x的后缀链接就指向这个节点。 那么这个性质不就刚好与我们上面的一致嘛?我们上面的想法是下一次枚举时,就尽量去枚举当前状态节点 x x x所表示的字符串中最短的那个串去掉首字符后所形成的新串 s t r str str,把它当作枚举的起点。

因此,这就启发我们,当在 p p p后面不能接 c c c时,我们下次枚举的起点应该是从 p p p的后缀链接节点开始枚举,而不是又去从前面开始枚举了。感觉这其实和KMP有点类似hhh

如下图所示,我们来感性认识一下上面提到的这个后缀链接的性质:

image-20210816010947913

如图,状态节点6所代表的字符串有{ b a b bab bab a b a b abab abab a a b a b aabab aabab},其中最短的串为 b a b bab bab,去掉首字符后得到的新串为 a b ab ab,然后我们发现状态节点 7 7 7是包含这个新串的,而且可以发现状态节点6的后缀链接确实是连向了状态节点7。

这里来解释一下这段代码是啥意思:

for(int j=1;str[j];j++)
{
    now[p] = max(now[p], t);
}

这里其实是处理单个字符串的,比如这里是处理B串,对于新来的一个字符 s t r [ j ] str[j] str[j],我们去SAM中看有没有可以接上它的,假设是状态节点 p p pnow[p]就表示B串此刻在SAM中与A串匹配的最长公共子串的长度。然后能接上 s t r [ j ] str[j] str[j]的状态节点 p p p可能有多个,因此我们取最大的那个,所以这里是 m a x max max操作

再来看这一段代码的意思:

for(int i=0;i<n-1;i++)
{
    for (int j = 1; j <= tot; j ++ ) ans[j] = min(ans[j], now[j]);
}

这里其实是处理多个字符串的。假设有 B , C , D B,C,D B,C,D这三个字符串,它们在跑SAM时,所对应的某个状态节点 p p p的now值分别为 n o w 1 [ p ] = 3 now_1[p]=3 now1[p]=3 n o w 2 [ p ] = 4 now_2[p]=4 now2[p]=4 n u m 3 [ p ] = 5 num_3[p]=5 num3[p]=5。那么对于这三个串,考虑这个状态节点 p p p,我们应该取哪个now值呢?很明显,应该取最小的 n o w 1 [ p ] = 3 now_1[p]=3 now1[p]=3。因为 n o w 1 [ p ] now_1[p] now1[p]一定是 n o w 2 [ p ] now_2[p] now2[p]的子集,因此 n o w 1 [ p ] now_1[p] now1[p]中有的,那么 n o w 2 [ p ] now_2[p] now2[p]中也一定都有;但是 n o w 2 [ p ] now_2[p] now2[p]中有的, n o w 1 [ p ] now_1[p] now1[p]中却不一定有,如果我们选择较长的 n o w 2 [ p ] now_2[p] now2[p],那么就不能保证最长公共子串了(因为 n o w 1 [ p ] now_1[p] now1[p]中没有 n o w 2 [ p ] now_2[p] now2[p]含有的字符)。因此,这里是 m i n min min操作。而且这里是对于SAM中的每个状态节点,处理多个字符串时,都是取 m i n min min操作

最后再来看这一段代码的意思:

    int res = 0;
    for (int i = 1; i <= tot; i ++ ) res = max(res, ans[i]);

这里其实也是处理多个字符串的。上面的那一段代码我们是综合考虑了所有串在状态节点 p p p的取值。但是SAM中有多个状态节点,假设在上一段代码中我们求出了 p 1 p_1 p1所对应的为 x 1 x_1 x1 p 2 p_2 p2所对应的为 x 2 x_2 x2 ⋯ \cdots ,那么我们现在就应该来考虑 x 1 , x 2 ⋯   , x n x_1,x_2\cdots,x_n x1,x2,xn中哪个是最大的呢?然后选取最大的那个,那么就是这些字符串的最长公共子串的长度了。因此这里是遍历SAM中的所有状态节点,选择最大的那个ans[]。因此,这里是取 m a x max max操作

这里还有一个关键的地方容易忽略,就是比如状态节点 p p p所表示的串为 c b a cba cba,状态节点 q q q所表示的串为 d b a dba dba,虽然它们不是最长公共子串,但是它俩中的后缀 b a ba ba是相同,因此我们要把状态节点 p p p所表示的串的信息传递给它的后缀 b a ba ba,同理也要把状态节点 q q q所表示的串的信息传递给它的后缀 b a ba ba。那么我们该如何将p串的信息传递给它的后缀呢?这在SAM中其实可以把p串理解为子节点,它的后缀看作是p串后缀链接后的父节点。我们可以把含有后缀链接的边建立出来,然后我们是从p串后缀向p中连一条有向边,为什么呢?因为这样当我们dfs到叶子节点时,我们就会把叶子节点的信息传递给其父节点,而我们建图时叶子节点其实也就是p串,其父节点其实也就是p串的后缀,回溯时将叶子节点的信息传递给其父节点,也就是将p串的信息传递给了p串的后缀。因此是正确的。

这就是下面这段代码的意思:

 for (int i = 2; i <= tot; i ++ ) add(node[i].fa, i);

下面解释这段代码:

for (int i = 1; i <= tot; i ++ ) ans[i] = node[i].len;
now[u] = max(now[u], now[e[i]]);

要注意,p串传递的信息的长度可以会超过p串后缀的长度,因此要选择最长的那个,不然容易丢失信息。


代码

#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N=20010,M=N;
//tot记录的是后缀自动机中的状态节点 初始化为根节点1
//last记录的是上一个状态节点
int tot = 1, last = 1;
struct Node
{
    int len;    //记录这个状态节点所形成的串中最大的那个串的长度
    int link;   //后缀链接
    int ch[26]; //类似于trie树中的孩子
}node[N];
char str[N];
int ans[N],now[N];
int h[N], e[M], ne[M], idx;
int n;

//后缀自动机模板
void extend(int c)
{
    //先用p来记录上一个状态节点
    //然后由于来了一个字符c,需要进行状态转移了
    // 因此给转移后得到的节点np分配一个编号tot
    int p = last, np = last = ++ tot;
    //由于np是从p通过新添一个字符c转移过去的  因此长度+1
    node[np].len = node[p].len + 1;
    //沿着p的后缀链接遍历节点  如果遍历到的节点p它没有孩子c
    //则要创建出来  让np连一条后缀链接边到node[p].ch[c]就相当于
    //该节点p通过字符c转移到了np
    for (; p && !node[p].ch[c];p = node[p].link)
        node[p].ch[c] = np;
    //沿着后缀链接一直来到了根节点  仍然没有发现
    //那么np的后缀链接就是根节点
    if (!p)
        node[np].link = 1;
    else
    {
        int q = node[p].ch[c];  //找到状态节点p的c孩子节点 q
        //np沿着后缀链接找到了q 发现q中有np想要的串 能够进行后缀邻接
        //那么此时nq就可以引出一条后缀链接边到q
        if (node[q].len == node[p].len + 1)
            node[np].link = q;
        else
        {
            //将q一分为二 变成q 和 nq
            int nq = ++ tot;
            //nq是q克隆出来了  但是nq包含了从q中抽离出来的能够让np进行后缀链接的串
            node[nq] = node[q], node[nq].len = node[p].len + 1;
            //分裂前的q引一条后缀链接边到nq
            //从新开的状态节点np引一条后缀链接边到nq
            node[q].link = node[np].link = nq;
            //沿着p的后缀链接一直往回走 将所遍历到的节点 都 引一条后缀链接边到nq
            for (; p && node[p].ch[c] == q; p = node[p].link)
                node[p].ch[c] = nq;
        }
    }
}

void add(int a, int b)
{
    e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}

void dfs(int u)
{
    for (int i = h[u]; ~i; i = ne[i])
    {
        int v=e[i];
        dfs(v);
        //回溯时将u串的信息传递给其后缀v
        now[u]=max(now[u],now[v]);
    }
}

int main()
{
    memset(h, -1, sizeof h);
    scanf("%d", &n);
    scanf("%s", str+1);
    //将第一个串去构建后缀自动机
    for (int i = 1; str[i]; i ++ ) 
        extend(str[i] - 'a');
    //初始化每个状态节点的答案为其长度
    for (int i = 1; i <= tot; i ++ ) 
        ans[i] = node[i].len;
    //这里是从i=2开始  因此i=2的link是根节点1 所以从2开始就可以了
    //从后缀链接的那个节点node[i].link向节点i连一条有向边
    for (int i = 2; i <= tot; i ++ ) 
        add(node[i].link, i);
    //读入剩下的n-1个串
    for (int i = 0; i < n - 1; i ++ )
    {
        scanf("%s", str+1);
        memset(now, 0, sizeof now);
        int p = 1, t = 0;   //t是长度
        for (int j = 1; str[j]; j ++ )
        {
            int c = str[j] - 'a';
            //当沿着后缀链接还没有走到根节点
            //并且状态节点p所表示的串中后面不能接上字符c时
            while (p > 1 && !node[p].ch[c]) 
            {
                p = node[p].link;   //沿着后缀链接一直往前走
                t = node[p].len;    //更新此时最长公共串的长度
            }
            //退出循环时 特判如果node[p].ch[c]不为0
            //则在状态节点p所表示的串中接上字符c
            if (node[p].ch[c])
            {
                p = node[p].ch[c];  //新添了一个字符 p转移到下一个状态节点node[p].ch[c]
                t++;    //长度+1
            }
            //取每个状态节点中的最大值
            now[p] = max(now[p], t);
        }
        //从根节点1开始进行深搜遍历这颗后缀链接树
        dfs(1);
        //选择所有的这些串中关于状态节点p的最小的那个长度
        for (int j = 1; j <= tot; j ++ ) 
            ans[j] = min(ans[j], now[j]);
    }
    int res = 0;    //这些串的最长公共子串的长度
    //遍历所有状态节点  选择能够取到最大值的那个状态节点p
    for (int i = 1; i <= tot; i ++ ) 
        res = max(res, ans[i]);
    printf("%d\n", res);
    return 0;
}

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

卷心菜不卷Iris

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值