可持久化数据结构

一种比较高级的科技。

我们以往学习的数据结构都具有即时性,即进行修改之后无法回溯。

当我们想要了解某次修改后的状态时(你可以理解为「回档」),就需要额外维护数据结构的历史版本。

从朴素的角度考虑,我们完全可以开 \(n\) 个数据结构进行维护。但这样的空间复杂度往往过高,无法承受。

于是,可持久化数据结构最核心的思想即为「共用」(事实上,这也是一些运用可持久化数据结构题目中的明显提示)。具体而言:

image

在这张图中,我们以线段树为例,构建了一棵以 \(root\) 为根的线段树。当我们尝试对于 \(c\) 这个叶子节点进行修改时,因为 \(root-a-b-c\) 这条路径上的点都需要修改,所以我们仅需复制一份路径 \(root'-a'-b'-c'\),并保持原树的形态(即 \(a'\)\(a\) 的左右儿子不变)。事实上,新生成的是一个森林,但对于线段树而言,这并无大碍。

以上便是可持久化数据结构的基本逻辑。

补充:容易发现,主席树是一种类似于前缀和的数据结构,所以什么前缀和、差分之类的东西都可以往上面套。

P3919

模板。

实现
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N=1e6+5;
int n,m,tot;
int a[N],root[N];
struct TREE{
	int lt,rt,val;
}tree[N*24]; //注意24倍空间

int build(int lt,int rt){ //建树
	int p=++tot;
	if(lt==rt){
		tree[p].val=a[lt];
		return p;
	}
	int mid=(lt+rt)>>1;
	tree[p].lt=build(lt,mid);
	tree[p].rt=build(mid+1,rt);
	return p;
}
int upd(int cur,int lt,int rt,int pos,int val){
	int p=++tot; //新建一个节点
	tree[p]=tree[cur]; //保持原树形态
	if(lt==rt){
		tree[p].val=val; //修改
		return p;
	}
	int mid=(lt+rt)>>1;
	if(pos<=mid) //必须这样写,不能两棵子树都修改
		tree[p].lt=upd(tree[cur].lt,lt,mid,pos,val); //连接新的左子树
	else
		tree[p].rt=upd(tree[cur].rt,mid+1,rt,pos,val); //或者连接新的右子树
	return p;
}
int qry(int cur,int lt,int rt,int pos){
	if(lt==rt)
		return tree[cur].val;
	int mid=(lt+rt)>>1;
	if(pos<=mid)
		return qry(tree[cur].lt,lt,mid,pos);
	else
		return qry(tree[cur].rt,mid+1,rt,pos);
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)
		cin>>a[i];
	root[0]=build(1,n); //初始版本
	for(int i=1;i<=m;i++){
		int v,op,pos,val;
		cin>>v>>op>>pos;
		if(op==1)
			cin>>val,root[i]=upd(root[v],1,n,pos,val); //新建一个版本
		else
			cout<<qry(root[v],1,n,pos)<<'\n',root[i]=root[v]; //注意查询也需要新建(依题意)
	}
	return 0;
}

P3834

事实上,这才是可持久化线段树的经典应用。

首先,我们考虑静态整体第 \(k\) 大如何使用线段树解决。

我们开一棵权值线段树,维护值域区间。同时,我们统计对于每个值,它出现的次数 \(cnt\)。对于线段树上的一个区间 \([l,r]\),若 \([l,mid]\)\(\sum cnt > k\),说明要去右区间寻找答案,否则去左区间。

回归本题,现在加上了区间的限制,如何解决?

我们考虑刻画这个约束条件。运用前缀和的思想,我们直接将区间 \([l,r]\) 划分为 \([1,r]-[1,l-1]\),线段树维护相同的信息。这样,我们便可以将每一个区间 \([1,x]\) 看作一个历史版本,然后用两个版本作差的方法得到 \(\sum cnt\) 即可直接做了。

总结:将区间看作历史版本

实现
#include<bits/stdc++.h>
#define int long long
using namespace std;

const int N=2e5+5;
int n,m,tot;
int a[N],t[N],root[N];
struct TREE{
	int lt,rt,sum;
}tree[N*24];

void pushup(int p){
	tree[p].sum=tree[tree[p].lt].sum+tree[tree[p].rt].sum;
}
int build(int lt,int rt){
	int p=++tot;
	tree[p].sum=0;
	if(lt==rt)
		return p;
	int mid=(lt+rt)>>1;
	tree[p].lt=build(lt,mid);
	tree[p].rt=build(mid+1,rt);
	return p; 
}
int upd(int cur,int lt,int rt,int pos,int val){
	int p=++tot;
	tree[p]=tree[cur];
	if(lt==rt){
		tree[p].sum+=val;
		return p;
	}
	int mid=(lt+rt)>>1;
	if(pos<=mid)
		tree[p].lt=upd(tree[cur].lt,lt,mid,pos,val);
	else
		tree[p].rt=upd(tree[cur].rt,mid+1,rt,pos,val);
	pushup(p);
	return p;
}
int qry(int cur,int last,int lt,int rt,int pos){
	if(lt==rt)
		return lt;
	int lsum=tree[tree[cur].lt].sum-tree[tree[last].lt].sum;
	int mid=(lt+rt)>>1;
	if(pos<=lsum)
		return qry(tree[cur].lt,tree[last].lt,lt,mid,pos);
	else
		return qry(tree[cur].rt,tree[last].rt,mid+1,rt,pos-lsum);
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)
		cin>>a[i],t[i]=a[i];
	sort(t+1,t+n+1);
	int len=unique(t+1,t+n+1)-t-1;
	root[0]=build(1,len);
	for(int i=1;i<=n;i++){
		a[i]=lower_bound(t+1,t+len+1,a[i])-t;
		root[i]=upd(root[i-1],1,len,a[i],1);
	}
	for(int i=1,l,r,k;i<=m;i++){
		cin>>l>>r>>k;
		cout<<t[qry(root[r],root[l-1],1,len,k)]<<'\n';	
	}
	return 0;
}

P2839

起初我想了个假做法,就是通过 \(a,b,c,d\) 确定中位数变动的区间,然后直接求区间 \(\max\) 即可。这个方法显然是错的,因为中位数不一定在这个区间里能全部取到。

回归正题。看到中位数考虑二分答案。

如何 check?这时我们需要对数组进行处理。对于一个答案 \(x\),将大于等于它的设为 \(1\),否则设为 \(-1\)

容易发现,这样处理之后,若区间(即 \([a,b]\) 的最大后缀 + \([b+1,c-1]\) + \([c,d]\) 的最大前缀)和 \(\ge 0\) 则需要变大,否则需要变小。这样便完成了 check 的设计。

现在的问题在于时间复杂度过高。如何优化?可以发现瓶颈在于对数组的处理,容易想到对于每一个 \(x\) 开一棵线段树,但空间炸了。于是运用可持久化线段树,对于每一个 \(x\) 开一个历史版本即可。

总结:

  • 看到中位数考虑二分答案

  • 对数组处理的思想

  • 运用可持久化的思想优化空间

实现
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=2e4+5;
int n,q,tot,len;
int a[N],t[N],root[N];
struct TREE{
	int lt,rt,sum,pre,suf;
}tree[N<<6];
vector<int> num[N];

void pushup(int p){
	tree[p].sum=tree[tree[p].lt].sum+tree[tree[p].rt].sum;
	tree[p].pre=max(tree[tree[p].lt].pre,tree[tree[p].lt].sum+tree[tree[p].rt].pre);
	tree[p].suf=max(tree[tree[p].rt].suf,tree[tree[p].rt].sum+tree[tree[p].lt].suf);
}
int build(int lt,int rt){
	int p=++tot;
	tree[p].sum=tree[p].pre=tree[p].suf=0;
	if(lt==rt)
		return p;
	int mid=(lt+rt)>>1;
	tree[p].lt=build(lt,mid);
	tree[p].rt=build(mid+1,rt);
	return p; 
}
int upd(int cur,int lt,int rt,int pos,int val){
	int p=++tot;
	tree[p]=tree[cur];
	if(lt==rt){
		tree[p].sum=tree[p].pre=tree[p].suf=val;
		return p;
	}
	int mid=(lt+rt)>>1;
	if(pos<=mid)
		tree[p].lt=upd(tree[cur].lt,lt,mid,pos,val);
	else
		tree[p].rt=upd(tree[cur].rt,mid+1,rt,pos,val);
	pushup(p);
	return p;
}
int qrysum(int cur,int lt,int rt,int ql,int qr){
	if(lt>qr||rt<ql)
		return 0;
	if(ql<=lt&&rt<=qr)
		return tree[cur].sum;
	int mid=(lt+rt)>>1;
	return qrysum(tree[cur].lt,lt,mid,ql,qr)+qrysum(tree[cur].rt,mid+1,rt,ql,qr);
}
int qrypre(int cur,int lt,int rt,int ql,int qr){
	if(lt>qr||rt<ql)
		return 0;
	if(ql<=lt&&rt<=qr)
		return tree[cur].pre;
	int mid=(lt+rt)>>1;
	return max(qrypre(tree[cur].lt,lt,mid,ql,qr),qrysum(tree[cur].lt,lt,mid,ql,qr)+qrypre(tree[cur].rt,mid+1,rt,ql,qr));
}
int qrysuf(int cur,int lt,int rt,int ql,int qr){
	if(lt>qr||rt<ql)
		return 0;
	if(ql<=lt&&rt<=qr)
		return tree[cur].suf;
	int mid=(lt+rt)>>1;
	return max(qrysum(tree[cur].rt,mid+1,rt,ql,qr)+qrysuf(tree[cur].lt,lt,mid,ql,qr),qrysuf(tree[cur].rt,mid+1,rt,ql,qr));
}

int fnd(int a,int b,int c,int d){
	int l=0,r=len+1;
	while(l+1<r){
		int mid=(l+r)>>1;
		if(qrysuf(root[mid],1,n,a,b-1)+qrysum(root[mid],1,n,b,c)+qrypre(root[mid],1,n,c+1,d)>=0)
			l=mid;
		else
			r=mid;
	}
	return l;
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++)
		cin>>a[i],t[i]=a[i];
	sort(t+1,t+n+1);
	len=unique(t+1,t+n+1)-t-1;
	for(int i=1;i<=n;i++){
		a[i]=lower_bound(t+1,t+len+1,a[i])-t;
		num[a[i]].push_back(i);
	}
	root[len+1]=build(1,n);
	for(int i=1;i<=n;i++)
		root[len+1]=upd(root[len+1],1,n,i,-1);
	for(int i=len;i;i--){
		root[i]=root[i+1];
		for(int j:num[i])
			root[i]=upd(root[i],1,n,j,1);
	}
	cin>>q;
	int last=0;
	for(int i=1,a,b,c,d;i<=q;i++){
		cin>>a>>b>>c>>d;
		a=(a+last)%n+1,b=(b+last)%n+1,c=(c+last)%n+1,d=(d+last)%n+1;
		int q[]={a,b,c,d}; sort(q,q+4);
		last=t[fnd(q[0],q[1],q[2],q[3])];
		cout<<last<<'\n';
	}
	return 0;
}

CF1000F

显然可以莫队做,但时间复杂度过高。

对于一个元素 \(x\),令其上一次出现的位置为 \(last_x\),则对于一个区间 \([l,r]\),若\(\exist x \in [l,r],last_x<l\),说明 \([l,r]\) 中有只出现一次的数。

进一步的,若区间 \([l,r]\) 的所有元素中最小的那个 \(last_x<l\),才说明 \([l,r]\) 中有只出现一次的数。

于是,问题转化为求 \([l,r]\) 最小的 \(last_x\)。静态区间最值,可以使用可持久化线段树轻松解决。

注意,直接做是不行的,因为可能存在一个元素 \(x\),它出现了多次,但它第一次出现时的 \(last_x<l\),这样显然会导致判断错误。一个较简单的解决方案是,每遇到一个 \(x\),就将其 \(last_{last_x}\) 设为 \(\infty\),这样可以保证只留下最后一个 \(x\),从而保证答案的正确性。

总结:刻画答案和约束条件(转化成判断句)。

submission

P3168

看到第 \(k\) 小和,显然主席树可以处理,对于时间轴上的每个时刻作为历史版本即可。

然后,我们发现区间和实际上是不好维护的。因为对于一个时刻,可能有许多任务区间能覆盖它,我如何知道这些区间对它的影响?这启发我们用一种方式去刻画任务区间,自然地,我们想到了差分

具体而言,我们可以每到达一个时刻,令从它开始的任务区间的 \(l\) 能影响的所有区间(具体见代码) 都加上一个区间优先级,然后令从它结束的任务区间的 \(r+1\) 能影响的所有区间 都减去一个区间优先级。这样就可以很方便地维护区间和以及区间任务个数了。

然后这个题就做完了,注意几个代码中的细节即可。

总结:

  • 见第 \(k\) 用主席树

  • 差分思想

实现
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=1e5+5;
int m,n,tot;
int p[N],root[N*48],t[N];
vector<int> L[N],R[N];
struct TREE{
	int lt,rt,sum,cnt;
}tree[N*48];

int build(int lt,int rt){
	int p=++tot;
	tree[p].sum=tree[p].cnt=0;
	if(lt==rt)
		return p;
	int mid=(lt+rt)>>1;
	tree[p].lt=build(lt,mid);
	tree[p].rt=build(mid+1,rt);
	return p;
}
int upd(int cur,int lt,int rt,int pos,int val){
	int p=++tot;
	tree[p]=tree[cur];
	tree[p].cnt+=val,tree[p].sum+=val*t[pos];
	if(lt==rt)
		return p;
	int mid=(lt+rt)>>1;
	if(pos<=mid)
		tree[p].lt=upd(tree[cur].lt,lt,mid,pos,val);
	else
		tree[p].rt=upd(tree[cur].rt,mid+1,rt,pos,val);
	return p;
}
int qry(int cur,int lt,int rt,int rnk){
	if(lt==rt)
		return tree[cur].sum/tree[cur].cnt*rnk; //细节 *rnk
	int lcnt=tree[tree[cur].lt].cnt;
	int mid=(lt+rt)>>1;
	if(rnk<=lcnt)
		return qry(tree[cur].lt,lt,mid,rnk);
	return qry(tree[cur].rt,mid+1,rt,rnk-lcnt)+tree[tree[cur].lt].sum;
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	cin>>m>>n;
	for(int i=1,s,e;i<=m;i++){
		cin>>s>>e>>p[i],t[i]=p[i];
		L[s].push_back(i);
		R[e+1].push_back(i);
	}
	sort(t+1,t+m+1);
	int len=unique(t+1,t+m+1)-t-1;
	for(int i=1;i<=m;i++)
		p[i]=lower_bound(t+1,t+len+1,p[i])-t;
	root[0]=build(1,len);
	for(int i=1;i<=n;i++){
		root[i]=root[i-1];
		for(int j:L[i])
			root[i]=upd(root[i],1,len,p[j],1);
		for(int j:R[i])
			root[i]=upd(root[i],1,len,p[j],-1);
	}
	int last=1;
	for(int i=1,x,a,b,c,k;i<=n;i++){
		cin>>x>>a>>b>>c;
		k=1+(a*last+b)%c;
		if(k>tree[root[x]].cnt)
			last=tree[root[x]].sum;
		else
			last=qry(root[x],1,len,k);
		cout<<last<<'\n';
	}
	return 0;
}

CF1514D

这种众数题又不带修首先考虑主席树吧。

但我们目前还不知道怎么使用它,先放着。

考虑刻画一个合法区间的形态:若一个区间内有 \(tot\) 个「目标众数」(即严格大于区间长度一半向上取整的数),则必定有 \(tot-1\) 个非「目标众数」与之抵消。

接着,我们考虑划分的最优策略:显然对于每个众数分一个集合是最劣的,我们考虑两两进行合并。对于两个集合 \(tot_1,tot_1-1\),和 \(tot_2,tot_2-1\)(前者为「目标众数」,后者为非「目标众数」),它们合并之后为 \(tot_1+tot_2,tot_1+tot_2-2\),这并不符合要求。应该扔掉一个「目标众数」才行。这样,原先是两个集合,现在还是两个集合,这是最劣的情形。当「目标众数」很少时,还有可能更优。这便说明,合并两个集合不会更劣

这里补充一下,为什么每个集合都是形如 \(tot,tot-1\),这是因为,给每组「目标众数」都分配最少的非「目标众数」,则后面的其他「目标众数」将会有更多的选择,这是贪心的思想。

得出上述结论后,考虑把所有集合合并到一块,即所有非「目标众数」均分布在一个集合内,这样必定是最不劣的。令「目标众数」有 \(tot\) 个,此时除了那个集合能够抵消的「目标众数」外,其余的必须自成一个集合,则答案即为

\[1+tot-(r-l+1-tot+1)\\ =2 \times tot-(r-l+1) \]

后面那部分是一定的,我们仅需求出 \(tot\) 即可,这就是主席树干的事情了,在线段树上二分即可(就是看左边的个数大于区间长一半就去左边,反之去右边,如果都不行就无解)。

实现:here.

总结:众数不带修考虑主席树、刻画答案、往最优化的方向思考。

CF893F

把深度看作历史版本,这样可以解决 \(k\) 的限制,然后上主席树求最小值即可。

需要注意的是,建树的时候不能在 dfs 里从父节点继承,而是同深度的都得加进去,所以要按照深度从小到大排序建。

具体见代码
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <cstring>
#include <string>
#include <stdlib.h>
#include <vector>
#include <queue>
#include <cmath>
#include <stack>
#include <map>
#include <set>
#define int long long
using namespace std;

const int N=1e5+5;
const int INF=1e9;
int n,r,m,cnt,tot,lans,maxdep=-1e9;
int a[N],p[N],dep[N],siz[N],dfn[N],root[N];
vector<int> G[N];
struct TREE{
	int lt,rt,mn;
}tree[N*32];

void pushup(int p){
	tree[p].mn=min(tree[tree[p].lt].mn,tree[tree[p].rt].mn);
}
int upd(int p,int lt,int rt,int pos,int val){
	int cur=++tot;
	tree[cur]=tree[p];
	if(lt==rt){
		tree[cur].mn=val;
		return cur;
	}
	int mid=(lt+rt)>>1;
	if(pos<=mid)
		tree[cur].lt=upd(tree[cur].lt,lt,mid,pos,val);
	else
		tree[cur].rt=upd(tree[cur].rt,mid+1,rt,pos,val);
	pushup(cur);
	return cur;
}
int qry(int p,int lt,int rt,int ql,int qr){
	if(lt>qr||rt<ql)
		return INF;
	if(ql<=lt&&rt<=qr)
		return tree[p].mn;
	int mid=(lt+rt)>>1;
	return min(qry(tree[p].lt,lt,mid,ql,qr),qry(tree[p].rt,mid+1,rt,ql,qr));
}
void dfs(int cur,int fa){
	siz[cur]=1;
	dep[cur]=dep[fa]+1;
	dfn[cur]=++cnt;
	maxdep=max(maxdep,dep[cur]);
	for(int i:G[cur]){
		if(i==fa)
			continue;
		dfs(i,cur);
		siz[cur]+=siz[i];
	}
}
bool cmp(int &x,int &y){
	return dep[x]<dep[y];
}

signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0);
	tree->mn=INF;
	cin>>n>>r;
	for(int i=1;i<=n;i++)
		cin>>a[i],p[i]=i;
	for(int i=1,u,v;i<n;i++){
		cin>>u>>v;
		G[u].push_back(v);
		G[v].push_back(u);
	}
	dfs(r,0);
	sort(p+1,p+n+1,cmp);
	for(int i=1;i<=n;i++)
		root[dep[p[i]]]=upd(root[dep[p[i-1]]],1,n,dfn[p[i]],a[p[i]]);
	cin>>m;
	while(m--){
		int x,k;
		cin>>x>>k;
		x=(x+lans)%n+1,k=(k+lans)%n;
		lans=qry(root[min(dep[x]+k,maxdep)],1,n,dfn[x],dfn[x]+siz[x]-1);
		cout<<lans<<'\n';
	}
	return 0;
}

总结:拓宽思维,万物皆可为历史版本。

结语

主席树有什么用?

  • 优化空间

  • 在单 \(\log\) 的复杂度内达成区间限制与值域限制的双重满足。

本文提到的技巧点?

  • 万物皆可为历史版本

  • 众数不带修考虑主席树

  • 见第 \(k\) 用主席树

  • 看到中位数考虑二分答案

以上。

posted @ 2025-06-08 22:23  _KidA  阅读(15)  评论(0)    收藏  举报