线段树
1、概述
线段树,即在各个节点保存一条线段(数组中的一段子数组),主要用于高效解决连续区间的动态查询问题,由于二叉结构的特性,它基本能保持每个操作的复杂度为O(logn)。
线段树的每个节点表示一个区间,子节点则分别表示父节点的左右半区间,例如父亲的区间是[a,b],那么(mid=(a+b)/2) 左儿子的区间是[a,mid],右儿子的区间是[mid+1,b]。
2、实例
C国的死对头A国这段时间正在进行军事演习,所以C国间谍头子Derek和他手下Tidy又开始忙乎了。A国在海岸线沿直线布置了N个工兵营地,Derek和Tidy的任务就是要监视这些工兵营地的活动情况。由于采取了某种先进的监测手段,所以每个工兵营地的人数C国都掌握的一清二楚,每个工兵营地的人数都有可能发生变动,可能增加或减少若干人手,但这些都逃不过C国的监视。
中央情报局要研究敌人究竟演习什么战术,所以Tidy要随时向Derek汇报某一段连续的工兵营地一共有多少人,例如Derek问:“Tidy,马上汇报第3个营地到第10个营地共有多少人!”Tidy就要马上开始计算这一段的总人数并汇报。但敌兵营地的人数经常变动,而Derek每次询问的段都不一样,所以Tidy不得不每次都一个一个营地的去数,很快就精疲力尽了,Derek对Tidy的计算速度越来越不满:”你个死肥仔,算得这么慢,我炒你鱿鱼!”Tidy想:“你自己来算算看,这可真是一项累人的工作!我恨不得你炒我鱿鱼呢!”无奈之下,Tidy只好打电话向计算机专家Windbreaker求救,Windbreaker说:“死肥仔,叫你平时做多点acm题和看多点算法书,现在尝到苦果了吧!”Tidy说:”我知错了。。。”但Windbreaker已经挂掉电话了。Tidy很苦恼,这么算他真的会崩溃的,聪明的读者,你能写个程序帮他完成这项工作吗?不过如果你的程序效率不够高的话,Tidy还是会受到Derek的责骂的.
比如,已知一个数列,你需要进行下面两种操作:
1.将某区间每一个数加上x
2.求出某区间每一个数的和
这种情况我们就常用线段树这种数据结构来实现过程
为什么呢?
假设有数组arr[0…n-1],其中数组大小固定,但是数组中的元素的值可以随时更新。
对这个问题一个特别简单的解法是:遍历数组区间,直接累加起来其中每个元素的和,(或将每个元素加上x)时间复杂度是O(n),额外的空间复杂度O(1)(一个sum就完事了)。但当数据量特别大,而查询操作很频繁的时候,耗时可能会不满足需求。
另一种解法:使用一个二维数组来保存提前计算好的区间[i,j]内的和,那么预处理时间为O(n^2),查询耗时O(1), 但是需要额外的O(n^2)空间,当数据量很大时,这个空间消耗是庞大的,而且当改变了数组中的某一个值时,更新二维数组中的最小值也很麻烦。
我们可以用线段树来解决这个问题:预处理耗时O(n),查询、更新操作O(logn),需要额外的空间O(n)。根据这个问题我们构造如下的二叉树
节点代表它的所有子孙叶子节点所在区间的和
最底下的结点因为的他区间只包含他自身,所以就是原数组的那个元素的下标
例如对于数组[2, 5, 1, 4, 9, 3]可以构造如下的二叉树(我们会很清楚的看到他的结构,根节点存了最大的区间的和,每个左右结点会将父结点代表的区间对半分开,再求和,直到最底下一层,每一个节点装的就是对应的数组中的元素为止):
那现在一颗线段树包含的东西已经很清晰明了了,那么如何通过代码来建出这颗树呢。我们来看看
首先分析一下我们现在的需求,我们现在首先需要一个数组来装我们的元素,然后还需要一个tree。事实上装这个线段和的tree我们也可以通过一个一维数组就轻松的实现
#include<bits/stdc++.h>
using namespace std;
const int maxn=100005;
int a[maxn+2],sum[maxn*4+2];
int main()
{
int N;
cin>>N;
for(int i=1;i<=N;i++)
cin>>a[i];
因为线段树是一颗完全二叉树,他的左子结点的编号一定是父结点编号 * 2,右子结点的编号一定是父结点的编号 * 2+1
对于一个有N个数的数组,他的线段树用4N的数组一定能装下,我们可以通过数学的关系来证明,虽然我不会证,但事实就是4N一定能装下这棵树
这一点你们可以自己数数或者画一颗树,就会发现这个性质
建树与维护
那么根据线段树的服务对象,可以得到线段树的维护:
void pushup(int p)
{
sum[p]=sum[p*2]+sum[p*2+1];
}
这个函数相信大家不难看懂,对于树中任何一个结点,他存储值就是他的两个子节点的和(因为我们现在要做的线段树就是用一颗线段树要存线段和)
此处一定要注意,pushup操作的目的是为了维护父子节点之间的逻辑关系。当我们递归建树时,对于每一个节点我们都需要遍历一遍,并且电脑中的递归实际意义是先向底层递归,然后从底层向上回溯,所以开始递归之后必然是先去整合子节点的信息,再向它们的祖先回溯整合之后的信息。(这其实是正确性的证明啦)
呐,我们在这儿就能看出来,实际上pushup是在合并两个子节点的信息,所以需要信息满足结合律!
(结合律就是三个数相加,我先加后面两个再加前面或者反过来不会影响结果,你们应该懂什么意思吧,毕竟如果不符合的话,那么我们孙子结点那一层的数据就不应当传到我们父结点这一层来了)
那么对于建树,由于二叉树自身的父子节点之间的可传递关系,所以可以考虑递归建树(emmmmemmmm之前好像不小心剧透了qwqqwq),并且在建树的同时,我们应该维护父子节点的关系:
上面这几段话是什么意思并且代码的实现过程是什么样子的,我画一下图给你们你们应该就懂了
void build(int p,int l,int r)//最开始建树就是build(1,1,N)来调用,根节点就是1结点
{
if(l==r)
{
sum[p]=a[l];
return ;
}
int mid=(l+r)/2;
build(p*2,l,mid);
build(p*2+1,mid+1,r);
pushup(p);
}
来,我带着你们按建树的过程走一遍左子树的建立,你们就能懂建树的过程了(右子树同理)
①:左边界不等于右边界,我们的if不生效(代表还没到最小结点),我们就将区间取半,取mid,往下走,执行 build(p*2,l,mid); 现在我们就进入了下一层的build
②:同①
③:同①
④:这一次我们的左边界等于右边界了,我们就开始执行if中的语句,sum[8]=a[1],这样我们就把a数组中的值存进sum中了,是不是很巧妙,然后就执行return; 我们会回到上一层
⑤:回到上一层后,请注意,之前我们一直都是在第一个build的语句就进入了下一层build,但现在我们这一次的build函数调用中的第一个build的执行完了,我们将会执行第二个build,去建他的右子树
⑥:同④,执行if,sum[9]=a[2],然后return回去
⑦:对于第⑤的那一次build,我们终于走到了最后,执行pushup函数,我们将两个子节点值相加,就得到了父结点的值,sum[4]=sum[8]+sum[9],现在,整一个build走完了,return回去
⑧、⑨、⑩同理,至此,我们左子树已经通过递归建完了
然后按照相同的办法建右子树
至此,整一个build函数相信大家已经完全理解了
区间修改
虽然我很想先从更简单的区间和查询讲起,但查区间和却必然会用到部分区间修改定义的内容和函数,所以我们会先讲更难一点的区间修改
首先明确,单点修改就是区间修改的一个子问题而已,即区间长度为1时进行的区间修改操作罢了qwq
所以只要完成了区间修改,单点修改之类的问题一并解决了
然后对于区间操作,我们考虑引入一个名叫“lazy tag”(懒标记)的东西——之所以称其“lazy”,是因为原本区间修改需要通过先改变叶子节点的值,然后不断地向上递归修改祖先节点直至到达根节点,时间复杂度最高可以到达O(nlogn)的级别。但当我们引入了懒标记之后,区间更新的期望复杂度就降到了O(logn)的级别且甚至会更低.
这是什么意思我等下我仔细的说明,这也是线段树的精华所在
懒标记:每个节点新增加一个标记,记录这个节点是否进行了某种修改(这种修改操作会影响其子节点),即我们可以直接建立一个新的数组tag[N],他的每一个元素都和树中每一个结点一一对应
int add[maxn*4+2];//和sum数组是一样大小的,且与其一一对应
对于任意区间的修改,我们先按照区间查询的方式将其划分成线段树中的节点,然后修改这些节点的信息,并给这些节点标记上代表这种修改操作的标记。在修改和查询的时候,如果我们到了一个节点p,并且决定考虑其子节点,那么我们就要看节点p是否被标记,如果有,就要按照标记修改其子节点的信息,并且给子节点都标上相同的标记,同时消掉节点p的标记。 如果我们不需要考虑其子节点,那么这个标记就仅是一个标记,我们不会对其进行操作,这样可以极大的优化我们的时间,换句话说,这种标记就是一种延迟标记 只有当你需要用到相关的东西的时候,我们才根据标记修改值,不然就不管他,这也是懒标记名字的由来
void pushdown(int p,int total)
{
if(add[p])
{
sum[p*2]+=add[p]*(total-total/2);
sum[p*2+1]+=add[p]*(total/2);
add[p*2]+=add[p];
add[p*2+1]+=add[p];
add[p]=0;
}
}
我相信这个函数你们肯定看得懂,他的意思很简单,如果某个结点有修改的标记,那么对他的所有子结点的和进行更新,左子结点和更新为k * 左边的结点个数,右子结点和更新为k * 右边的结点个数
然后把标记下传到他的子结点,把他本身的子结点清0
首先,懒标记的作用是记录每次、每个节点要更新的值,也就是delta,但线段树的优点不在于全记录(全记录依然很慢qwq),而在于传递式记录:
整个区间都被操作,记录在公共祖先节点上;只修改了一部分,那么就记录在这部分的公共祖先上;如果四环以内只修改了自己的话,那就只改变自己。
如果我们采用上述的优化方式的话,我们就需要在每次区间的查询修改时pushdown一次,以免重复或者冲突或者爆炸qwq,我相信你们能够理解是什么意思的
那么对于pushdown而言,其实就是纯粹的pushup的逆向思维(但不是逆向操作): 因为修改信息存在父节点上,所以要由父节点向下传导lazy tag
区间修改的时候,我们按照如下原则:
1、如果当前区间被完全覆盖在目标区间里,讲这个区间的sum+k * (tree[i].r-tree[i].l+1)
2、如果没有完全覆盖,则先下传懒标记
3、如果这个区间的左儿子和目标区间有交集,那么搜索左儿子
4、如果这个区间的右儿子和目标区间有交集,那么搜索右儿子
代码实现如下
void update(int p,int be,int ed,int l,int r,int x)
//正常调用就是update(1,x,y,1,N,x),xy是要修改的区间,x是这个区间要修改的值,+-x
{
if(be<=l && ed>=r){
add[p]+=x;
sum[p]+=(r-l+1)*x;
return;
}
pushdown(p,r-l+1);
int mid=(l+r)/2;
if(be<=mid) update(p*2,be,ed,l,mid,x);
if(ed>mid) update(p*2+1,be,ed,mid+1,r,x);
pushup(p);
}
老规矩,我画图说明
重提:原来的数组是【2,5,1,4,9,3】
假设我们要将下标2~4的数全部加上k 。即[5,1,4]三个数全部加上k(请记住,我们一开始就是用a[1]存的第一个元素)
①:一开始,我们要找的区间是【2,4】,显然,按照程序执行的顺序,我们最开始的1结点代表的【1,6】区间覆盖了我们所要找的区间,我们的if判断不成立,所以我们的add数组现在还是全部为0,因为没有进行过赋值操作。但我们可以看见,【1,6】区间的左儿子,即【1,mid=3】和【2,4】区间是有交集的。那么,我们会先去找他的左区间,进入下一个update函数
②:同上,我们要找的区间【2,4】,现在我们在的区间是【1,3】,if还是判断失效,pushdown继续不动(你可以看见,如果add的值为0,pushdown几次都不会对数据造成影响的),然后还是继续看下面两个if
现在,mid=2了,【1,2】与【2,4】还是有交集,我们继续进入下一个update
③:在这一次情况中,我们所在的区间【1,2】,第一个if显然不生效,继续,现在,我们的mid=1,我们第一次发现了左子树已经和我们要找的区间没有交集了,但ed即4>1,所以我们【1,2】区间的右子树显然是在我们要找的区间中
④:在上一步我们向右走走到了【2,2】这个区间,现在,我们第一个if生效了,be=2,l=2,ed=4,r=2
我们的add【9】就会加上k,意味着我们标记了9结点,9结点会加上k,然后sum【9】+=(r-l+1)*k; 显然,如果我们给一个结点加k,那么他所有的子结点理应也加k,所以,这个节点代表的和就应该是他包含的元素个数 * k。接着return回去,注意,它之后的pushdown不执行,恰符合了懒的定义,因为我们还用不到他的子节点
⑤:当我们回到了【1,2】区间,我们会执行完这一层update的最后一句,pushup函数重新维护我们的线段树,现在,我们的7结点装的和已经被更新成为了新的和
⑥:现在我们回到了【1,3】区间,然后他的右子树显然也是我们的解,我们继续向右update
⑦:同第4步,我们这一次if生效,更新值,return回去
⑧:同第五步,我们会执行完这一层update的最后一句,pushup重新维护线段树,现在,3结点的和已被更新
同理,我们接下来也会同样的去找到我们右边的完全覆盖解的区间
最后,我们的区间就这么被我们更新完毕了
区间和查找
如果你真的已经理解了区间更新的步骤的话,那么区间查找显然不在话下了,我们还是按照那4步
1、如果当前区间被完全覆盖在目标区间里,讲这个区间的sum[p]直接传出来
2、如果没有完全覆盖,则先下传懒标记
3、如果这个区间的左儿子和目标区间有交集,那么搜索左儿子
4、如果这个区间的右儿子和目标区间有交集,那么搜索右儿子
int query(int p,int be,int ed,int l,int r)
{
if(be<=l && ed>=r)
return sum[p];
pushdown(p,r-l+1);
int res=0;
int mid=(l+r)/2;
if(be<=mid) res+=query(p*2,be,ed,l,mid);
if(ed>mid) res+=query(p*2+1,be,ed,mid+1,r);
return res;
}
步骤你们可以按照区间修改的那幅图再重新模拟一次,这也可以检验你是否真正理解了我讲的内容
最后附上完整的模板源码和题目描述
题目描述
如题,已知一个数列,你需要进行下面两种操作:
1.将某区间每一个数加上x
2.求出某区间每一个数的和
输入格式
第一行包含两个整数N、M,分别表示该数列数字的个数和操作的总个数。
第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。
接下来M行每行包含3或4个整数,表示一个操作,具体如下:
操作1: 格式:1 x y k 含义:将区间[x,y]内每个数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和
输出格式
输出包含若干行整数,即为所有操作2的结果。
PS:想AC的话你得把我所有的int改成long long 哦
#include<bits/stdc++.h>
using namespace std;
const int maxn=100005;
int a[maxn+2],sum[maxn*4+2],add[maxn*4+2];
void pushup(int p)
{
sum[p]=sum[p*2]+sum[p*2+1];
}
void pushdown(int p,int total)
{
if(add[p])
{
sum[p*2]+=add[p]*(total-total/2);
sum[p*2+1]+=add[p]*(total/2);
add[p*2]+=add[p];
add[p*2+1]+=add[p];
add[p]=0;
}
}
void build(int p,int l,int r)
{
if(l==r)
{
sum[p]=a[l];
return ;
}
int mid=(l+r)/2;
build(p*2,l,mid);
build(p*2+1,mid+1,r);
pushup(p);
}
int query(int p,int be,int ed,int l,int r)
{
if(be<=l && ed>=r)
return sum[p];
pushdown(p,r-l+1);
int res=0;
int mid=(l+r)/2;
if(be<=mid) res+=query(p*2,be,ed,l,mid);
if(ed>mid) res+=query(p*2+1,be,ed,mid+1,r);
return res;
}
void update(int p,int be,int ed,int l,int r,int x)
{
if(be<=l && ed>=r){
add[p]+=x;
sum[p]+=(r-l+1)*x;
return;
}
pushdown(p,r-l+1);
int mid=(l+r)/2;
if(be<=mid) update(p*2,be,ed,l,mid,x);
if(ed>mid) update(p*2+1,be,ed,mid+1,r,x);
pushup(p);
}
int main()
{
int N,M;
cin>>N>>M;
for(int i=1;i<=N;i++)
cin>>a[i];
build(1,1,N);
while(M--)
{
int op,x,y,k;
cin>>op;
if(op==1)
{
cin>>x>>y>>k;
update(1,x,y,1,N,k);
}
if(op==2)
{
cin>>x>>y;
cout<<query(1,x,y,1,N)<<endl;
}
}
return 0;
}