一、为什么使用线段树
题目一:
10000个正整数,编号1到10000,用A[1],A[2],A[10000]表示。
修改:无
统计:1.编号从L到R的所有数之和为多少? 其中1<= L <= R <= 10000.
方法一:对于统计L,R ,需要求下标从L到R的所有数的和,从L到R的所有下标记做[L..R],问题就是对A[L..R]进行求和。
这样求和,对于每个询问,需要将(R-L+1)个数相加。
方法二:更快的方法是求前缀和,令 S[0]=0, S[k]=A[1..k] ,那么,A[L..R]的和就等于S[R]-S[L-1],
这样,对于每个询问,就只需要做一次减法,大大提高效率。
题目二:
10000个正整数,编号从1到10000,用A[1],A[2],A[10000]表示。
修改:1.将第L个数增加C (1 <= L <= 10000)
统计:1.编号从L到R的所有数之和为多少? 其中1<= L <= R <= 10000.
再使用方法二的话,假如A[L]+=C之后,S[L],S[L+1],,S[R]都需要增加C,全部都要修改,见下表。
从上表可以看出,方法一修改快,求和慢。 方法二求和快,修改慢。
那有没有一种结构,修改和求和都比较快呢?答案当然是线段树。
二:线段树的点修改
上面的问题二就是典型的线段树点修改。
线段树先将区间[1..10000]分成不超过4*10000个子区间,对于每个子区间,记录一段连续数字的和。
之后,任意给定区间[L,R],线段树在上述子区间中选择约2*log2(R-L+1)个拼成区间[L,R]。
如果A[L]+=C ,线段树的子区间中,约有log2(10000)个包含了L,所以需要修改log2(10000)个。
于是,使用线段树的话,
A[L]+=C 需要修改log2(10000) 个元素
求和A[L...R]需要修改2*log2(R-L+1) <= 2 * log2(10000) 个元素。
log2(10000) < 14 所以相对来说线段树的修改和求和都比较快。
问题一:开始的子区间是怎么分的?
方法:
① 首先是讲原始子区间的分解,假定给定区间[L,R],只要L < R ,线段树就会把它继续分裂成两个区间。
② 计算 MID = (L+R)/2,左子区间为[L,MID],右子区间为[MID+1,R],然后如果子区间不满足条件就递归分解。
以区间[1..13]的分解为例,分解结果见下图:
问题二:给定区间【L,R】,如何分解成上述给定的区间?
对于给定区间[2,12]要如何分解成上述区间呢?
分解方法一:自下而上合并——利于理解
① 先考虑树的最下层,将所有在区间[2,12]内的点选中
② 然后,若相邻的点的直接父节点(相邻的最近的一层)是同一个,那么就用这个父节点代替这两个节点(父节点在上一层)。这样操作之后,本层最多剩下两个节点。
③ 若最左侧被选中的节点是它的父节点的右子树,那么这个节点会被剩下。
(如果不这样做的话,就会多加一些点,比如说下图的[2] ,是最左侧,如果不被剩下反而被父亲节点取代,那么会多 加结点[1])
若最右侧被选中的节点是它的父节点的左子树,那么这个节点会被剩下。(同理)
中间的所有节点都被父节点取代。
对最下层处理完之后,考虑它的上一层,继续进行同样的处理。
下图为n=13的线段树,区间[2,12],按照上面的叙述进行操作的过程图:
由图可以看出:在n=13的线段树中,[2,12]=[2] + [3,4] + [5,7] + [8,10] + [11,12] 。
分解方法二:自上而下分解——利于计算
首先对于区间[1,13],计算(1+13)/2 = 7,于是将区间[2,12]“切割”成了[2,7]和[8,12]。
其中[2,7]处于节点[1,7]的位置,[2,7] < [1,7] 所以继续分解,计算(1+7)/2 = 4, 于是将[2,7] 切割成[2,4]和[5,7]。
[5,7]处于节点[5,7]的位置,所以不用继续分解,[2,4]处于区间[1,4]的位置,所以继续分解成[2]和[3,4]。
最后【2】 < 【1,2】,所以计算(1+2)/2=1 ,将【2】用1切割,左侧为空,右侧为【2】
当然程序是递归计算的,不是一层一层计算的,上图只表示计算方法,不代表计算顺序。
问题三:如何进行区间统计?
假设这13个数为1,2,3,4,1,2,3,4,1,2,3,4,1. 在区间之后标上该区间的数字之和:
如果要计算[2,12]的和,按照之前的算法:
[2,12]=[2] + [3,4] + [5,7] + [8,10] + [11,12]
2 + 7 + 6 + 7 + 7 = 29
计算5个数的和就可以算出[2,12]的值。
问题四:如何进行点修改?
假设把A[6]+=7 ,看看哪些区间需要修改?[6],[5,6],[5,7],[1,7],[1,13]这些区间全部都需要+7.其余所有区间都不用动。
于是,这颗线段树中,点修改最多修改5个线段树元素(每层一个)。
下图中,修改后的元素用蓝色表示。
问题五:如何进行区间查询?
线段树能快速进行区间查询的基础是下面的定理:
定理:n>=3时,一个[1,n]的线段树可以将[1,n]的任意子区间[L,R]分解为不超过个子区间。
这样,在查询[L,R]的统计值的时候,只需要访问不超过个节点,就可以获得[L,R]的统计信息,实现了O(log2(n))的区间查询。
问题六:如何进行单点更新?
单点更新其实就是区间更新的特殊情况,只要在调用区间更新函数的时候让左节点等于右节点就可以。
显然,单点查询和区间查询的关系也是如此。
问题七:如何进行区间更新?
在大部分的区间更新操作中,都会涉及到区间的长度
比如说
把区间[2,9]中的每一个数都加上3
,结合Lazy标记我们只需要把区间的权值加上3乘以这个区间的长度就可以,因为区间的长度代表着这个区间包含有几个元素。
Lazy操作一般用于线段树的区间更新。因为区间更新涉及到的叶子节点不止一个,而叶子节点会影响到其它的非叶父子节点,那么回溯需要更新的非叶子节点就会非常多,时间复杂度肯定远大于O(lgn),为此,线段树引入了延迟标记(Lazy)的概念,也是线段树之所以这么快的精华所在。
延迟标记:每有一个节点新增加一个标记,说明这个节点所对应的区间被更新过,但是这个节点的左右子节点却没有被更新过。如果我们在操作过程中要涉及到这个节点的子节点,而这个节点又存在Lazy标记,那么我们就必须调用pushdown()函数来清除这个节点的Lazy标记,否则会导致结果出错。
在此通俗的解释我理解的Lazy意思,比如现在需要对[a,b]区间值进行加c操作,那么就从根节点[1,n]开始调用update函数进行操作
如果刚好执行到一个子节点,它的节点标记为rt,这时tree[rt].l == a && tree[rt].r == b
这时我们可以一步更新此时rt节点的sum[rt]的值,sum[rt] += c * (tree[rt].r - tree[rt].l + 1)
(需要更新的话 rt 以下子节点已经被更新了,所以加上个数)
注意关键的时刻来了,如果此时按照常规的线段树的update操作,这时候还应该更新rt子节点的sum[]值,而Lazy思想恰恰是暂时不更新rt子节点的sum[]值,到此就return,直到下次需要用到rt子节点的值的时候才去更新,这样避免许多可能无用的操作,从而节省时间 。
即,如果要给一个区间的所有值都加上1,那么,实际上并没有给这个区间的所有值都加上1,而是打个标记,记下来,这个节点所包含的区间需要加1.
打上标记后,要根据标记更新本节点的统计信息,比如,如果本节点维护的是区间和,而本节点包含5个数,那么,打上+1的标记之后,要给本节点维护的和+5。这是向下延迟修改,但是向上显示的信息是修改以后的信息,所以查询的时候可以得到正确的结果。
有的标记之间会相互影响,所以比较简单的做法是,每递归到一个区间,首先下推标记(若本节点有标记,就下推标记),然后再打上新的标记,这样仍然每个区间操作的复杂度是O(log2(n))。
思想:
延迟标记:每个节点新增加一个标记,记录这个节点是否进行了某种修改(这种修改操作会影响其子节点),对于任意区间的修改,我们先按照区间查询的方式将其划分成线段树中的节点,然后修改这些节点的信息,并给这些节点标记上代表这种修改操作的标记。在修改和查询的时候,如果我们到了一个节点p,并且决定考虑其子节点,那么我们就要看节点p是否被标记,如果有,就要按照标记修改其子节点的信息,并且给子节点都标上相同的标记,同时消掉节点p的标记。
问题七:存储结构是怎样的?
线段树是一种二叉树,当然可以像一般的树那样写成结构体,指针什么的。
但是它的优点是,它也可以用数组来实现树形结构,可以大大简化代码。
数组形式适合在编程竞赛中使用,在已经知道线段树的最大规模的情况下,直接开足够空间的数组,然后在上面建立线段树。
简单的记法: 足够的空间 = 数组大小n的四倍。
怎么用数组来表示一颗二叉树呢?
假设某个节点的编号为v, 那么它的左子节点编号为2*v,右子节点编号为2*v+1。
然后规定根节点为1.这样一颗二叉树就构造完成了。
通常2*v在代码中写成 v<<1 。 2*v+1写成 v<<1|1 。
问题六:代码中如何实现?
(1) 定义
#define maxn 100007 //元素总个数
#define ls l,m,rt<<1
#define rs m+1,r,rt<<1|1
int Sum[maxn<<2],Add[maxn<<2];//Sum求和,Add为懒惰标记
int A[maxn],n;//存原数组数据下标[1,n]
(2) 建树
//PushUp函数更新节点信息 ,这里是求和
void PushUp(int rt){
Sum[rt]=Sum[rt<<1]+Sum[rt<<1|1];
}
//Build函数建树
void Build(int l,int r,int rt){ //l,r表示当前节点区间,rt表示当前节点编号
if(l==r) {//若到达叶节点
Sum[rt]=A[l];//储存数组值
return;
}
int m=(l+r)>>1;
//左右递归
Build(l,m,rt<<1);
Build(m+1,r,rt<<1|1);
//更新信息
PushUp(rt);
}
(3) 点修改
void Update(int L,int C,int l,int r,int rt){ //l,r表示当前节点区间,rt表示当前节点编号
if(l==r){ //到叶节点,修改
Sum[rt]+=C;
return;
}
int m=(l+r)>>1;
//根据条件判断往左子树调用还是往右
if(L <= m)
Update(L,C,l,m,rt<<1);
else
Update(L,C,m+1,r,rt<<1|1);
PushUp(rt);//子节点更新了,所以本节点也需要更新信息
}
(4) 区间修改
void Update(int L,int R,int C,int l,int r,int rt){
//L,R表示操作区间,l,r表示当前节点区间,rt表示当前节点编号
if(L <= l && r <= R){ // 如果本区间完全在操作区间[L,R]以内
Sum[rt]+=C*(r-l+1); // 更新数字和,向上保持正确
Add[rt]+=C; //增加Add标记,表示本区间的Sum正确,子区间的Sum仍需要根据Add的值来调整
//该结点可能会修改很多次,所以要 Add[] +=
return ;
}
int m=(l+r)>>1;
PushDown(rt,m-l+1,r-m); //下推标记
//这里判断左右子树跟[L,R]有无交集,有交集才递归
if(L <= m)
Update(L,R,C,l,m,rt<<1);
if(R > m)
Update(L,R,C,m+1,r,rt<<1|1);
PushUp(rt);//更新本节点信息
}
(5) 区间查询
询问A[L,R]的和
首先是下推标记的函数:
void PushDown(int rt,int ln,int rn){ // ln rn 为区间长度
//ln,rn为左子树,右子树的数字数量。
if(Add[rt]){
//下推标记
Add[rt<<1]+=Add[rt];
Add[rt<<1|1]+=Add[rt];
//修改子节点的Sum使之与对应的Add相对应
Sum[rt<<1]+=Add[rt]*ln;
Sum[rt<<1|1]+=Add[rt]*rn;
//清除本节点标记
Add[rt]=0;
}
}
区间查询函数
int Query(int L,int R,int l,int r,int rt){
//L,R表示操作区间,l,r表示当前节点区间,rt表示当前节点编号
if(L <= l && r <= R){
//在区间内,直接返回
return Sum[rt];
}
int m=(l+r)>>1;
//下推标记,否则Sum可能不正确
PushDown(rt,m-l+1,r-m);
//累计答案
int ANS=0;
if(L <= m) ANS+=Query(L,R,l,m,rt<<1);
if(R > m) ANS+=Query(L,R,m+1,r,rt<<1|1);
return ANS;
}