前言
博客园同步
由于noip就要开始了,所以想着还是要把基础算法都给过一遍,并且要精通,
因此一直咕着的博客开始更新了,最近先把线段树巩固完。
本文先讲解线段树的基础知识及其运用,更多进阶内容以后有时间再写(等我学会了再说 )
正片开始
线段树是oi中最为基础的数据结构,同时也是入门与普及之间的分水岭。
其通俗易懂的思想以及简单的实现使得它一直是竞赛热门的考察对象。
问题引入
线段树模板题1
题目大意,维护一个数组。要求如下,每次操作对一个区间整体加一个数,或者询问一个区间的和。
其中数组大小n以及操作次数m是1e5数量级。
我们发现这道题数据范围非常大,如果一个一个数进行运算,最坏复杂度是O(nm)
这显然不能满足我们的要求。
我们先思考简单点,假如每次修改一个数,查询一个数。
那么这很简单,不多说,复杂度O(m)
如果修改一个数,查询一个区间呢?
修改一个数是O(1)的,查询是O(n)的。我们发现这样子复杂度极其不平衡,导致整体复杂度很不稳定。
我们有什么办法呢?
初探线段树及建树
简单介绍一下线段树。字面意思上,线段,这里实际上就是代表一个区间,树呢?那就是指树形结构了。(每一个节点有一个父亲和若干个儿子的图)
那么线段树,实际上就是维护线段的树形结构,那么其中一个节点代表一个区间。
也就是这个样子(百科
我们挖掘一下这颗树的性质。
1.每个节点表示一段区间
2.每个节点要不有两个儿子,要不没有儿子(这时这个节点维护的区间只是一个点)
3.儿子的区间的并集就是父亲的区间,同时儿子没有交集。
4.更具体我们发现儿子的区间恰好是父亲的一半(不整除2的话哪一边多都没问题),左儿子是前半段,右儿子是后半段。
通过观察我们发现线段树也实质上是一棵二叉搜索树(不知道也不要紧)
我们根据它的特征可以把一棵线段树建出来。
//num 当前节点的编号,l 当前区间的左端点,r,当前区间的右端点
#define lson num<<1,l,mid // 左儿子及其区间
#define rson num<<1|1,mid+1,r // 右儿子及其区间
#define ls num<<1 // 左儿子
#define rs num<<1|1 // 右儿子
void build(int num,int l,int r){
if(l==r) tr[num]=a[l];//a就是要维护的数组;到了最底层,只维护一个节点,不用找儿子了
else{
int mid=l+r>>1;//mid是区间的中点,l~mid是左儿子的区间,mid+1~r是右儿子的区间
// >>1相当于除以2,由于>>等级低,所以不用打括号
build(lson),build(rson);//递归儿子
//用了宏定义,也就是build(num<<1,l,mid) build(num<<1|1,mid+1,r)
tr[num]=tr[ls]+tr[rs];//维护区间信息,如最大值,区间和等等
}
}
int main(){
build(1,1,n)//根节点的编号是1,它维护的区间就是整一个数组
}
再说一些细节
- 这段代码是以上面问题为例子的。一个节点维护的区间就是题目中数组的一个区间。
- tr[num]是指这个节点维护的东西,因此下标是节点的编号,如果维护的东西多也可以用一个结构体。
- 如上面代码,tr就是区间和。由于儿子的我们已经求出来了,所以当前的答案就是两个儿子的答案的和。
- 两个儿子的编号是num<<1,num<<1|1,这个与上面的mid都是运用了位运算来简化。这里分别指num * 2,num*2+1。关于原因大家查一查左移右移,按位与,等位运算就知道了。
- 其实这种儿子编号的处理方式跟堆是类似的,而且实现相对简单。(还有动态开点方式的建树,但不在这篇讨论里)
讨论一下时空复杂度,由于每次递归下去区间是除以2的,所以树高一定是log(n)的,根据调和级数就可以知道时间复杂度是nlog(n)的,至于空间的话一般线段树是开到原数组的4倍差不多。
线段树的单点修改
根据上面建树的思想,我们可以想到,如果要修改一个原数组的位置的权值,我们就在线段树里不断递归,
找到最底层的一个节点,使得其的区间只代表我们这一个被修改的位置。然后由于其的值只会影响他的祖宗。
所以我们只需要回溯的时候维护一下祖宗的答案就好了。so easy
//num 当前节点的编号,l 当前区间的左端点,r,当前区间的右端点,x表示原数组修改的位置,k表示要加的值
#define lson num<<1,l,mid // 左儿子及其区间
#define rson num<<1|1,mid+1,r // 右儿子及其区间
#define ls num<<1 // 左儿子
#define rs num<<1|1 // 右儿子
void change(int num,int l,int r,int x,ll k){
if(l==r) tr[num]+=k;//区间只包含一个数,说明找到了,修改一下答案。
else{
int mid=l+r>>1;//同理建树
if(x<=mid) change(lson,x,k);//如果x在l~mid之间,那么最后找的一定是左儿子的儿孙。
else change(rson,x,k);//否则一定是右儿子的儿孙
tr[num]=tr[ls]+tr[rs];//由于此时底部的值被修改,所以我们要重新维护一下当先的答案。
}
}
递归查找时只需要搜索在范围内的那一边,由于树高log,所以最多只有log个点,所以修改一个点的复杂度就是O(log(n))
唉不对啊!!!明明我们暴力膜你都才O(n),你这搞这么复杂效率还差了???
实际上之前说暴力的问题就是修改和维护的复杂度极度不平衡,
所以我们为了保证复杂度的整体稳定,因此当然要牺牲点原本快速的地方的时间。
线段树的区间查询
如果一个区间刚好被线段树的一个节点所代表,那么我们只需要找到这个节点然后返回它的值就可以了。
但是若不是这么刚刚好的话怎么办呢???
我们可以把要求区间拆分成不同的区间。
我们设要求的区间左右端点为x,y,当前区间为l,r,中点为mid
- 如果x在l ~ mid之间,那么要求的区间一定有一部分在 l ~ mid之间,于是递归到左儿子
- 同理若y在mid+1~r之间,那么一定有部分在mid+1 ~ r 之间,于是递归右儿子
- 如果要求的区间包含了当前区间的话,那么返回当前区间的答案
当前的答案就是递归返回的答案之和,见代码。
//num 当前节点的编号,l 当前区间的左端点,r,当前区间的右端点;
// x表示查询区间左端点,y是右端点
#define lson num<<1,l,mid // 左儿子及其区间
#define rson num<<1|1,mid+1,r // 右儿子及其区间
#define ls num<<1 // 左儿子
#define rs num<<1|1 // 右儿子
ll query(int num,int l,int r,int x,int y){
if(x<=l&&r<=y) return tr[num];// 要求的区间包含当前区间,直接返回
int mid=l+r>>1;
ll a=0;
if(x<=mid) a+=query(lson,x,y);
// 如果x在l ~ mid之间,那么要求的区间一定有一部分在 l ~ mid之间,答案加上递归左儿子返回的
if(y>mid) a+=query(rson,x,y);//同理
return a;
}
不知道有没有童鞋看不懂递归的。。。。。。
反正这个思路对于当前区间是对的,那么对于所有区间都是对的。(神马逻辑)
然后就是复杂度了,其实也是O(log(n))
看起来似乎最坏情况左右都会递归下去复杂度退化成nlogn,但事实是不会的。
证(kou)明 (hu)
对于线段树的每一层,最多只会有4个节点被遍历到,其中两个是部分节点(也就是说不被要求区间包含),另外两个就是完全节点(被完全包含)
感性理解,完全节点是不会下传的,而会下传的部分节点只会在区间的两边,一边一个,也就是2个。那么最多各下传2个节点,一共4个,之后就同理。
好了,好了,这样子我们就会在正确复杂度内解决刚刚设想的简单版问题了。
那么一开始的题呢?
某同学:不就是单点修改变成区间修改吗,这不跟区间查一个做法,不用看了,直接上!
结果。。。
怎么回事?
线段树的区间修改
我们先打一个类似于区间查询的修改,时间复杂度一致。
//num 当前节点的编号,l 当前区间的左端点,r,当前区间的右端点;
// x表示查询区间左端点,y是右端点,k是加上的权值
#define lson num<<1,l,mid // 左儿子及其区间
#define rson num<<1|1,mid+1,r // 右儿子及其区间
#define ls num<<1 // 左儿子
#define rs num<<1|1 // 右儿子
void change(int num,int l,int r,int x,int y,ll k){
if(x<=l&&r<=y){
tr[num]+=(r-l+1)*k;//区间内的话直接修改,一个数加上k,一个(r-l+1)个数。
}else{
int mid=l+r>>1;
if(x<=mid) change(lson,x,y,k);