[OI] 可持久化数据结构

学了一年 OI 才看懂这句话:

\(\log n\) 是以什么为底的?

其实没什么区别

因为我们自动忽略常数,因此 \(\log_{a}n=\frac{\log_{x}n}{\log_{x}a}=\log_{x}n\)

可持久化数组

可持久化数组是基于可持久化线段树的一种数据结构. 它可以支持如下操作:

  • 单点修改
  • 查询历史版本
  • 在历史版本上修改

考虑到,为了实现这个功能,我们可以给每个历史版本都复制一份副本,但是这样的复杂度是 \(O(nt)\)

考虑优化空间复杂度:现在把整个数组放在线段树上,令数组为线段树的全部叶节点,因为我们每次修改节点最多只会带来 \(\log n\) 的改变,因此我们考虑在原树上仅仅改变这些点. 用动态开点思想可以很轻松地做到这一点.

比较水,直接放代码了

namespace HIST_Stree{
	const int N=1000000;
	int root[N];
	#define mid(l,r) mid=((l)+(r))/2
	struct tree{
		int tol,tor;
		int w;
	}t[30*N];
	#define tol t[id].tol
	#define tor t[id].tor
	int cnt=0;
	int newnode(){
		t[++cnt]={};
		return cnt;
	}
	int clone(int node){
		t[++cnt]=t[node];
		return cnt;
	}
	void build(int &id,int l,int r){
		id=newnode();
		if(l==r){
			t[id].w=a[l];
			return;
		}
		int mid(l,r);
		build(tol,l,mid);
		build(tor,mid+1,r);
	}
	void update(int &id,int l,int r,int pos,int val){
		id=clone(id);
		if(l==r){
			t[id].w=val;
			return;
		}
		int mid(l,r);
		if(pos<=mid) update(tol,l,mid,pos,val);
		else update(tor,mid+1,r,pos,val);
	}
	int ask(int id,int l,int r,int pos){
		if(l==r){
			return t[id].w;
		}
		else{
			int mid(l,r);
			if(pos<=mid) return ask(tol,l,mid,pos);
			else return ask(tor,mid+1,r,pos);
		}
	}
}

if(op==1){
	scanf("%d",&val);
	update(root[i]=root[t],1,n,pos,val);
}
else{
	printf("%d\n",ask(root[t],1,n,pos));
	root[i]=root[t];
}

可持久化线段树

可持久化线段树最经典的应用即为求区间第 \(k\)

先不考虑可持久化,考虑开一颗权值线段树,那么我们按照下述方法即可求出区间 \([1,r]\) 内的第 \(k\) 小:

先将原数组 \(a\) 排序离散化为 \(b\),将 \(a\) 中的元素依次插入权值线段树内. 每个节点插在位于 \(b\) 数组的位置上

为了方便统计答案,我们规定:插入节点时,途径节点的值加一

比如下列数组:

a: 1 5 2 6 3 7 4

排序后为

b: 1 2 3 4 5 6 7

图源

首先插入 \(a[1]=1\),其路径如下:

其次插入 \(a[2]=5\):

以此类推

最终会变成这样:

可以发现:因为我们开的是权值线段树,因此总是满足左区间小于右区间,因此要求 \([1,r]\) 的第 \(k\) 小,我们只需要考虑想二叉搜索树一样递归减掉 \(rank\) 即可.

那么对于求区间 \([l,r]\) 的问题怎么办呢?其实可以基于权值线段树有可减性的思想,直接用版本 \(r\) 减去版本 \(l-1\),剩下的就是在 \([l,r]\) 内的修改了.

实际实现的时候并不需要真的减出一颗树来,可以通过作差来做.

namespace HIST_Stree{
	#define mid(x,y) mid=((x)+(y))/2
	const int N=200001;
	int root[N],cnt=0;
	struct tree{
		int tol,tor;
		int sum;
	}t[N*32];
	#define tol(id) t[id].tol
	#define tor(id) t[id].tor
	int newnode(){
		t[++cnt]={};
		return cnt;
	}
	int clone(int node){
		t[++cnt]=t[node];
		return cnt;
	}
	void build(int &id,int l,int r){
		id=newnode();
		if(l==r){
			return;
		}
		int mid(l,r);
		build(tol(id),l,mid);
		build(tor(id),mid+1,r);
	}
	void change(int &id,int l,int r,int pos){
		id=clone(id);
		t[id].sum++;
		if(l==r) return;
		int mid(l,r);
		if(pos<=mid) change(tol(id),l,mid,pos);
		else change(tor(id),mid+1,r,pos);
	}
	int ask(int x,int y,int l,int r,int k){
		if(l==r) return l;
		int mid(l,r);
		if(t[t[y].tol].sum-t[t[x].tol].sum>=k){
			return ask(t[x].tol,t[y].tol,l,mid,k);
		}
		else{
			return ask(t[x].tor,t[y].tor,mid+1,r,k-(t[t[y].tol].sum-t[t[x].tol].sum));
		}
	} 
}
using namespace HIST_Stree;

    sort(b+1,b+n+1);
	int l=unique(b+1,b+n+1)-b-1;
	build(root[0],1,l);
	for(int i=1;i<=n;++i){
		change(root[i]=root[i-1],1,l,lower_bound(b+1,b+l+1,a[i])-b);
	}
	while(m--){
		int L,R,K;
		scanf("%d %d %d",&L,&R,&K);
		printf("%d\n",b[ask(root[L-1],root[R],1,l,K)]);
	}

可持久化线段树小结

根据上述问题的解法可以发现,可持久化值域线段树因为其出色的可加减性,因而经常被当做前缀和用.

什么时候才会让可持久化线段树充当前缀和的角色?通常是那些需要对不同版本求前缀和的问题

但实际上并没有哪个题会容易让你看出来怎么构建历史版本(或者根本看不出来可以构建历史版本),这才是可持久化线段树题最难的地方.

可持久化线段树并不全是值域线段树,但是其他的并不常见,多用来构成其他可持久化数据结构. 如可持久化数组,可持久化并查集等.

在维护前缀和时,通常我们需要维护一个求答案的式子,比如:

推知答案 \(ans=w_{x}-w_{y}\)(举例)

注意到这个答案式里面有两个参数,那么只需要传两个历史版本 \((a,b)\),在可持久化线段树里先维护每个节点的 \(f(x)\),然后再在求解过程中维护两个版本的 \(f_{a}(x)-f_{b}(y)\)(这是由你推的式子决定的),再同时跳到各自版本的下一个节点.

多维函数也是一样,比如 P2633,它的答案式为 \(f(x,y)=w_{x}+w_{x}-w_{lca(x,y)}-w_{fa_lca(x,y)}\),因此我们传四棵树(\(root_{x},root_{y},root_{lac},root_{fa_{lca}}\))来统计答案,跳的时候四棵树一起跳.

这些题都是类似的,实际情况实际处理即可.

posted @ 2024-08-13 17:46  HaneDaniko  阅读(27)  评论(1编辑  收藏  举报