可持久化线段树(主席树)

摘要

主席树,又称可持久化线段树,属于可持久化数据结构。“主席”这一名词是由于发明者缩写为HJT,和某位主席拼音缩写相同(有些牵强),故将该数据结构称为主席树。
主席树既保留了线段树的灵活,也拥有了可持久化数据结构的特点,在处理某些特定问题时有着其它数据结构不具有的优势。
本文将首先介绍什么是“可持久化数据结构”,随后介绍主席树的思想,关于代码实现将结合例题讲解。

可持久化数据结构

可持久数据结构主要指的是我们可以查询历史版本的情况并支持插入,利用使用之前历史版本的数据结构来减少对空间的消耗(能够对历史进行修改的是函数式编程 [1])。

我们经常会遇到这样的问题:我们需要维护一个数据结构,我们可以修改单一结点的值,查询单一结点的值,但是最关键的是我们可能还需要回退之前做过的某些操作。这里回退是指回到未做这些操作之前的状态。

在无回退操作的情况下,我们有大把的数据结构可供选择来解决这些问题。但是一旦涉及到回退操作,选择就少的多了。我们将支持回退操作的数据结构称为可持久化数据结构。

稍微思考一下如何可以在原来数据结构的基础上使其变得可持久化,有一个很简单的方案。我们每次操作都将重新建立一个新的数据结构,并将之前的操作都先在其上执行一次,之后执行该次操作。我们按操作执行顺序将这些数据结构维护成一个序列S,此时S[0]表示未经任何操作的初始数据结构。对于i>0,S[i]表示在S[0]的基础上执行过序号1到i的所有操作后得到的新的数据结构。在这样的做法下,我们称S[i]为版本i,回退操作等价于切换到某个特定版本。若操作i表示切换为版本j,那么我们可以直接将S[i]设置为S[j]的克隆。

上面提到的做法下很容易发现可以使得任意数据结构都可以支持回退操作,但是缺点也是非常明显,空间和时间的复杂度都奇高。每一次操作都需要累加之前操作的时间复杂度,空间也是,我们为了保存各个版本需要耗费大量的内存。

先说明时间复杂度的优化,对于i号操作,我们完全可以直接克隆版本S[i-1]并在其上执行i号操作,这样时间复杂度基本上就向空间复杂度看齐了。下面我们就可以专注于空间复杂度的优化(对应的也就是时间复杂度的优化)。

数据结构是用于保存数据的,我们将其保存数据的单元称为结点,我们可以利用结点来刻画整个数据结构的骨架。数据结构基本分为两类,一类是稳定的,一类是不稳定的。稳定的数据结构,其特定是在修改的结点的值之后不会改变结点之间的关系,而不稳定的数据结构在结点值变更后需要重新维护结点之间的关联。稳定的数据结构有线段树,后缀数组,前缀树等等,不稳定的数据结构主要就是各种二叉平衡树。对于稳定的树状结构,若孩子没有保存指向父结点的指针,即由父亲负责记录所有的孩子,我们很容易发现,当我们对某个结点更改时(修改值,新增,删除等操作),我们只需要同时修改该结点的所有祖先结点即可,那我们是不是也可以只克隆这些结点而非整个数据结构呢?答案是肯定的。由于父亲维护孩子,因此一个孩子允许有多个父亲,故所有没有被直接影响的结点都可以继续复用。我们将部分树状数据结构(特定是稳定和父亲维护父子关系)的一次操作的空间复杂度优化到了O(h),其中h是树状数据结构的高度。

当我们将上面的想法作用到线段树时,就得到了常说的主席树。其高度为 O ( l o g 2 ( n ) ) O(log_2(n)) O(log2(n)),其中n为线段树维护的区间大小,同时其时间和空间复杂度均为 O ( l o g 2 ( n ) 2 ) O(log_2(n)^2) O(log2(n)2)

引用自:陶无语的博客

静态主席树

我们按照“是否支持修改”来将主席树划分为静态和动态。静态主席树一旦建树成功,就不再支持修改,只能够用于查询。静态主席树维护元素出现次数的前缀和。

例题:洛谷P3834,查询区间第k大值
给定n个元素,共m个询问,每次询问给出[l ,r]和k,回答区间[l , r]内第k大元素值是多少。

建树

考虑用主席树解决上述问题,给出如下建树步骤:

  1. 新建一棵完整的空树,其根节点编号存放在root[0]内。
  2. 依次将n个元素插入到“版本0”的空树中,他们的“版本号”(根节点)存放在root[i]中。

对于每一个“新版本”,我们都在原来基础上新增“需要修改的节点”,并将其根节点记录在root数组中。
如此我们的时空花费都与修改的路径成正比,即每次 O ( l o g 2 N ) O(log_2N) O(log2N)

当然这些都是从理论思想上来讲的,比较抽象;具体到这一题,我们令线段树维护区间内元素数量,每个节点有三个变量,分别是 ls , rs , sum,即左儿子编号,右儿子编号,区间内元素个数。
初始时sum都为0,随后将n个元素依次插入形成n棵新的线段树,而这n+1棵(包括编号为0的空树)构成了一棵静态主席树。
所以建树操作其实分为两步:BuildTree()建立一棵空树并返回根节点编号;updata()在原树基础上“增加一棵新树”,并返回该版本的根节点编号。

虽然不同历史版本的线段树节点之间有交叉以重复利用,但每个历史版本都有唯一且独立的根节点

查询

由于线段树维护的是区间内元素的数量,所以不同版本的线段树的对应节点是可以加减的,那么root[i] - root[j]意义就是“第 i 个版本的线段树比第 j 个版本的线段树多几个元素”。如果我们再加上区间范围限制[l , r],那么我们也可以查询“第 i 个版本比第 j 个版本,在[l , r]上多几个元素”。

具体到本题,我们需要查询区间[l , r]内第k大的元素,已知线段树可以加减,那么对于询问(l , r , k),我们就需要在root[l-1] 与 root[r]两棵线段树上找寻答案,不要忘记了这颗线段树是根据权值建立的,也就是所谓的权值线段树。那么在res个数中找第k个,显然二分(树上),参见代码。

代码模板

见附录部分code-1:洛谷P3834静态主席树模板-求区间第k大值

动态主席树

静态主席树虽然支持历史查询,但其功能还是不太强大,因为其不支持更改。我们将支持修改的主席树称为动态主席树,但是这个功能添加起来并不容易。静态主席树还可以看作是线段树通过小修改得到,而动态主席树则是树套树。

思想依旧是维护元素出现次数的前缀和,可以类比差分数组,我们都知道前缀和数组是不支持修改的,如果要修改,就需要用 树状数组/线段树 来维护,这里也是类似。

我们考虑“外层用树状数组,内层用记录区间内数值出现次数的线段树”来实现支持修改的可持久化线段树,也即动态主席树。

在静态主席树上,可以通过两个权值线段树相减来求得区间第k大值,在这里我们仍旧是通过这种方法求区间第k大,不同的是我们需要保证所有线段树的数据是正确的(维护修改)。
如果我们用树状数组来维护不同版本的权值线段树的编号,那么对于“将位置 p 的 x 修改为 y”这一操作,我们需要修改共logN个版本的权值线段树,如此修改操作的时间复杂度是 O ( ( l o g 2 N ) 2 ) O((log_2N)^2) O((log2N)2)
值得注意的是,此时空间复杂度 = 树状数组空间 * 权值线段树空间 = N 2 N^2 N2,但是树状数组实际上只保存权值线段树的“版本号”而已,因此实际上用到的空间也就只有权值线段树上的 O ( N ( l o g 2 N ) 2 ) O(N(log_2N)^2) O(N(log2N)2)个节点的空间,因此动态开点即可。

例题2:洛谷P2617

代码模板: 见附录部分code-2

注释

[1] 函数式编程:“函数式编程”是一种“编程范式”(programming paradigm),也就是如何编写程序的方法论。它属于“结构化编程”的一种,主要思想是把运算过程尽量写成一系列嵌套的函数调用。

引用自:aezero的博客

附录

code-1:洛谷P3834静态主席树模板-求区间第k大值

#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 2e5+10;
int n,m,a[N];
struct zxTree{
    int ls,rs,sum;//左右儿子,区间内元素个数
    #define ls(x) tr[x].ls
    #define rs(x) tr[x].rs
    #define sum(x) tr[x].sum
} tr[N*40];//注意数组大小
int sz = 0;//不同版本的树的总数 
int root[N];//root[i]存放第i棵树的树根的编号
int BuildTree(int l,int r){
    /*建树,和普通线段树相同*/
    int rt = ++sz;sum(rt) = 0;
    if(l == r) return rt;
    int mid = l+r>>1;
    ls(rt) = BuildTree(l,mid);
    rs(rt) = BuildTree(mid+1,r);
    return rt;
}
int updata(int pre,int l,int r,int x){
    /*新建一棵树,其比pre树多一个元素x*/
    int rt = ++sz;
    tr[rt] = tr[pre]; sum(rt)++;
    if(l == r) return rt;
    int mid = l+r>>1;
    if(x <= mid) ls(rt) = updata(ls(pre),l,mid,x);
    else rs(rt) = updata(rs(pre),mid+1,r,x);
    return rt;
}
int ask(int pre,int rt,int l,int r,int k){
    /*  依次是:上一个树根,当前树根,区间左右端点,所求区间第k大
        返回该区间第k大数的下标 */
    if(l >= r) return l;
    int res = sum(ls(rt)) - sum(ls(pre));
    int mid = l+r>>1;
    if(res >= k) return ask(ls(pre),ls(rt),l,mid,k);
    else return ask(rs(pre),rs(rt),mid+1,r,k-res);
}
int tmp[N];
int main(){
    scanf("%d%d",&n,&m);
    for(int i = 1;i <= n;i++) scanf("%d",a+i),tmp[i] = a[i];
    //离散化
    sort(tmp+1,tmp+1+n);
    int tot = unique(tmp+1,tmp+1+n)-tmp-1;
    root[0] = BuildTree(1,tot);
    for(int i = 1;i <= n;i++){
        int x = lower_bound(tmp+1,tmp+1+tot,a[i])-tmp;
        root[i] = updata(root[i-1],1,tot,x);
    }
    //利用主席树可以加减原理计算
    for(int i = 1,l,r,k;i <= m;i++){
        scanf("%d%d%d",&l,&r,&k);
        int x = ask(root[l-1],root[r],1,tot,k);
        printf("%d\n",tmp[x]);
    }
    return 0;
}

code2-洛谷P2617动态主席树

#include<cstdio>
#include<algorithm>
using namespace std;
const int N = 1e5+10;
/*线段树节点,要存放左右儿子编号,区间内元素个数*/
struct SegmentTree{
    int ls,rs,sum;
    #define ls(x) tr[x].ls
    #define rs(x) tr[x].rs
    #define sum(x) tr[x].sum
}tr[N*400];//空间要N(logN)^2大小
/*因为要离散化,所以要提前读取所有操作*/
struct Query{
    int l,r,k;//查询操作,询问区间[l,r]内第k大数的值
    int p,x;    //修改操作,修改位置p位置上的元素为x
}qy[N];
int sz = N;/*不同版本的线段树总数,
即动态申请节点编号,初始值要为N,因为前n个节点被使用*/
int a[N],n,m,tot = 0;//tot离散化用
char op[10];
void Insert(int rt,int l,int r,int p,int d){
    /*对以rt为根的线段树,区间[l,r]内新增一个元素x*/
    sum(rt) += d;
    if(l == r) return; int mid = l+r>>1;
    if(!ls(rt)) ls(rt) = ++sz;//如果该子树没有子节点则新建
    if(!rs(rt)) rs(rt) = ++sz;//动态申请节点
    if(p <= mid) Insert(ls(rt),l,mid,p,d);
    else Insert(rs(rt),mid+1,r,p,d);
}
void add(int l,int p,int y){
    /*向树状数组中的 线段树中 位置p值+d,要从l开始哦*/
    for(int x = l;x <= n;x += x&-x) Insert(x,1,tot,p,y); 
}
int t1[N],t2[N],c1,c2;//临时记录遍历路径
int ask(int l,int r,int k){
    /*返回区间[l,r]内第k大元素的值(离散化后的)*/
    if(l == r) return l;
    int res = 0, mid = l+r>>1;
    for(int i = 1;i <= c1;i++) res -= sum(ls(t1[i]));
    for(int i = 1;i <= c2;i++) res += sum(ls(t2[i]));
    if(res >= k){
    	for(int i = 1;i <= c1;i++) t1[i] = ls(t1[i]);
    	for(int i = 1;i <= c2;i++) t2[i] = ls(t2[i]);
    	return ask(l,mid,k);
	}else{
		for(int i = 1;i <= c1;i++) t1[i] = rs(t1[i]);
    	for(int i = 1;i <= c2;i++) t2[i] = rs(t2[i]);
		return ask(mid+1,r,k-res);
	} 
}
int tmp[N*2];//离散化用
int query(int l,int r,int k){
	c1 = c2 = 0;
	/*我们将树状数组上待查询的线段树的左儿子编号先存储*/
	for(int i = l;i;i -= i&-i) t1[++c1] = i;
	for(int i = r;i;i -= i&-i) t2[++c2] = i;
	int x = ask(1,tot,k);	//注意查询区间是[1,tot] 
	return tmp[x];
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i = 1;i <= n;i++) scanf("%d",a+i),tmp[++tot] = a[i];
    for(int i = 1;i <= m;i++){
        scanf("%s",op);
        if(op[0] == 'C'){
            scanf("%d%d",&qy[i].p,&qy[i].x);
            tmp[++tot] = qy[i].x;//先存储,方便离散化
        }else scanf("%d%d%d",&qy[i].l,&qy[i].r,&qy[i].k);
    }
    //离散化
    sort(tmp+1,tmp+1+tot);
    tot = unique(tmp+1,tmp+1+tot)-tmp-1;
    for(int i = 1;i <= n;i++){
        int p = lower_bound(tmp+1,tmp+1+tot,a[i])-tmp;
        add(i,p,1);
    }
    for(int i = 1;i <= m;i++){
        if(qy[i].l){
            int l = qy[i].l, r = qy[i].r;
            printf("%d\n",query(l-1,r,qy[i].k));
        }else{
            int p = lower_bound(tmp+1,tmp+1+tot,qy[i].x)-tmp;
            int pre = lower_bound(tmp+1,tmp+1+tot,a[qy[i].p])-tmp;
            add(qy[i].p,pre,-1); add(qy[i].p,p,1); a[qy[i].p] = qy[i].x;
        }
    }
    return 0;
}
已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 程序猿惹谁了 设计师:白松林 返回首页