线段树详解

我本身在学这些数据结构以及算法的时候,网上的博客不少都是给出一个大体思想,而后就直接给代码了,多是我智商过低,思惟跳跃没有那么大,无法直接代码实现,并且有些学完以后也没有获得深层次的理解和运用,仍是停留在只会使用模板的基础上。因此我但愿我写的东西能让更多的人看明白,我会尽可能写详细,也会写出我初学的时候哪些地方没有理解或者难以运用,又是怎样去熟练的使用这些东西的。可能仍是不能让全部的人都读明白,但我尽可能作的更好。算法


 

1、什么是线段树?

  • 线段树是怎样的树形结构?

  线段树是一种二叉搜索树,什么叫作二叉搜索树,首先知足二叉树,每一个结点度小于等于二,即每一个结点最多有两颗子树,何为搜索,咱们要知道,线段树的每一个结点都存储了一个区间,也能够理解成一个线段,而搜索,就是在这些线段上进行搜索操做获得你想要的答案。数组

  • 线段树可以解决什么样的问题。

  线段树的适用范围很广,能够在线维护修改以及查询区间上的最值,求和。更能够扩充到二维线段树(矩阵树)和三维线段树(空间树)。对于一维线段树来讲,每次更新以及查询的时间复杂度为O(logN)。数据结构

  • 线段树和其余RMQ算法的区别

  经常使用的解决RMQ问题有ST算法,两者预处理时间都是O(NlogN),并且ST算法的单次查询操做是O(1),看起来比线段树好多了,但两者的区别在于线段树支持在线更新值,而ST算法不支持在线操做。函数

  这里也存在一个误区,刚学线段树的时候就觉得线段树和树状数组差很少,用来处理RMQ问题和求和问题,但其实线段树的功能远远不止这些,咱们要熟练的理解线段这个概念才能更加深层次的理解线段树。优化

2、线段树的基本内容

  如今请各位不要带着线段树只是为了解决区间问题的数据结构,事实上,是线段树多用于解决区间问题,并非线段树只能解决区间问题,首先,咱们得先明白几件事情。ui

  每一个结点存什么,结点下标是什么,如何建树。spa

  下面我以一个简单的区间最大值来阐述上面的三个概念。.net

  对于A[1:6] = {1,8,6,4,3,5}来讲,线段树如上所示,红色表明每一个结点存储的区间,蓝色表明该区间最值。指针

  能够发现,每一个叶子结点的值就是数组的值,每一个非叶子结点的度都为二,且左右两个孩子分别存储父亲一半的区间。每一个父亲的存储的值也就是两个孩子存储的值的最大值。code

  上面的每条结论应该都容易看出来。那么结点究竟是如何存储区间的呢,以及如何快速找到非叶子结点的孩子以及非根节点的父亲呢,这里也就是理解线段树的重点以及难点所在,如同树状数组你理解了lowbit就能很快理解树状数组同样,线段树你只要理解告终点与结点之间的关系便能很快理解线段树的基本知识。

  对于一个区间[l,r]来讲,最重要的数据固然就是区间的左右端点l和r,可是大部分的状况咱们并不会去存储这两个数值,而是经过递归的传参方式进行传递。这种方式用指针好实现,定义两个左右子树递归便可,可是指针表示过于繁琐,并且不方便各类操做,大部分的线段树都是使用数组进行表示,那这里怎么快速使用下标找到左右子树呢。

  对于上述线段树,咱们增长绿色数字为每一个结点的下标

  则每一个结点下标如上所示,这里你可能会问,为何最下一排的下标直接从9跳到了12,道理也很简单,中间实际上是有两个空间的呀!!虽然没有使用,可是他已经开了两个空间,这也是为何无优化的线段树建树须要2*2k(2k-1 < n < 2k)空间,通常会开到4*n的空间防止RE。

  仔细观察每一个父亲和孩子下标的关系,有发现什么联系吗?不难发现,每一个左子树的下标都是偶数,右子树的下标都是奇数且为左子树下标+1,并且不难发现如下规律

  • l = fa*2 (左子树下标为父亲下标的两倍)
  • r = fa*2+1(右子树下标为父亲下标的两倍+1)

  具体证实也很简单,把线段树当作一个彻底二叉树(空结点也看成使用)对于任意一个结点k来讲,它所在此二叉树的log2(k) 层,则此层共有2log2(k)个结点,一样对于k的左子树那层来讲有2log2(k)+1个结点,则结点k和左子树间隔了2*2log2(k)-k + 2*(k-2log2(k))个结点,而后这就很简单就获得k+2*2log2(k)-k + 2*(k-2log2(k)) = 2*k的关系了吧,右子树也就等于左子树结点+1。

  是否是以为其实很简单,并且由于左子树都是偶数,因此咱们经常使用位运算来寻找左右子树

  • k<<1(结点k的左子树下标)
  • k<<1|1(结点k的右子树下标)

   整理一下思绪,如今已经明白了数组如何存在线段树,结点间的关系,以及使用递归的方式创建线段树,那么具体如何创建线段树,咱们来看代码,代码中不清楚的地方都有详细的注释说明。

 1 const int maxn = 100005;
 2 int a[maxn],t[maxn<<2];        //a为原来区间,t为线段树
 3 
 4 void Pushup(int k){        //更新函数,这里是实现最大值 ,同理能够变成,最小值,区间和等
 5     t[k] = max(t[k<<1],t[k<<1|1]);
 6 }
 7 
 8 //递归方式建树 build(1,1,n);
 9 void build(int k,int l,int r){    //k为当前须要创建的结点,l为当前须要创建区间的左端点,r则为右端点
10     if(l == r)    //左端点等于右端点,即为叶子节点,直接赋值便可
11         t[k] = a[l];
12     else{
13         int m = l + ((r-l)>>1);    //m则为中间点,左儿子的结点区间为[l,m],右儿子的结点区间为[m+1,r]
14         build(k<<1,l,m);    //递归构造左儿子结点
15         build(k<<1|1,m+1,r);    //递归构造右儿子结点
16         Pushup(k);    //更新父节点
17     }
18 }

 

  如今再来看代码,是否是以为清晰不少了,使用递归的方法创建线段树,确实清晰易懂,各位看到这里也请本身试着实现一下递归建树,如果哪里有卡点再来看一下代码找到哪里出了问题。那线段树有没有非递归的方式建树呢,答案是有,可是非递归的建树方式会使得线段树的查询等操做和递归建树方式彻底不同,由简至难,后面咱们再说非递归方式的实现。

  到如今你应该能够创建一颗线段树了,并且知道每一个结点存储的区间和值,若是上述操做还不能实现或是有哪里想不明白,建议再翻回去看一看所讲的内容。不要急于看完,理解才更重要。

3、线段树的基本操做

  基本操做有哪些,你应该也能想出来,在线的二叉搜索树,所拥有的操做固然有,更新和询问两种。

  1.点更新

  如何实现点更新,咱们先不急看代码,仍是对于上面那个线段树,假使我把a[3]+7,则更新后的线段树应该变成

  更新了a[3]后,则每一个包含此值的结点都须要更新,那么有多少个结点须要更新呢?根据二叉树的性质,不难发现是log(k)个结点,这也正是为何每次更新的时间复杂度为O(logN),那应该如何实现呢,咱们发现,不管你更新哪一个叶子节点,最终都是会到根结点的,而把这个往上推的过程逆过来就是从根结点开始,找到左子树仍是右子树包含须要更新的叶子节点,往下更新便可,因此咱们仍是可使用递归的方法实现线段树的点更新

 1 //递归方式更新 updata(p,v,1,n,1);
 2 void updata(int p,int v,int l,int r,int k){    //p为下标,v为要加上的值,l,r为结点区间,k为结点下标
 3     if(l == r)    //左端点等于右端点,即为叶子结点,直接加上v便可
 4         a[l] += v,t[l] += v;    //原数组和线段树数组都获得更新
 5     else{
 6         int m = l + ((r-l)>>1);    //m则为中间点,左儿子的结点区间为[l,m],右儿子的结点区间为[m+1,r]
 7         if(p <= m)    //若是须要更新的结点在左子树区间
 8             updata(p,v,l,m,k<<1);
 9         else    //若是须要更新的结点在右子树区间
10             updata(p,v,m+1,r,k<<1|1);
11         Pushup(k);    //更新父节点的值
12     }
13 }

 

  看完代码是否是很清晰,这里也建议本身再次手动实现一遍理解递归的思路。

  2.区间查询

  说完了单点更新确定就要来讲区间查询了,咱们知道线段树的每一个结点存储的都是一段区间的信息 ,若是咱们恰好要查询这个区间,那么则直接返回这个结点的信息便可,好比对于上面线段树,若是我直接查询[1,6]这个区间的最值,那么直接返回根节点信息返回13便可,可是通常咱们不会凑巧恰好查询那些区间,好比如今我要查询[2,5]区间的最值,这时候该怎么办呢,咱们来看看哪些区间是[2,5]的真子集,

  一共有5个区间,并且咱们能够发现[4,5]这个区间已经包含了两个子树的信息,因此咱们须要查询的区间只有三个,分别是[2,2],[3,3],[4,5],到这里你能经过更新的思路想出来查询的思路吗? 咱们仍是从根节点开始往下递归,若是当前结点是要查询的区间的真子集,则返回这个结点的信息且不须要再往下递归了,这样从根节点往下递归,时间复杂度也是O(logN)。那么代码则为

 1 //递归方式区间查询 query(L,R,1,n,1);
 2 int query(int L,int R,int l,int r,int k){    //[L,R]即为要查询的区间,l,r为结点区间,k为结点下标
 3     if(L <= l && r <= R)    //若是当前结点的区间真包含于要查询的区间内,则返回结点信息且不须要往下递归
 4         return t[k];
 5     else{
 6         int res = -INF;    //返回值变量,根据具体线段树查询的什么而自定义
 7         int mid = l + ((r-l)>>1);    //m则为中间点,左儿子的结点区间为[l,m],右儿子的结点区间为[m+1,r]
 8         if(L <= m)    //若是左子树和须要查询的区间交集非空
 9             res = max(res, query(L,R,l,m,k<<1));
10         if(R > m)    //若是右子树和须要查询的区间交集非空,注意这里不是else if,由于查询区间可能同时和左右区间都有交集
11             res = max(res, query(L,R,m+1,r,k<<1|1));
12 
13         return res;    //返回当前结点获得的信息
14     }
15 }

  若是你能理解建树和更新的过程,那么这里的区间查询也不会太难理解。仍是建议再次手动实现。

  3.区间更新

  树状数组中的区间更新咱们用了差分的思想,而线段树的区间更新相对于树状数组就稍微复杂一点,这里咱们引进了一个新东西,Lazy_tag,字面意思就是懒惰标记的意思,实际上它的功能也就是偷懒= =,由于对于一个区间[L,R]来讲,咱们可能每次都更新区间中的没个值,那样的话更新的复杂度将会是O(NlogN),这过高了,因此引进了Lazy_tag,这个标记通常用于处理线段树的区间更新。
  线段树在进行区间更新的时候,为了提升更新的效率,因此每次更新只更新到更新区间彻底覆盖线段树结点区间为止,这样就会致使被更新结点的子孙结点的区间得不到须要更新的信息,因此在被更新结点上打上一个标记,称为lazy-tag,等到下次访问这个结点的子结点时再将这个标记传递给子结点,因此也能够叫延迟标记。

  也就是说递归更新的过程,更新到结点区间为须要更新的区间的真子集再也不往下更新,下次如果遇到须要用这下面的结点的信息,再去更新这些结点,因此这样的话使得区间更新的操做和区间查询相似,复杂度为O(logN)。

 1 void Pushdown(int k){    //更新子树的lazy值,这里是RMQ的函数,要实现区间和等则须要修改函数内容
 2     if(lazy[k]){    //若是有lazy标记
 3         lazy[k<<1] += lazy[k];    //更新左子树的lazy值
 4         lazy[k<<1|1] += lazy[k];    //更新右子树的lazy值
 5         t[k<<1] += lazy[k];        //左子树的最值加上lazy值
 6         t[k<<1|1] += lazy[k];    //右子树的最值加上lazy值
 7         lazy[k] = 0;    //lazy值归0
 8     }
 9 }
10 
11 //递归更新区间 updata(L,R,v,1,n,1);
12 void updata(int L,int R,int v,int l,int r,int k){    //[L,R]即为要更新的区间,l,r为结点区间,k为结点下标
13     if(L <= l && r <= R){    //若是当前结点的区间真包含于要更新的区间内
14         lazy[k] += v;    //懒惰标记
15         t[k] += v;    //最大值加上v以后,此区间的最大值也确定是加v
16     }
17     else{
18         Pushdown(k);    //重难点,查询lazy标记,更新子树
19         int m = l + ((r-l)>>1);
20         if(L <= m)    //若是左子树和须要更新的区间交集非空
21             update(L,R,v,l,m,k<<1);
22         if(m < R)    //若是右子树和须要更新的区间交集非空
23             update(L,R,v,m+1,r,k<<1|1);
24         Pushup(k);    //更新父节点
25     }
26 }

  注意看Pushdown这个函数,也就是当须要查询某个结点的子树时,须要用到这个函数,函数功能就是更新子树的lazy值,能够理解为平时先把事情放着,等到哪天要检查的时候,就临时再去作,并且作也不是一次性作完,检查哪一部分它就只作这一部分。是否是感觉到了什么是Lazy_tag,实至名归= =。

  值得注意的是,使用了Lazy_tag后,咱们再进行区间查询也须要改变。区间查询的代码则变为

 1 //递归方式区间查询 query(L,R,1,n,1);
 2 int query(int L,int R,int l,int r,int k){    //[L,R]即为要查询的区间,l,r为结点区间,k为结点下标
 3     if(L <= l && r <= R)    //若是当前结点的区间真包含于要查询的区间内,则返回结点信息且不须要往下递归
 4         return t[k];
 5     else{
 6         Pushdown(k);    /**每次都须要更新子树的Lazy标记*/
 7         int res = -INF;    //返回值变量,根据具体线段树查询的什么而自定义
 8         int mid = l + ((r-l)>>1);    //m则为中间点,左儿子的结点区间为[l,m],右儿子的结点区间为[m+1,r]
 9         if(L <= m)    //若是左子树和须要查询的区间交集非空
10             res = max(res, query(L,R,l,m,k<<1));
11         if(R > m)    //若是右子树和须要查询的区间交集非空,注意这里不是else if,由于查询区间可能同时和左右区间都有交集
12             res = max(res, query(L,R,m+1,r,k<<1|1));
13 
14         return res;    //返回当前结点获得的信息
15     }
16 }

  其实变更也不大,就是多了一个临时更新子树的值的过程。

4、线段树的其余操做

  若是你明白了上述线段树处理区间最值的全部操做,那么转变成求最小值以及区间和问题应该也能很快解决,请手动再实现一下查询区间最小值的线段树和查询区间和的线段树。

  区间和线段树等代码再也不给出,自行实现,若不能实现能够去网上搜索模板对比本身为什么不能实现。这里便再也不浪费篇幅讲述。

  这里我即是想说一下线段树还能处理的问题以及一些具体问题讲解。上述咱们只是再讲线段树处理裸区间问题,可是大部分问题不会是让你直接更新查询,而是否真正理解线段树便在于思惟是否能从区间跳到线段。

  区间只是一个线段的一小部分,还有一些非区间问题也能够演变成一段一段的线段,而后再经过线段树进行各类操做。下面针对几道例题讲解一下线段树的其余具体用法。

  下面三道题讲解并不是本身所写,而是摘取了另外一篇线段树的博客,特此声明,原博客地址:https://blog.csdn.net/whereisherofrom/article/details/78969718

  1.区间染色

  给定一个长度为n(n <= 100000)的木板,支持两种操做:
  一、P a b c       将[a, b]区间段染色成c;
  二、Q a b         询问[a, b]区间内有多少种颜色;
  保证染色的颜色数少于30种。
  对比区间求和,不一样点在于区间求和的更新是对区间和进行累加;而这类染色问题则是对区间的值进行替换(或者叫覆盖),有一个比较特殊的条件是颜色数目小于30。
  咱们是否是要将30种颜色的有无与否都存在线段树的结点上呢?答案是确定的,可是这样一来每一个结点都要存储30个bool值,空间太浪费,并且在计算合并操做的时候有一步30个元素的遍历,大大下降效率。然而30个bool值正好能够压缩在一个int32中,利用二进制压缩能够用一个32位的整型完美的存储30种颜色的有无状况。
  由于任何一个整数均可以分解成二进制整数,二进制整数的每一位要么是0,要么是1。二进制整数的第i位是1表示存在第i种颜色;反之不存在。
  数据域须要存一个颜色种类的位或和colorBit,一个颜色的lazy标记表示这个结点被彻底染成了lazy,基本操做的几个函数和区间求和很是像,这里就不出示代码了。
  和区间求和不一样的是回溯统计的时候,对于两个子结点的数据域再也不是加和,而是位或和。

  2.区间第K大

  给定n个数,每次询问问[l,r]区间内的第K大数,这个问题有不少方法,可是用线段树应该如何解决呢。

  利用了线段树划分区间的思想,线段树的每一个结点存的不仅是区间端点,而是这个区间内全部的数,而且是按照递增顺序有序排列的,建树过程是一个归并排序的过程,从叶子结点自底向上进行归并,对于一个长度为6的数组[4, 3, 2, 1, 5, 6],创建线段树如图所示。

  从图中能够看出,线段树的任何一个结点存储了对应区间的数,而且进行有序排列,因此根结点存储的必定是一个长度为数组总长的有序数组,叶子结点存储的递增序列为原数组元素。
  每次询问,咱们将给定区间拆分红一个个线段树上的子区间,而后二分枚举答案T,再利用二分查找统计这些子区间中大于等于T的数的个数,从而肯定T是不是第K大的。
  对于区间K大数的问题,还有不少数据结构都能解决,这里仅做简单介绍。

  3.矩阵面积并

  对于给定的n(n<=100000)个平行于XY轴的矩形,求他们的面积并。

  这是一个二维的问题,若是我告诉你这道题使用线段树解决,你该如何入手呢,首先线段树是一维的,因此咱们须要化二维为一维,因此咱们可使用x的坐标或者y的坐标创建线段树,另外一坐标用来进行枚举操做。

  咱们用x的坐标来建树的化,那么咱们把矩阵平行于x轴的线段舍去,则变成了

  每一个矩形都剩下两条边,定义x坐标较小的为入边(值为+1),较大为出边(值为-1),而后用x的升序,记第i条线段的x坐标即为X[i]

  接下来将全部矩形端点的y坐标进行重映射(也能够叫离散化),缘由是坐标有可能很大并且不必定是整数,将原坐标映射成小范围的整数能够做为数组下标,更方便计算,映射能够将全部y坐标进行排序去重,而后二分查找肯定映射后的值,离散化的具体步骤下文会详细讲解。如图所示,蓝色数字表示的是离散后的坐标,即一、二、三、4分别对应原先的五、十、2三、25(需支持正查和反查)。假设离散后的y方向的坐标个数为m,则y方向被分割成m-1个独立单元,下文称这些独立单元为“单位线段”,分别记为<1-2>、<2-3>、<3-4>。

  以x坐标递增的方式枚举每条垂直线段,y方向用一个长度为m-1的数组来维护“单位线段”的权值,如图所示,展现了每条线段按x递增方式插入以后每一个“单位线段”的权值。
  当枚举到第i条线段时,检查全部“单位线段”的权值,全部权值大于零的“单位线段”的实际长度之和(离散化前的长度)被称为“合法长度”,记为L,那么(X[i] - X[i-1]) * L,就是第i条线段和第i-1条线段之间的矩形面积和,计算完第i条垂直线段后将它插入,所谓"插入"就是利用该线段的权值更新该线段对应的“单位线段”的权值和(这里的更新就是累加)。

  如图四-4-6所示:红色、黄色、蓝色三个矩形分别是3对相邻线段间的矩形面积和,其中红色部分的y方向由<1-2>、<2-3>两个“单位线段”组成,黄色部分的y方向由<1-2>、<2-3>、<3-4>三个“单位线段”组成,蓝色部分的y方向由<2-3>、<3-4>两个“单位线段”组成。特殊的,在计算蓝色部分的时候,<1-2>部分的权值因为第3条线段的插入(第3条线段权值为-1)而变为零,因此不能计入“合法长度”。
  以上全部相邻线段之间的面积和就是最后要求的矩形面积并。

  优化天然就是用线段树了,以前提到了降维的思想,x方向咱们继续采用枚举,而y方向的“单位线段”则能够采用线段树来维护,

  而后经过一个扫描线来求扫描线覆盖的y的长度。线段的扫描按照x的大小从小到大扫描,求出当前扫描线覆盖的矩阵竖线的长度,而后乘如下条线段的跨度,则为这个区域矩阵覆盖的面积,具体关于扫描线的操做这里再也不阐述。这里只讲明白如何建树。

5、线段树的一些重难点以及技巧

  1.离散化

  离散化经常使用于二维状态在一维线段树建树,所谓离散化就是将无限的个体映射到有限个体中,提升算法效率,并且支持正查和反查(从开始遍历和从末尾遍历),可用Hash等实现。

  2.Lazy_tag

  这个标记就是用于线段树的区间更新,上面已经提到,便再也不累赘,可是区间更新并不局限于使用Lazy_tag,还有一种不使用Lazy_tag的区间更新方法,会在提升篇中讲到。

  3.空间优化

  父节点k,左儿子k<<1,右儿子k<<1|1,则须要n<<2的空间,但咱们知道并非全部的叶子节点都占用到了2*n+1 —— 4*n的范围,形成了大量空间浪费。这时候就要考虑离散化,压缩空间。或者使用dfs序做为结点下标,父亲k,左儿子k+1,右儿子k+左儿子区间长度*2,具体实现再也不累赘,可自行经过修改左右儿子的下标推出。

  4.多维推广

  例如矩阵树,空间树,这些即是线段树的拓展,好比要在两种不一样的参数找到最适变量,例如对于一我的的身高和体重,找到必定范围内且年龄最小的人,就能够用到多维推广了。

  5.可持久化

  主席树。之后讲= =

  6.非递归形式

  前面提到过这个概念,非递归形式的某些操做会快于递归形式,之后将会专门将非递归形式。

  7.子树收缩

  就是子树继承的逆过程,继承是为了获得父节点信息,而收缩则是在回溯时候,若是两棵子树拥有相同数据的时候在将数据传递给父结点,子树的数据清空,这样下次在访问的时候就能够减小访问的结点数。

6、相关例题

  • codevs 1080 (单点修改+区间查询)
  • codevs 1081 (区间修改+单点查询)
  • codevs 1082 (区间修改+区间查询)
  • codevs 3981 (区间最大子段和)
  • Bzoj 3813   (区间内某个值是否出现过)
  • Luogu P2894 (区间连续一段空的长度)
  • codevs 2000 (区间最长上升子序列)
  • codevs 3044 (矩阵面积求并)
  • Hdu 1698 (区间染色+单次统计)
  • Poj 2777 (区间染色+批量统计)
  • Hdu 4419 (多色矩形面积并)
  • Poj 2761 (区间第K大)
  • Hdu 2305 (最值维护)

 

  暂时只写了这么多,这算是基本的线段树内容,主要就是要明白建树以及各类操做的过程,而且作题时候想这道题是否能够化成线段树建树来解决。

相关文章
相关标签/搜索