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\),那么答案就是

\[\sum_i wl_is_i \]

修改的时候是容易维护的。

所以现在问题就变成了对于每一个 \(l\),我们求

\[\forall l,s_l = \sum_{r=l}^n \left(\sum_{x \in Right(S[l,r])} wr_x \right) |Right(S[l,r])| \]

这里的 \(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;
}

posted @ 2025-07-19 19:27  H_W_Y  阅读(20)  评论(0)    收藏  举报