线段树的可持久化
线段树进阶
可持久化
能够保留每一个历史版本的数据结构。
那么可持久化线段树就是能保留历史版本的线段树。
原谅我之前一直叫它可持续化线段树 。
可持久化线段树
一般来说,可持久化线段树本质其实是可持久化数组,即支持单点修改、单点查询。
因为要保留历史版本,那么如果对于每次的的修改和查询均新生成一棵线段树的话。
一棵线段树需要 \(4\times n\) 的空间, \(m\) 次操作代表了 \(m\) 棵线段树,空间复杂度 \(O(4mn)\) ……
炸空间怎么办?想想之前值域 \(10^9\) 的权值线段树。
没错,动态开点。
当对一个叶节点进行修改之后,你会发现整棵树上修改的点会形成一条链,那我们就将这条链维护出来,也就是下图的红色节点(图片来自 OI wiki )。
建树
很常规的动态开点线段树。
void build(int &i,int l,int r)
{
i=++cnt;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(T[i].lc,l,mid);
build(T[i].rc,mid+1,r);
}
修改
在修改的过程中,我们先新建一个节点,赋给它对应的历史节点的信息(包括左右子节点和权值)。
还有,每新建一棵线段树,都要将其根节点记录下来,作为历史版本的查找依据。
void change(int &i,int last,int l,int r,int pos,int k)
{
i=++cnt;T[i]=T[last];
if(l==r)
{
T[i].val=k;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)change(T[i].lc,T[last].lc,l,mid,pos,k);
else change(T[i].rc,T[last].rc,mid+1,r,pos,k);
}
查询
查询和普通动态开点线段树基本一样。
int ask(int i,int l,int r,int pos)
{
if(l==r)return T[i].val;
int mid=(l+r)>>1;
if(pos<=mid)return ask(T[i].lc,l,mid,pos);
else return ask(T[i].rc,mid+1,r,pos);
}
主函数中:
build(rot[0],1,n);
int var=re(),op=re(),pos=re();
if(op==1)change(rot[i],rot[var],1,n,pos,re());
else rot[i]=rot[var],wr(ask(rot[i],1,n,pos)),putchar('\n');
还记得那首诗嘛?
一年 OI 一场空,不开 long long 见祖宗。
坑点
-
空间
线段树对的空间的需求极大,典型的空间换时间,所以数组千万不要开太大,可持续化线段树开到原数组大小的 二十三倍 就好了,否则就会无情的
Runtime Error.
和Memory Limit Exceeded.
。所以线段树中就不要储存所代表区间的左右端点了,将端点作为递归的参数传下去,否则会 MLE。
update in 2022.09.14 空间好像放开了,之前是 500MB,现在看是 1GB。
-
关于区间修改
一般来说,可持久化线段树不支持区间修改,因为懒标在下传时会导致之前版本的节点发生改变,然后空间复杂度爆炸。
Code
const int inf=1e6+7;
int n,m,a[inf];
struct Seg_Tree{
int lc,rc,val;
}T[inf*23];
int rot[inf],cnt;
void build(int &i,int l,int r)
{
i=++cnt;
if(l==r)
{
T[i].val=a[l];
return;
}
int mid=(l+r)>>1;
build(T[i].lc,l,mid);
build(T[i].rc,mid+1,r);
}
void change(int &i,int last,int l,int r,int pos,int k)
{
i=++cnt;T[i]=T[last];
if(l==r)
{
T[i].val=k;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)change(T[i].lc,T[last].lc,l,mid,pos,k);
else change(T[i].rc,T[last].rc,mid+1,r,pos,k);
}
int ask(int i,int l,int r,int pos)
{
if(l==r)return T[i].val;
int mid=(l+r)>>1;
if(pos<=mid)return ask(T[i].lc,l,mid,pos);
else return ask(T[i].rc,mid+1,r,pos);
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
a[i]=re();
build(rot[0],1,n);
for(int i=1;i<=m;i++)
{
int var=re(),op=re(),pos=re();
if(op==1)change(rot[i],rot[var],1,n,pos,re());
else rot[i]=rot[var],wr(ask(rot[i],1,n,pos)),putchar('\n');
}
return 0;
}
主席树
线段树能可持久化,权值线段树也能。
主席树(Hjt 树),全名 可持久化权值线段树 。
引入
给定 n 个整数构成的序列 a,将对于指定的闭区间 [l,r] 查询其区间内的第 k 小值。
好像就是把区间从 \(1\sim n\) 扩展到了 \(l\sim r\) 。
于是我们就从权值线段树扩展到了可持续化权值线段树。
思路
发明者的原话是:“对于原序列的每一个前缀 \([1\sim i]\) 建出一棵线段树维护值域上每个数出现的次数,则其树是可减的。”
其实就是前缀和。
权值线段树储存的是值域上数的个数。对于版本 \(R\) 的权值线段树减去版本 \(L-1\) 的权值线段树,就得到了维护 \([L,R]\) 的权值线段树。
Code
const int inf=2e5+7;
int n,m,num,a[inf],bok[inf];
struct Seg_Tree{
int lc,rc;
int sum;
}T[inf<<5];
int rot[inf],cnt;
void insert(int &i,int j,int l,int r,int k)
{
i=++cnt;T[i]=T[j];
if(l==r){T[i].sum++;return;}
int mid=(l+r)>>1;
if(k<=mid)insert(T[i].lc,T[j].lc,l,mid,k);
else insert(T[i].rc,T[j].rc,mid+1,r,k);
T[i].sum=T[T[i].lc].sum+T[T[i].rc].sum;
}
int ask(int i,int j,int l,int r,int k)
{
if(l==r)return l;
int mid=(l+r)>>1,sum=T[T[j].lc].sum-T[T[i].lc].sum;
if(k<=sum)return ask(T[i].lc,T[j].lc,l,mid,k);
return ask(T[i].rc,T[j].rc,mid+1,r,k-sum);
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
bok[i]=a[i]=re();
sort(bok+1,bok+n+1);
num=unique(bok+1,bok+n+1)-bok-1;
for(int i=1;i<=n;i++)
a[i]=lower_bound(bok+1,bok+num+1,a[i])-bok;
for(int i=1;i<=n;i++)
insert(rot[i],rot[i-1],1,num,a[i]);
for(int i=1;i<=m;i++)
{
int l=re(),r=re(),k=re();
wr(bok[ask(rot[l-1],rot[r],1,num,k)]),putchar('\n');
}
return 0;
}
拓展
对于带修改的主席树,一般用 树状数组套主席树。
练习
可持久化并查集
前置知识
如果只学过并查集的路径压缩,那么上边的文章对你来说很重要。
因为可持久化并查集并不能路径压缩,它需要按秩合并。
思路
回忆一下,并查集我们需要维护什么?
联通块的根 fa
,联通树的树高 dep
。
而本质上,这两个都是数组,而且只涉及单点修改,单点查询。
那么就可以用可持久化线段树(可持久化数组)维护。
代码对比
这是一个普通的按秩合并并查集:
const int inf=1e4+7;
int n,m;
int fa[inf],dep[inf];
int find(int x)
{
if(fa[x]==x)return x;
return find(fa[x]);
}
void merge(int x,int y)
{
x=find(x),y=find(y);
if(x==y)return;
if(dep[x]==dep[y])
fa[x]=y,dep[y]++;
else
{
if(dep[x]>dep[y])swap(x,y);
fa[x]=y;
}
}
int main()
{
n=re();m=re();
for(int i=1;i<=n;i++)
fa[i]=i;
for(int i=1;i<=m;i++)
{
int op=re(),u=re(),v=re();
if(op==1)merge(u,v);
else
{
int r1=find(u),r2=find(v);
puts((r1^r2)?"N":"Y");
}
}
return 0;
}
将其可持久化就是这样:
const int inf=2e5+7;
int n,m;
struct Seg_Tree{
int lc,rc,val;
}T[inf<<6];
int fat[inf],dep[inf],cnt;
void build(int &i,int l,int r)
{
i=++cnt;
if(l==r)
{
T[i].val=l;
return;
}
int mid=(l+r)>>1;
build(T[i].lc,l,mid);
build(T[i].rc,mid+1,r);
}
void change(int &i,int last,int l,int r,int pos,int k)
{
i=++cnt;T[i]=T[last];
if(l==r)
{
T[i].val=k;
return;
}
int mid=(l+r)>>1;
if(pos<=mid)change(T[i].lc,T[last].lc,l,mid,pos,k);
else change(T[i].rc,T[last].rc,mid+1,r,pos,k);
}
int ask(int i,int l,int r,int pos)
{
if(l==r)return T[i].val;
int mid=(l+r)>>1;
if(pos<=mid)return ask(T[i].lc,l,mid,pos);
else return ask(T[i].rc,mid+1,r,pos);
}
int find(int i,int x)
{
int fax=ask(fat[i],1,n,x);
if(fax==x)return x;
return find(i,fax);
}
void merge(int i,int x,int y)
{
x=find(i-1,x),y=find(i-1,y);
if(x==y)
{
fat[i]=fat[i-1],dep[i]=dep[i-1];
return;
}
int depx=ask(dep[i-1],1,n,x),depy=ask(dep[i-1],1,n,y);
if(depx==depy)
{
change(fat[i],fat[i-1],1,n,x,y);
change(dep[i],dep[i-1],1,n,y,depy+1);
}
else
{
if(depx>depy)swap(x,y);
change(fat[i],fat[i-1],1,n,x,y);
dep[i]=dep[i-1];
}
}
int main()
{
n=re();m=re();
build(fat[0],1,n);
for(int i=1;i<=m;i++)
{
int op=re();
if(op==1)
{
int x=re(),y=re();
merge(i,x,y);
}
if(op==2)
{
int k=re();
fat[i]=fat[k],dep[i]=dep[k];
}
if(op==3)
{
int x=re(),y=re();
int fax=find(i-1,x),fay=find(i-1,y);
wr(fax==fay),putchar('\n');
fat[i]=fat[i-1],dep[i]=dep[i-1];
}
}
return 0;
}
值得注意的是,在合并和查询的时候,都应该以上一个版本为准(因为当前版本什么都没有)。
STL 中的可持久化
STL 真是 C 党福利!!!
rope,是 STL 扩展提供的一种可持久化平衡树。
注意啊,是 STL 扩展,和 pb_ds 一个爹。
头文件 #include<ext/rope>
,名字空间 __gnu_cxx
。
变量定义 rope<int> *h[1000007];
。
可持久化 h[i]=new rope<int>(*h[j]);
,时间复杂度 \(O(1)\)。
函数调用:
h[i]->push_back(val)
在h[i]
的末尾加入val
。h[i]->at(k)
访问第k
个元素。h[i]->replace(pos,val)
将位置为pos
的元素换成val
。h[i]->size()
返回h[i]
的元素个数。h[i]->insert(pos,val)
在pos
位置插入val
。h[i]->erase(pos,k)
从pos
位置向后删除k
个元素。h[i]->substr(pos,k)
从pos
位置开始提取k
个元素。h[i]->copy(pos,k,q)
将从pos
位置向后x
个元素拷贝到q
中。
和 vector 差不多。
如果维护文艺平衡树(即区间翻转),就维护一正一反两个 rope,区间翻转就把两个 rope 的对应区间 swap 一下就行了。
但是!!
STL 的通病:常数巨大。
并且,rope 空间复杂度极高!!
实现 P3919:
#include<cstdio>
#include<ext/rope>
using namespace __gnu_cxx;
int re()
{
int s=0,f=1;char ch=getchar();
while(ch>'9'||ch<'0')
{
if(ch=='-')f=-1;
ch=getchar();
}
while(ch>='0'&&ch<='9')
s=s*10+ch-48,ch=getchar();
return s*f;
}
void wr(int s)
{
if(s<0)putchar('-'),s=-s;
if(s>9)wr(s/10);
putchar(s%10+48);
}
const int inf=1e6+7;
int n,m;
rope<int>*a[inf];
int main()
{
n=re();m=re();a[0]=new rope<int>(0);
for(int i=1;i<=n;i++)
a[0]->push_back(re());
for(int i=1;i<=m;i++)
{
int var=re();
a[i]=new rope<int>(*a[var]);
int op=re(),x=re();
if(op==1)a[i]->replace(x,re());
else wr(a[i]->at(x)),putchar('\n');
}
return 0;
}
时间空间双重爆炸。
不过这个代码量是真的友好,不会可持久化的可以用来骗分。
代码长度:691B | 用时:6.51s | 内存:1.00GB