主席树 笔记
废话
首先介绍一下主席树与其名字的来历。主席树是“可持久化线段树”(Persistant Segment Tree
)的中文民间俗称。不知道是因为有人把 Persistant
看成了 Presidant
,还是因为它的发明者是 HJT
(和某一任国家主席简称相同),被叫做“主席树”。
但是,可持久化是啥呢?
可持久化是啥
可持久化是亿点小优化,当你要开 个线段树,但是线段树之间只差一次修改的时候,把空间复杂度降到 。
如何优化呢?考虑我们现在要对一个单点进行修改,那么我们能影响到的位置(就是这个点以及它的祖先),一共才 ,个而其它位置都一样,所以我们可以 只新建 个位置,整一个副本出来,其余位置直接继承原树即可。
蒯一张图举个例子,,修改了第四个点:
(原作者)
(原博客)
这样就能节省下超级多的空间!
但是,有利总有弊。这样的结构破坏的原来线段树的编号的完美特性,不能再用 index<<1
和 index<<1|1
来访问左右儿子了。我们要在线段树中多维护两个元素,左儿子和右儿子的编号。
经典题型
静态/动态区间第 位:给定一个序列,每次给出 ,求 中的数排序后,第 个位置的数是谁。(当然,我们不会实际去排序,所以询问操作对原序列没有影响)
如果是动态的,那你还要支持单点修改的操作,会给定 ,要支持把第 个位置上的数改成 。
(假设我们离散化过了所有 ,和所有询问修改后变成的 )
静态的问题
板子见 洛谷 3834
对于一个查询 ,(假设我们能)把 拿出来建一颗权值线段树 ,查询就很容易了:把区间分成左半部分和右半部分,设左半部分中数字的个数为 。如果 ,那么就在左半部分中查找,否则就在右半部分中查找第 个。
但是我们怎么把 拿出来建一颗权值线段树呢?这个时空复杂度都是 每次,再乘一个询问次数,肯定炸。
但是我们发现,线段树满足一种微妙的“可减性”:我们考虑建 颗线段树 , 表示 组成的权值线段树。然后 组成的权值线段树的对应位置就是 的对应位置减去 的对应位置。但是把 建出来,光空间就够炸的了,是 的
考虑用上面“可持久化”的办法来优化求 的过程: 和 之间,差的只是在(离散化后的) 位置上加一。那么我们就让 在 的基础上,复制其中的 条链即可。这样就可以空间复杂度 ,时间复杂度 的过去了。
一个伏笔
请把它理解成“前缀和套线段树”。
那么恭喜您,现在您已经会了一个嵌套型的数据结构了
静态问题(板子)的代码
点击展开/折叠
#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
做好准备,这比静态的问题要困难的多。我当时调试了 个小时才过去。
(后期:纯粹是我傻,抱歉)
前置芝士: 树状数组
不会这个,学个锤子主席树。
为了方便说明,下面设 ,
故 技 重 施
动态的问题就是我们要支持对其中一个位置进行修改了。修改 了第 个位置后, 范围内的所有线段树都要被修改一次,时间复杂度就爆炸了。
等等,这个问题我们是不是见过一次?
把时间倒回大约一两年前,我们遇到过这样一个问题:“单点修改,求区间和”。当时,懵懂的我们也在想:求区间和就要维护前缀和;当时改了一个点, 范围内的所有前缀和都要被修改一次,时间复杂度就爆炸了。
(对比一下这两句话)
我们当时是用的树状数组解决的问题:树状数组 ,第 个位置 维护 到 的和。要查询 的前缀和,只要求 的和即可。
那么我们可以用树状数组的思路维护主席树啊!
总体思路
那么我们怎么写呢?我们写一个“树状数组套主席树”:维护 个线段树 ,其中 维护 之间的所有 组成的权值线段树即可。
实际上这个引号可以去掉,这个就是树状数组套主席树
初始化
对于初始化操作,我们建树。先开 颗线段树,但是初始的时候 颗线段树 全部 和初始的线段树共用 (也就是对于每个 ,)
然后对于第 个位置,它只能影响到 位置上的线段树。对于这些位置上的每一个线段树 ,我们在它 自己 的基础上,插入 位置(并加一)。
修改操作
对于修改第 个位置从原来的 变成 (修改完令 ),我们要找到所有包含它的线段树,,在第 个位置 ,在 的位置 ,这样就完成了修改操作。
查询操作
静态的问题中,查询 的第 大,用 来减掉 ,然后判断答案在左边还是在右边。传参数只要传入 和 的根节点编号即可。
然而我们现在是树状数组套线段树,两者相减,可不是两颗线段树相减了,而是
第 颗线段树的和,减去
第 颗线段树的和。
然后我们显然没法一次性传这么多参数进去(而且还是不定长度,更麻烦了)。我们的办法是,在查询之前,先把所有的 保存在一个数组 里,再把所有的 保存在一个数组 里(并记下这两个数组的长度)(从 开始编号的同学可以考虑用第 个位置作为长度,我就是这么写的)
每次查询前缀 颗树减去前缀 颗树的时候,就遍历 ,把第 颗树的对应位置都加起来,把第 颗树的对应位置都减掉,得到 。然后用静态里面的做法即可。
代码
点击展开/折叠
#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;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】