Night的数据结构杂谈-可持久化线段树

要想知道可持久化线段树是什么呢,我们得先了解一下所谓“可持久化”这四个字代表什么。
恩一个可持久化数据结构(persistent data structure)就是一个可以在操作时保留自身先前版本的数据结构。
那么怎么支持可持久化呢?最简单的方案当然就是每次操作我们重新建立一个数据结构,然后将之前的操作全部都在这个结构上进行一次,然后接着进行当前操作。或者是可以对于第 i 次操作,我们把第 i 1 个版本复制在第 i 个版本上,然后在这个第 i 个版本上进行第 i 个操作。于是这样空间复杂度时间复杂度就是一个数量级的了。于是这样我们就朴素地使得所有数据结构都支持(让你TLE+MLE的)可持久化了(误)。
然后优化这个朴素的方法也当然就是按照这个尽量使得相同的节点不会重复开这样的思路来优化了。
形式化地说,例如对于线段树这种稳定的树形数据结构,显然我们保证从父亲节点往儿子节点访问,这样就无需记录儿子向父亲节点的指针,而是由父亲记录儿子节点的指针,这样一来,我们就可以更新儿子节点的时候只修改(其实是新建)这个节点的祖先节点,这样我们就可以把单次操作的复杂度优化到了 O ( h ) 。(假定 h 为树形结构的高度。)


那么可持久化线段树呢,就是能访问历史版本的线段树咯。
对于一棵线段树的历史版本,显然是与当前版本同构的嘛,因此我们就能拿它来进行加减啊之类的运算。(这可是一个很优秀的性质)
(接下来讲的东西假定大家普通的线段树都能写且比较熟了)
那么要怎么让一棵线段树支持历史版本呢?根据上面的朴素做法:(算了这个很显然不可行啊)
经过上古神犇们的不懈努力观察与实践,发现对于每次线段树的单点修改,我们只会动到这棵线段树上的 l o g ( n ) 个节点,因此就要想办法只动到这 l o g ( n ) 个节点,保留其余的节点,以此来优化时空的复杂度。
于是我们的单点修改就是这样的:
首先先从你要修改的当前版本 i 这个根往下查询找到你要修改的节点 x ,并对于路径上所有的点都复制一遍。
找到 x 以后,我们就新建这个叶子节点,并且返回这个节点的地址。
对于一个非叶子节点,我们对于它需要修改的那个字节点调用修改函数,并将指针指向这个函数返回的地址即可。
于是我们在每一步的时候都向上返回当前节点的地址,使得父节点指向叶子节点的指针能够正确地指向修改后的叶子节点。
这个修改的过程并没有对于旧的版本进行修改,而是完全地新建节点,这符合函数式编程的思想,因此可持久化线段树也被称之为函数式线段树。
如下图:

然后你就成功地修改了4号叶子节点并且维护了上来。
查询呢,只需要从要查询的版本的根节点开始,像查询普通的(动态开点)线段树一样查找就可以了,并不难。


于是我们来看几道例题:

可持久化数组

本题你需要设计一个可持久化数组。
给定正整数 n , m 以及 m 个操作,初始时有一个空数组 S 0 。你的任务是执行 m 个操作,第 i 个操作用两个整数 u i , f i 1 u i n 0 f i < m )表示从第 f i 个版本的数组 S f i 中加入一个 u i 变成第 i 个版本 S i
为了检查你是否正确维护了该数组,你需要在每次操作后回答:是否存在唯一的数 x ,在数组 S i 中出现的次数不是 3 的倍数?如果存在,请输出 x 的值。
本题强制在线。

显然这题可以直接用一棵线段树来维护一个序列每个数的出现次数,每次往这棵线段树里 u 这个位置 +1,然后简单回溯维护 a n s 即可,可持久化就写个可持久化线段树的板子就好了。

代码如下:

#include <bits/stdc++.h>
#define LL long long
#define R register
#define Max(a_a,b_b) (a_a<b_b?b_b:a_a)
#define Min(a_a,b_b) (a_a<b_b?a_a:b_b)
using namespace std;

template<class TT>inline void read(R TT &x){
    x=0;R bool f=0;R char ch=getchar();
    for(;ch<48||ch>57;ch=getchar())f|=(ch=='-');
    for(;ch>47&&ch<58;ch=getchar())
        x=(x<<1)+(x<<3)+(ch^48);
    (f)&&(x=-x);
}
struct Tree_node{
    Tree_node *l,*r;
    int sum,ans;
    Tree_node(){
        l=r=NULL;
        ans=-1;
        sum=0;
    }
};
Tree_node T[23333333],*root[1000010],*use=T+1;

int u,f;
void add(R Tree_node* &now,R int l,R int r){
    *use=*now;now=use++;
    if(l==r){
        now->sum=(now->sum+1)%3;
        now->ans=now->sum?u:-1;
        return ;
    }
    R int mid=l+r>>1;
    if(u>mid)add(now->r,mid+1,r);
    else add(now->l,l,mid);
    if(now->r->ans==-1)now->ans=now->l->ans;
    else if(now->l->ans==-1)now->ans=now->r->ans;
    else now->ans=-2;
}
int n,m,lastans;
int main(){
    read(n);read(m);
    root[0]=T[0].r=T[0].l=T;
    for(R int i=1;i<=m;++i){
        read(u);read(f);
        u^=lastans,f^=lastans;
        root[i]=root[f];
        add(root[i],1,n);
        printf("%d\n",lastans=root[i]->ans);
    }
    return 0;
}

POJ P2114 K-th Number

这题虽然也是个模板题,但是它并不像上一题一样这么模板,我们仔细分析一番。
可以先考虑弱化题目条件,假设我们每次只需要求区间 1 n 的第 k 小数,要怎么做呢?显然可知可以对于序列的权值建一棵线段树模,然后在线段树上二分。
那么如果每次询问 1 R 呢?可以发现按照 R 进行排序,然后不断地线段树里插入节点即可。
那么同时求区间 [ L , R ] 的第 k 小数呢?这个时候如果我们还按照刚刚的做法是不可行的了。但是注意到对于一个区间 [ L , R ] ,我们可以转化成一个前缀线段树相减的问题。
要开这么多棵线段树,每一棵又都有很多共同的节点,是不是和可持久化线段树非常类似呢?
因此就可以把序列的位置从左到右看成时间戳,每次往当前这一棵权值线段树加入节点,询问的时候我们从 R 这棵线段树来减去 L 1 这棵线段树就可以了。
还有就是建权值线段树的时候要记得离散化。
代码如下:

#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>
#define R register
#define LL long long
#define Max(__a,__b) (__a<__b?__b:__a)
#define Min(__a,__b) (__a<__b?__a:__b)
using namespace std;
template<class TT>inline void read(R TT &x){
    x=0;R bool f=false;R char c=getchar();
    for(;c<48||c>57;c=getchar())f|=(c=='-');
    for(;c>47&&c<58;c=getchar())x=(x<<1)+(x<<3)+(c^48);
    (f)&&(x=-x);
}
#define maxn 100010
vector<int>v;
int n,m,a[maxn],num[maxn],cnt;
struct node{node *l,*r;int sum;}pool[maxn<<5],*now=pool,*root[maxn];
inline int getid(R int x){return lower_bound(v.begin(),v.end(),x)-v.begin()+1;}
void modify(R int l,R int r,R node* &x,R node *y,R int p){
    now++;*now=*y;
    (now->sum)++;x=now;
    if(l==r)return;
    R int mid=l+r>>1;
    if(mid>=p)modify(l,mid,x->l,y->l,p);
    else modify(mid+1,r,x->r,y->r,p);
}
int query(R int l,R int r,R node *x,R node *y,R int p){
    if(l==r)return l;
    R int mid=l+r>>1;
    R int now=(y->l->sum)-(x->l->sum);
    if(now>=p)return query(l,mid,x->l,y->l,p);
    else return query(mid+1,r,x->r,y->r,p-now);
}
int main(){
    read(n);read(m);
    for(R int i=1;i<=n;++i){
        read(a[i]);
        v.push_back(a[i]);
    }
    sort(v.begin(),v.end());
    v.erase(unique(v.begin(),v.end()),v.end());
    root[0]=pool[0].l=pool[0].r=pool;
    for(R int i=1;i<=n;++i){
        modify(1,n,root[i],root[i-1],getid(a[i]));
    }
    for(R int l,r,k;m--;){
        read(l);read(r);read(k);
        printf("%d\n",v[query(1,n,root[l-1],root[r],k)-1]);
    }
    return 0;
}

所以观察这两道题,我们可以发现可持久化数据结构的时间戳既有可能是显式的“时间”,也有可能可以把题目的一些地方抽象成时间戳,再去利用可持久化数据结构的性质进行维护。
对于可持久化线段树,常见的做法如可以利用每个线段树保存区间含有数字的次数,然后把求前缀加入元素的过程抽象成时间流逝的过程这样子的。
(然后如果是动态的话你这个相当于是前缀和就可以用树状数组套上去啊)

猜你喜欢

转载自blog.csdn.net/night2002/article/details/79614420