线段树讲解

一.线段树概念及说明

线段树(Segment Tree):线段树是一种二叉搜索,其最擅长的是进行区间处理操作,通常树上的每个节点都维护一个区间,线段树树根维护的是整个区间。每个子节点维护的是其父节点所维护区间二等分后的两个区间的其中之一。


线段树节点的结构如图1所示:


图1

给出一个【1,11】的区间,构建线段树,如图2所示。


图2

通常构建【1,N】的线段树,往往需要4*N个节点去构建,这是为什么呢?

   在此对该结论不做理论上的证明,仅仅通过线段树的建树规则,来阐述该结论的正确性。

假设现在对【L,R】这个区间构建线段树,该区间有N=(R-L+1)个整数。按照上面的方式

去构建线段树,这样的一个区间,其构建出的树,其节点数为2*N-1。这个结论不做证明,

可以拿任意区间去画树,N个元素的区间,其节点数总为2*N-1个。

构建线段树实际上就是去构建一颗二叉树,因为线段树本身就是二叉树。我们在学习数据

结构课程时,我们会学到二叉树有两种最基本的存储方式。

    (1)链式存储

   (2)顺序存储

  构建线段树的时候,我们通常采用的就是二叉树的第二种存储方式

二叉树的顺序存储结构就是用一组连续的地址连续的存储单元来存放二叉树数据元素。其基本

方法是,对一颗二叉树中的节点进行从上到下从左到右的顺序依次从1开始对节点编号,假设每

个节点都有两个孩子,则当某个节点编号为i的时候,其左孩子编号为2*i,右孩子的编号为2*i+1,

我们根据二叉树节点的编号,把他们放置到数组中与他们编号相等的槽中,如果存在左孩子或

右孩子不存在的情况,我们用特殊符号填充对应位置。这就是二叉树的顺序存储方法。例子如

图3所示。


图三

从例子中这颗二叉树中可以看出,虽然有些节点的左、右孩子是空的,他们依旧占据了一个存储空间,

原因是,在存储时候严格按照根、左、右的顺序对节点进行编号,同时严格按照编号对节点进行存储,

线段树就是这样来存储的,刚才已经说过,对于N个整数的区间,构建维护这个区间的线段树需要

2*N-1个节点,又因为根节点从数组下标为1的地方开始存储,那么顺序存储线段树数组至少要开到2*N,

然而实际上存储二叉树我们会开4*N,那么多余的2*N个空间,又是从哪来的呢?

答案很简单,就像图3所示的那样,在存储线段树的时候一些位置让空节点占据了。有些节点本身不

存在,但是它需要占据一个存储空间。如下图所示。


图4

图中叶子节点分布在后两层,而倒数第二层的叶子节点其左右孩子节点不存在,但任然占据了存储空间,

原因是编号要到最后一个真实存在的节点,最后中间一行六个位置必然为空,现在考虑最极限的情况,

一个线段树维护【L,R】这个区间,整个区间N个元素,构建线段树后假设该树有H层,假设第H-1层

只有一个节点可以继续分解,并且该节点是H-1层最右侧的一个节点,第H-1层的其他节点都是叶子节点,

已知最终叶子节点有N个,第H-1层最后一个节点分解后产生2个叶子节点,则第H-1层有N-2个叶子节点,

由于第H-1层的非叶子节点在最右侧,因此前N-2个叶子节点虽然其左右孩子不存在,但仍需占用存储空

间,空的节点在H层总共占据2*(N-2)= 2*N-4个空间,非空节点有2*N-1个,相加得总空间 4*N-5个。

4*N-5是构建一个线段树所占据空间的极限情况,因此通常我们开设4*N的空间。

线段树的结构决定了我们在执行区间操作的时候可以在O(logN)时间内完成操作,原因是线段树

的构树法是基于二分思想的。


二.线段树的构建


1.如何构建线段树(递归法)



图5
从上图可以看出,对于一个区间,如果其不是叶子节点,则对其进行均等拆分(此处均的含义并非完全
平均),为左右节点,如果其左右孩子不是叶子节点,则继续按上述规则拆分。从描述可见这是一个递归
的过 程(当然也可转化为非递归方法实现)。这里建树代码以维护区间最大值为例。
const int maxn = 1e4;
int num[maxn];          ///各个位置上的数值

///线段树节点类型
typedef struct Node {
    int left;
    int right;
    int Max;
}node[maxn<<2];     ///范围扩大四倍

///构建线段树
void build(int root,int left,int right) {
    node[root].left = left;
    node[root].right = right;
    if(left == right) {
        node[root].Max = num[left];
        return;
    }
    int mid = (left+right)>>1;
    build(root<<1,left,mid);
    build(root<<1|1,mid+1,right);
    push_up(root);   ///更新当前节点的函数。
}

push_up(root)是更新当前节点的操作,节点维护区间最大值,则当前区间的最大值就是其左右子区间中
的最大值, 如果节点维护的是区间的和值,则当前区间和值就是左右子区间的和。
///维护区间最大值
void push_up(int root) {
    node[root].Max = max(node[root<<1].Max,node[root<<1|1].Max);
}

///维护区间的和值
void push_up(root) {
    node[root].sum = node[root<<1].sum + node[root<<1|1].sum;
}

例如给定数组 int num = {0,9,3,7,4,1,8,10};     不带第一个数字0,总共有7个数字。构建线段树的
过程如图6 所示。

图6

三.线段树单点更新,区间查询

1.单点更新

单点更新问题:把第pos个位置上的值更新为value.

基本思路:把pos位置上的值更新为value,其过程和二分查找的过程一致,对于当前区间[L,R】,如果

pos<=mid,说明第pos个位置在当前区间的左子区间中,否则说明第pos个位置在当前区间右子区间中,

到找到的区间是叶子节点,则说明找到了第pos个位置,则进行更新操作即可。

在图6所示的线段树中,把num数组下标为3的位置改成12的过程,如图7所示。


图7

单点更新操作代码:

//单点更新操作,把原来数组中的pos位置上的数字更新成value。
void update(int pos,int value,int root) {
    //是叶子节点
    if(node[root].left == node[root].right) {
        node[root].Max = value;   //更新对应节点的值返回
        return;
    }
    int mid = (left+right)>>1;
    if(pos <= mid) update(pos,value,root<<1);   //节点位于左区间
    else update(pos,value,root<<1|1);           //节点位于右区间
    push_up(root);                              //子节点更新后,更新父节点。
}
2.区间查询

区间查询是线段树中的一个重要操作,给出[L,R]区间,从该区间中查询所需要的信息,查询时的情况可以

分为以下三种情况,设当前节点维护的区间是[left,right],mid = (left+right)/2,当前要查询的区间是【L,R】。

我们做如下处理。

(1)若R<=mid,则说明欲查询的区间【L,R】整个都在该区间的左侧,则只需要查询其左子树。

(2)若L>mid,则说明欲查询的区间【L,R】整个都在该区间的右侧,则只需要查询其右子树。

(3)除1,2两种情况外,说明【L,R】横跨当前节点的左右区间,此时我们讲整个查询区间拆分

         为两部分别查询,即分为【L,mid】,[mid+1,R]分别进入左右子树进行查询操作。

仍然是针对图6所示的树,查询区间[4,6]内的最大值。其过程如图8所示。


图8


四.线段树区间更新,区间查询

1.区间更新

区间更新就是给出【L,R】,对【L,R】这个区间中的每个数都进行同一种操作,例如将区间【L,R】

中的每个数字都加上add。在此将区间更新操作,以线段树区间求和为例。

假如在区间【L,R】间的每个数都加上add,首先我们可以知道【L,R】区间中总共有R-L+1个数,

则每个数都加上add的时候,该区间的和值加上了(R-L+1)*add,对于区间更新我们通常并非会将所有

在【L,R】范围内的节点全部更新,如果这样做在更新的时候通常就要深入到叶子节点,这样增加了时间

复杂度,我们通常采用的做法是对线段树中的每个节点添加上标记lazy,在进行区间更新的时候,如果发

现【L,R】的子区间我们更新当前节点的和值,同时将标记留下,该标记为该区间要加上的数字的总和。

为什么我们不直接在更新时将标记下推一步到位?

对于线段树来说区间更新、区间查询都是常见操作,如果对某个区间更新,但是后续或许我们的查

并不会用到那些被更新的区间,这时我们如果在区间更新的时候就费心将【L,R】的所有子区间都更新

成正确的值,完全是在浪费时间,如果我们在查询过程中用来的这些区间,也必然会在递归的过程中到

达这些区间,我们完全可以在查询的过程中边下推标记边查询,则此时更新时的下推也该操作重复,

浪费时间。因此通常更新操作仅仅更新【L,R】下最靠上的子区间,并把标记留下,这个标记通常存

在lazy数组中,叫做懒惰标记,然后在区间查询的过程中,由于区间查询时必须保证节点的数值正确,

因此在区间查询的时候,我们用到那个节点,如果发现该节点有标记,则说明其子节点没有得到更新,

此时我们下推标记,把标记推给它的左右孩子,没有发现标记的话则说明该节点左右孩子值正确。但

是在更新过程中,发现有以前更新时留下的标记,也时需要下推的,而本次更新的标记则是点到为止。

加上lazy标记后,线段树的节点类型变成下面的结构:

const int maxn = 1e5;
typedef struct Node {
    int left;       //节点所维护区间的左边界
    int right;      //节点所维护区间的右边界
    int data;       //节点的数据
    int lazy;       //节点的懒惰标记
}node[maxn<<2];
例如下面这棵线段树,我们在【1,6】区间给每个数字都加上3.如下图所示


图9
该树更新后如下图所示:

图10

从图中可以看出黄色节点和粉色节点的值都是正确的,黄色节点是已经更新了值的节点,粉色节点并

不在更新操作的区域内,其值也正确,而绿色节点属于黄色节点的子节点,由于标记没有下推,其值正确,

在该图基础在区间【3,6】上加4,如下图所示。


图11

图中红色字体代表之前的标记下推,而蓝色字体代表当前更新操作。该操作完成后如下图所示。


图12

区间更新代码

//更新当前节点
void push_up(int root) {
    node[root].sum = node[root<<1].sum + node[root<<1|1].sum;
} 
//下推标记
void push_down(int root) {
    //说明该节点有标记
    if(node[root].lazy>0) {
        //求左区间的长度
        int leftLen = node[root<<1].right - node[root<<1].left + 1;
        //求右区间的长度
        int rightLen = node[root<<1|1].right - node[root<<1|1].left + 1;
        //更新左区间和值
        node[root<<1].sum += leftLen*node[root].lazy;
        //更新右区间和值
        node[root<<1|1].sum += rightLen*node[root].lazy;
        //下推标记到左区间
        node[root<<1].lazy += node[root].lazy;
        //下推标记到右区间
        node[root<<1|1].lazy += node[root].lazy;
        //当前节点标记下推完毕,恢复成无标记状态
        node[root].lazy = 0; 
    }
}
//将区[L,R]中的数字都加上add
void update(int L,int R,int add,int root) {
    //到达子区间,更新该区间的值,并留下标记
    if(L<=node[root],left && node[root].right<=R) {
        node[root].sum += (node[root].right-node[root].left+1)*add;
        node[root].lazy += add;
        return;
    }
    push_down(root);    //下推之前残留的标记
    int mid = (node[root].left+node[root].right)/2;
    if(L<=mid) update(L,R,add,root<<1);    //更新左区间
    if(R>mid) update(L,R,add,root<<1|1);   //更新右区间
    push_up(root);   //更新当前节点
}
2.区间查询

和区间更新的步骤类似,如果所查询的区间有标记则下推,保证其查询区间的值是正确的,然后就

和单点更新区间查询的过程是一样的。在此不在赘述。

区间查询代码:

//更新当前节点
void push_up(int root) {
    node[root].sum = node[root<<1].sum + node[root<<1|1].sum;
} 
//下推标记
void push_down(int root) {
    //说明该节点有标记
    if(node[root].lazy>0) {
        //求左区间的长度
        int leftLen = node[root<<1].right - node[root<<1].left + 1;
        //求右区间的长度
        int rightLen = node[root<<1|1].right - node[root<<1|1].left + 1;
        //更新左区间和值
        node[root<<1].sum += leftLen*node[root].lazy;
        //更新右区间和值
        node[root<<1|1].sum += rightLen*node[root].lazy;
        //下推标记到左区间
        node[root<<1].lazy += node[root].lazy;
        //下推标记到右区间
        node[root<<1|1].lazy += node[root].lazy;
        //当前节点标记下推完毕,恢复成无标记状态
        node[root].lazy = 0; 
    }
}
int query(int L,int R,int root) {
    if(L<=node[root].left && node[root].right<=R) {
        return node[root].sum; 
    }
    push_down(root);    //下推残留标记
    int mid = (node[root].left+node[root].right)/2;
    int ans = 0;
    if(L<=mid) ans += query(L,R,root<<1);
    if(R>mid) ans += query(L,R,root<<1|1);
    return ans;
}

五.线段树区间合并

线段树的区间合并是在上面基础上而扩展出来的一类题目,区间合并就是涉及到将区间合并成一个区间。

其题目的类型也比较多,题目对区间合并时的要求也不尽相同。

例题:CDOJ 360:Another LCIS

题目描述:

给出N个数字和Q次操作。(区间更新+区间合并)

如果操作类型是A,L,  R,V 则在区间【L,R】上加上V,另外一种操作是查询【L,R】区间中最长连续递增

子序列的长度。对于操作A,给【L,R】内的值加上V,并不会对该区间的最长连续递增子序列造成影响,但

是会给其父区间造成影响,因为其父区间是由子区间合并得来的。

对于一个区间,其连续递增子序列的分布可以分为下面三种情况。


图13

该题目线段树节点维护的值比较多。

Max        维护区间最长连续递增子序列的长度.

lnum       保留区间左边界位置的值。

rnum       保留区间右边界位置的值

lsum        以该区间左边界为开始的最长连续递增子序列的长度。

rsum        以该区间右边界为结束的最长连续递增子序列的长度。

当区间进行合并的时候,由于我们需要比较两个子区间相邻边界值的大小,则每个节点必须保留左右边界

的值,又由于需要根据边界大小,需要对区间进行合并,所以也需要维护lsum,rsum这两个变量。

每次我们都要用子区间去更新父区间。

更新MAX:

1.不考虑区间拼接问题,则父区间的最长连续递增子序列是其左子区间和右子区间中最长的。

2.考虑拼接问题,如果左区间的右边界值比右区间的左边界值小,则区间可以合并,以左区间右边界为结

尾的最长连续递增子序列长度+以右区间左边界值为开始的最长连续递增子序列长度。

两种情形如下图所示:


图14

更新lnum,rnum:

父区间的左边界值为其左子区间左边界子,父区间右边界值为其右子区间边界值。这个很好理解

更新lsum,rsum:

1.不考虑区间拼接,父区间lsum,应该继承左子区间的lsum。

2.考虑区间拼接,如果左子区间和右子区间可以进行拼接,则如果左子区间整个区间都是递增的,

则可以和右子区间的lsum拼接起来,形成父区间的lsum.

3.不考虑区间拼接,父区间rsum,应该继承右子区间的rsum.

4.考虑区间拼接,如果两个子区间可以进行拼接,则如果真个右子区间是递增的,则可以和左子区

间的rsum拼接起来,形成父区间的rsum.


图15


题目代码
线段树区间合并题目类型比较多,具体问题具体分析,在此仅举一例。


六.二维线段树

树套树,是一大类问题:有线段树套线段树,线段树套树状数组,主席树套线段树等等。在此只讲解

简单的线段树套线段树,二维线段树。

有的时候,我们可能会用两个条件来维护一个值,要求查询X在【a,b】区间,Y在【c,d】区间内所能

获得的值,这是我们常常将X,Y分别作为2维的其中一维,二维数组的每个格子作为需要维护的值。和

普通一维线段树情况差不多,只不过相应的操作大部分需要写两遍。

例题:HDU 1832:Luck and Love

题目描述:

C个操作,如果操作符为‘I’,将H(身高),A(活跃度),L(缘分值)插入到二维线段树。

操作符为‘Q’,查询身高在【H1,H2】,活泼度在【A1,A2】中的最大缘分值。

解题思路:线段树一维作为维护身高,二维维护活跃度,二维数组的值为缘分值。

题目代码


七.线段树题目汇总

1.单点更新

hdu 1166:敌兵布阵

hdu 1754: I Hate It

hdu 1394: Minimum Inversion Number

hdu 2795:BillBoard

poj 2828:Buy Tickets

poj 2886:Who Get the Most Candies?

hdu 4288: Coder

CodeforceBeta Round #19D:Points

poj 2481: Cows

hdu 3950: Parking log

hdu 4521: 小明系列问题-小明序列

CodeforceBeta Round #99(Div.1) C:Mushroom Gnomes

hdu 4605:Magic Ball Game

URAL 1989:Subpalindromes

hdu 4777: Rabbit Kingdom


2.区间更新

hdu 1698: Just a Hook

poj 3468: A SimpleProblem with Integers

poj 2528: Mayor' sposter

poj 1436: Horizeontally Visible Segments

poj 2991: Crane

CodeforceRound #136(Div.2) D:Little Elephant and Array

uva 12436:RipVan Winkle's Code

codeforceRound #169(Div.2)E:Little Girl and Problemon Trees

codeforceRound #35(Div.2)E:Parade

zoj 3299:Fall the Brick

fzu 2105:Digits Count

hdu 4533:威威猫系列故事-晒被子

ural 1855:Trade Guilds of Erathia

hdu 4578:Transformation

hdu 4455:Substrings

hdu 4614: Vases and Flowers

hdu 4747: Mex

zoj 3724: Delivery

Codeforce 343D:Water Tree

ural 1977:Energy Wall


3.区间合并

poj 3667: Hotel

hdu 3308: LCIS

hdu 3397: Sequence operation

hdu 2871: Memory Control

hdu 1450: Tunnel Warfare

CodeforceBeta Round #43 D:Parking lot


4.扫描线

hdu 1542: Atlantis

hdu 1828: Picture

hdu 1255: 覆盖的面积

hdu 3642: Get the Treasury

poj 2482: Stars in Your Window

poj 2464: BrowniePoints II

hdu 3255: Farming

uva 11983: WeirdAdvertisement

hdu 4052: Adding New Machine

hdu 4419: Colorful Rectangle

zoj 3521: Fairy Wars

zoj 3525: Disppearance


5.一堆线段树的题目

poj 2104:Kth-Number

hdu 1832:Luck and Love










  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值