从SA入门到SAM精通
SA
基本应用
读入一个长度为 $ n $ 的由大小写英文字母或数字组成的字符串,请把这个字符串的所有非空后缀按字典序(用 ASCII 数值比较)从小到大排序。
解法
1.将每个后缀取出来,直接排序 \(O(n^2 \log n)\)
2.用hash二分LCP比较下一位,\(O(n \log^2 n)\)
3.倍增求后缀数组,\(O(n \log n)\)
4.高级方法求后缀数组,\(O(n)\)
倍增
先比较每个后缀的第一位,再比较前两位,前四位...
问题在于如何快速比较前两位,前四位。
一个有趣的性质是在比较\(2^k\)位时,我们知道\(2^{k-1}\)位的大小,所以\(2^k\)位的大小只与前一半\(2^{k-1}\)和后一半\(2^{k-1}\)有关,所以可以用基数排序由上一层推到这一层。

基数排序
正常基数排序,是按数位从高到低依次比较大小,比如说三位数,就先比较百位的数字,将百位为 \(0\) 的放在一起,将百位为 \(1\) 的放在一起...。然后,对十位进行比较,在百位为 \(0\) 的里面把十位为 \(0\) 的放在一起,十位为 \(1\) 的放在一起...,最后所有数都有序。
SA的基数排序,就是相当于只有两位数来排序。
代码实现
代码比较抽象要多理解,多思考
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,sa[N],rk[N],x[N],y[N],cnt,num;
char s[N];
void SA()
{
for(int i=1;i<=n;i++)rk[x[i]=s[i]]++;//rk辅助数组,x是上一层的排名
for(int i=1;i<=m;i++)rk[i]+=rk[i-1];
for(int i=n;i>=1;i--)sa[rk[x[i]]--]=i;//正序倒序都可以,sa是排名为i的后缀的起始下标
for(int k=1;k<=n;k<<=1)
{
cnt=0;
for(int i=n-k+1;i<=n;i++)y[++cnt]=i;//没有后一半是最强的,最靠前的
for(int i=1;i<=n;i++)if(sa[i]>k)y[++cnt]=sa[i]-k;//如果可以做后一半,就做
//正序枚举,因为y的顺序是后一半从小到大的顺序
for(int i=1;i<=m;i++)rk[i]=0;//清零
for(int i=1;i<=n;i++)rk[x[i]]++;//根据前一半
for(int i=1;i<=m;i++)rk[i]+=rk[i-1];
for(int i=n;i>=1;i--)sa[rk[x[y[i]]]--]=y[i],y[i]=0;//后一半更大的在前一半相同时排后面
swap(x,y);//y临时存一下上一层x的值。
x[sa[1]]=1,num=1;
for(int i=2;i<=n;i++)
{
x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;//确定这一层的排名
}
if(num==n)break;//分完了
m=num;
}
for(int i=1;i<=n;i++)cout<<sa[i]<<' ';
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>s+1;
n=strlen(s+1),m=150;
SA();
return 0;
}
进阶应用
LCP与hieght数组
LCP:最长公共前缀,\(lcp(i,j)\) 表示字符串 \(i\) 与字符串 \(j\) 的最长公共前缀。
思考题:如果一直\(sa\) 数组如何快速求任意两个后缀的最长公共前缀。(想不出来是正常的不然还有什么讲的必要)
要先引入一个新数组 \(height\),定义\(height[i]=lcp(sa[i],sa[i-1])\),现在先想如何求 \(height\) 数组。感觉也不好求。
那我们先研究它的性质,我们可以发现 \(height[rk[i]-1]-1 \le height[rk[i]]\) (发现不了一点) 考虑如何证明。

我们先将从 \(i-1\) 到 \(n\) 和 \(k\) 到 \(n\) 的后缀取出来.(\(rk[k]=rk[i-1]-1\))。图中的黄色部分就是它们的LCP,如果\(lcp\le 1\) 上面的式子显然成立,考虑大于 \(1\) 的情况。

我们可以去掉开头字母,则 \(k+1\) 与 \(i\)的lcp为 \(height[rk[i]-1]-1\),所以\(height[i]\) 至少为 \(height[rk[i]-1]-1\),因为只有可能出现下图的情况或就是\(k+1\).

这样就可以继承前面的信息暴力改。
void geth()
{
int kk=0;
for(int i=1;i<=n;i++)x[sa[i]]=i;
for(int i=1;i<=n;i++)
{
if(x[i]==1)continue;
if(kk)kk--;
int j=sa[x[i]-1];
while(i+kk<=n&&j+kk<=n&&s[i+kk]==s[j+kk])kk++;
hi[x[i]]=kk;
}
}
时间复杂度,\(k\) 不会超过 \(n\),最多加 \(2n\) 减 \(n\) 所以时间复杂度是 \(O(n)\)。
回到最开始的地方,现在一直 \(height\) 如何求任意后缀LCP,显然是它们的最小值(感性理解即可,不理解就是没有理解后缀数组多看几遍)。所以可以用st表维护最小值。
可重叠最长重复子串
\(height\)的最大值。
规定长度就是区间 \(height\) 的最小值的最大值:P2852
不可重叠最长重复子串
首先二分答案 \(x\), 对height数组进行分组,保证每一组最小都大于 \(x\)(有x的长度)
依次枚举每一组,记录下最大和最小长度,如果相减大于\(x\)(不重叠)那么可以更新答案。
本质不同子串
子串是后缀的前缀,后缀拍完序之后,每次新增的只有除LCP以外的子串。
所以总数为 \(\frac{n*(n+1)}{2}-\sum_{i=1}^{n}height[i]\) P2408
比较子串大小
求出两子串所属的后缀的LCP,如果LCP比长度大则一个是另一个的子串,所以按长度即可比较大小,如果不是,则直接根据 \(rk\) 比较大小。
更多
SAM
字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式。值得注意的事实是,SAM 将所有的这些信息以高度压缩的形式储存。对于一个长度为 \(n\) 它的空间复杂度仅为 \(O(n)\), 时间复杂度\(O(n)\) 。(准确的所是\(O(n\log m)\),m为字符集大小。
标准定义:字符串 \(s\) 的 SAM 是一个接受 \(s\) 所有后缀的最小 DFA(确定性有限状态自动机)
前置
endpos
定义为字符串在一个 非空子串 \(s'\) 在 \(s\) 中的 结束位置 构成的集合。
根据定义我们可以得出一些性质。
-
对于 \(s\) 的两个非空节点 \(a,b\) ( \(|a|\le |b|\) ) ,如果 \(endpos(a)=endpos(b)\) 则 \(a\) 是 \(b\) 的后缀 (同一位置出现了 \(a\) 和 \(b\) ,且 \(|a|\le |b|\) )。
-
如果 \(a\) 是 \(b\) 的后缀,\(endpos(b)\subseteq endpos(a)\) (所以 \(b\) 出现时 \(a\) 都会出现,但是 \(a\) 出现 \(b\) 不一定出现)。相反如果不是,则\(endpos(a) \cap endpos(b)=∅\) 。
有上面两条性质,我们可以推出,如果将 \(endpos\) 相同的放在一起叫一个 \(endpos\) 等价类,我们可以发现
- 同一个 \(endpos\) 等价类中的字符串的长度不相同且连续。
parent 树
因为只有包含或不交的东西(性质 \(2\) ),我们可以把它建成树的样子。
所以将一个 \(endpos\) 等价类当成一个节点,我们可以根据包含关系,建出树,这棵树就是 \(parent\) 树。树上的父亲就是最长的不属于的后缀。
在 \(parent\) 树上跳父亲,相当于在子串前面删一些字符 (不一定只有一个) 。
SAM 的构造
$parent $ 树已经接受了所有子串,我们用树上的节点来建 SAM 。我们现在可以实现在前面删字符,还要实现在后面加字符。这个我们可以仿照 \(tire\) 将加一个字符的转移到的节点记下来,来实现向后加数。
我们考虑新加入一个字符,我们肯定要新建一个点(整个串一定是第一次出现)。我们要记录上一个前缀节点 \(lst\) 。从这个点开始跳父亲(在前面删字符即取后缀),如果这个点没有这个字符的出边,就新建一条边。如果遇到了,就停下来,设沿这条边到的节点是 \(q\) 。我们要看 \(q\) 的 \(endpos\) 中的字符串是不是全是 \(lst\) 的 后缀,如果是就直接将新节点的父亲指向它,否则我们发现这个 \(endpos\) 中的字符串结束位置出现了不同,有一些出现了结尾的结束位置,有的没有。所以我们将 \(q\) 拆成两个点,将新点指向拆出来的只有后缀的点。怎么判断后缀?本节点的最大长度是否是上一个节点的最大长度加一(因为是长度递减地找,长度更长的串一定不是,是的话就不会在这找到,会先找到)。
void insert(int c)
{
int p=las,np=las=++tot;
sam[np].len=sam[p].len+1;
for(;!sam[p].ch[c]&&p;p=sam[p].fa)sam[p].ch[c]=np;
if(!p){sam[np].fa=1;return;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;sam[p].ch[c]==q&&p;p=sam[p].fa)sam[p].ch[c]=nq;
}
SAM的点数最多大约为为 \(O(2n-1)\) (考虑一个有 \(n\) 个的二叉树),边数最多也是 \(O(n)\) (字符集大小是常数 ,如果字符集过大,可以开 \(map\) ) 。构建的时间复杂度是 \(O(n)\) 。(我也没太懂,可以看这里具体证明)
广义SAM
定义多个字符串 \(s_1,s_2,...,s_n\) 的 SAM 是一个接受 \(s_1,s_2,...,s_n\) 所有后缀的最小 DFA。
有不少的假做法,我们直接讲正确的做法。
有两种,一种是无需特判的 离线BFS ,一种是加特判的在线做法(我还不会,先咕了)。
离线做法
我们可以先对所有的串建出一颗 \(Tire\) 树,然后遍历这颗 \(Trie\) 建 SAM ,建都是一样的建,所以建的东西一定是可以接受所有后缀的。然后又由于 \(Tire\) 自己压缩了很多重复的东西,所以复杂度也是对的。
可以证明复杂度为 \(O(tire树节点数)\) 。
void insert()
{
int len=strlen(s+1),u=1;
for(int i=1;i<=len;i++)
{
int c=s[i]-'a';
if(!sh[u][c])sh[u][c]=++cnt,fa[cnt]=u,z[cnt]=c;
u=sh[u][c];
}
}//建出trie树
void bfs()
{
for(int i=0;i<26;i++)if(sh[1][i])q.push(sh[1][i]);
pos[1]=1;//pos记录对应SAM上的点的编号
while(!q.empty())
{
int u=q.front();q.pop();
pos[u]=add(z[u],pos[fa[u]]);
for(int i=0;i<26;i++)if(sh[u][i])q.push(sh[u][i]);
}
}
SAM部分是一样的,唯一的区别是要返回新建的节点(要存下来,不能直接用 \(lst\)) 。
基本应用
本质不同子串个数
思路
- 因为SAM的每一条路径对应一个子串,等价于DAG求路径数,dp。
- 将每个点上的字符串相加,节点 \(x\) 的字符串数是 \(len_x-len_{fa_x}\) (父亲的长度是节点中最短的长度减一)
题目
P2408 不同子串个数
板子题。
按照上面的思路实现即可。
#include<bits/stdc++.h>
using namespace std;
using i64=long long;
constexpr int maxn=1e5+10;
char s;
int n,tot,lst,tr[maxn<<1][26],lnk[maxn<<1],len[maxn<<1];
i64 ans;
void ins(int c){
int p=lst;int nw=lst=++tot;
len[nw]=len[p]+1;
for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
if(!p) lnk[nw]=1;
else{
int q=tr[p][c];
if(len[p]+1==len[q]) lnk[nw]=q;
else{
int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
lnk[nw]=nq;lnk[nq]=lnk[q];lnk[q]=nq;
for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
}
}
}
int main(){
ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
cin>>n;tot=lst=1;for(int i=1;i<=n;++i){ cin>>s;ins(s-'a');}
for(int i=1;i<=tot;++i) ans+=len[i]-len[lnk[i]];
cout<<ans<<endl;
return 0;
}
SP7258 SUBLEX - Lexicographical Substring Search - 洛谷
变成了本质不同的第 \(k\) 小的字符串。
相同的,等价于求 DAG 路径的第 \(k\) 小,做一个 dp 即可。(具体地,记录每个点有多少条路径,然后从起点贪心的走)
#include<bits/stdc++.h>
using namespace std;
using i64=long long;
constexpr int maxn=5e5+10;
int n,t,k,tot,lst,tr[maxn<<1][26],len[maxn<<1],lnk[maxn<<1],siz[maxn<<1];
char s[maxn];
bool vis[maxn<<1];
i64 f[maxn<<1];
void ins(int c){
int p=lst;int nw=lst=++tot;
len[nw]=len[p]+1;
for(;p&&!tr[p][c];p=lnk[p]) tr[p][c]=nw;
if(!p) lnk[nw]=1;
else{
int q=tr[p][c];
if(len[q]==len[p]+1) lnk[nw]=q;
else{
int nq=++tot;len[nq]=len[p]+1;copy(tr[q],tr[q]+26,tr[nq]);
lnk[nq]=lnk[q];lnk[q]=nq;lnk[nw]=nq;
for(;p&&tr[p][c]==q;p=lnk[p]) tr[p][c]=nq;
}
}
}
void dfs(int u){
if(vis[u]) return;vis[u]=true;
for(int x,i=0;i<26;++i) if(x=tr[u][i]){ dfs(x);f[u]+=f[x];}
}
void solve(int u,int s){
if(s<=siz[u]) return;
s-=siz[u];
for(int i=0;i<26;++i){
int x=tr[u][i];
if(!x) continue;
else if(s>f[x]) s-=f[x];
else{ cout<<(char)(i+'a');solve(x,s);return;}
}
}
int main(){
ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
cin>>(s+1)>>t;n=strlen(s+1);tot=lst=1;
for(int i=1;i<=n;++i) ins(s[i]-'a');
for(int i=1;i<=tot;++i) f[i]=siz[i]=1;
siz[1]=f[1]=0;dfs(1);
while(t--){
cin>>k;
if(f[1]<k){ cout<<"-1"<<endl;continue;}
solve(1,k);cout<<endl;
}
return 0;
}
P3975 [TJOI2015] 弦论
上一道题的加强版。
我们只需要解决不同位置算多个的方案数。思考求本质不同答案时,每个点初始化为 \(1\) , 相当于到达一个点后就至少一种本质不同字符串,在加上走一条边到达的字符串。现在,我们可以初始化为每个点的 \(endpos\) 集合大小,表示到了这个点就有至少这么多个字符串。
而每个点的 \(endpos\) 大小怎么求? 将所有前缀节点赋为 \(1\) ,子树求和。
#include <bits/stdc++.h>
using namespace std;
#define ll long long
const int N=2e6+10;
struct node
{
int len,siz,fa,ch[30];
}sam[N];
int tot=1,t,k,lst=1;
ll f[N];
char s[N];
vector<int> ve[N];
void insert(int c)
{
int np=++tot,p=lst;lst=np;
sam[np].len=sam[p].len+1;sam[np].siz=1;
while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
if(!p){sam[np].fa=1;}
else
{
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1)sam[np].fa=q;
else
{
int nq=++tot;
sam[nq]=sam[q];sam[nq].siz=0;sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
}
}
}
void dfs1(int u)
{
int len=ve[u].size();
for(int i=0;i<len;i++)
{
int v=ve[u][i];
dfs1(v),sam[u].siz+=sam[v].siz;
}
}
void dfs(int u)
{
if(f[u])return;
f[u]=t?sam[u].siz:1;
for(int i=0;i<26;i++)
{
int v=sam[u].ch[i];
if(!v)continue;
dfs(v);
f[u]+=f[v];
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>(s+1);
int len=strlen(s+1);
for(int i=1;i<=len;i++)insert(s[i]-'a');
//建sam
for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);//将parent树建出来
dfs1(1);
//预处理
cin>>t>>k;k+=t?len:1;
dfs(1);
if(k>f[1]){cout<<"-1"<<'\n';return 0;}
int u=1;
while(1)
{
int z=t?sam[u].siz:1;
if((k-=z)<=0)break;
for(int i=0;i<26;i++)
{
int v=sam[u].ch[i];
if(!v)continue;
if(f[v]<k)k-=f[v];
else
{
cout<<char(i+'a');
u=v;break;
}
}
}
return 0;
}
P4070 [SDOI2016]生成魔咒 - 洛谷
我们发现它就是要求本质不同字符串,唯一的不同是每加入一个就要输出答案,所以不能每次都在 DAG上跑或每次重新建树求答案,所以我们可以考虑维护答案的变化量。我们可以用对每一个节点求个数的方案来理解,每一次新建节点的时候都暴力维护贡献的变化。(减去旧的加上新的)
#include <bits/stdc++.h>
using namespace std;
const int N=2e6+10;
int n,m,k,cnt=1,las=1,num,tot,hd[N],go[N],nxt[N];
char s[N];
#define ll long long
ll zhi[N],ans=0;
void insert(int x,int y)
{
nxt[++tot]=hd[x];go[tot]=y;hd[x]=tot;
return ;
}
struct node
{
int fa,len;
map<int,int> ch;
}dian[N];
void add(int c)//char?
{
int p=las,np=las=++cnt;zhi[cnt]=1;
dian[np].len=dian[p].len+1;
for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np;
if(!p)dian[np].fa=1,ans+=(dian[np].len-dian[1].len);//1kaishi
else
{
int q=dian[p].ch[c];
if(dian[q].len==dian[p].len+1)dian[np].fa=q,ans+=(dian[np].len-dian[dian[np].fa].len);
else
{
int nq=++cnt;
dian[nq]=dian[q];dian[nq].len=dian[p].len+1;
ans+=(dian[dian[q].fa].len-dian[q].len);
ans+=(dian[nq].len-dian[dian[nq].fa].len);
dian[np].fa=dian[q].fa=nq;
ans+=(dian[np].len-dian[dian[np].fa].len);
ans+=(dian[q].len-dian[dian[q].fa].len);
for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;
}
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)
{
int x;
cin>>x,add(x),cout<<ans<<'\n';
}
return 0;
}
中间那坨很吓人,而且很相似,我们仔细分析发现其实除了 \(3\) 以外的 \(3\) 个式子是可以消掉的,所以只有 \(np\) 点的有用。
其实这不是巧合,可以证明分裂节点不影响本质不同字符串数量只有新增才会影响。(可以感性理解分裂只是将本来有字符串分到两个点来计数)。
最小表示法
每次可以将做最左边的方块放在最右边,求出能操作出的最小字典序。
思路
显然是一个环的结构,所以断环为链(复制一边放在后面),那么答案就是这个字符串中长度为 \(n\) 的子串最小的字典序。
SAM每一条路径就是一个子串,所以贪心的选择最优走 \(n\) 次即可。
例题
P1368 【模板】最小表示法
板子题。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
int len,c[40],fa;
}sam[N];
int n,k[N],las=1,tot=1;
void insert(int x)
{
int p=las,u=++tot;
sam[u].len=sam[las].len+1;las=u;
while(p&&!sam[p].c[x])sam[p].c[x]=u,p=sam[p].fa;
if(!p){sam[u].fa=1;return;}
int q=sam[p].c[x];
if(sam[q].len==sam[p].len+1)sam[u].fa=q;
else
{
int nq=++tot;
sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[u].fa=sam[q].fa=nq;
while(p&&sam[p].c[x]==q)sam[p].c[x]=nq,p=sam[p].fa;
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)cin>>k[i],k[i+n]=k[i];
for(int i=1;i<=2*n;i++)insert(k[i]);
int pos=1;
for(int i=1;i<=n;i++)
{
for(int j=0;j<=30;j++)
if(sam[pos].c[j])
{
pos=sam[pos].c[j];
cout<<j<<" ";
break;
}
}
return 0;
}
求SAM上两个节点对应的串的最长公共后缀
比较重要的性质,但是也比较简单。
思路
因为对于一个节点到根的路径上的点都是后缀,所以两个的公共后缀就是树上 \(lca\) 的最长串。
求一个串在区间 \([l,r]\) 中的出现次数
引出一个和SAM比较常结合的算法,(可持久化)线段树合并。
思路
比较显然的是,这个东西明显就是找到这个串的 \(endpos\) 然后求在 \(l,r\) 之间的有多少个。
刚才我们做了一个求 \(endpos\) 的大小的题,这一次我们不仅要求个数,还要记录下位置,最后的答案是区间求某个东西。这让我们想起了线段树,我们可以对每一个点开一个线段树维护 \(endpos\) 是否包含 \(i\) 这个位置,询问就是区间求和。将前缀节点初始化,然后线段树合并上去就可以得到每一个点的信息。
同时,这种做法还可以维护每个串的最早出现次数,所有出现位置(修改线段树即可)。
求两个串的最长公共子串
思路
首先我们要建SAM,两个串都是一样,所以我们可以考虑随便选一个比如拿 \(1\) 来建 SAM 。建完以后呢?我们可以双指针扫第二个串,对于每一个 \(i\) 记录最长的长度 \(len\) 使第二个串的 \([i-len+1,i]\) 出现在第一个串中,然后所有值取最大即可。
正确性显然。具体是双指针这么做?其实就是加入一个字符串就考虑在SAM上转移,如果没有这条出边就跳 \(parent\) 树,一直到有为止。时间复杂度显然 \(O(n)\) 。
例题
SP1811 LCS - Longest Common Substring - 洛谷
板子题。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
int ch[30],len,fa;
}sam[N];
int ans,cur,le,tot=1,las=1;
char s[N],t[N];
void insert(int c)
{
int p=las,np=las=++tot;
sam[np]=sam[p];sam[np].len=sam[p].len+1;
while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
if(!p){sam[np].fa=1;return ;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
}
void search(int c)
{
if(sam[cur].ch[c])
{
cur=sam[cur].ch[c];le++;
ans=max(ans,le);
return ;
}
while(cur&&!sam[cur].ch[c]) cur=sam[cur].fa;
if(!cur){cur=1,le=0;return ;}
le=sam[cur].len+1;cur=sam[cur].ch[c];//注意向上跳了之后,长度取是节点的maxlen
ans=max(ans,le);
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>(s+1)>>(t+1);
int len=strlen(s+1);
for(int i=1;i<=len;i++)insert(s[i]-'a');
//build sam
len=strlen(t+1);cur=1,le=0;
for(int i=1;i<=len;i++)search(t[i]-'a');
cout<<ans<<'\n';
return 0;
}
SP1812 LCS2 - Longest Common Substring II - 洛谷
和上一道唯一的区别是不只两个字符串,但是我们可以延续两个字符串的做法。
将第一个字符串建SAM,一个一个扫剩下的字符串。但是答案不能在想上一道题一样得到,我们考虑在 SAM 上统计答案,对每个点维护最大匹配长度。
如何维护?首先每一个字符串求出在每个点的匹配长度,最后答案一定是这些长度取 \(\min\) 。然后就变成一个串如何求? 首先一个点能匹配到,它的 \(parent\) 树上的祖先就都能被匹配到,所以子树答案取 \(\max\) 再与自身长度取 \(\min\) 。所以在每一次经过一个节点时就取 \(\max\) 当初始化,最后 dfs 一遍即可。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
int ch[30],len,fa;
}sam[N];
int cur,le,tot=1,las=1,f[N],ans[N];
char s[N],t[N];
vector<int> ve[N];
void insert(int c)
{
int p=las,np=las=++tot;
sam[np]=sam[p];sam[np].len=sam[p].len+1;
while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
if(!p){sam[np].fa=1;return ;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
}
void search(int c)
{
if(sam[cur].ch[c])
{
le++;cur=sam[cur].ch[c];
f[cur]=max(f[cur],le);
return ;
}
while(cur&&!sam[cur].ch[c]) cur=sam[cur].fa;
if(!cur){cur=1,le=0;return ;}
le=sam[cur].len+1;cur=sam[cur].ch[c];
f[cur]=max(f[cur],le);
}
void dfs(int u)
{
int len=ve[u].size();
for(int i=0;i<len;i++)
{
int v=ve[u][i];
dfs(v);
f[u]=max(f[u],f[v]);
}
ans[u]=min(ans[u],f[u]);
return;
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>(s+1);
int len=strlen(s+1);
for(int i=1;i<=len;i++)insert(s[i]-'a');
for(int i=1;i<=tot;i++)ans[i]=sam[i].len,ve[sam[i].fa].push_back(i);//建树
//build sam
while(cin>>(t+1))
{
len=strlen(t+1);cur=1,le=0;
for(int i=1;i<=len;i++)search(t[i]-'a');
dfs(1);
for(int i=1;i<=tot;i++)f[i]=0;
}
cout<<*max_element(ans+1,ans+tot+1)<<'\n';
return 0;
}
快速定位一个串在SAM对应的节点
先假设保证出现在 \(s\) 中。
将前缀节点记录。
然后倍增预处理出 \(k\) 级祖先,根据长度判断即可。
但是如果不一定出现,我们就可以先用刚才求公共子串的双指针,求出每一个 \(i\) 的最长匹配长度判断一下即可。
简单应用
到目前为止,你已经会基本使用SAM解决一些简单问题了。
接下来这有一些不那么一眼的题目。
P6640 [BJOI2020] 封印 - 洛谷
先回忆一下求两个串的最长公共子串怎么做?
这里两个串不同求 \(s\) 一个区间的答案,显然将 \(t\) 建成SAM。然后双指针扫出每个 \(i\) 的最长匹配。然后统计答案,发现显然对于分成两部分来统计,一部分是最长匹配会超出 \(l\) ,一段是不会超出,且这个分界点显然可以二分。所以将两部分的答案取 \(\max\) 即可。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node
{
int len,fa,ch[30];
}sam[N];
int q,ll[N],st[N][20],lg[N],las=1,tot=1;
char s[N],t[N];
void insert(int c)
{
int p=las,np=las=++tot;
sam[np].len=sam[p].len+1;
while(p&&!sam[p].ch[c])sam[p].ch[c]=np,p=sam[p].fa;
if(!p){sam[np].fa=1;return ;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return ;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
while(p&&sam[p].ch[c]==q)sam[p].ch[c]=nq,p=sam[p].fa;
}
int query(int l,int r)
{
if(r<l)return 0;
int k=lg[r-l+1];
return max(st[l][k],st[r-(1<<k)+1][k]);
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>(s+1)>>(t+1)>>q;
int len=strlen(t+1);
for(int i=1;i<=len;i++)insert(t[i]-'a');
len=strlen(s+1);
int cur=1,le=0;
lg[0]=-1;
for(int i=1;i<=len;i++)
{
lg[i]=lg[i>>1]+1;
int c=s[i]-'a';
while(cur&&!sam[cur].ch[c])cur=sam[cur].fa,le=sam[cur].len;
if(!cur)ll[i]=i-le,cur=1,le=0;
else
{
cur=sam[cur].ch[c];le++;
ll[i]=i-le;
}
st[i][0]=i-ll[i];
}
for(int k=1;k<20;k++)
for(int i=1;i+(1<<k)-1<=len;i++)
st[i][k]=max(st[i][k-1],st[i+(1<<(k-1))][k-1]);
for(int i=1;i<=q;i++)
{
int lt,rt;cin>>lt>>rt;
int l=lt,r=rt,jg;
while(l<=r)
{
int mid=(l+r)>>1;
if(ll[mid]<lt)jg=mid,l=mid+1;
else r=mid-1;
}
cout<<max(jg-lt+1,query(jg+1,rt))<<'\n';
}
return 0;
}
SP687 REPEATS - Repeats - 洛谷
题目虽然和子串有关,但是直接做SAM不好统计答案。
我们思考怎么求连续出现的相同的串?如果我们直接找答案串或重复出现的串都不好判断是否满足。
但是我们可以枚举答案串的border,有了border就可以算出答案串的长度。具体地,设border的长度为 \(l\) ,两个border重叠的长度是 \(len\) ,那么答案显然是 $\left \lfloor {l+len\over len} \right \rfloor $ 。(为了让答案最大,我们要让 \(l\) 尽量大,\(len\) 尽量小)
如果你不懂,可以看下面的例子自己试一下。
ABBAABBA
ABBAABBA
还有怎么证这个串一定是重复出现的?
(1)ABBA|(2)ABBA
(3) ABBA | (4)ABBA
根据 border 的定义可以知道,(1) 与 (3)、(2)与(4) 是相同的,又因为(2)和(3)代表的是同一段所以也是相同的。综上,这个字符串是由一个串连续重复出现构成的。
作为 border 的这两个子串一定在同一个 \(endpos\) 里 (因为它们完全相同),所以我们对每个 \(endpos\) 计算答案然后取最值。对于一个 \(endpos\) 的最大答案, \(l\) 就直接用最大值,错位的长度( \(j-i\) ) 要最小可以用线段树维护。然后线段树合并上去,就可在每个点上 \(O(1)\) 得到答案。总时间复杂度为 \(O(n\log n)\) 。
好像结束了,但是我还有一个问题,刚才的证明是两个子串有重叠,如果没有重叠不符合定义也会被统计到会影响答案吗?其实不会,错位的距离是用 $ j-i$ 来算的,如果距离大于 \(l\) 那么算出来答案是 \(1\) (显然是下界不影响真正的答案)。
#include <bits/stdc++.h>
using namespace std;
const int N=2e6+10,inf=0x3f3f3f3f,M=1e5+5;
vector<int> ve[2*M];
char s[10];
int rt[2*M],n,tot=1,las=1,cnt;
struct node
{
int l,r,ls,rs,mn;
node(){l=r=-1;mn=inf;}
}sh[M*60];
struct stu
{
int len,ch[30],fa;
}sam[2*M];
void update(int x)
{
int lls=sh[x].ls,rrs=sh[x].rs;
sh[x].mn=inf,sh[x].l=inf,sh[x].r=-1;
if(lls)sh[x].l=sh[lls].l,sh[x].r=max(sh[x].r,sh[lls].r),sh[x].mn=min(sh[x].mn,sh[lls].mn);
if(rrs)sh[x].r=sh[rrs].r,sh[x].l=min(sh[x].l,sh[rrs].l),sh[x].mn=min(sh[x].mn,sh[rrs].mn);
if(lls&&rrs)sh[x].mn=min(sh[x].mn,sh[rrs].l-sh[lls].r);
}
int modify(int x,int l,int r,int wz)
{
if(!x) x=++cnt;
if(l==r)
{
sh[x].l=sh[x].r=l;
return x;
}
int mid=(l+r)>>1;
if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
update(x);
return x;
}
int merge(int u,int v)
{
if(!u||!v)return u+v;
int x=++cnt;
sh[x].ls=merge(sh[u].ls,sh[v].ls);
sh[x].rs=merge(sh[u].rs,sh[v].rs);
update(x);
return x;
}
void insert(int c)
{
int p=las,np=las=++tot;
sam[np].len=sam[p].len+1;
rt[np]=modify(rt[np],1,M,sam[np].len);
for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
if(!p){sam[np].fa=1;return;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq;
}
void dfs(int u)
{
for(int v:ve[u])
{
dfs(v);
rt[u]=merge(rt[u],rt[v]);
}
}
void clear()
{
memset(rt,0,sizeof(rt));
for(int i=0;i<=cnt;i++)sh[i].l=sh[i].r=-1,sh[i].mn=inf,sh[i].ls=sh[i].rs=0;
cnt=0;
for(int i=0;i<=tot;i++)sam[i]=sam[0],ve[i].clear();
las=tot=1;
}
void solve()
{
clear();
//注意清零
cin>>n;
for(int i=1;i<=n;i++){cin>>s;insert(s[0]-'a');}//先建sam
for(int i=2;i<=tot;i++)
{
ve[sam[i].fa].push_back(i);//再建parent树
}
dfs(1);//遍历树,做线段树合并
int ans=0;
for(int i=1;i<=tot;i++)
{
if(sh[rt[i]].mn==inf)continue;
ans=max(ans,(sam[i].len+sh[rt[i]].mn)/sh[rt[i]].mn);//统计sam每个节点的答案
}
cout<<ans<<'\n';
return ;
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int t;cin>>t;
while(t--)solve();
return 0;
}
SP8093 JZPGYZ - Sevenk Love Oimaster - 洛谷
简单题。
首先对所有模板串建广义SAM,对模板串的每一个前缀节点打上标记,\(parent\) 树上当前节点到根的链上是当前前缀的所有后缀。子串的定义就是所有前缀的后缀,所以找到查询串在SAM上的对应节点,求出子树内有多少种不同的标记,直接用线段树维护信息,用线段树合并将信息上传。
注意字符集,因为离线广义SAM让我写的很难受,所以当做只有小写字母来写的,其实题目没有说明不能直接这样做,可以用下一道的写法。
#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct node
{
int len,fa,ch[30];
}sam[N];
int n,m,tot=1,ctot,cur,cnt=1,ch[N][30],rt[N],pos[N];
vector<int> ve[N],wz[N];
char s[N];
struct stu
{
int ls,rs,sum;
}sh[N*60];
void update(int x){sh[x].sum=sh[sh[x].ls].sum+sh[sh[x].rs].sum;}
int modify(int x,int l,int r,int wz)
{
if(!x)x=++ctot;
if(l==r)
{
sh[x].sum=1;
return x;
}
int mid=(l+r)>>1;
if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
update(x);
return x;
}
void ins(int c,int id)
{
if(!ch[cur][c])ch[cur][c]=++cnt;
cur=ch[cur][c];
wz[cur].push_back(id);
};
int insert(int las,int c,int id)
{
int p=las,np=++tot;
sam[np].len=sam[p].len+1;
for(int v:wz[id])rt[np]=modify(rt[np],1,n,v);
for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
if(!p){sam[np].fa=1;return np;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq;
return np;
}
queue<int> q;
void bfs()
{
for(int i=0;i<26;i++)
{
int v=ch[1][i];
if(v)q.push(v),pos[v]=insert(1,i,v);
}
while(!q.empty())
{
int u=q.front();q.pop();
for(int i=0;i<26;i++)
{
int v=ch[u][i];
if(v)q.push(v),pos[v]=insert(pos[u],i,v);
}
}
}
int merge(int u,int v,int l,int r)
{
if(!u||!v)return u+v;
int x=++ctot;
if(l==r)
{
sh[x].sum=sh[u].sum|sh[v].sum;
return x;
}
int mid=(l+r)>>1;
sh[x].ls=merge(sh[u].ls,sh[v].ls,l,mid);
sh[x].rs=merge(sh[u].rs,sh[v].rs,mid+1,r);
update(x);
return x;
}
void dfs(int u)
{
for(int v:ve[u])
{
dfs(v);
rt[u]=merge(rt[u],rt[v],1,n);
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
cin>>s;
int len=strlen(s);cur=1;
for(int j=0;j<len;j++)ins(s[j]-'a',i);
}
bfs();
for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);
dfs(1);
for(int i=1;i<=m;i++)
{
cin>>s;
int len=strlen(s),u=1,flag=0;
for(int j=0;j<len;j++)
{
int c=s[j]-'a';
if(!sam[u].ch[c])flag=1;
u=sam[u].ch[c];
}
if(flag) cout<<"0"<<'\n';
else cout<<sh[rt[u]].sum<<'\n';
}
return 0;
}
P2336 SCOI2012 喵星球上的点名 - 洛谷
上一道题的加强版,我们发现第一小问与上一题完全一样,但是我们可以用一种不同的做法(也可以用上一题的做法),子树问题可以通过 dfs 序转换为区间问题,那么第一小问转化为区间问题后就是经典的区间数颜色问题,可以用莫队和扫描线等算法来做(我选择了扫描线,毕竟理论时间复杂度更优)。
而第二小问相当于对每个颜色求这种颜色对多少个区间有贡献,我们仍可以用扫描线解决。从左往右扫 \(r\) ,树状数组上所维护的数组 下标 \(i\) 上存的是扫到的有多少个区间左端点是 \(i\) ,遇到一个区间左端点 \(l\) 就将 \(l\) 的位置加一,遇到右端点就将对应的 \(l\) 减一。答案的更新就是,当前位置 \(i\) 的颜色的答案加上包含 \(i\) 的区间数减去包含这个颜色上一次出现的位置的区间数。求区间数就是树状数组求前缀和。
#include <bits/stdc++.h>
using namespace std;
const int N=5e5+10;
struct node
{
int fi,se;
node(){}
node(int x,int y):fi(x),se(y){}
};
struct stu
{
int fa,len;
map<int,int> ch;
}sam[N];
int n,m,ans[N],las=1,tot=1,dfn[N],pre[N],dfnx,c[N],siz[N],sh[N],pp[N];
vector<int> ve[N];
vector<node> q1[N],q2[N];
int lowbit(int x){return x&(-x);}
void modify(int x,int z){while(x<=tot){sh[x]+=z;x+=lowbit(x);}}
int query(int x){int sum=0;while(x){sum+=sh[x];x-=lowbit(x);}return sum;}
void insert(int x)
{
int p=las,np=las=++tot;
sam[np].len=sam[p].len+1;
for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
if(!p){sam[np].fa=1;return ;}
int q=sam[p].ch[x];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
return;
}
void dfs(int u)
{
dfn[u]=++dfnx;pre[dfnx]=u;
siz[u]=1;
for(int v:ve[u])
{
dfs(v);
siz[u]+=siz[v];
}
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++)
{
int len;
cin>>len;
for(int j=1,x;j<=len;j++)cin>>x,insert(x),c[las]=i;
insert(-1);
cin>>len;
for(int j=1,x;j<=len;j++)cin>>x,insert(x),c[las]=i;
insert(-1);
}
for(int i=2;i<=tot;i++)ve[sam[i].fa].emplace_back(i);
dfs(1);
for(int i=1;i<=m;i++)
{
int len,flag=0,cur=1;cin>>len;
for(int j=1,x;j<=len;j++)
{
cin>>x;
if(!sam[cur].ch[x]){flag=1;}
cur=sam[cur].ch[x];
}
if(!flag)//有对应节点
{
int l=dfn[cur],r=dfn[cur]+siz[cur]-1;
q1[r].emplace_back(node(l-1,i));
q2[l].emplace_back(node(l,1));
q2[r+1].emplace_back(node(l,-1));
}
}
for(int i=1;i<=tot;i++)
{
int col=c[pre[i]];
if(col)
{
modify(i,1);
if(pp[col])modify(pp[col],-1);
pp[col]=i;
}
int len=q1[i].size();
for(int j=0;j<len;j++)
{
node qq=q1[i][j];
ans[qq.se]=query(i)-query(qq.fi);
}
}
for(int i=1;i<=m;i++)cout<<ans[i]<<'\n',ans[i]=0;
for(int i=1;i<=tot;i++)sh[i]=0,pp[i]=0;
for(int i=1;i<=tot;i++)
{
for(node qq:q2[i])
{
if(qq.se==1)modify(qq.fi,1);
else modify(qq.fi,-1);
}
int col=c[pre[i]];
ans[col]+=query(i);
if(pp[col])ans[col]-=query(pp[col]);
pp[col]=i;
}
for(int i=1;i<=n;i++)cout<<ans[i]<<' ';
return 0;
}
P4081 [USACO17DEC] Standing Out from the Herd P - 洛谷
并没有前面几道难,一样的打标记如果一个节点的子树里有不只一种标记就不管,否则对这种标记得答案加一,所以只需简单的 dfs 即可。
#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct stu
{
int fa,len,ch[30];
}sam[N];
int n,m,tot=1,cur=1,cnt=1,col[N],c[N],ch[N][30],wz[N],ans[N],fa[N],z[N];
char s[N];
vector<int> ve[N];
int insert(int p,int x)
{
int np=++tot;
sam[np].len=sam[p].len+1;
for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
if(!p){sam[np].fa=1;return np;}
int q=sam[p].ch[x];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
return np;
}
void ins(int c,int x)
{
int u=cur;
if(!ch[u][c])ch[u][c]=++cnt,fa[cnt]=u,z[cnt]=c,col[cnt]=x;
else if(col[ch[u][c]]!=x)col[ch[u][c]]=-1;
cur=u=ch[u][c];
}
queue<int> q;
void bfs()//新写法
{
wz[1]=1;
for(int i=0;i<26;i++)
{
int j=ch[1][i];
if(j)q.push(j),wz[j]=insert(1,i),c[wz[j]]=col[j];
}
while(!q.empty())
{
int u=q.front();q.pop();//cout<<u<<'\n';
for(int i=0;i<26;i++)
{
int j=ch[u][i];
if(j)q.push(j),wz[j]=insert(wz[u],i),c[wz[j]]=col[j];
}
}
}
void dfs(int u)
{
for(int v:ve[u])
{
dfs(v);
if(!c[u])c[u]=c[v];
else if(c[u]!=c[v]) c[u]=-1;
}
if(c[u]!=-1)ans[c[u]]+=(sam[u].len-sam[sam[u].fa].len);
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)
{
cin>>(s+1);cur=1;
int len=strlen(s+1);
for(int j=1;j<=len;j++)ins(s[j]-'a',i);
}
bfs();
for(int i=2;i<=tot;i++)ve[sam[i].fa].push_back(i);
dfs(1);
for(int i=1;i<=n;i++)cout<<ans[i]<<'\n';
return 0;
}
P4465 [国家集训队] JZPSTR - 洛谷
只会暴力,会单独写题解然后扔到 \(bitset\) 里面。
SAM做法比较复杂,块状链表加SAM,块内用SAM,块间用KMP。
P4094 [HEOI2016/TJOI2016] 字符串 - 洛谷
SAM擅长处理公共后缀,所以将整个字符串翻转求最长公共后缀。
答案显然有可二分性,所以我们二分这个公共后缀的长度 \(len\) ,问题就变成了给出字符串 \(s[d-len+1,d]\) 是否是 \(s[a,b]\) 的子串。
我们只需要提前记录,快速定位到 \(s[d-len+1,d]\) 在SAM上的位置,提前用线段树合并处理出 \(endpos\) 集合,在线段树上求出不超过 \(d\) 的最大的存在的值,然后只需要看这个值是否存在大于 \(a+len-1\) 即可。
#include <bits/stdc++.h>
using namespace std;
const int N=2e5+10;
struct stu
{
int fa,len,ch[30];
}sam[N];
struct node
{
int ls,rs,sum;
}sh[N*60];
int n,m,tot=1,las=1,cnt,zb[N],fa[N][21],rt[N];
char s[N];
vector<int> ve[N];
void insert(int x)
{
int p=las,np=las=++tot;
sam[np].len=sam[p].len+1;
for(;p&&!sam[p].ch[x];p=sam[p].fa)sam[p].ch[x]=np;
if(!p){sam[np].fa=1;return ;}
int q=sam[p].ch[x];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[np].fa=sam[q].fa=nq;
for(;p&&sam[p].ch[x]==q;p=sam[p].fa)sam[p].ch[x]=nq;
return;
}
void update(int x)
{
int ls=sh[x].ls,rs=sh[x].rs;
sh[x].sum=sh[ls].sum+sh[rs].sum;
return ;
}
int modify(int x,int l,int r,int wz)
{
if(!x)x=++cnt;
if(l==r)
{
sh[x].sum+=sh[x].sum?0:1;
return x;
}
int mid=(l+r)>>1;
if(wz<=mid)sh[x].ls=modify(sh[x].ls,l,mid,wz);
else sh[x].rs=modify(sh[x].rs,mid+1,r,wz);
update(x);
return x;
}
int merge(int u,int v)
{
if(!u||!v)return u+v;
int x=++cnt;
sh[x].ls=merge(sh[u].ls,sh[v].ls);
sh[x].rs=merge(sh[u].rs,sh[v].rs);
update(x);
return x;
}
void dfs(int u)
{
// cout<<fa[u][0]<<' ';
for(int i=1;i<=20;i++)
{
fa[u][i]=fa[fa[u][i-1]][i-1];
// cout<<fa[u][i]<<' ';
}
// cout<<'\n';
for(int v:ve[u])
{
// cout<<v<<'\n';
dfs(v);
rt[u]=merge(rt[u],rt[v]);
}
}
int query2(int x,int l,int r)
{
if(l==r)return l;
int mid=(l+r)>>1;
if(sh[sh[x].rs].sum)return query2(sh[x].rs,mid+1,r);
else return query2(sh[x].ls,l,mid);
}
int query(int x,int l,int r,int lt,int rt)
{
if(l>=lt&&r<=rt)return sh[x].sum?query2(x,l,r):0;
int mid=(l+r)>>1,ans=0;
if(lt<=mid)ans=max(ans,query(sh[x].ls,l,mid,lt,rt));
if(rt>mid)ans=max(ans,query(sh[x].rs,mid+1,r,lt,rt));
return ans;
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m>>(s+1);
for(int i=1;i<=n;i++)
{
insert(s[n-i+1]-'a');//翻转
zb[i]=las;
rt[las]=modify(rt[las],1,n,i);
}
//fa[1][0]=1;
for(int i=2;i<=tot;i++)
{
fa[i][0]=sam[i].fa;
ve[sam[i].fa].push_back(i);
}
//for(int i=2;i<=tot;i++)cout<<fa[i][0]<<'\n';
dfs(1);
for(int i=1;i<=m;i++)
{
int a,b,c,d;cin>>a>>b>>c>>d;
a=n-a+1,b=n-b+1,c=n-c+1,d=n-d+1;swap(a,b);swap(c,d);
int p=zb[d];
for(int j=20;j>=0;j--)
{
int np=fa[p][j];//cout<<np<<'\n';
if(!np)continue;
int mxl=query(rt[np],1,n,1,b);
if(sam[np].len>=mxl-a+1)p=np;
}
//cout<<p<<'\n';
int mxl=query(rt[p],1,n,1,b)-a+1;
cout<<min(max(mxl,sam[sam[p].fa].len),d-c+1)<<'\n';
}
return 0;
}
P4022 [CTSC2012] 熟悉的文章 - 洛谷
首先我们在这看到了熟悉的模型,最小值最大,考虑二分这个最小值 \(len\) 。
再思考如何 \(check\) ,我们可以思考 dp,设 \(dp_i\) 表示处理 \(1-i\) 的字符串在满足长度限制的匹配最多选择多少长度,转移就是 \(f_i=max(f_{i-1},f_j+i-j,j\in [i-mx,i-len])\) 。( \(mx\) 就是 \(i\) 的最长匹配距离)。观察发现两个边界都单调不降,所以上单调队列优化即可。
现在问题就变成了求 \(mx\) ,发现这就是 P6640 [BJOI2020] 封印,双指针做一下就好了。
#include <bits/stdc++.h>
using namespace std;
const int N=2e6+10;
struct node
{
int ch[30],len,fa;
}sam[N];
int ch[N][30],pos[N],fa[N],col[N],dp[N],ll[N],tot=1,cnt=1,len,qq[N];
char s[N],t[N];
int insert(int c,int las)
{
int p=las,np=++tot;
sam[np].len=sam[las].len+1;
for(;p&&!sam[p].ch[c];p=sam[p].fa)sam[p].ch[c]=np;
if(!p){sam[np].fa=1;return np;}
int q=sam[p].ch[c];
if(sam[q].len==sam[p].len+1){sam[np].fa=q;return np;}
int nq=++tot;sam[nq]=sam[q];sam[nq].len=sam[p].len+1;
sam[q].fa=sam[np].fa=nq;
for(;p&&sam[p].ch[c]==q;p=sam[p].fa)sam[p].ch[c]=nq;
return np;
}
queue<int> q;
void bfs()
{
for(int i=0;i<26;i++)if(ch[1][i])q.push(ch[1][i]);
pos[1]=1;
while(!q.empty())
{
int u=q.front();q.pop();
pos[u]=insert(col[u],pos[fa[u]]);
for(int i=0;i<26;i++)if(ch[u][i])q.push(ch[u][i]);
}
}
bool check(int x)
{
int h=1,t=0;
for(int i=1;i<=len;i++)
{
dp[i]=dp[i-1]+1;
if(i-x>=0)
{
while(t>=h&&dp[qq[t]]>=dp[i-x])t--;
qq[++t]=i-x;
}
while(h<=t&&qq[h]<ll[i])h++;
if(h<=t)dp[i]=min(dp[i],dp[qq[h]]);
//if(x==5)cout<<dp[i]<<'\n';
}
// cout<<x<<" "<<dp[len]<<'\n';
return dp[len]*10<=len;
}
void solve()
{
cin>>(s+1);
len=strlen(s+1);
int cur=1,le=0;
for(int i=1;i<=len;i++)
{
int c=s[i]-'0';
while(!sam[cur].ch[c]&&cur)cur=sam[cur].fa,le=sam[cur].len;
if(cur==0)cur=1,le=0;
else{cur=sam[cur].ch[c],le++;}
ll[i]=i-le;
}//对于每个右端点预处理出可以匹配的左端点
int l=1,r=len,ans=0;
while(l<=r)
{
int mid=(l+r)>>1;
if(check(mid))ans=mid,l=mid+1;
else r=mid-1;
}
cout<<ans<<'\n';
}
int main()
{
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
int n,m;cin>>n>>m;
for(int i=1;i<=m;i++)
{
cin>>(t+1);
len=strlen(t+1);int cur=1;
for(int j=1;j<=len;j++)
{
int c=t[j]-'0';
if(!ch[cur][c]) ch[cur][c]=++cnt,col[cnt]=c,fa[cnt]=cur;
cur=ch[cur][c];
}
}
bfs();//建广义sam
while(n--)solve();
return 0;
}
困难模式
前面的题已经基本会用SAM,接下来是考验你ds和字符串基础的好时候。
遗留的问题
-
时间复杂度证明
-
在线广义SAM
-
叶子节点是否一定是前缀节点,前缀节点是否一定是叶子?(建分裂节点时就有两个儿子,前缀节点可以不是叶子(\(aba\)))

浙公网安备 33010602011771号