数据结构讲解-什么是可持久化线段树?

(这是写给我们集训队公众号“广外acm集训队”的推送)

传统区间和问题

存在一个整数序列(最多10的5次方个),并且定义三种操作:查询[l,r]区间的和、更新第i个的值。

如果你看过我们之前的推送,一定能想到线段树的做法,线段树是区间问题求解的强力经典算法。
但是如果我们再增加一种操作呢?

查询第n次更新时的[l,r]区间和。

当我们更新一个值之后,线段树维护的区间和就变了,要怎样才能查询到历史数据呢?
其实很容易想到,我们每一次更新,都建一颗全新的线段树就好啦!这样就能轻易的查询到历史的区间和数据。
毫无疑问这是一个正确的思维导向,然而却存在着致命的问题:空间爆炸。如果你已经学过《数据结构》的课程,就会知道我们在用顺序表(数组)储存二叉树数据结构的时候,是这样一种情况:
树

对于一颗线段树,我们一般要把空间开到区间长度的4倍,假设有100个更新操作,就要建立100个线段树,这样可怕的空间消耗显然是不可接受的。
既然每次更新都新建线段树的思路是绝对可行的,那么如何解决空间爆炸这个问题?
这时候,我们就要用到函数式编程的思想。
函数式编程思维, 就是用计算(函数)来表示程序, 用计算(函数)的组合来表达程序的组合的思维方式,代表性的函数式编程语言有haskell、lisp等。
解决这个问题,我们不需要学会函数式编程,更不需要学会函数式编程语言,我们需要的是函数式编程中的重要思想:
如无必要,勿增实体。
落实到这个问题上来就是说:没必要的空间,你就别再开了啊。
那么什么样的空间是不需要开的呢?我们观察一下每次更新操作时发生的变化,就会轻易发现,由于题目的更新操作每次只更新一个点,所以每次更新操作后,线段树只会有一半的一半的一半……会发生变化(即logN个):
这里写图片描述
如图所示,如果我们要更新5号节点,那么只有1、2、5三个节点(红圈)的值会发生改变,其他的都没有发生变化。既然如此,为什么还要整个再建一遍啊?想一想普通线段树的更新操作,我们也只去更新需要的那些节点而已。于是解决问题的方法就来了:变化了的节点重建,没变的?那就用以前的!
全新的强大数据结构诞生了,这就是在上一篇推送“权值线段树”中提到的:

可持久化线段树(Persistent Segment Tree)

可持久化数据结构的思维其实刚刚已经讲完了,就是一种典型的函数式编程思维,重用,重用,再重用。如此一来,我们不但省下了空间,还降低了时间复杂度。
给一幅图玄学理解一下:

主席树

(图片来自https://blog.csdn.net/farestorm/article/details/50337527

建立可持久化线段树,一般只需要把空间开到区间大小的30倍左右就够了。

思路有了,就到了动手阶段。怎样去造一个可持久化线段树的轮子?
首先是建树。我们之前在建线段树时,遵循“一个父节点的左/右子节点位于父节点索引的2i/2i+1位置”。而可持久化线段树中,左/右子节点理论上来说可以在除根节点以外的任意位置,这时候就需要专门的变量来记录,于是开两个数组lc(leftChild)和rc(rightChild)来存子节点索引。之后是线段树都有的,维护区间的部分,这里是区间和,所以开一个sum数组。如上所述,这三个数组都开30倍。最后,还需要一个r(roots)数组,记录每一颗线段树的根节点在哪。

int lc[3000010];//左孩子
int rc[3000010];//右孩子
int sum[3000010];//区间和
int r[100010];

我们就在几个数组里,建立更新次数+1个互相重用的线段树!

毫无疑问,第一颗树的根节点肯定是是sum[0]或者sum[1]啦(为了更符合人类思维和查询操作一般选择索引1),那后面的根节点该选择在哪?有的神仙(比如说洲哥,%%%)可以通过仙术找到,还有的学霸肯定会说,可以根据区间大小算出一颗线段树用了多少空间……怎么算?我是学渣蒟蒻,我反正不会算,所以我选择拿个计数器(count)来数数。众所周知,线段树是递归创建/更新/查询的,只要我在创建的时候每递归一次,都count++数一数,就知道他用了多少空间了。

完美,这就可以写出build了。

int count = 0;
Main函数里调用:build(r[0] = ++count ,1,n);

void build(int now,int l,int r){

    now = count;
    if(l == r){
        sum[now] = (你读了什么这就是什么);
        return;
    }
    build(lc[now] = ++count, l, (l+r)/2);
    build(rc[now] = ++count, (l+r)/2+1, r);

    //push_up
    sum[now]=sum[lc[now]]+sum[rc[now]];
}

注意,我在填参数的时候写了很多赋值语句,一边建树,一边完成了根节点位置的记录+左右节点位置的记录,是不是很强?

强个鬼,我要是在项目里看到这种代码就打死写它的人。

在这里强烈安利引用写法:

void build(int &now,int l,int r){

    now=++count;
    if(l==r){
        sum[now]=(你读了什么这就是什么);
        return;
}
int mid = (l+r)/2; 
    build(lc[now],l,mid);
    build(rc[now],mid+1,r);

    //push_up
    sum[now]=sum[lc[now]]+sum[rc[now]];
}

打了一个小小的&符号,引用传值,效果一样,不会的同学该回去补C++了。
(本文作者使用Java,Java没有指针的概念,导致作者花了好几个小时去思考怎么写得更帅,这里就不贴了,反正你们也看不懂)

接着是更新(假设是第一次更新,目标改成666):

Main函数调用:update(r[0],r[1],目标节点,1,n);

void update(int last,int &now,int aim,int l,int r){
    now=++count;
    lc[now]=lc[last];//把左右子节点的索引复制过来
    rc[now]=rc[last];//只是索引,不是值
if(l == r){
  sum[now] = 666return;
}
    int mid=(l+r)/2;
if(aim<=m){//有需要的部分,我们再递归去改
update(lc[last],lc[now],aim,l,mid);
}else{
update(rc[last],rc[now],aim,mid+1,r);
}
sum[now]=sum[lc[now]]+sum[rc[now]];
}

查询操作……没啥变化,只是从通过2i/2i+1访问子节点的方式改成了通过lc/rc数组来访问而已。

本文重点是思想部分,完整代码就不贴了(你知道我是写Java的就不要为难我了)。
主席树还可以解决经典的“区间第k大”问题,鉴于篇幅,可以期待后续推送或者自己去学习。

猜你喜欢

转载自blog.csdn.net/cymbals/article/details/80560677