最长公共子串
题目描述
核心思路
题意:求 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又回到上一次枚举的位置的下一个字符呢?
如下图分析:
由此可见,既然状态节点 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
如下图所示,我们来感性认识一下上面提到的这个后缀链接的性质:
如图,状态节点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
p,now[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;
}