浅谈线段树——基础篇

前言

csdn同步
由于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);
		if(y>mid) change(rson,x,y,k);
		tr[num]=tr[ls]+tr[rs];
		//同理,不说了、
	}
} 

然后发现跟姓某 名同学的人一样爆0。
为什么内?
玩一玩数据发现一个重要的事情:我们修改当前区间答案时,它的儿孙区间不会被修改!
怎么办?如果要把儿孙也给修改的话,那复杂度就退化到跟建树一样了啊!难道线段树只能止步于此了吗?
不!线段树永不止步,止步的只有自己!
观察发现,儿孙被修改了,但是其实很少的可以被用到,就算被用到也都是很久过后了。
于是神犇们发明了一个叫做懒标记的东西!
其实很简单,我们更新完当前节点以后,就给节点打上标记。等到以后再次遇到这个节点的时候,我们通过打上的标记来对儿子进行更新,同时也更新儿子的标记。
而等到儿子被访问的时候,我们再修改儿子的儿子。
这样我们就会最大化效率,时间复杂度不会增加。也就是恰好在老师说要检查作业之后做作业,不检查就不做。

//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 downdate(int num,int l,int r){//下传
	if(lazy[num]){//如果lazy有值的话再更新,否则没必要
		int mid=l+r>>1;
		tr[ls]+=(mid-l+1)*lazy[num];
		tr[rs]+=(r-mid)*lazy[num];
		lazy[ls]+=lazy[num];//儿子懒标记加上父亲懒标记
		lazy[rs]+=lazy[num];
		lazy[num]=0;//由于父亲更新完了,所以懒标记改为0
	}
}
void change(int num,int l,int r,int x,int y,ll k){
	downdate(num,l,r);
	//懒标记的灵魂!遇到这个节点的时候,下传更新儿子的答案
	if(x<=l&&r<=y){
		tr[num]+=(r-l+1)*k;
		lazy[num]+=k;//这个区间被整体加k,懒标记也加上k
	}else{
		int mid=l+r>>1;
		if(x<=mid) change(lson,x,y,k);
		if(y>mid) change(rson,x,y,k);
		tr[num]=tr[ls]+tr[rs];
	}
} 

注意!downdate除了建树每个函数都要打!
注意!downdate除了建树每个函数都要打!
注意!downdate除了建树每个函数都要打!
(3遍)
另外记住如果是没有儿子的节点那就不用懒标记,如果你不理会这个问题的话,把数组开到8倍及以上应该也没问题。

模板题代码

#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#define fo(i,a,b) for(int i=a;i<=b;i++)//压代码系列
#define fd(i,b,a) for(int i=b;i>=a;i--)
#define ll long long
#define N 200007
#define lson num<<1,l,mid
#define rson num<<1|1,mid+1,r//不说了,这个代码没有ls,rs,用的是num<<1,num<<1|1
using namespace std;
int n,m;
ll a[N],tr[N<<2],lazy[N<<2];//位运算,与*4一致
void build(int num,int l,int r){//建树
	if(l==r) tr[num]=a[l];
	else{
		int mid=l+r>>1;
		build(lson),build(rson);
		tr[num]=tr[num<<1]+tr[num<<1|1];
	}
}
void downdate(int num,int l,int r){//下传
	if(lazy[num]){
		int mid=l+r>>1;
		tr[num<<1]+=(mid-l+1)*lazy[num];
		tr[num<<1|1]+=(r-mid)*lazy[num];
		lazy[num<<1]+=lazy[num];
		lazy[num<<1|1]+=lazy[num];
		lazy[num]=0;
	}
}
void change(int num,int l,int r,int x,int y,ll k){//区间加
	downdate(num,l,r);
	if(x<=l&&r<=y){
		tr[num]+=(r-l+1)*k;
		lazy[num]+=k;
	}else{
		int mid=l+r>>1;
		if(x<=mid) change(lson,x,y,k);
		if(y>mid) change(rson,x,y,k);
		tr[num]=tr[num<<1]+tr[num<<1|1];
	}
} 
ll query(int num,int l,int r,int x,int y){//区间查询
	downdate(num,l,r);
	if(x<=l&&r<=y) return tr[num];
	int mid=l+r>>1;
	ll a=0;
	if(x<=mid) a+=query(lson,x,y);
	if(y>mid) a+=query(rson,x,y);
	return a;
}
int main(){
	scanf("%d%d",&n,&m);
	fo(i,1,n) scanf("%lld",&a[i]);
	build(1,1,n);
	while(m--){
		int opt,l,r;
		scanf("%d%d%d",&opt,&l,&r);
		if(opt==1){
			ll k;
			scanf("%lld",&k);
			change(1,1,n,l,r,k);
		}else printf("%lld\n",query(1,1,n,l,r));
	}
}

由于上面有很详细的注释,所以完整版没什么注释(应该没事吧

进阶,lazy的综合运用

线段树模板题2
多了个区间乘。。。
感觉没什么变化诶,码一下试试,a few moments later
这懒标记怎么打??(就是嘛,人家都变绿了怎么可能那么简单?)

这种时候我们就发现似乎需要两个lazy数组,但是还有问题,我的两个lazy数组是否会互相影响?如果是的话该怎么处理。所以我们先思考如何处理两个lazy数组。
。。。。。。
最后推一推数学,我们发现我们可以尝试给两个数组定义先后顺序,那么由于乘法优先级高,所以我们也让乘法优先。

  • 假如当前我们是加一个数,那么实际上对于之前做的乘法是没有关系的(推一推式子就发现了)所以加的时候我们只用修改代表加的懒标记就可以了
  • 如果是乘法呢?我们发现假如之前加的数在懒标记里面,那么现在区间的答案应该是(原本区间答案+加的懒标记里面的数)*现在乘的数,展开发现其会使得现在 加的懒标记里面的数 乘以当前乘的数。

那么就跟之前一模一样了。

模板题2代码

#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#define fo(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,b,a) for(int i=b;i>=a;i--)//缩代码
#define ll long long
#define N 300007
#define ls num<<1//左儿子
#define rs num<<1|1//右儿子
#define lson num<<1,l,mid//左儿子及其区间
#define rson num<<1|1,mid+1,r //右儿子及其区间
using namespace std;
int n,m;
ll a[N],tr[N<<2],lazy1[N<<2],lazy2[N<<2],mod;//lazy1是乘法的,lazy2是加法的,tr是区间加
void build(int num,int l,int r){//建树
	if(l==r) tr[num]=a[l],lazy1[num]=1;//由于乘法对于懒标记的贡献是乘起来的,所以初值必须是1
	else{
		int mid=l+r>>1;
		build(lson),build(rson);
		tr[num]=tr[ls]+tr[rs];tr[num]%=mod;
		lazy1[num]=1;//赋初值
	}
}
void downdate(int num,int l,int r){//下传
	if(lazy1[num]!=1||lazy2[num]){//两个懒标记都有就可以更新
		int mid=l+r>>1;
		tr[ls]=tr[ls]*lazy1[num]+lazy2[num]*(mid-l+1);//优先是乘法,所以先加再乘
		tr[ls]%=mod;
		tr[rs]=tr[rs]*lazy1[num]+lazy2[num]*(r-mid);//同理
		tr[rs]%=mod;
		lazy1[ls]*=lazy1[num];lazy1[ls]%=mod;//乘法懒标记直接乘
		lazy1[rs]*=lazy1[num];lazy1[rs]%=mod;
		lazy2[ls]=lazy2[ls]*lazy1[num]+lazy2[num];//加法懒标记与区间和一样,先乘再加
		lazy2[rs]=lazy2[rs]*lazy1[num]+lazy2[num];
		lazy2[ls]%=mod,lazy2[rs]%=mod;
		lazy2[num]=0;
		lazy1[num]=1;//初值
	}
}
void times(int num,int l,int r,int x,int y,ll k){//乘
	downdate(num,l,r);
	if(x<=l&&r<=y){
		tr[num]*=k;tr[num]%=mod;
		lazy1[num]*=k;lazy1[num]%=mod;
		lazy2[num]*=k;lazy2[num]%=mod;//与上面一致
	}else{
		int mid=l+r>>1;
		if(x<=mid) times(lson,x,y,k);
		if(y>mid) times(rson,x,y,k);
		tr[num]=tr[ls]+tr[rs];tr[num]%=mod;//回溯
	}
}
void add(int num,int l,int r,int x,int y,ll k){//加
	downdate(num,l,r);
	if(x<=l&&r<=y){
		tr[num]+=k*(ll)(r-l+1)%mod;tr[num]%=mod;
		lazy2[num]+=k;lazy2[num]%=mod;//不讲了,剩下都是一样的
	}else{
		int mid=l+r>>1;
		if(x<=mid) add(lson,x,y,k);
		if(y>mid) add(rson,x,y,k);
		tr[num]=tr[ls]+tr[rs];tr[num]%=mod;
	}
}
ll query(int num,int l,int r,int x,int y){
	downdate(num,l,r);
	if(x<=l&&r<=y) return tr[num];
	int mid=l+r>>1;
	ll a=0;
	if(x<=mid) a=query(lson,x,y);
	if(y>mid) a+=query(rson,x,y);
	a=(a%mod+mod)%mod;
	return a;
}
int main(){
	scanf("%d%d%lld",&n,&m,&mod);
	fo(i,1,n) scanf("%lld",&a[i]),a[i]%=mod;
	build(1,1,n);
	fo(i,1,m){
		int opt,l,r;
		scanf("%d%d%d",&opt,&l,&r);
		if(opt==1){
			ll k;
			scanf("%lld",&k);
			times(1,1,n,l,r,k);
		}
		if(opt==2){
			ll k;
			scanf("%lld",&k);
			add(1,1,n,l,r,k);
		}
		if(opt==3) printf("%lld\n",query(1,1,n,l,r));
	}
}

初步总结

线段树功能强大,即使有些问题解决不出,也有它的各种升级版本来实现。
但是单考虑普通线段树,能否解决的一大关键就是它是否满足区间可并性,如最大值,区间和都是满足的。当然还有要满足懒标记可以下传(如果要用到的话)
比如一个问题:
维护序列,满足以下几个操作:
1.区间取max,也就是小于x的数都变成x
2.区间和。
想想这个问题,似乎很难维护吧?因为修改操作不能单纯地找到包含区间就更新。
于是有一种线段树叫做吉司机线段树,不过目前我还不会,以后进阶篇应该会讲吧。
所以呢在做有关线段树的题目时,有了一种想法先不要急着去打,再思考一下,如果发现有点不对劲,应该及时调整。

权值线段树

权值线段树与普通线段树的不同在于,普通线段树的下标是数组的下标,也就是说一个区间维护的是数组的一个区间。
而权值线段树就是以权值为下标,也就是一个区间维护的是这个范围的数。
那这有什么用呢?
例题:逆序对
这题老熟悉了,最常用的就是归并排序及树状数组了。
但是我们就是要用线段树做,怎么办???
考虑第i个数,1~i-1中如果有一个数大于a[i]那么贡献加1,。
也就是说我们可以把它转化成另一个问题,每次先求出先前的数中比当前数大的个数,然后把当前数加入。
如果只是单纯以数组下标为区间,那么是做不了的。
但是我们发现若是以权值为下标,那么对于a[i],相当于查询,a[i]+1~n中的已经有的个数,然后把a[i]插入进去。
实现也就呼之欲出了。

#include<cstdio>
#include<iostream>
#include<algorithm>
#include<cstring>
#include<iostream>
#define fo(i,a,b) for(int i=a;i<=b;i++)
#define fd(i,b,a) for(int i=b;i>=a;i--)
#define ll long long
#define N 1000007
#define lson num<<1,l,mid
#define rson num<<1|1,mid+1,r
#define ls num<<1
#define rs num<<1|1 
using namespace std;
int n,tr[N<<2],a[N],b[N];
void insert(int num,int l,int r,int x){
	if(l==r) tr[num]++;
	else{
		int mid=l+r>>1;
		if(x<=mid) insert(lson,x);
		else insert(rson,x);
		tr[num]=tr[ls]+tr[rs];
	}
}
int query(int num,int l,int r,int x,int y){
	if(x>y) return 0;
	if(x<=l&&r<=y) return tr[num];
	int mid=l+r>>1,a=0;
	if(x<=mid) a=query(lson,x,y);
	if(y>mid) a+=query(rson,x,y);
	return a;
}
int main(){
	ll ans=0;
	scanf("%d",&n);
	fo(i,1,n) scanf("%d",&a[i]),b[i]=a[i];
	sort(b+1,b+n+1);
	int u=unique(b,b+n+1)-b-1;
	fo(i,1,n) a[i]=lower_bound(b+1,b+u+1,a[i])-b;
	fo(i,1,n){
		int x=a[i];
		ans+=query(1,1,u,x+1,u);//数字最大是u,所以范围1到u
		insert(1,1,u,x);
	}
	printf("%lld\n",ans);
}

唯一可能要注意的就是权值线段树需要离散化,除非别人数据范围与n是同阶的。

总结

这篇主要讲了基本的线段树实现以及权值线段树这一思想。
关于例题将会专门出一篇整合版本,另一个作用也主要是给自己记录用。
随着题目的积累,预计会在提高篇会讲解主席树(可持久化线段树)二维线段树,吉司机线段树,动态开点小技巧,扫描线,线段树合并与分裂,李超树(?yong bu dao)等等等等。
同时也会出树状数组,分块,莫队等可以在noip有用(bao)武(li)之(pian)地(fen)的数据结构与算法。以及dp,图论,数论应该也会做吧。(问号(可不要咕了
那么尽情期待。

posted @ 2021-07-24 23:00  jay_zjy1022  阅读(72)  评论(0)    收藏  举报