String(二)
根据 String(一)中的基本概念和基本应用的了解,我们对 String 的题目 + 技巧进行更深一层的探讨。
此内容源于 cjl 的讲课,可以算作一个杂题选讲。
众所周知,我很懒,不想写 sol。
ABC312Ex snukesnuke
后缀数据结构
P4218 [CTSC2010] 珠宝商
给你一棵 \(n\) 个点的树和一个长度为 \(m\) 的字符串 \(S\),树的每个点上有一个字符,求树上的每一条路径形成的字符串在 \(S\) 中的出现次数之和。
\(n,m \le 50000\)。
暴力:每次以每一个点为根,在 dfs 的过程中去 SAM 上面跑,每次把答案加上 \(|Right(P)|\),时间复杂度 \(\mathcal O(n^2)\)。
树上问题考虑点分树。
考虑跨过分治中心的答案,每次把一个点到分治中心的串拉到 SAM 上面跑,再在 parent 树上进行一次 dfs,得到有多少个串以 \(i\) 结尾 / 以 \(i\) 开头即可计算答案。
具体来说,假设当前分治中心为 \(z\),我们计算 \(time_r\) 表示有多少 \(x\) 使 \(x \to z\) 出现在 \(s[\cdots r]\),和 \(time'_r\) 表示有多少个 \(x\) 使 \(z \to x\) 出现在 \(s^R[\cdots r]\),两个东西对称,所以我们只需要考虑前者。
每次加串时,等价于在 \(parent\) 树上面往下跳,跳到一个节点 \(u\),相当于 \(\forall r \in Right(u),time_r++\),所以可以离线下来,打标记,最后在 parent 树上面 dfs 一遍统计答案。
每次时间复杂度 \(\mathcal O(m+sz)\)。
容斥部分同样可以每次 \(\mathcal O(m)\) 处理子树。
根据以上两种做法,我们考虑 根号分治。
如果点分树过程中,\(sz \le B\),则用 \(\mathcal O(sz^2)\) 的时间复杂度暴力,反之用 \(\mathcal O(m)\) 的时间复杂度求。
而容斥的时候同样对于每棵子树大小和 \(B\) 的关系选择如何容斥,不影响时间复杂度。
分析时间复杂度,点分治卡满一定可以看成每次分两个一样大的,所以复杂度应该是 \(\mathcal O(\frac {mn}B +nB)\),所以把 \(B\) 取到 \(\sqrt n\) 即可做到 \(\mathcal O((n+m)\sqrt n)\)。代码。
P6152 [集训队作业2018] 后缀树节点数
给定一个长度为 \(n\) 的字符串 \(P\),有 \(m\) 次询问,每次给定两个参数 \(l\) , \(r\),询问子串 \(P[l,r]\) 所构成的后缀树的结点数。
如果你不了解后缀树,你也可以理解为对 \([l,r]\) 的反串建后缀自动机。
注意在本题中,后缀树的根节点不计入答案。
\(n\le 10^5\),\(m\le 3\times 10^5\),字符串的每个数字 \(\le n\)。
陈江伦本人出的。
考虑全部反串,去计数 SAM 上面的节点数。
容易发现一个区间的 SAM 一定是原串 SAM 的子集,因为区间中的串能表示那么原串也能表示。
所以一个简单的想法是在 parent 树上找到这些节点的虚树。
真是虚树吗?
首先我们认为一个点是关键点,当且仅当它能表示某一个 \([l,x]\),注意到其实关键点不一定在点上,有可能在边上。
我们认为一个节点是分裂节点当且仅当它在当前询问区间 \([l,r]\) 的 SAM 里面,并且有两棵子树。
那么一个点是分裂节点,当且仅当它在 parent 树上面存在两个来自不同子树的节点分别表示 \([l,x]\) 和 \([l,y]\),并且 \(l \le \min(x,y)-len,\max(x,y) \le r\),其中 \(len\) 是当前节点的最长长度。
这就意味着对于 \([l,x]\) 和 \([l,y]\) 两个串,它们能在当前分裂节点 \(u\) 的控制范围内分出差异来,那么这个 \(u\) 就在询问的 SAM 中。
关键点的个数是好维护的,为 \(r-l+1\)。
而分裂节点个数可以用离线下来启发式合并 + 扫描线完成,具体来说,对于一个节点 \(u\),因为我们要求对应的点要来自不同的子树,所以每合并两棵子树做产生的支配区间个数是 \(\mathcal O(\min)\) 的(对于每一个小的子树中的点,我们都找到它的前驱后继匹配),那么总的支配区间个数就是 \(\mathcal O(n \log n)\)。
这里的支配区间表示若询问区间包含任意一个这样的区间,那么节点 \(u\) 就是分裂节点。
我们用线段树合并 / 启发式合并求出这 \(\mathcal O(n \log n)\) 个支配区间,再在最后拿下来扫一次就可以知道每个询问有多少个分裂节点了。
看起来做完了,但是我们并没有意识到:有些点有可能既是关键点又是分裂节点。
我们考虑容斥掉这一部分,需要一个重要的发现:
假设 \([l,i],i \gt l\) 对应的点是分裂节点,那么 \([l,i-1]\) 对应的点也是分裂节点。
因为你考虑分裂节点的性质,它是一定在 \(l\) 之前就比出差异了,而 parent 树是往前面比而不是往后面比,所以你把右端点往前移动一定是不影响的。
所以我们考虑去二分这个前缀,每次 check 当前 \([l,mid]\) 对应的节点是否是分裂节点(如果是边上的点就肯定不是)。
而找到对应的节点一种方法是 \(\mathcal O(\log n)\) 在 parent 树上面倍增跳,另外一种是对于每一个原树上面的分裂节点维护哈希。
看起来前者多一个 \(\log\),但实际上前者快很多(有可能是 unordered_map 常数太大)。
于是总的时间复杂度 \(\mathcal O(n \log ^2n+m \log^2n)\),如果你用哈希后面就是 \(\mathcal O(m \log n)\)。
以上启发式合并的过程疑似可以扫描线 + LCT 实现,不会 LCT。代码。
基本子串结构
这是一个非常牛逼的东西。
我们从 压缩后缀自动机 引入。
对于一个 SAM,考虑它上面的有些边,如果一个点只有一条出边,那么我们就知道走到这个点一定会走到接下来的那个点,所以就把两个点缩起来变成一个。
这样有什么好处?
发现这个缩点的过程非常类似于后缀树的构建,也就是说压缩后缀自动机一定是在后缀树的基础上再做了压缩的,所以因为后缀树只有 \(\mathcal O(n)\) 条路径,则在 压缩后缀自动机 上面也就只会有 \(\mathcal O(n)\) 条路径。
在一个 DAG 上面从根出发之后有 \(\mathcal O(n)\) 条路径,这是多么美妙的性质啊!
而对于最后在 压缩后缀自动机 上面位于同一个节点的串,我们就把它们认为是 基本子串结构 中的一个等价类。
如果我们把 \([l,r]\) 对应的子串看成一个二维平面上 \((l,r)\) 的点,容易发现一个等价类一定对应一个左上角的梯形,理解就是我们会现在 SAM 的过程中把相应的 \(l\) 给缩起来,再在压缩的过程中把一些相邻的 \(r\) 缩起来。
上述过程简单表示成,对于两个串 \(b_1,b_2\),如果存在一个串 \(b\) 使得 \(b_1,b_2\) 分别是 \(b\) 的子串,并且 \(|Right(b)|=|Right(b_1)|=|Right(b_2)|\),则 \(b_1,b_2\) 在基本子串结构中的一个等价类里面。
一些应用:
压缩后缀自动机的建立相当简单,直接在 DAG 上面进行简单缩点即可。
namespace SAM{
int idx=1,las=1,sz[N],tg[N];
struct sgt{
int len,fa,s[4];
}tr[N];
vector<int> E[N];
void ins(int c,int id){
int p=las,cur=las=++idx;tg[idx]=id,++sz[idx];
tr[cur].len=tr[p].len+1;
while(p&&!tr[p].s[c]) tr[p].s[c]=cur,p=tr[p].fa;
if(!p) tr[cur].fa=1;
else{
int q=tr[p].s[c];
if(tr[q].len==tr[p].len+1) tr[cur].fa=q;
else{
int r=++idx;tr[r]=tr[q];
tr[r].len=tr[p].len+1;
tr[q].fa=tr[cur].fa=r;
while(p&&tr[p].s[c]==q) tr[p].s[c]=r,p=tr[p].fa;
}
}
}
void dfs(int u){
if(tg[u]) sum[u]=wr[tg[u]];
for(int v:E[u]) dfs(v),sz[u]+=sz[v],sum[u]+=sum[v];
}
void init(){
for(int i=1;i<=n;i++) ins(a[i]=mp[S[i-1]],i);
for(int i=2;i<=idx;i++) E[tr[i].fa].pb(i);
dfs(1);
}
}
using namespace SAM;
void build_dag(){
for(int i=1;i<=idx;i++) ++c[tr[i].len];
for(int i=1;i<=n;i++) c[i]+=c[i-1];
for(int i=idx;i>=1;i--) q[c[tr[i].len]--]=i;
for(int i=las;i>1;i=tr[i].fa) vis[i]=1;
for(int i=idx,u;i>1;i--){
u=q[i],sum[u]*=sz[u];
dis[u]=0,nxt[u]=u;
for(int j=0,v;j<4;j++){
v=tr[u].s[j];
if(!vis[u]&&v&&sz[u]==sz[v]) nxt[u]=nxt[v],sum[u]+=sum[v],dis[u]=dis[v]+1;
}
}
}
晚上做梦梦见基本子串结构怎么办?
感觉不如看一些例题。
UOJ577【ULR #1】打击复读
为了提升搜索引擎的关键词匹配度以加大访问量,某些网站可能在网页中无意义复读大量关键词。
你设计了一种方法量化评价一个文本(字符串 \(s\))的"复读程度":
字符串 \(s\) 的下标从 \(1\) ~ \(n\) 标号,第 \(i\) 个字符被赋予两个权值:左权值 \(u_i\), 和右权值 \(w_i\),代表该位置的重要度。
定义一个子串 \(s[l, r]\) 的左权值 \(u(s[l, r])\) 为:其在原串中各个匹配的左端点的左权值 \(u_i\) 和;右权值 \(w(s[l, r])\) 为:其在原串中各个匹配的右端点的右权值 \(w_i\) 和。这里 \(t\) 在 \(s\) 中所有的匹配是 \(\forall 1 \leq i \leq j \leq n\), \(s[i, j] = t\),我们把这样的 \(i\) 和 \(j\) 分别叫做一个匹配的左右端点。
定义一个子串 \(s[l, r]\) 的复读程度是它的左权值与右权值的乘积,即 \(w(s[l, r]) = u(s[l, r]) \cdot w(s[l, r])\)。
\(s\) 的"复读程度"定义为所有子串复读程度的和,即:
\[\sum_{i=1}^{|S|} \sum_{j=1}^{|S|} w(s[i, j]) \]根据网站文本抽样的复读程度情况,就可以达到打击无意义复读行为的目的。
隔壁生命科学实验室正在分析新颖的基因序列。他们对基因的复读情况很感兴趣,于是顺便把这个误差给了你。
基因片段可以被视作字符集为 \(\{A, T, G, C\}\) 的字符串,你要求出给定的基因片段 \(S\) 的复读程度。
有些时候,由于新的科学发现,某个位置 \(u\) 的左权值 \(wl_u\) 会相应修改为 \(v\),修改过后你需要给出基因片段 \(S\) 的新的复读程度。
由于答案很大,你只需要输出答案对 \(2^{64}\) 取模后的结果。
\(1 \le n,m \le 5 \times 10^5,0 \le wl_i,wr_i,v_i \lt 2^{64},1 \le u_i \le n\)。
这道题存在传统的 SAM 做法,但我们认为这种做法不是很高级。
对于这道题,一种比较自然的思路就是去计算每一个 \(wl_i\) 对应的系数 \(s_i\),那么答案就是
修改的时候是容易维护的。
所以现在问题就变成了对于每一个 \(l\),我们求
这里的 \(Right\) 集合让我们想到了 SAM 上面的 \(Right\) 集合,所以我们给 SAM 上面每一个点赋一个权值 \(wr_i\),建出 SAM 之后我们在 parent 树上面 dfs 计算出每个节点的 \(Right\) 中 \(wr\) 之和和 \(sz\),于是对于一个 \(l\),它的 \(s_l\),就等价于把 \(S[l,n]\) 在 SAM 上面跑,走到一个点就加上 \(sum_p \times sz_p\)。
但是 DAG 上面的路径有 \(\mathcal O(n^2)\) 条(因为每一条路径对应一个子串),显然这样做是不行的。
这个时候我们就想到,由于我们关心的是每一个后缀,所以我们可以对这个 SAM 进行 压缩,那么建出 压缩后缀自动机 之后再在上面做 DAG 上的 dp 就是对的了。
这样由于 DAG 上面从根出发的路径只有 \(\mathcal O(n)\) 条,所以总的时间复杂度 \(\mathcal O(n)\)。代码。
Code
#include <bits/stdc++.h>
using namespace std;
#define ull unsigned long long
#define pb push_back
const int N=1e6+5;
int n,m,a[N],c[N],q[N],dis[N],nxt[N];
ull wl[N],wr[N],sum[N],val[N],ans=0;
string S;
map<char,int> mp;
bool vis[N];
namespace SAM{
int idx=1,las=1,sz[N],tg[N];
struct sgt{
int len,fa,s[4];
}tr[N];
vector<int> E[N];
void ins(int c,int id){
int p=las,cur=las=++idx;tg[idx]=id,++sz[idx];
tr[cur].len=tr[p].len+1;
while(p&&!tr[p].s[c]) tr[p].s[c]=cur,p=tr[p].fa;
if(!p) tr[cur].fa=1;
else{
int q=tr[p].s[c];
if(tr[q].len==tr[p].len+1) tr[cur].fa=q;
else{
int r=++idx;tr[r]=tr[q];
tr[r].len=tr[p].len+1;
tr[q].fa=tr[cur].fa=r;
while(p&&tr[p].s[c]==q) tr[p].s[c]=r,p=tr[p].fa;
}
}
}
void dfs(int u){
if(tg[u]) sum[u]=wr[tg[u]];
for(int v:E[u]) dfs(v),sz[u]+=sz[v],sum[u]+=sum[v];
}
void init(){
for(int i=1;i<=n;i++) ins(a[i]=mp[S[i-1]],i);
for(int i=2;i<=idx;i++) E[tr[i].fa].pb(i);
dfs(1);
}
}
using namespace SAM;
void build_dag(){
for(int i=1;i<=idx;i++) ++c[tr[i].len];
for(int i=1;i<=n;i++) c[i]+=c[i-1];
for(int i=idx;i>=1;i--) q[c[tr[i].len]--]=i;
for(int i=las;i>1;i=tr[i].fa) vis[i]=1;
for(int i=idx,u;i>1;i--){
u=q[i],sum[u]*=sz[u];
dis[u]=0,nxt[u]=u;
for(int j=0,v;j<4;j++){
v=tr[u].s[j];
if(!vis[u]&&v&&sz[u]==sz[v]) nxt[u]=nxt[v],sum[u]+=sum[v],dis[u]=dis[v]+1;
}
}
}
void calc(int u,int len=0,ull s=0){
if(vis[u]) val[n-len+1]=s;
for(int i=0,v;i<4;i++) if((v=tr[u].s[i])) calc(nxt[v],len+1+dis[v],s+sum[v]);
}
int main(){
/*2025.4.15 H_W_Y UOJ #577. 【ULR #1】打击复读 SAM*/
ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
cin>>n>>m>>S;
mp['A']=0,mp['T']=1,mp['G']=2,mp['C']=3;
for(int i=1;i<=n;i++) cin>>wl[i];
for(int i=1;i<=n;i++) cin>>wr[i];
init(),build_dag(),calc(1);
for(int i=1;i<=n;i++) ans+=wl[i]*val[i];
cout<<ans<<'\n';
while(m--){
int id;ull x;
cin>>id>>x;
ans-=val[id]*wl[id];
wl[id]=x;
ans+=val[id]*wl[id];
cout<<ans<<'\n';
}
return 0;
}
CF1817F Entangled Substrings
给定字符串 \(s\)。
\(s\) 的非空子串对 \((a, b)\) 合法,当且仅当存在可空字符串 \(c\),使得每次 \(a\) 在 \(s\) 中出现,后面都紧跟着 \(cb\);且每次 \(b\) 在 \(s\) 中出现,前面都紧跟着 \(ac\)。即 \(a, b\) 只以 \(acb\) 的形式在 \(s\) 中出现。
求 \(s\) 的合法非空子串对数量。
\(1 \leq |s| \leq 10^5\)。
感受一下,根据 基本子串结构 的定义,发现 \(a,b,acb\) 在一个等价类中。
进而,我们发现对于一个 \(a,b\),如果它们在同一个等价类中,并且不相交,就一定合法。
于是对于每一个阶梯形,我们考虑用双指针去统计答案。
我的做法是去枚举第一个串 \(r\),然后第二个串的 \(l\) 是可以竖着扫出来了。
这样就做完了,时间复杂度线性,代码还是非常有价值的。代码。
Code
#include <bits/stdc++.h>
using namespace std;
#define pb push_back
#define ll long long
const int N=5e5+5;
int n;
ll ans=0;
string S;
namespace SAM{
int idx=1,las=1,sz[N],to[N];
ll c[N],q[N];
bool vis[N];
struct sam{
int fa,len,s[26];
}tr[N];
vector<int> E[N];
void ins(int c){
int p=las,cur=las=++idx;++sz[idx];
tr[cur].len=tr[p].len+1;
while(p&&!tr[p].s[c]) tr[p].s[c]=cur,p=tr[p].fa;
if(!p) tr[cur].fa=1;
else{
int q=tr[p].s[c];
if(tr[q].len==tr[p].len+1) tr[cur].fa=q;
else{
int r=++idx;tr[r]=tr[q];
tr[r].len=tr[p].len+1;
tr[q].fa=tr[cur].fa=r;
while(p&&tr[p].s[c]==q) tr[p].s[c]=r,p=tr[p].fa;
}
}
}
void dfs(int u){for(int v:E[u]) dfs(v),sz[u]+=sz[v];}
void init(){
n=(int)S.size();
for(int i=0;i<n;i++) ins(S[i]-'a');
for(int i=2;i<=idx;i++) E[tr[i].fa].pb(i);
dfs(1);
for(int i=1;i<=idx;i++) c[tr[i].len]++;
for(int i=1;i<=n;i++) c[i]+=c[i-1];
for(int i=idx;i>=1;i--) q[c[tr[i].len]--]=i;
for(int i=idx;i>1;i--){
int u=q[i];
for(int j=0;j<26;j++){
int v=tr[u].s[j];
if(v&&sz[u]==sz[v]) vis[v]=1,to[u]=v;
}
}
}
}
using namespace SAM;
int main(){
/*2025.4.17 H_W_Y CF1817F Entangled Substrings 基本子串结构*/
ios::sync_with_stdio(false);cin.tie(0);cout.tie(0);
cin>>S,init();
for(int i=2;i<=idx;i++) if(!vis[i]){
int len=0,mx=0;
for(int t=i;t;t=to[t]) q[++len]=tr[t].len-tr[tr[t].fa].len,mx=tr[t].len;
for(ll j=len,k=len+1,s=0;j>=1;j--){
while(k>1&&len-mx+q[k-1]>j) --k;
s+=len-k+1,ans+=1ll*s*q[j];
}
}
cout<<ans;
return 0;
}

浙公网安备 33010602011771号