【学习笔记】可持久化线段树基础

点击查看目录

前言

参考资料:oi-wiki

前置知识:

  • 线段树基本操作

  • 动态开点线段树

概念

可持久化线段树,又称主席树。

(事实上,据说,主席树应该是可持久化线段树的一个子集,主席树应该是单纯的针对静态查询第 \(k\) 小的问题,但是似乎大家都酱紫说主席树就是可持久化线段树)

引入一个问题:给定长度为 \(n\) 的序列 \(a\),求其闭区间 \([x,y]\) 的第 \(k\) 小值。

似乎有许多做法,如分块+二分 \(O(n\sqrt{n}logn)\),整体二分等。

但是如果用线段树来看这个问题呢?

如果我们对于每次插入一个数都建立一棵线段树,而线段树维护的权值 \(val\) 是当前值域 \([l,r]\) 中有多少个数,那么在 \(y\) 时的线段树节点 \(k\)\(val\) 减去 \(x-1\) 时的线段树节点 \(k\)\(val\) 就是 \([x,y]\) 区间内,值域在 \([l,r]\) 的数的数量。

所以我们查询第 \(k\) 小的点,如果 \(k\) 大于左子树的 \(val_2-val_1\),那么这个点就在右子树,否则就在左子树。

大体思路已经成型,值域我们离散化就可以减小内存,但是问题在于我们如果真的每一个点都建一颗线段树,空间开销是巨大的,于是就有了可持久化线段树。

实现

现在我们增加了权值为 \(1\) 的点。

(图片来源:oi-wiki)

那么红色的点就被新增,这些红色的点所连成的树就恰好是我们修改后的树,而黑色节点所连成的树则是我们上一版本的树。

因此,我们称黑色节点练成的树叫历史版本。

单次修改可以新增 \(logn\) 个节点,如何增加节点?考虑从上往下动态开点。

在未遍历到子节点的时候,红色节点所连的边就指向自己历史版本的儿子,而当前的点进行动态开点。

// pre 指历史版本,id是动态开点的当前点,[l,r]是当前的值域,x是你要修改的点的值(离散后)
#define lid tr[id].lc
#define rid tr[id].rc
#define plid tr[pre].lc
#define prid tr[pre].rc
#define mid (l+r>>1)
void update(int pre,int &id,int l,int r,int x){
        id=++cnt;tr[id]=tr[pre];++tr[id].sum;// 每插入一个点其权值+1
        if(l==r)    return;
        (x<=mid)?update(plid,lid,l,mid,x):update(prid,rid,mid+1,r,x);
    }

于是,问题就解决了。

【模板】可持久化线段树 2 为例,代码如下:

Miku's Code
#include<bits/stdc++.h>
#define il inline
#define rg register int
#define cout std::cout
#define cerr std::cerr
#define push_back emplace_back
#define make_pair std::make_pair
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef double ff;
typedef long double llf;
typedef std::pair<int,int> PII;
const ff eps=1e-8;
int Max(int x,int y){ return x<y?y:x; }
int Min(int x,int y){ return x<y?x:y; }
int Abs(int x){ return x>0?x:-x; }
// #if ONLINE_JUDGE
char INPUT[1<<20],*p1=INPUT,*p2=INPUT;
#define getchar() (p1==p2 && (p2=(p1=INPUT)+fread(INPUT,1,1<<20,stdin),p1==p2)?EOF:*p1++)
// #endif
il int read(){
    char c=getchar();
    int x=0,f=1;
    while(c<48) { if(c=='-')f=-1;c=getchar(); }
    while(c>47) x=(x<<3)+(x<<1)+(c^48),c=getchar();
    return x*f;
}const int maxn=2e5+5;

int n,m,a[maxn],b[maxn],rt[maxn],cnt;

namespace PersistentSegementTree{
#define lid tr[id].lc
#define rid tr[id].rc
#define plid tr[pre].lc
#define prid tr[pre].rc
#define mid (l+r>>1)
    struct Tree{ int sum,lc,rc; };Tree tr[maxn<<5];
    void build_tree(int &id,int l,int r){
        id=++cnt;
        if(l==r)    return;
        build_tree(lid,l,mid);build_tree(rid,mid+1,r);
    }
    void update(int pre,int &id,int l,int r,int x){
        id=++cnt;tr[id]=tr[pre];++tr[id].sum;
        if(l==r)    return;
        (x<=mid)?update(plid,lid,l,mid,x):update(prid,rid,mid+1,r,x);
    }
    int query(int pre,int id,int l,int r,int x){
        if(l==r)    return b[l];
        int smid=tr[lid].sum-tr[plid].sum;
        return (x<=smid)?query(plid,lid,l,mid,x):query(prid,rid,mid+1,r,x-smid);
    }
#undef lid
#undef rid
#undef plid
#undef prid
#undef mid
}using namespace PersistentSegementTree;

il void input(){
    n=read(),m=read();
    for(rg i=1;i<=n;++i)  b[i]=a[i]=read(); 
    build_tree(rt[0],1,n);
    std::sort(b+1,b+1+n);
    for(rg i=1;i<=n;++i){
        int rank=std::lower_bound(b+1,b+1+n,a[i])-b;
        update(rt[i-1],rt[i],1,n,rank);
    }
}

int main(){
#ifndef ONLINE_JUDGE
freopen("tree.in","r",stdin);
freopen("tree.out","w",stdout);
#endif
    input();int l,r,k;
    while(m--){
        l=read(),r=read(),k=read();
        printf("%d\n",query(rt[l-1],rt[r],1,n,k));
    }
    return 0;
}

单次查询时间复杂度 \(O(logn)\),总空间复杂度 \(O(nlogn)\)

因此建议数组内存往大里开,建议开 maxn<<5

标记永久化

正常的主席树都是单点修改,如果查询和修改区间呢?

我们不可能单点一个个改,而对于普通线段树,我们考虑每次修改只找 \(logn\) 个节点打上懒标记,我们当然也可以在主席树上找到这 \(logn\) 个节点。

但是问题在于,如果我们下传标记,被修改的版本连接着的历史就会被修改。

所以就不下传标记,也不上合并,故称标记永久化。

posted @ 2023-10-17 07:25  Sonnety  阅读(45)  评论(4编辑  收藏  举报