线段树入门

1、概述

线段树,也叫区间树,是一个完全二叉树,它在各个节点保存一条线段(即“子数组”),因而常用于解决数列维护问题,它基本能保证每个操作的复杂度为O(lgN)。

2、线段树基本操作

线段树的基本操作主要包括构造线段树,区间查询和区间修改。

(1)    线段树构造

首先介绍构造线段树的方法:让根节点表示区间[0,N-1],即所有N个数所组成的一个区间,然后,把区间分成两半,分别由左右子树表示。不难证明,这样的线段树的节点数只有2N-1个,是O(N)级别的,如图:

显然,构造线段树是一个递归的过程,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//构造求解区间最小值的线段树
 
function 构造以v为根的子树
 
   if v所表示的区间内只有一个元素
 
      v区间的最小值就是这个元素, 构造过程结束
 
   end if
 
   把v所属的区间一分为二,用w和x两个节点表示。
 
   标记v的左儿子是w,右儿子是x
 
   分别构造以w和以x为根的子树(递归)
 
   v区间的最小值 <- min(w区间的最小值,x区间的最小值)
 
end function

线段树除了最后一层外,前面每一层的结点都是满的,因此线段树的深度

h =ceil(log(2n -1))=O(log n)。

(2)    区间查询

区间查询指用户输入一个区间,获取该区间的有关信息,如区间中最大值,最小值,第N大的值等。

比如前面一个图中所示的树,如果询问区间是[0,2],或者询问的区间是[3,3],不难直接找到对应的节点回答这一问题。但并不是所有的提问都这么容易回答,比如[0,3],就没有哪一个节点记录了这个区间的最小值。当然,解决方法也不难找到:把[0,2]和[3,3]两个区间(它们在整数意义上是相连的两个区间)的最小值“合并”起来,也就是求这两个最小值的最小值,就能求出[0,3]范围的最小值。同理,对于其他询问的区间,也都可以找到若干个相连的区间,合并后可以得到询问的区间。

区间查询的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// node 为线段树的结点类型,其中Left 和Right 分别表示区间左右端点
 
// Lch 和Rch 分别表示指向左右孩子的指针
 
void Query(node *p, int a, int b) // 当前考察结点为p,查询区间为(a,b]
 
{
 
   if (a <= p->Left && p->Right <= b)
 
   // 如果当前结点的区间包含在查询区间内
 
   {
 
      ...... // 更新结果
 
      return ;
 
   }
 
   Push_Down(p); // 等到下面的修改操作再解释这句
 
   int mid = (p->Left + p->Right) / 2; // 计算左右子结点的分隔点
 
   if (a < mid) Query(p->Lch, a, b); // 和左孩子有交集,考察左子结点
 
   if (b > mid) Query(p->Rch, a, b); // 和右孩子有交集,考察右子结点
 
}

可见,这样的过程一定选出了尽量少的区间,它们相连后正好涵盖了整个[l,r],没有重复也没有遗漏。同时,考虑到线段树上每层的节点最多会被选取2个,一共选取的节点数也是O(log n)的,因此查询的时间复杂度也是O(log n)。

线段树并不适合所有区间查询情况,它的使用条件是“相邻的区间的信息可以被合并成两个区间的并区间的信息”。即问题是可以被分解解决的。

(3)    区间修改

当用户修改一个区间的值时,如果连同其子孙全部修改,则改动的节点数必定会远远超过O(log n)个。因而,如果要想把区间修改操作也控制在O(log n)的时间内,只修改O(log n)个节点的信息就成为必要。

借鉴前一节区间查询用到的思路:区间修改时如果修改了一个节点所表示的区间,也不用去修改它的儿子节点。然而,对于被修改节点的祖先节点,也必须更新它所记录的值,否则查询操作就肯定会出问题(正如修改单个节点的情况一样)。

这些选出的节点的祖先节点直接更新值即可,而选出的节点的子孙却显然不能这么简单地处理:每个节点的值必须能由两个儿子节点的值得到,如这幅图中的例子:

这里,节点[0,1]的值应该是4,但是两个儿子的值又分别是3和5。如果查询[0,0]区间的RMQ,算出来的结果会是3,而正确答案显然是4。

问题显然在于,尽管修改了一个节点以后,不用修改它的儿子节点,但是它的儿子节点的信息事实上已经被改变了。这就需要我们在节点里增设一个域:标记。把对节点的修改情况储存在标记里面,这样,当我们自上而下地访问某节点时,就能把一路上所遇到的所有标记都考虑进去。

但是,在一个节点带上标记时,会给更新这个节点的值带来一些麻烦。继续上面的例子,如果我把位置0的数字从4改成了3,区间[0,0]的值应该变回3,但实际上,由于区间[0,1]有一个“添加了1”的标记,如果直接把值修改为3,则查询区间[0,0]的时候我们会得到3+1=4这个错误结果。但是,把这个3改成2,虽然正确,却并不直观,更不利于推广(参见下面的一个例子)。

为此我们引入延迟标记的一些概念。每个结点新增加一个标记,记录这个结点是否被进行了某种修改操作(这种修改操作会影响其子结点)。还是像上面的一样,对于任意区间的修改,我们先按照查询的方式将其划分成线段树中的结点,然后修改这些结点的信息,并给这些结点标上代表这种修改操作的标记。在修改和查询的时候,如果我们到了一个结点p ,并且决定考虑其子结点,那么我们就要看看结点p 有没有标记,如果有,就要按照标记修改其子结点的信息,并且给子结点都标上相同的标记,同时消掉p 的标记。代码框架为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// node 为线段树的结点类型,其中Left 和Right 分别表示区间左右端点
 
// Lch 和Rch 分别表示指向左右孩子的指针
 
void Change(node *p, int a, int b) // 当前考察结点为p,修改区间为(a,b]
 
{
 
   if (a <= p->Left && p->Right <= b)
 
   // 如果当前结点的区间包含在修改区间内
 
   {
 
      ...... // 修改当前结点的信息,并标上标记
 
      return ;
 
   }
 
   Push_Down(p); // 把当前结点的标记向下传递
 
   int mid = (p->Left + p->Right) / 2; // 计算左右子结点的分隔点
 
   if (a < mid) Change(p->Lch, a, b); // 和左孩子有交集,考察左子结点
 
   if (b > mid) Change(p->Rch, a, b); // 和右孩子有交集,考察右子结点
 
   Update(p); // 维护当前结点的信息(因为其子结点的信息可能有更改)
 
}

3、应用

下面给出线段树的几个应用:

(1)有一列数,初始值全部为0。每次可以进行以下三种操作中的一种:

a. 给指定区间的每个数加上一个特定值;

b.将指定区间的所有数置成一个统一的值;

c.询问一个区间上的最小值、最大值、所有数的和。

给出一系列a.b.操作后,输出c的结果。

[问题分析]

这个是典型的线段树的应用。在每个节点上维护一下几个变量:delta(区间增加值),same(区间被置为某个值),min(区间最小值),max(区间最大值),sum(区间和),其中delta和same属于“延迟标记”。

(2)在所有不大于30000的自然数范围内讨论一个问题:已知n条线段,把端点依次输入给你,然后有m(≤30000)个询问,每个询问输入一个点,要求这个点在多少条线段上出现过。

[问题分析]

在这个问题中,我们可以直接对问题处理的区间建立线段树,在线段树上维护区间被覆盖的次数。将n条线段插入线段树,然后对于询问的每个点,直接查询被覆盖的次数即可。

但是我们在这里用这道题目,更希望能够说明一个问题,那就是这道题目完全可以不用线段树。我们将每个线段拆成(L,+1),(R+1,-1)的两个事件点,每个询问点也在对应坐标处加上一个询问的事件点,排序之后扫描就可以完成题目的询问。我们这里讨论的问题是一个离线的问题,因此我们也设计出了一个很简单的离线算法。线段树在处理在线问题的时候会更加有效,因为它维护了一个实时的信息。

这个题目也告诉我们,有的题目尽管可以使用线段树处理,但是如果我们能够抓住题目的特点,就可能获得更加优秀的算法。

(3)某次列车途经C个城市,城市编号依次为1到C,列车上共有S个座位,铁路局规定售出的车票只能是坐票,即车上所有的旅客都有座,售票系统是由计算机执行的,每一个售票申请包含三个参数,分别用O、D、N表示,O为起始站,D为目的地站,N为车票张数,售票系统对该售票申请作出受理或不受理的决定,只有在从O到D的区段内列车上都有N个或N个以上的空座位时该售票申请才被受理,请你写一个程序,实现这个自动售票系统。

[问题分析]

这里我们可以把所有的车站顺次放在一个数轴上,在数轴上建立线段树,在线段树上维护区间的delta与max。每次判断一个售票申请是否可行就是查询区间上的最大值;每个插入一个售票请求,就是给一个区间上所有的元素加上购票数。

这道题目在线段树上维护的信息既包括自下至上的递推,也包括了自上至下的传递,能够比较全面地对线段树的基本操作进行训练。

(4)给一个n*n的方格棋盘,初始时每个格子都是白色。现在要刷M次黑色或白色的油漆。每次刷漆的区域都是一个平行棋盘边缘的矩形区域。

输入n,M,以及每次刷漆的区域和颜色,输出刷了M次之后棋盘上还有多少个棋格是白色。

[问题分析]

首先我们从简单入手,考虑一维的问题。即对于一个长度为n的白色线段,对它进行M次修改(每次更新某一子区域的颜色)。问最后还剩下的白色区域有多长。

对于这个问题,很容易想到建立一棵线段树的模型。复杂度为O(Mlgn)。

扩展到二维,需要把线段树进行调整,即首先在横坐标上建立线段树,它的每个节点是一棵建立在纵坐标上的线段树(即树中有树。称为二维线段树)。复杂度为O(M(logn)^2)。

4、总结

利用线段树,我们可以高效地询问和修改一个数列中某个区间的信息,并且代码也不算特别复杂。

但是线段树也是有一定的局限性的,其中最明显的就是数列中数的个数必须固定,即不能添加或删除数列中的数。

5、参考资料

(1)    杨弋文章:《线段树》:

http://download.csdn.net/source/2255479

(2)    林涛文章《线段树的应用》:

http://wenku.baidu.com/view/d65cf31fb7360b4c2e3f64ac.html

(3)    朱全民文章《线段树及其应用》:

http://wenku.baidu.com/view/437ad3bec77da26925c5b0ba.html

(4)    线段树:

http://wenku.baidu.com/view/32652a2d7375a417866f8f51.html



-----------------------------------------------------------------------------------------------------------------------------




线段树的入门级 总结


      线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。
      对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。
      使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,因此有时需要离散化让空间压缩。

----来自百度百科
【以下以 求区间最大值为例】
先看声明:

[cpp]  view plain  copy
  1. #include <stdio.h>  
  2. #include <math.h>  
  3. const int MAXNODE = 2097152;  
  4. const int MAX = 1000003;  
  5. struct NODE{  
  6.     int value;        // 结点对应区间的权值  
  7.     int left,right;   // 区间 [left,right]  
  8. }node[MAXNODE];  
  9. int father[MAX];     // 每个点(当区间长度为0时,对应一个点)对应的结构体数组下标  


【创建线段树(初始化)】:

       由于线段树是用二叉树结构储存的,而且是近乎完全二叉树的,所以在这里我使用了数组来代替链表上图中区间上面的红色数字表示了结构体数组中对应的下标。

在完全二叉树中假如一个结点的序号(数组下标)为 I ,那么 (二叉树基本关系)

I 的父亲为 I/2,

I 的另一个兄弟为 I/2*2 或 I/2*2+1

I 的两个孩子为 I*2 (左)   I*2+1(右)

有了这样的关系之后,我们便能很方便的写出创建线段树的代码了。


[cpp]  view plain  copy
  1. void BuildTree(int i,int left,int right){ // 为区间[left,right]建立一个以i为祖先的线段树,i为数组下标,我称作结点序号  
  2.     node[i].left = left;    // 写入第i个结点中的 左区间  
  3.     node[i].right = right;  // 写入第i个结点中的 右区间  
  4.     node[i].value = 0;      // 每个区间初始化为 0  
  5.     if (left == right){ // 当区间长度为 0 时,结束递归  
  6.         father[left] = i; // 能知道某个点对应的序号,为了更新的时候从下往上一直到顶  
  7.         return;  
  8.     }  
  9.     // 该结点往 左孩子的方向 继续建立线段树,线段的划分是二分思想,如果写过二分查找的话这里很容易接受  
  10.     // 这里将 区间[left,right] 一分为二了  
  11.     BuildTree(i<<1, left, (int)floor( (right+left) / 2.0));  
  12.     // 该结点往 右孩子的方向 继续建立线段树  
  13.     BuildTree((i<<1) + 1, (int)floor( (right+left) / 2.0) + 1, right);  
  14. }  


【单点更新线段树】:

       由于我事先用 father[ ] 数组保存过 每单个结点 对应的下标了,因此我只需要知道第几个点,就能知道这个点在结构体中的位置(即下标)了,这样的话,根据之前已知的基本关系,就只需要直接一路更新上去即可。


[cpp]  view plain  copy
  1. void UpdataTree(int ri){ // 从下往上更新(注:这个点本身已经在函数外更新过了)  
  2.   
  3.     if (ri == 1)return// 向上已经找到了祖先(整个线段树的祖先结点 对应的下标为1)  
  4.     int fi = ri / 2;        // ri 的父结点  
  5.     int a = node[fi<<1].value; // 该父结点的两个孩子结点(左)  
  6.     int b = node[(fi<<1)+1].value; // 右  
  7.     node[fi].value = (a > b)?(a):(b);    // 更新这个父结点(从两个孩子结点中挑个大的)  
  8.     UpdataTree(ri/2);       // 递归更新,由父结点往上找  
  9. }  



【查询区间最大值】:
       将一段区间按照建立的线段树从上往下一直拆开,直到存在有完全重合的区间停止。对照图例建立的树,假如查询区间为 [2,5] 

红色的区间为完全重合的区间,因为在这个具体问题中我们只需要比较这 三个区间的值 找出 最大值 即可。


[cpp]  view plain  copy
  1. int Max = -1<<20;  
  2. void Query(int i,int l,int r){ // i为区间的序号(对应的区间是最大范围的那个区间,也是第一个图最顶端的区间,一般初始是 1 啦)  
  3.     if (node[i].left == l && node[i].right == r){ // 找到了一个完全重合的区间  
  4.         Max = (Max < node[i].value)?node[i].value:(Max);  
  5.         return ;  
  6.     }  
  7.     i = i << 1; // get the left child of the tree node  
  8.     if (l <= node[i].right){ // 左区间有涉及  
  9.         if (r <= node[i].right) // 全包含于左区间,则查询区间形态不变  
  10.             Query(i, l, r);  
  11.         else // 半包含于左区间,则查询区间拆分,左端点不变,右端点变为左孩子的右区间端点  
  12.             Query(i, l, node[i].right);  
  13.     }  
  14.     i += 1; // right child of the tree  
  15.     if (r >= node[i].left){ // 右区间有涉及  
  16.         if (l >= node[i].left) // 全包含于右区间,则查询区间形态不变  
  17.             Query(i, l, r);  
  18.         else // 半包含于左区间,则查询区间拆分,与上同理  
  19.             Query(i, node[i].left, r);  
  20.     }  
  21. }  


线段树的入门级 总结


      线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。
      对于线段树中的每一个非叶子节点[a,b],它的左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b]。因此线段树是平衡二叉树,最后的子节点数目为N,即整个线段区间的长度。
      使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,因此有时需要离散化让空间压缩。

----来自百度百科
【以下以 求区间最大值为例】
先看声明:

[cpp]  view plain  copy
  1. #include <stdio.h>  
  2. #include <math.h>  
  3. const int MAXNODE = 2097152;  
  4. const int MAX = 1000003;  
  5. struct NODE{  
  6.     int value;        // 结点对应区间的权值  
  7.     int left,right;   // 区间 [left,right]  
  8. }node[MAXNODE];  
  9. int father[MAX];     // 每个点(当区间长度为0时,对应一个点)对应的结构体数组下标  


【创建线段树(初始化)】:

       由于线段树是用二叉树结构储存的,而且是近乎完全二叉树的,所以在这里我使用了数组来代替链表上图中区间上面的红色数字表示了结构体数组中对应的下标。

在完全二叉树中假如一个结点的序号(数组下标)为 I ,那么 (二叉树基本关系)

I 的父亲为 I/2,

I 的另一个兄弟为 I/2*2 或 I/2*2+1

I 的两个孩子为 I*2 (左)   I*2+1(右)

有了这样的关系之后,我们便能很方便的写出创建线段树的代码了。


[cpp]  view plain  copy
  1. void BuildTree(int i,int left,int right){ // 为区间[left,right]建立一个以i为祖先的线段树,i为数组下标,我称作结点序号  
  2.     node[i].left = left;    // 写入第i个结点中的 左区间  
  3.     node[i].right = right;  // 写入第i个结点中的 右区间  
  4.     node[i].value = 0;      // 每个区间初始化为 0  
  5.     if (left == right){ // 当区间长度为 0 时,结束递归  
  6.         father[left] = i; // 能知道某个点对应的序号,为了更新的时候从下往上一直到顶  
  7.         return;  
  8.     }  
  9.     // 该结点往 左孩子的方向 继续建立线段树,线段的划分是二分思想,如果写过二分查找的话这里很容易接受  
  10.     // 这里将 区间[left,right] 一分为二了  
  11.     BuildTree(i<<1, left, (int)floor( (right+left) / 2.0));  
  12.     // 该结点往 右孩子的方向 继续建立线段树  
  13.     BuildTree((i<<1) + 1, (int)floor( (right+left) / 2.0) + 1, right);  
  14. }  


【单点更新线段树】:

       由于我事先用 father[ ] 数组保存过 每单个结点 对应的下标了,因此我只需要知道第几个点,就能知道这个点在结构体中的位置(即下标)了,这样的话,根据之前已知的基本关系,就只需要直接一路更新上去即可。


[cpp]  view plain  copy
  1. void UpdataTree(int ri){ // 从下往上更新(注:这个点本身已经在函数外更新过了)  
  2.   
  3.     if (ri == 1)return// 向上已经找到了祖先(整个线段树的祖先结点 对应的下标为1)  
  4.     int fi = ri / 2;        // ri 的父结点  
  5.     int a = node[fi<<1].value; // 该父结点的两个孩子结点(左)  
  6.     int b = node[(fi<<1)+1].value; // 右  
  7.     node[fi].value = (a > b)?(a):(b);    // 更新这个父结点(从两个孩子结点中挑个大的)  
  8.     UpdataTree(ri/2);       // 递归更新,由父结点往上找  
  9. }  



【查询区间最大值】:
       将一段区间按照建立的线段树从上往下一直拆开,直到存在有完全重合的区间停止。对照图例建立的树,假如查询区间为 [2,5] 

红色的区间为完全重合的区间,因为在这个具体问题中我们只需要比较这 三个区间的值 找出 最大值 即可。


[cpp]  view plain  copy
  1. int Max = -1<<20;  
  2. void Query(int i,int l,int r){ // i为区间的序号(对应的区间是最大范围的那个区间,也是第一个图最顶端的区间,一般初始是 1 啦)  
  3.     if (node[i].left == l && node[i].right == r){ // 找到了一个完全重合的区间  
  4.         Max = (Max < node[i].value)?node[i].value:(Max);  
  5.         return ;  
  6.     }  
  7.     i = i << 1; // get the left child of the tree node  
  8.     if (l <= node[i].right){ // 左区间有涉及  
  9.         if (r <= node[i].right) // 全包含于左区间,则查询区间形态不变  
  10.             Query(i, l, r);  
  11.         else // 半包含于左区间,则查询区间拆分,左端点不变,右端点变为左孩子的右区间端点  
  12.             Query(i, l, node[i].right);  
  13.     }  
  14.     i += 1; // right child of the tree  
  15.     if (r >= node[i].left){ // 右区间有涉及  
  16.         if (l >= node[i].left) // 全包含于右区间,则查询区间形态不变  
  17.             Query(i, l, r);  
  18.         else // 半包含于左区间,则查询区间拆分,与上同理  
  19.             Query(i, node[i].left, r);  
  20.     }  
  21. }  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值