首页 > 代码库 > (转)线段树的区间更新

(转)线段树的区间更新

写的很好,昨天刚刚开始写线段树,有些地方还不是很明白,看了这篇博文,学会了数组形式保存线段树,还学会了区间更新

以下为转载的博文内容

 

距离第一次接触线段树已经一年多了,再次参加ACM暑假集训,这一次轮到我们这些老家伙们给学弟学妹们讲解线段树了,所以就自己重新把自己做过的题目看了一遍,然后写篇博客纪念一下。作为一个菜鸟,文中肯定有很多表达不是很准确甚至错误的地方,欢迎各位大牛指正。

        作为近年来算法竞赛里面最火爆的数据结构考点,它的用法和问法层出不穷。而作为解决反复对区间的更新和查询问题最好的数据结构,它拥有其他数据结构无法取代的地位。树状数组虽然也能解决很多问题,而且代码量较低,空间复杂度较低,但是局限性较大,比如区间最值的问题就不能用树状数组完美解决。

        那么接下来就为大家带来线段树的两种基本用法:单点更新和区间更新(又叫成段更新)。

        首先线段树它是一棵高度平衡的二叉树,那么很多二叉树的性质它是完美继承的,比如用数组去模拟的话,父节点的下标*2=左儿子的下标,父节点的下标*2+1=右儿子的下标。而这个性质也在线段树的实现中得到了很好的运用(当然线段树还有很多不同的写法,大家可以自行研究,这里不做展开)。

        那么什么是线段树呢?

        我们来看一道题:

HDU1166敌兵布阵

        这道题如果用常规暴力的做法,就把所有营地的士兵存在一个数组里面,然后对于每次操作直接更新对应位置的数,对于每次询问直接从i到j加起来。然而这么操作下来,对于极限数据50000个人,40000条命令,显然是会超时的,那么一种新的数据结构线段树就应运而生了。

        首先第一个疑问:为什么线段树会快?

        显然对于m个点n次询问,暴力的做法时间复杂度是O(m*n)的。然而线段树作为一棵二叉树,继承了二叉树O(logn)的优良品质,对于这道题最坏的复杂度也是O(m*logn)的,这个量显然是符合时间要求的。

        第二:线段树如何处理?

        倘若节点x(x为奇数)记录的是第1个点的数据,节点x+1记录的是第2个点的数据,那么节点x/2记录的就是区间[1,2]上的有效数据,以此类推,最顶端的父节点记录的就是区间[1,n]上的有效数据,那么对于每个点的数据,有且仅有logn个节点的数据会被它影响,因此每次更新只用更新logn个点,查询亦然,这样就有效地节约了时间。

        对于每个节点,其代表的是区间[x,y]之间的值,那么其左儿子节点代表的就是[x,(x+y)/2]区间的值,右儿子节点代表的是区间[(x+y)/2+1,y]上的值,既保证了无重复,又保证了树的层数最短,查询效率最高。

        第三:线段树的具体实现呢?

        那么我们就跟着刚才拿到题目来详细讲解。

int tre[N*4];  void build(int num,int le,int ri)  {      if(le==ri)      {          scanf("%d",&tre[num]);          return ;      }      int mid=(le+ri)/2;      build(num*2,le,mid);      build(num*2+1,mid+1,ri);      tre[num]=tre[num*2]+tre[num*2+1];  }  

 首先是建树,在这里num存的是下标,而le和ri表示的是这个区间的左右端点,那么每往下一层num*2,区间则折半,保证了最少的层数,而此时内存占用大约为4倍的点数,所以开数组的时候开tre[4*N]。这个题因为需要读入每个点,作为二叉树的先序遍历,很好地保证了第x个点正好读入在le=ri=x的那个tre[num]里面。而父亲节点所代表的区间包含了子节点所代表的区间,所以子节点的值又会影响父节点,因此每次建立完儿子节点之后,又会通过tre[num]=tre[num*2]+tre[num*2+1];操作将父亲节点初始化,当然此处为求和操作所以是+,不同的题可以选择取最值等不同运算符。当然不同的题根据需求可以采取对tre[num]赋值或者memset等方法来建树以及初始化。

void update(int num,int le,int ri,int x,int y)  {      if(le==ri)      {          tre[num]+=y;          return ;      }      int mid=(le+ri)/2;      if(x<=mid)          update(num*2,le,mid,x,y);      else          update(num*2+1,mid+1,ri,x,y);      tre[num]=tre[num*2]+tre[num*2+1];  }  

接下来是修改操作,继承了上面的num,le,ri,保证了一致性,同时此处做的是对于第x个点增加y个人的操作,所以寻找到x所对应的tre[num],然后操作,并回退。而此时需要注意的是,对于x操作了之后,所有包含x的区间的tre[num]都需要被修改,因此也就有了在回退前的tre[num]=tre[num*2]+tre[num*2+1];操作。而这个题操作的是增加减少(减少直接传-x),而其他的诸如取最大最小值、取异或值等等都只用对于对应的运算符做修改即可。

int query(int num,int le,int ri,int x,int y)  {      if(x<=le&&y>=ri)      {          return tre[num];      }      int mid=(le+ri)/2;      int ans=0;      if(x<=mid)          ans+=query(num*2,le,mid,x,y);      if(y>mid)          ans+=query(num*2+1,mid+1,ri,x,y);      return ans;  }  

最后是查询操作,依然继承了num,le,ri。而此处做的是区间查询,(其实如果x=y就成了单点查询)那么如果查询区间[x,y]包含了目前的区间[le,ri],即x<=le&&y>=ri,那么此时的tre[num]就已经是这一部分的有效数据了,所以直接return即可,否则继续分区间查询。同样,此时根据题意所做的求和操作可以对应替换为异或、取最值等操作。

 

        以上就是线段树最简单的功能——单点更新。

        下面为大家带来的线段树稍微难一点但是基本是最常用的一个用法:区间更新。

区间更新对于初学者来说是一个坎,其中有几步相对较难理解。但是只要掌握,就能解决绝大多数线段树的题目了。

        首先刚刚那个题每次是每个营地增减人,那么如果每次是x号营地到y号营地每次都增减人呢?这样我们就会发现单点更新操作不适用了,无论我们如何调整都无法达到效果,而且即使每次对于x到y之间每个营地都执行一次单点操作,结果上看似可以,但是极限情况下我们每次对于1到n号进行更新的话,复杂度就会达到O(m*n*logn),这样就绝对会超时了,那么怎么做呢?这里就要用到区间更新了。

        对于区间更新的,我们来看下面这个题:

HDU1556Color the ball

        这个题很显然满足刚刚所提到的每次对于其中的一段里面的所有点进行操作。

        那么既然是线段树,首先我们依然是建树初始化,在建树阶段区别不多,该读值的读值,该赋值的赋值,该置0的置0,这题根据需要,在最开始所有的球都是未被涂色的,那么直接所有tre[num]置为0即可,于是这一次我们就可以不必单独写一个build了,直接memset(tre,0,sizeof(tre));即可。

        但是与单点更新最大的不同就是:它多了一个lazy数组!!!!!!!!!!重要的地方要打10个感叹号。

        laz,全称lazy,中文叫懒惰标记或者延迟更新标记。

        因为我们知道,如果我们每次都把段更新到节点上,那么操作次数和每次对区间里面的每个点单点更新是完全一样的哇!那么怎么办呢?仔细观察线段树,你会发现一个非常神奇的地方:每个节点表示的值都是区间[le,ri]之间的值有木有!!!!!!!!!!为什么说它神奇呢?更新的区间的值,存的区间的值!简直就是天作之合,我每次更新到对应区间了我就放着,我等下次需要往下更新更小的区间的时候,再把两次的值一起更新下去有木有啊!可以节约非常多时间啊有木有啊!

       对,这就是laz[num]的作用。下面我们跟着题再来逐步感受。

       首先在最最最最最最开始,是没有进行过更新操作的,那么laz[num]自然是全部置为0(当然有的题有额外的初始化要求,大家根据题目自行定夺)。

       那么初始化结束之后,就开始更新操作。

void update(int num,int le,int ri,int x,int y)  {      if(x<=le&&y>=ri)      {          tre[num]++;          laz[num]++;          return ;      }      pushdown(num);      int mid=(le+ri)/2;      if(x<=mid)          update(num*2,le,mid,x,y);      if(y>mid)          update(num*2+1,mid+1,ri,x,y);  }  

这一段是对区间[x,y]上的每个气球都涂色一次,当然你也可以涂z次色,无非就是额外再传一个变量代表涂色次数嘛。然后依然是分区间查找,一直到目标区间[x,y]包含了当前区间[le,ri],那么就对tre[num]和laz[num]操作,代表对这个区间我这么修改,而这个区间的分区间也应该被做修改,但是这时候我为了节约时间,暂时不做修改,但是我把这个修改动作存在laz[num]里,下次需要的时候再修改。

 

       而下次是什么时候呢?就是当前区间比我需要的目标区间大的时候,我必须用到下面的值了,那么就迫不得已了,不能再懒惰下去了,必须往下修改了,这时候,我们就把之前堆积起来的懒惰标记pushdown了,于是就有了一个神奇的pushdown操作。

void pushdown(int num)  {      if(laz[num]!=0)      {          tre[num*2]+=laz[num];          tre[num*2+1]+=laz[num];          laz[num*2]+=laz[num];          laz[num*2+1]+=laz[num];          laz[num]=0;      }  }  

 以上就是这个题的pushdown操作。当laz[num]!=0时,也就是这个点存在懒惰标记的时候,我们就要往下更新了,然而这个题是否判断laz[num]的有无对整体影响不大,但是有部分题再pushdown的同时会对整体有影响,例如取最小值的时候对于一段同时置为一个数,那么如果不判断0,就会把0给误pushdown下去,这就必须判断laz[num]的有无了。

 

        那么laz[num]本来是应该修改下去的,所以会对两个儿子节点的tre[num]有影响,这里因为是求加,所以采用的是+号,其它操作根据具体题目替换。同时,儿子节点的儿子节点也应该被更新,但是我们依然懒,赶一步走一步,所以此时依然不更新儿子节点的儿子节点,而是用更新儿子节点的laz标记来代替。

        回到之前的updata操作,在每次修改了儿子节点的tre[num]之后,正常情况下应该对父亲节点的tre[num]进行修改,但是此题父亲节点的tre[num]不会对后面的结果造成影响,所以这里就偷懒省略了这一步操作,实际操作中绝大部分题目都是不可以省略的,必须在更新完儿子节点的值之后再反过来更新父亲节点。

        最后依然是query操作。

int query(int num,int le,int ri,int x)  {      if(le==ri)      {          return tre[num];      }      pushdown(num);      int mid=(le+ri)/2;      if(x<=mid)          return query(num*2,le,mid,x);      else          return query(num*2+1,mid+1,ri,x);  }  

与单点更新时的query非常相似(废话吼,线段树本来就是一个比较模板的东西),也是额外加了一个pushdown操作,原因与update的一样,最后也缺省了反过来对父亲节点的更新,原因同上,不再赘述。

 

 

        至此线段树的两种基本用法:单点更新和区间更新操作就已经介绍完毕了。相信大家如果能仔细看完的话,对于这个数据结构也应该有了一定的认识。而线段树还有扫描线、区间合并等高级用法,而且线段树作为一个数据结构,是必然会和其它算法发生化学反应的,例如矩阵、dp等操作都有可能被巧妙地嵌套在线段树上形成一个综合题,所以大家下去一定要多做题,多感悟,才能深入透彻地吃下这个知识点。

 

(转)线段树的区间更新