题解 P5576 【[CmdOI2019]口头禅】
command_block
·
·
题解
这里是官方题解。
题意 : 给出 n 个字符串 S_1,S_2,...S_n。
共有 m 次询问,每次给出 l,r 求字符串 S_l,S_{l+1},...S_r 的最长公共子串长度。
------------
本题的 SAM 上两个自然根号做法,以及一个 ${\rm poly}(\log)$ 做法 /jy。
关于“自然根号”结论说明,可见 : [分块相关杂谈](https://www.luogu.com.cn/blog/command-block/fen-kuai-xiang-guan-za-tan)。
小声说 :这是 $\rm CmdOI2019$ 中唯一一个先有题面后有算法的题目。
- ### **做法①**(std): 分治
广告 : [后缀自动机学习笔记(应用篇)](https://www.luogu.com.cn/blog/command-block/hou-zhui-zi-dong-ji-xue-xi-bi-ji-ying-yong-pian-post)
- 前置知识 A : 用 **SAM** 做 [SP-LCS2](https://www.luogu.org/problem/SP1812)
假设有 $n$ 个串,总长为 $len$ ,我们把某个串 $S$ 当做基准匹配串,剩下的是文本串。
对于其中一个匹配串 $T$ ,求 :
$slen[i]$ 表示 $S$ 中 $S[i-slen[i]+1,i]$ 在 $T$ 中出现过,即以 $i$ **结尾**的位置的**最长**匹配长度。
这是经典问题,用 $S$ 在 $T$ 的 SAM 中匹配一次即可求得,故不赘述。
最后将所有匹配串所得到的 $slen[i]$ 取 $\min$ ,最后把整个数组取 $\max$ 即可。
复杂度为 $O(|S|n+len)$ ,建 SAM 的总复杂度 $O(len)$ ,$S$ 要在每个串上跑匹配,复杂度是$O(|S|n)$。
很明显,选择这些字符串中长度最短的串做基准串,跑的最快。
- 前置知识 B :猫树分治,又称二区间合并等。
可见 [P6240 好吃的题目](https://www.luogu.com.cn/problem/P6240) 及相关题解。
- 原问题
并没有强制在线,考虑猫树分治。
(附 : 把分治树存下来,类似于猫树就可以做到强制在线了。但是所需空间较大,并不实用)
当分治到某个区间时 $[l,r]$ ,选取关键串 $S_k,k\in[l,r]$。处理所有跨越 $k$ 的询问。
以 $S_k$ 为基准匹配串,分别向左向右匹配,求出各个 $slen$ 数组的“前缀 $\min$”
在求答案时,将询问区间的两个端点处的 $slen$ 前缀 $\min$ 合并,然后对整个数组取 $\max$ 即可。
这样,若分治时选择的 $S_k$ 较长,则复杂度会退化。直接使用取中点的普通分治是不可行的,考虑设计更好的分治策略 : **倍增分治**。
对于一个阈值 $x$ ,称长度小于等于 $2^x$ 的为短串,长度大于 $2^{x+1}$ 的为长串。显然长串的个数为 $O(len/2^x)$。
分治到某区间之后,找出所有短串,取其中间位置做基准串。这样分治直到区间里都是长串,将阈值 $x$ 增加 $1$。
接下来计算该算法的复杂度。首先考虑求 $slen$ 的部分。
观察分治树,对于一个 $x$ ,对应的短串在 $O(\log n)$ 层分治后就被耗尽。
而阈值为 $x$ 时,分治区间的总大小是 $O(len/2^x)$ 的,基准匹配串的长度为 $O(2^x)$ ,故花费的时间为 $O(2^x(len/2^x)\log n)=O(len\log n)$。
阈值 $x$ 共有 $O(\log len)$ 个,故总复杂度为 $O(len\log len\log n)$ 。
接下来考虑处理询问的复杂度。
分治中合并答案的复杂度为 $O(\text{对应基准串长度})$。
观察上面的分治,不难发现,一个询问区间所对应的基准串,不会超过区间中最短串的 $2$ 倍。(因为倍增嘛)
由最小值分治的结论,这里会产生一个自然根号。
即 : 对询问记忆化后,复杂度为 $O(len\sqrt{m})$。
综上所述,总时间复杂度为 $O\big((len+m)\log len\log n+len\sqrt{m}\big)$ ,空间复杂度线性。
由于所有操作均为暴力 `for` 和取 $\min$ ,常数**较小**,可以轻松通过本题的数据范围。
**Code:**
特别神奇的是,下面这份代码我一遍写完就和暴力拍上了,可喜可贺。
```cpp
#include<algorithm>
#include<cstring>
#include<cstdio>
#include<map>
#define MaxS 400500
#define MaxM 100500
#define MaxN 20500
using namespace std;
inline int read()
{
int X=0;char ch=0;
while(ch<48||ch>57)ch=getchar();
while(ch>=48&&ch<=57)X=X*10+(ch^48),ch=getchar();
return X;
}
struct Node
{int t[2],f,len;}a[MaxS<<1];
int tn;
struct SAM
{
int las,root;
void add(int c)
{
int np=++tn,p=las; las=np;
a[np].len=a[p].len+1;
for (;p&&!a[p].t[c];p=a[p].f)a[p].t[c]=np;
if (!p)a[np].f=root;
else {
int v=a[p].t[c];
if (a[p].len+1==a[v].len)a[np].f=v;
else {
int nv=++tn; a[nv]=a[v];
a[nv].len=a[p].len+1;
for (;p&&a[p].t[c]==v;p=a[p].f)a[p].t[c]=nv;
a[v].f=a[np].f=nv;
}
}
}
void build(char *str,int len)
{
las=root=++tn;
for (int i=0;i<len;i++)
add(str[i]-'0');
}
}t[MaxN];
char _str[MaxS],*sp=_str,*str[MaxN];
int ans[MaxM],len[MaxN];
struct Data
{int l,r,pos;}b[MaxM],bl[MaxM],br[MaxM];
int _slen[MaxS<<3],*slen[MaxN],*lenp;
int tp[MaxN];
map<pair<int,int>,int> sav;
void solve(int l,int r,int tl,int tr,int lim)
{
if (tl>tr)return ;
int tot=0;
while(1){
for (int i=l;i<=r;i++)
if (len[i]<=lim)tp[++tot]=i;
if (tot)break;
else lim<<=1;
}int mid=tp[(tot+1)/2];
lenp=_slen;
for (int i=l;i<=r;i++){
slen[i]=lenp;
lenp+=len[mid]+1;
}for (int i=0;i<len[mid];i++)slen[mid][i]=i+1;
for (int i=mid-1,p,plen;i>=l;i--){
p=t[i].root;plen=0;
for (int j=0,c;j<len[mid];j++){
c=str[mid][j]-'0';
if (!a[p].t[c]){
while(p!=t[i].root&&!a[p].t[c])p=a[p].f;
plen=a[p].len;
}
if (a[p].t[c]){
p=a[p].t[c];plen++;
}slen[i][j]=min(slen[i+1][j],plen);
}
}
for (int i=mid+1,p,plen;i<=r;i++){
p=t[i].root;plen=0;
for (int j=0,c;j<len[mid];j++){
c=str[mid][j]-'0';
if (!a[p].t[c]){
while(p!=t[i].root&&!a[p].t[c])p=a[p].f;
plen=a[p].len;
}
if (a[p].t[c]){
p=a[p].t[c];plen++;
}slen[i][j]=min(slen[i-1][j],plen);
}
}
int nl=0,nr=0;
for (int i=tl;i<=tr;i++){
if (b[i].l<=mid&&mid<=b[i].r){
pair<int,int> kk=make_pair(b[i].l,b[i].r);
if (sav.count(kk))
ans[b[i].pos]=sav[kk];
else {
int ret=0;
for (int j=0;j<len[mid];j++)
ret=max(ret,min(slen[b[i].l][j],slen[b[i].r][j]));
sav[kk]=ans[b[i].pos]=ret;
}
}else if (b[i].r<mid)bl[++nl]=b[i];
else br[++nr]=b[i];
}int tmid=tl+nl-1;
for (int i=1;i<=nl;i++)b[tl+i-1]=bl[i];
for (int i=1;i<=nr;i++)b[tl+nl+i-1]=br[i];
solve(l,mid-1,tl,tl+nl-1,lim);
solve(mid+1,r,tl+nl,tl+nl+nr-1,lim);
}
int n,m;
int main()
{
n=read();m=read();
for (int i=1;i<=n;i++){
scanf("%s",str[i]=sp);
len[i]=strlen(sp);
t[i].build(str[i],len[i]);
sp+=len[i];
}
for (int i=1;i<=m;i++){
b[i].l=read();b[i].r=read();
b[i].pos=i;
}
solve(1,n,1,m,10);
for (int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}
```
**附** : 利用 $slen$ 数组也可求出区间本质不同公共子串数目。留做习题。
- ### **做法②** : 广义 SAM + 扫描线
对于广义 SAM 上的节点 $u$ ,记 $P_u$ 为在 $parent$ 树中 $u$ 子树内存在终止节点的串的集合。
当询问区间 $[l,r]$ 时,若 $[l,r]\subseteq P_u$ ,则点 $u$ 可以向答案贡献。
考虑逐步增大 $r$ ,维护每个 $l$ 的答案。
对于 SAM 上的点 $u$ ,记 $P_u$ 中从 $r$ 向前的极长连续段的左端点为 $l_u$。
在询问 $[l,r]$ 时,若 $l_u\leq l$ 则点 $u$ 能贡献。
这里又有一个自然根号 : 广义 SAM 上 $\sum_u |T_u|$ 是 $O(len\sqrt{len})$ 级别的。
于是,在 $r$ 增加时,对所有 $P_u$ 中含 $r$ 的点暴力更新即可。
有 $O(len\sqrt{len})$ 次单点修改,$O(m)$ 次区间查 $\rm max$ ,使用 $O(1)-O(\sqrt{n})$ 分块,复杂度为 $O(len\sqrt{len}+m\sqrt{n})$。
卡常后可以通过本题。
- ### **做法③** : 广义 SAM + 线段树
可见 [(Mina)【题解】[CmdOI2019] 口头禅 广义 SAM -永无岛](https://www.mina.moe/archives/13606)
这里也简要地介绍一下该做法。
用 `std::set` 维护 $P_u$ 中的连续段,然后启发式合并。这部分复杂度为 $O(len\log len\log n)$。
按照节点的 $len$ 从大到小枚举,由于 $parent$ 树上 $len$ 从深到浅递减,故按这个顺序也可以顺便进行合并。
每次合并后,若产生新的连续段(该事件最多会发生 $O(len)$ 次),回答所有当前节点能够贡献到的询问,然后将这些询问删除。
使用线段树维护询问,将询问 $[l,r]$ 挂在位置 $l$ ,权值为 $r$。
需要寻找 $[L,R]$ 包含的所有询问时,查询 $[L,R]$ 内的权值最小值 $c$ ,若 $c\leq R$ ,则说明找到了一个合适的区间。
则复杂度为 $O(len\log len\log n)$ ,空间也是线性。
有空补代码。