主席树 笔记

废话

首先介绍一下主席树与其名字的来历。主席树是“可持久化线段树”(Persistant Segment Tree)的中文民间俗称。不知道是因为有人把 Persistant 看成了 Presidant,还是因为它的发明者是 HJT(和某一任国家主席简称相同),被叫做“主席树”。

但是,可持久化是啥呢?

可持久化是啥

可持久化是亿点小优化,当你要开 \(n\) 个线段树,但是线段树之间只差一次修改的时候,把空间复杂度降到 \(O(n\log n)\)

如何优化呢?考虑我们现在要对一个单点进行修改,那么我们能影响到的位置(就是这个点以及它的祖先),一共才 \(\log n\) ,个而其它位置都一样,所以我们可以 只新建 \(\log n\) 个位置,整一个副本出来,其余位置直接继承原树即可。

蒯一张图举个例子,\(n=5\),修改了第四个点:

原作者

原博客

这样就能节省下超级多的空间!

但是,有利总有弊。这样的结构破坏的原来线段树的编号的完美特性,不能再用 index<<1index<<1|1 来访问左右儿子了。我们要在线段树中多维护两个元素,左儿子和右儿子的编号。

经典题型

静态/动态区间第 \(k\) 位:给定一个序列,每次给出 \(l,r,k\),求 \([l,r]\) 中的数排序后,第 \(k\) 个位置的数是谁。(当然,我们不会实际去排序,所以询问操作对原序列没有影响)

如果是动态的,那你还要支持单点修改的操作,会给定 \(x,y\),要支持把第 \(x\) 个位置上的数改成 \(y\)

(假设我们离散化过了所有 \(a_i\),和所有询问修改后变成的 \(y\)

静态的问题

板子见 洛谷 3834

对于一个查询 \((l,r,k)\) ,(假设我们能)把 \(a[l\cdots r]\) 拿出来建一颗权值线段树 \(T\),查询就很容易了:把区间分成左半部分和右半部分,设左半部分中数字的个数为 \(lsum\)。如果 \(k\le lsum\),那么就在左半部分中查找,否则就在右半部分中查找第 \(k-lsum\) 个。

但是我们怎么把 \(a[l\cdots r]\) 拿出来建一颗权值线段树呢?这个时空复杂度都是 \(O(nlogn)\) 每次,再乘一个询问次数,肯定炸。

但是我们发现,线段树满足一种微妙的“可减性”:我们考虑建 \(n\) 颗线段树 \(T\)\(T_i\) 表示 \(a[1\cdots i]\) 组成的权值线段树。然后 \(a[l\cdots r]\) 组成的权值线段树的对应位置就是 \(T_r\) 的对应位置减去 \(T_{l-1}\)的对应位置。但是把 \(T\) 建出来,光空间就够炸的了,是 \(O(n^2)\)

考虑用上面“可持久化”的办法来优化求 \(T\) 的过程:\(T_i\)\(T_{i-1}\)之间,差的只是在(离散化后的) \(a_i\) 位置上加一。那么我们就让 \(T_i\)\(T_{i-1}\) 的基础上,复制其中的 \(\log\) 条链即可。这样就可以空间复杂度 \(O(n\log n)\) ,时间复杂度 \(O(n\log^2 n)\) 的过去了。

一个伏笔

请把它理解成“前缀和套线段树”。

那么恭喜您,现在您已经会了一个嵌套型的数据结构了

静态问题(板子)的代码
点击展开/折叠
#include <bits/stdc++.h>
using namespace std;
namespace Flandre_Scarlet
{   
    #define N 200005
    #define F(i,l,r) for(int i=l;i<=r;++i)
    #define D(i,r,l) for(int i=r;i>=l;--i)
    #define Fs(i,l,r,c) for(int i=l;i<=r;c)
    #define Ds(i,r,l,c) for(int i=r;i>=l;c)
    #define MEM(x,a) memset(x,a,sizeof(x))
    #define FK(x) MEM(x,0)
    #define Tra(i,u) for(int i=G.Start(u),v=G.To(i);~i;i=G.Next(i),v=G.To(i))
    #define p_b push_back
    #define sz(a) ((int)a.size())
    #define iter(a,p) (a.begin()+p)
    int I()
    {
        int x=0;char c=getchar();int f=1;
        while(c<'0' or c>'9') f=(c=='-')?-1:1,c=getchar();
        while(c>='0' and c<='9') x=(x<<1)+(x<<3)+(c^48),c=getchar();
        return (x=(f==1)?x:-x);
    }
    void Rd(int cnt,...)
    {
        va_list args; va_start(args,cnt);
        F(i,1,cnt) {int* x=va_arg(args,int*);(*x)=I();}
        va_end(args);
    }
    class Persistant_Tree
    {
    public:
        struct node{int lid,rid; int l,r; int s;} t[N<<5];
        // 注意空间往死里开
        // lid,rid 存储左右儿子编号
        int rt[N],tot=0;
        // rt[i] 存第 i 颗线段树的根的编号
        #define ls(x) t[x].lid
        #define rs(x) t[x].rid
        #define L  t[rt].l
        #define R  t[rt].r
        #define S  t[rt].s
        #define up(x) t[x].s=t[t[x].lid].s+t[t[x].rid].s
        void Init() {tot=0;}
        int Build(int l,int r) // 建树,这一步和普通的线段树差别不大
        {
            int rt=++tot;
            L=l,R=r,S=0;
            if (l<r)
            {
                int mid=(l+r)>>1;
                ls(rt)=Build(l,mid); 
                rs(rt)=Build(mid+1,r); 
            }
            return rt;
        }
        int Insert(int pos,int rt) 
        // 在 rt 这个根的基础上,修改了 pos 位置
        // 把一路上修改的线段树上的链存一个副本,并返回这个链的顶端
        {
            int rr=++tot;
            t[rr].l=L; t[rr].r=R; t[rr].s=S+1;
            ls(rr)=ls(rt); rs(rr)=rs(rt); // 默认直接继承

            if (L<R)
            {
                int mid=(L+R)>>1;
                if (pos<=mid) ls(rr)=Insert(pos,ls(rt)); // 如果要修改左儿子,那么就左儿子开一个副本
                else          rs(rr)=Insert(pos,rs(rt)); // 右儿子同理
            }
            return rr;
        }
        int QueryKth(int r1,int r2,int l,int r,int k) // 在 T[r2]-T[r1] 这颗权值线段树中,查询排名为 k 的位置
        {
            if (l==r) return l;

            int mid=(l+r)>>1;
            int lsum=t[ls(r2)].s-t[ls(r1)].s; // 左边有多少数
            if (k<=lsum) return QueryKth(ls(r1),ls(r2),l,mid,k); // k<=lsum,在左边找
            else         return QueryKth(rs(r1),rs(r2),mid+1,r,k-lsum); // 否则在右边找
        }
    }T;

    int n,q,a[N];
    void Input()
    {
        Rd(2,&n,&q);
        F(i,1,n) a[i]=I();
    }

    int d[N];
    void Soviet()
    {
        F(i,1,n) d[i]=a[i];
        sort(d+1,d+n+1);
        F(i,1,n) a[i]=lower_bound(d+1,d+n+1,a[i])-d;

        T.Init();
        T.rt[0]=T.Build(1,n);
        F(i,1,n)
        {
            T.rt[i]=T.Insert(a[i],T.rt[i-1]);
            // 第 i 颗线段树的根,就是在第 i-1 颗线段树的基础上,在 a[i] 的位置上加了一
        }
        F(i,1,q)
        {
            int l=I(),r=I(),k=I();
            int pos=T.QueryKth(T.rt[l-1],T.rt[r],1,n,k);
            // 查询第 r 颗树减去第 l-1 颗树的第 k 位,就相当于 a[l...r] 中的第 k 位
            printf("%d\n",d[pos]);
        }
    }

    #define Flan void
    Flan IsMyWife()
    {
        Input();
        Soviet();
    }
}
int main()
{
    Flandre_Scarlet::IsMyWife();
    getchar();getchar();
    return 0;
}

动态的问题

板子见 洛谷 2617

做好准备,这比静态的问题要困难的多。我当时调试了 \(10\) 个小时才过去。

(后期:纯粹是我傻,抱歉)

前置芝士: 树状数组

不会这个,学个锤子主席树。

为了方便说明,下面设 \(nex(i)=i+\operatorname{lowbit}(i)\)\(pre(i)=i-\operatorname{lowbit}(i)\)

故 技 重 施

动态的问题就是我们要支持对其中一个位置进行修改了。修改 了第 \(i\) 个位置后,\([i,n]\) 范围内的所有线段树都要被修改一次,时间复杂度就爆炸了。

等等,这个问题我们是不是见过一次?

把时间倒回大约一两年前,我们遇到过这样一个问题:“单点修改,求区间和”。当时,懵懂的我们也在想:求区间和就要维护前缀和;当时改了一个点,\([i,n]\) 范围内的所有前缀和都要被修改一次,时间复杂度就爆炸了。

(对比一下这两句话)

我们当时是用的树状数组解决的问题:树状数组 \(T\),第 \(i\) 个位置 \(T_i\) 维护 \(pre(i)+1\)\(i\) 的和。要查询 \(i\) 的前缀和,只要求 $T_i,T_{pre(i)},T_{pre(pre(i))}\cdots $ 的和即可。

那么我们可以用树状数组的思路维护主席树啊!

总体思路

那么我们怎么写呢?我们写一个“树状数组套主席树”:维护 \(n\) 个线段树 \(T\),其中 \(T_i\) 维护 \([pre(i)+1,i]\) 之间的所有 \(a_{x}\) 组成的权值线段树即可。

实际上这个引号可以去掉,这个就是树状数组套主席树

初始化

对于初始化操作,我们建树。先开 \(n\) 颗线段树,但是初始的时候 \(n\) 颗线段树 全部 和初始的线段树共用 (也就是对于每个 \(i\)\(root[i]=root[0]\)

然后对于第 \(i\) 个位置,它只能影响到 $i,nex(i),nex(nex(i))\cdots $ 位置上的线段树。对于这些位置上的每一个线段树 \(T_i\),我们在它 自己 的基础上,插入 \(a[i]\) 位置(并加一)。

修改操作

对于修改第 \(x\) 个位置从原来的 \(a[x]\) 变成 \(y\) (修改完令 \(a[x]=y\)),我们要找到所有包含它的线段树,\(T\),在第 \(a[x]\) 个位置 \(-1\),在 \(y\) 的位置 \(+1\),这样就完成了修改操作。

查询操作

静态的问题中,查询 \([l,r]\) 的第 \(k\) 大,用 \(T_r\) 来减掉 \(T_{l-1}\),然后判断答案在左边还是在右边。传参数只要传入 \(T_{l-1}\)\(T_r\) 的根节点编号即可。

然而我们现在是树状数组套线段树,两者相减,可不是两颗线段树相减了,而是

\(r,pre(r),pre(pre(r))\cdots\) 颗线段树的和,减去

\(l-1,pre(l-1),pre(pre(l-1)) \cdots\) 颗线段树的和。

然后我们显然没法一次性传这么多参数进去(而且还是不定长度,更麻烦了)。我们的办法是,在查询之前,先把所有的 \(l-1,pre(l-1),pre(pre(l-1)) \cdots\) 保存在一个数组 \(R_1\) 里,再把所有的 \(r,pre(r),pre(pre(r)) \cdots\) 保存在一个数组 \(R_2\) 里(并记下这两个数组的长度)(从 \(1\) 开始编号的同学可以考虑用第 \(0\) 个位置作为长度,我就是这么写的)

每次查询前缀 \(r\) 颗树减去前缀 \(l-1\) 颗树的时候,就遍历 \(R_1,R_2\),把第 \(R_{2i}\) 颗树的对应位置都加起来,把第 \(R_{1i}\) 颗树的对应位置都减掉,得到 \(lsum\)。然后用静态里面的做法即可。

代码
点击展开/折叠
#include <bits/stdc++.h>
using namespace std;
namespace Flandre_Scarlet
{
	#define N 100005
	#define F(i,l,r) for(int i=l;i<=r;++i)
	#define D(i,r,l) for(int i=r;i>=l;--i)
	#define Fs(i,l,r,c) for(int i=l;i<=r;c)
	#define Ds(i,r,l,c) for(int i=r;i>=l;c)
	#define MEM(x,a) memset(x,a,sizeof(x))
	#define FK(x) MEM(x,0)
	#define Tra(i,u) for(int i=G.Start(u),v=G.To(i);~i;i=G.Next(i),v=G.To(i))
	#define p_b push_back
	#define sz(a) ((int)a.size())
	#define iter(a,p) (a.begin()+p)
	int I()
	{
	    int x=0;char c=getchar();int f=1;
	    while(c<'0' or c>'9') f=(c=='-')?-1:1,c=getchar();
	    while(c>='0' and c<='9') x=(x<<1)+(x<<3)+(c^48),c=getchar();
	    return (x=(f==1)?x:-x);
	}
	void Rd(int cnt,...)
	{
	    va_list args; va_start(args,cnt);
	    F(i,1,cnt) {int* x=va_arg(args,int*);(*x)=I();}
	    va_end(args);
	}

	class Persistant_tree // 被卡常了的主席树...
	{
	public:
		struct node{int lid,rid,s;} t[N*400];
		// 去掉了存储左右区间的成员变量,左右区间在函数调用过程中记录
		// 从而把空间优化成原来的 3/5,然后数组开大点
		int rt[N],tot;
		#define ls(x) t[x].lid
		#define rs(x) t[x].rid
		#define S     t[rt].s
		void Init() {tot=0;}
		int Build(int l,int r)
		{
			int rt=++tot; S=0;
			if (l<r)
			{
				int mid=(l+r)>>1;
				ls(rt)=Build(l,mid);
				rs(rt)=Build(mid+1,r);
			}
			return rt;
		} // 这个一样的
		int Insert(int l,int r,int pos,int type,int rt)
		// 修改操作可能会有 -1,于是加上一个 type,type=1或-1表示加还是减
		{
			int rr=++tot;
			ls(rr)=ls(rt); rs(rr)=rs(rt); t[rr].s=S+type;
			if (l<r)
			{
				int mid=(l+r)>>1;
				if (pos<=mid) ls(rr)=Insert(l,mid,pos,type,ls(rt));
				else 		  rs(rr)=Insert(mid+1,r,pos,type,rs(rt));
				// 这些一样的
			}
			return rr;
		}
		int R2[30],R1[30];
		// R2,R1 含义见上面
		// 数组第 0 个位置保存数组长度
		int QueryKth(int l,int r,int k)
		{
			if (l==r) return l;
	
			int lsum=0;
			F(i,1,R2[0]) lsum+=t[ls(R2[i])].s;
			F(i,1,R1[0]) lsum-=t[ls(R1[i])].s;
			// 这个就是要遍历累加来求出 lsum 的值了
			int mid=(l+r)>>1;
			if (k<=lsum) 
			{
				F(i,1,R2[0]) R2[i]=ls(R2[i]);
				F(i,1,R1[0]) R1[i]=ls(R1[i]);
				// 跳左右儿子也要一块跳...这个复杂度活生生的多一个 log 啊
				return QueryKth(l,mid,k);
			}
			else
			{
				F(i,1,R2[0]) R2[i]=rs(R2[i]);
				F(i,1,R1[0]) R1[i]=rs(R1[i]);
				return QueryKth(mid+1,r,k-lsum);
			}
		}
	}T;
	
	int n,m,a[N];
	struct node{char type; int a,b,c;} q[N];
	// 如果是 C 操作,那么我们只用 a,b,表示将 a 位置变成了 b
	// 如果是 Q 操作,那么我们 a,b,c 都用,表示 [a,b] 区间排名第 c 位的数
	void Input()
	{
		Rd(2,&n,&m);
		F(i,1,n) a[i]=I();
		F(i,1,m)
		{
			char o[3]; scanf("%s",o);
			if (o[0]=='Q') 
			{
				q[i]=(node){o[0],I(),I(),I()};
			}
			else
			{
				q[i]=(node){o[0],I(),I()};
			}
		}
	}
	
	int d[N<<2],dcnt=0;
	#define Find(x) (lower_bound(d+1,d+dcnt+1,x)-d)
	void Insert(int pos,int type)
	{
		int x=Find(a[pos]);
		for(int i=pos;i<=n;i+=(i&(-i))) 
		{
			int lastr=T.rt[i]; // 保存原来的根
			T.rt[i]=T.Insert(1,dcnt,x,type,lastr); // 以便在原来的基础上插入
		}
	}
	int Query(int l,int r,int k)
	{
		F(i,0,22) T.R1[i]=T.R2[i]=0;
		for(int i=r;i>0;i-=(i&(-i))) T.R2[++T.R2[0]]=T.rt[i];
		for(int i=l;i>0;i-=(i&(-i))) T.R1[++T.R1[0]]=T.rt[i];
		// 先预先求好 R1,R2
		return T.QueryKth(1,dcnt,k);
	}
	void Soviet()
	{
		F(i,1,n) d[++dcnt]=a[i];
		F(i,1,m) if (q[i].type=='C') d[++dcnt]=q[i].b;
		sort(d+1,d+dcnt+1);
	
		T.Init();
		T.rt[0]=T.Build(1,dcnt); F(i,1,n) T.rt[i]=1;
		F(i,1,n) Insert(i,1);
		F(i,1,m)
		{
			if (q[i].type=='Q')
			{
				int pos=Query(q[i].a-1,q[i].b,q[i].c);
				printf("%d\n",d[pos]);
			}
			if (q[i].type=='C')
			{
				int x=q[i].a,y=q[i].b;
				Insert(x,-1); a[x]=y;
				Insert(x,1);
			}
		}
	}
	
	#define Flan void
	Flan IsMyWife()
	{
		Input();
		Soviet();
	}
}
int main()
{
	Flandre_Scarlet::IsMyWife();
	getchar();getchar();
	return 0;
}
posted @ 2021-05-04 22:57  Flandre-Zhu  阅读(162)  评论(0编辑  收藏  举报