主席树,又名可持久化线段树,可以访问多个历史版本的树上存的信息。

图及其他来源于此:https://www.cnblogs.com/hyfhaha/p/10678275.html

基本思想

用到的基本思想就是对于每一个修改版本的树,只新建修改后的节点,如果是每一个版本新开一个线段树的话空间一定不够。

image

这是普通的线段树单点修改。

image

这是主席树的单点修改。

几个月前我被这个东西劝退了,但现在来看挺好理解的,无非就是把修改的点从下到上涉及到的值有变化的点新开一个点,然后对于一些有没有变化的点,直接和修改后的连起来即可,根据根节点的不同可以判断是那个版本的点。

具体一点说,我们如果是暴力地每修改一次就建一棵新树的话,对于数据大一点的题目就会 MLE ,所以我们根据上面的图可以发现,我们进行一次单点修改的操作时,我们实际上修改了值的点是只有 log n 个的,其余的大部分点都是没有修改直接 ctrl cv 过来的,所以就有了第二张图所呈现的,我们开新点的时候只开修改的点,这样就可以使空间的消耗大大降低,同时又能做到对于每一个的版本都进行查询操作。

对于单点修改的操作,具体实现如下:

inline void ins(int &x,int pre,int l,int r,int q,int v)//在某个版本修改某个值 
{
	x=++cnt;ls[x]=ls[pre];rs[x]=rs[pre];val[x]=val[pre];//复制节点 
	if(l==r){val[x]=v;return ;}//修改当前节点的值 
	int mid=(l+r)>>1;//计算mid的值 
	if(q<=mid)ins(ls[x],ls[pre],l,mid,q,v);//根据版本去修改 
	else ins(rs[x],rs[pre],mid+1,r,q,v);
}

其中 x 是当前版本的编号,因为要进行赋值的操作,所以需要加传址符,当然也有不用传址符的,需要你最后返回当前编号的值,在递归的过程中对于每个新开的节点用 ins 函数进行赋值。pre表示的是上一个版本,也就是 x 版本是基于哪一个版本进行修改的, l,r 即为当前节点所代表的区间左右端点,而 q 是要修改的值的位置,v 是要修改成的值。

对于单点的查询操作,我们则通过以下代码实现:

inline int sum(int x,int l,int r,int q)//区间求值 
{
	if(l==r)return val[x];//如果当前点就是要差查的值就直接返回 
	int mid=(l+r)>>1;//计算mid的值 
	if(q<=mid)return sum(ls[x],l,mid,q);//按编号查询 
	else return sum(rs[x],mid+1,r,q);
}

x 是要查询的版本,l,r 是当前点代表的区间,p 是要查询的值的编号(位置)。

这个地方和普通的线段树没有多大区别,所以不细说了。

下面是模板题。

P3919 【模板】可持久化线段树 1(可持久化数组)

这个题目就是单点修改单点查询,可以配合代码以及注释理解一下。

#include<bits/stdc++.h>
#define N 1000010
using namespace std;
int a[N],n,m,q,rt[N*30];//a存放输入的每一个点的值,rt存放每一个版本的根节点 
int ls[N*30],rs[N*30],val[N*30],cnt;//存放每一个节点的左右儿子,值 
inline int read(){int x=0,f=1;char ch=getchar();while(!isdigit(ch)){f=ch!='-';ch=getchar();}while(isdigit(ch)){x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}return f?x:-x;}
inline void build(int &x,int l,int r)//建树 
{
	x=++cnt;//存编号 
	if(l==r){val[x]=a[l];return ;}//如果到了叶子节点就直接赋值退出 
	int mid=(l+r)>>1;//计算mid的值 
	build(ls[x],l,mid);//递归建左子树 
	build(rs[x],mid+1,r);//递归建右子树 
}
inline void ins(int &x,int pre,int l,int r,int q,int v)//在某个版本修改某个值 
{
	x=++cnt;ls[x]=ls[pre];rs[x]=rs[pre];val[x]=val[pre];//复制节点 
	if(l==r){val[x]=v;return ;}//修改当前节点的值 
	int mid=(l+r)>>1;//计算mid的值 
	if(q<=mid)ins(ls[x],ls[pre],l,mid,q,v);//根据版本去修改 
	else ins(rs[x],rs[pre],mid+1,r,q,v);
}
inline int sum(int x,int l,int r,int q)//区间求值 
{
	if(l==r)return val[x];//如果当前点就是要差查的值就直接返回 
	int mid=(l+r)>>1;//计算mid的值 
	if(q<=mid)return sum(ls[x],l,mid,q);//按编号查询 
	else return sum(rs[x],mid+1,r,q);
}
int main()
{
	n=read();m=read();
	for(int i=1;i<=n;i++)a[i]=read();
	build(rt[0],1,n);//建树,初始版本号:0 
	for(int i=1;i<=m;i++)//m次操作,m个新版本 
	{
		int pre=read(),op=read(),x=read();//输入版本号,操作,序号 
		if(op==1){int v=read();ins(rt[i],rt[pre],1,n,x,v);}//操作一,修改值 
		else {cout<<sum(rt[pre],1,n,x)<<endl;rt[i]=rt[pre];}
	}
	return 0;//好习惯 
}

当然上面的主席树操作并不常用,因为太明显了,更常用的是下面这种处理区间第 k 大的值的问题。

对于这种问题,我们考虑每一个版本维护什么,只要想到了版本维护的是什么就很好做了。

我们想到要进行排序,因为是第 k 大,如果值域太大,还需要进行离散化。

我们对于从 1~n 的所有数,进行一个前缀和类似的维护思想,第 i 个版本表示前 i 个数的出现情况。

然后我们得到了一个第 i 个版本表示前 i 个数的排名情况。

然后我们利用前缀和的思想,用第 r 棵线段树上的值,减去第 l-1 棵树上的值,得到的就是当前 l 到 r 区间内的所有数的排名情况了,我们在查询的时候,递归到当前点,看看左子树的大小是不是大于 k 的值,如果大于了,说明是在左子树,反之是在右子树,递归到叶子节点返回答案即可。

P3834 【模板】可持久化线段树 2

这题我们需要考虑主席树

首先我们可以想到,我们先从小到大排序离散化一下,然后我们可以一个一个把数插入到主席树上,一共是 \(n\) 个版本,然后第 \(i\) 个版本代表的是前 \(i\) 个数在数列中的排名情况,然后对于 \(l\)\(r\) 之间的点我们可以直接用前缀和思想,用 \(i\) 版本减去 \(i-1\) 版本的树上节点就可以了。

#include<bits/stdc++.h>
#define N 1000100
#define endl '\n'
using namespace std;
int n,m,rt[N],ls[N<<5],rs[N<<5];
int cnt,b[N],a[N],val[N<<5];
inline int read(){int x=0,f=1;char ch=getchar();while(!isdigit(ch)){f=ch!='-';ch=getchar();}while(isdigit(ch)){x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}return f?x:-x;}
inline void build(int &x,int l,int r)
{
	x=++cnt;
	if(l==r)return ;
	int mid=(l+r)>>1;
	build(ls[x],l,mid);
	build(rs[x],mid+1,r);
}
inline void add(int &x,int pre,int l,int r,int p)
{
	x=++cnt;ls[x]=ls[pre];rs[x]=rs[pre];val[x]=val[pre]+1;
	if(l==r)return ;
	int mid=(l+r)>>1;
	if(p<=mid)add(ls[x],ls[pre],l,mid,p);
	else add(rs[x],rs[pre],mid+1,r,p);
}
inline int ask(int u,int v,int l,int r,int k)
{
	if(l==r)return b[l];
	int mid=(l+r)>>1;
	int num=val[ls[v]]-val[ls[u]];
	if(num>=k)return ask(ls[u],ls[v],l,mid,k);
	else return ask(rs[u],rs[v],mid+1,r,k-num);
}
signed main()
{
	n=read();m=read();
	for(int i=1;i<=n;i++)b[i]=a[i]=read();
	sort(b+1,b+n+1);
	int len=unique(b+1,b+n+1)-b-1;
	build(rt[0],1,len);
	for(int i=1;i<=n;i++)
	{
		int x=lower_bound(b+1,b+len+1,a[i])-b;
		add(rt[i],rt[i-1],1,len,x);
	}
	for(int i=1;i<=m;i++)
	{
		int l=read(),r=read(),x=read();
		cout<<ask(rt[l-1],rt[r],1,len,x)<<endl;
	}
	return 0;
}

P3939 数颜色

这道题目乍一看好像不好办,因为他需要支持一个交换的操作。

对于查询的操作,我们是可以直接用跟上面那题一样的前缀和思想来做的,而对于交换的操作,我们可以把交换操作拆成两部分,对于交换 \(a\)\(b\) 我们可以看成是在当前版本上减去原来的 \(a\) 值,然后再加上 \(b\) 的值,最后交换一下原数组中两数的值即可。

#include<bits/stdc++.h>
#define N 1000100
#define endl '\n'
using namespace std;
int n,m,rt[N],val[N<<5],ls[N<<5],rs[N<<5],cnt;
int a[N],b[N],vis[N];
inline int read(){int x=0,f=1;char ch=getchar();while(!isdigit(ch)){f=ch!='-';ch=getchar();}while(isdigit(ch)){x=(x<<1)+(x<<3)+(ch^48);ch=getchar();}return f?x:-x;}
inline void add(int &x,int pre,int l,int r,int p,int v)
{
	x=++cnt;ls[x]=ls[pre],rs[x]=rs[pre],val[x]=val[pre]+v;
	if(l==r)return ;int mid=(l+r)>>1;
	if(p<=mid)add(ls[x],ls[pre],l,mid,p,v);
	else add(rs[x],rs[pre],mid+1,r,p,v);
}
inline int ask(int u,int v,int l,int r,int p)
{
	if(l==r)return val[v]-val[u];int mid=(l+r)>>1;
	if(p<=mid)return ask(ls[u],ls[v],l,mid,p);
	else return ask(rs[u],rs[v],mid+1,r,p);
}
signed main()
{
	n=read(),m=read();
	for(int i=1;i<=n;i++)b[i]=a[i]=read();
	sort(b+1,b+n+1);
	int len=unique(b+1,b+n+1)-b-1;
	for(int i=1;i<=n;i++)
	{
		int o=a[i];
		vis[o]=a[i]=lower_bound(b+1,b+len+1,a[i])-b;
		add(rt[i],rt[i-1],1,len,a[i],1);
	}
	for(int i=1;i<=m;i++)
	{
		int op=read();
		if(op==1)
		{
			int l=read(),r=read(),k=read();
			if(!vis[k]){cout<<"0"<<endl;continue;}
			cout<<ask(rt[l-1],rt[r],1,len,vis[k])<<endl;
		}
		else
		{
			int k=read();
			if(a[k]==a[k+1])continue;
			add(rt[k],rt[k],1,len,a[k],-1);
			add(rt[k],rt[k],1,len,a[k+1],1);
			swap(a[k],a[k+1]);
		}
	}
	return 0;
}