数据结构-线段树①

数据结构-线段树

线段树是所有 RMQ 中最常用的数据结构。

功能:区间修改区间查询。不止最值、求和。只要可递推的值都可以构造线段树。

如果区间大小为 n n ,线段树有 c n t cnt 个节点,那么 2 n 1 c n t < 4 n 2n-1\le cnt<4n

节点

对于每个节点 x x ,和堆类似,父亲节点为 x > > 1 x>>1 (即 x / 2 x/2 下取整的位运算方法,位运算方便而且快),左儿子为 x < < 1 x<<1 (即 2 x 2x ),右儿子为 x < < 1 1 x<<1|1 (即 2 x + 1 2x+1 )。

同时每个节点对应一段区间,所以叫线段树。节点 1 1 对应的区间为 1 n 1\sim n 。设一个节点对应的区间为 l r l\sim r ,那么它的左儿子对应的区间就是 l m i d l\sim mid ,其中 m i d = ( l + r ) > > 1 mid=(l+r)>>1 ,右儿子区间为 m i d + 1 r mid+1\sim r 。如果一个节点对应单点区间,就没有儿子。

同时每个节点对应一个值,即该区间的 RMQ 值。如果是求最值问题,就表示该区间最大值;如果是求和问题,就表示该区间的和。

操作(单点修改区间查询)

一个线段树是求和还是求最值或者求别的东西,取决于 p u s h u p ( k ) pushup(k) 函数,其中 k k 为节点编号,时间复杂度 O ( 1 ) O(1)

void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}//求最大值

根据原序列构造初始的线段树用 b u i l d ( ) build() 函数,单点节点上的值就为单点的值,递归从下到上构造,时间复杂度 O ( n log n ) O(n\log n)

void build(int k=1,int l=1,int r=n){//表示外部应用默认k=1,l=1,r=n
	if(l==r){v[k]=a[l];return;} //单点节点
	build(k<<1,l,mid),build(k<<1|1,mid+1,r); //递归构造
	pushup(k); //递推
}

先讲单点修改(加上 y y ),只需与 b u i l d ( ) build() 函数类似的递归操作即可,如果到达单点节点,就修改,不走那些跟查询单点没关系的区间、别忘了修改完后也要递推,时间复杂度 O ( log n ) O(\log n)

void fix(int x,int y,int k=1,int l=1,int r=n){
	if(l==x&&r==x){v[k]+=y;return;} //单点修改
	if(mid>=x) fix(x,y,k<<1,l,mid); //递归左儿子
	else fix(x,y,k<<1|1,mid+1,r); //递归右儿子
	pushup(k);//递推
}

区间查询,如果单前节点在查询区间内,就返回值。否则,递归左儿子右儿子,递推得区间查询值。时间复杂度 O ( log n ) O(\log n) ,因为只会走相关的 log n \log n 个节点。

int fmax(int x,int y,int k=1,int l=1,int r=n){
	if(x<=l&&r<=y) return v[k]; //在查找区间内,返回值
	int res=0;
	if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));
	if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));
	return res;
}

总时间复杂度 O ( n log n ) O(n\log n) ,全代码:

#include <bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,m,a[N];
namespace Sumtree{
	#define mid ((l+r)>>1)
	int v[N<<2];
	void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}
	void build(int k=1,int l=1,int r=n){
		if(l==r){v[k]=a[l];return;}
		build(k<<1,l,mid),build(k<<1|1,mid+1,r);
		pushup(k);
	}
	void fix(int x,int y,int k=1,int l=1,int r=n){
		if(l==x&&r==x){v[k]+=y;return;}
		if(mid>=x) fix(x,y,k<<1,l,mid);
		else fix(x,y,k<<1|1,mid+1,r);
		pushup(k);
	}
	int fmax(int x,int y,int k=1,int l=1,int r=n){
		if(x<=l&&r<=y) return v[k];
		int res=0;
		if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));
		if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));
		return res;
	}
	#undef mid
}using namespace Sumtree;
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
		scanf("%d",a+i);
	build();
	for(int i=1,x,y,z;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		if(x==1) fix(y,z);
		else printf("%d\n",fmax(y,z));
	}
	return 0;
}

线段树如果只能单点修改区间查询,代码还这么长,就没人用他了。所以可想而知,线段树还可以区间修改,区间查询。

操作(区间修改区间查询)

先看如何区间修改,初学者会这么写:

void fix(int x,int y,int z,int k=1,int l=1,int r=n){
	if(x<=l&&r<=y){v[k]+=z;return;}
	if(mid>=x) fix(x,y,z,k<<1,l,mid);
	if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);
	pushup(k);
}

问题是这样的话对于每个区间属于 [ x , y ] [x,y] 的节点,它的子节点就会没被修改。

初学者还可能这么写:

void fix(int x,int y,int z,int k=1,int l=1,int r=n){
	if(l==r){v[k]+=z;return;}
	if(mid>=x) fix(x,y,z,k<<1,l,mid);
	if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);
	pushup(k);
}

问题在于时间复杂度为 O ( n ) O(n)

那么区间修改的主角就要出场了——懒标记( lazytag \texttt{lazytag} )。对于每个节点,多加一个值, m k [ ] mk[] m k [ x ] mk[x] 表示 x x 节点的标记。每次修改在前面那个初学者的代码上加上终止区间懒标记。

void fix(int x,int y,int z,int k=1,int l=1,int r=n){
	if(x<=l&&r<=y){v[k]+=z,mk[k]+=z;return;}
	pushdown(k);
	if(mid>=x) fix(x,y,z,k<<1,l,mid);
	if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);
	pushup(k);
}

这时你注意到了上方代码第 3 3 行有一个 p u s h d o w n ( k ) pushdown(k) ,那就是一个专门用来处理懒标记的函数,负责把标记下放,时间复杂度为 O ( 1 ) O(1)

void pushdown(int k){
	if(!mk[k]) return;
	v[k<<1]+=mk[k],v[k<<1|1]+=mk[k];
	mk[k<<1]+=mk[k],mk[k<<1|1]+=mk[k],mk[k]=0;
}

有了它,区间修改就没必要一直修改到单点了,可以修改到所属区间,然后记下懒标记。下次到这个区间的时候把它 p u s h d o w n pushdown 下放。

然后区间修改区间查询的代码就是这样:

#include <bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int n,m,a[N];
namespace Sumtree{
	#define mid ((l+r)>>1)
	int v[N<<2],mk[N<<2];
	void pushup(int k){v[k]=max(v[k<<1],v[k<<1|1]);}
	void pushdown(int k){
		if(!mk[k]) return;
		v[k<<1]+=mk[k],v[k<<1|1]+=mk[k];
		mk[k<<1]+=mk[k],mk[k<<1|1]+=mk[k],mk[k]=0;
	}
	void build(int k=1,int l=1,int r=n){
		mk[k]=0;
		if(l==r){v[k]=a[l];return;}
		build(k<<1,l,mid),build(k<<1|1,mid+1,r);
		pushup(k);
	}
	void fix(int x,int y,int z,int k=1,int l=1,int r=n){
		if(x<=l&&r<=y){v[k]+=z,mk[k]+=z;return;}
		pushdown(k);
		if(mid>=x) fix(x,y,z,k<<1,l,mid);
		if(mid<y) fix(x,y,z,k<<1|1,mid+1,r);
		pushup(k);
	}
	int fmax(int x,int y,int k=1,int l=1,int r=n){
		if(x<=l&&r<=y) return v[k];
		pushdown(k);
		int res=0;
		if(mid>=x) res=max(res,fmax(x,y,k<<1,l,mid));
		if(mid<y) res=max(res,fmax(x,y,k<<1|1,mid+1,r));
		return res;
	}
	#undef mid
}using namespace Sumtree;
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
		scanf("%d",a+i);
	build();
	for(int i=1,x,y,z;i<=m;i++){
		scanf("%d",&x);
		if(x==1) scanf("%d%d%d",&x,&y,&z),fix(x,y,z);
		else scanf("%d%d",&x,&y),printf("%d\n",fmax(x,y));
	}
	return 0;
}

时间复杂度还是 O ( n log n ) O(n\log n) 的。

线段树有个经典例题,可以帮助你弄懂线段树的其他操作。

[USACO08FEB]酒店Hotel

第一行输入 n n m m n n 代表有 n n 个房间,编号为 1 n 1\sim n ,开始都为空房。 m m 表示以下有 m m 行操作,以下每行先输入一个数 i i ,表示一种操作:

i i 为1,表示查询房间。再输入一个数 x x ,表示在 1 n 1\sim n 房间中找到长度为 x x 的连续空房,输出连续 x x 个房间中左端的房间号,尽量让这个房间号最小。若找不到长度为 x x 的连续空房,输出 0 0 。并且在这 x x 个空房间中住上人。

i i 2 2 ,表示退房,再输入两个数 x x y y 代表 房间号 x x + y 1 x\sim x+y-1 退房,即让房间为空。

讲解:

那么这题中每个线段树节点需要有四个值:

lf[k]: \texttt{lf[k]:} k k 这个节点区间从左边开始连续空房数。
rt[k]: \texttt{rt[k]:} k k 这个节点区间从右边开始连续空房数。
v[k]: \texttt{v[k]:} k k 这个节点区间内最长的连续空房数。
mk[k]: \texttt{mk[k]:} k k 这个节点退房、住人区间修改懒标记。

所以有递推式(其中 ? ? 为三目运算符):

void pushup(int k,int l,int r){
	int mid=(l+r)>>1;
	lf[k]=lf[k<<1]==mid-l+1?lf[k<<1]+lf[k<<1|1]:lf[k<<1];
	rt[k]=rt[k<<1|1]==r-mid?rt[k<<1|1]+rt[k<<1]:rt[k<<1|1];
	v[k]=max(max(v[k<<1],v[k<<1|1]),rt[k<<1]+lf[k<<1|1]);
}

可以这么初始化:

void build(int k=1,int l=1,int r=n){
	mk[k]=-1;
	if(l==r){lf[k]=rt[k]=v[k]=1;return;}
	int mid=(l+r)>>1;
	build(k<<1,l,mid),build(k<<1|1,mid+1,r);
	pushup(k,l,r);
}

重点在于怎么查询。如下代码, f i n d ( x , k , l , r ) find(x,k,l,r) 表示寻找 k k 这个节点区间里寻找最左的连续 x x 空房。

int find(int x,int k=1,int l=1,int r=n){
	if(v[k]<x) return -1; //如果区间内最长连续空房小于x,逃
	int mid=(l+r)>>1;
	pushdown(k,l,r);//千万别忘了pushdown
	if(v[k<<1]>=x) return find(x,k<<1,l,mid); //如果左儿子有满足要求的区间,返回左儿子满足要求的区间左端点
	if(rt[k<<1]+lf[k<<1|1]>=x) return mid-rt[k<<1]+1;//如果满足区间横跨左右儿子区间,返回横跨区间左端点
	return find(x,k<<1|1,mid+1,r);//返回右儿子满足区间左端点
}

可以发现,这个代码的时间复杂度也是 O ( n log n ) O(n\log n) 的。

蒟蒻的 AC \color{#44cc00}\texttt{AC} 代码:

#include <bits/stdc++.h>
using namespace std;
const int N=5e4+10;
int n,m;
namespace sumtree{
	int lf[N<<2],rt[N<<2],v[N<<2],mk[N<<2];
	void pushup(int k,int l,int r){
		int mid=(l+r)>>1;
		lf[k]=lf[k<<1]==mid-l+1?lf[k<<1]+lf[k<<1|1]:lf[k<<1];
		rt[k]=rt[k<<1|1]==r-mid?rt[k<<1|1]+rt[k<<1]:rt[k<<1|1];
		v[k]=max(max(v[k<<1],v[k<<1|1]),rt[k<<1]+lf[k<<1|1]);
	}
	void pushdown(int k,int l,int r){
		if(mk[k]==-1) return;
		int mid=(l+r)>>1;
		lf[k<<1]=rt[k<<1]=v[k<<1]=(!mk[k])*(mid-l+1);
		lf[k<<1|1]=rt[k<<1|1]=v[k<<1|1]=(!mk[k])*(r-mid);
		mk[k<<1]=mk[k<<1|1]=mk[k],mk[k]=-1;
	}
	void build(int k=1,int l=1,int r=n){
		mk[k]=-1;
		if(l==r){lf[k]=rt[k]=v[k]=1;return;}
		int mid=(l+r)>>1;
		build(k<<1,l,mid),build(k<<1|1,mid+1,r);
		pushup(k,l,r);
	}
	int find(int x,int k=1,int l=1,int r=n){
		if(v[k]<x) return -1;
		if(l==r) return l;
		int mid=(l+r)>>1;
		pushdown(k,l,r);
		if(v[k<<1]>=x) return find(x,k<<1,l,mid);
		if(rt[k<<1]+lf[k<<1|1]>=x) return mid-rt[k<<1]+1;
		return find(x,k<<1|1,mid+1,r);
	}
	void clear(int x,int y,int k=1,int l=1,int r=n){
		if(x<=l&&r<=y){
			lf[k]=rt[k]=v[k]=r-l+1;
			mk[k]=0; return;
		}
		pushdown(k,l,r);
		int mid=(l+r)>>1;
		if(mid>=x) clear(x,y,k<<1,l,mid);
		if(mid<y) clear(x,y,k<<1|1,mid+1,r);
		pushup(k,l,r);
	}
	void full(int x,int y,int k=1,int l=1,int r=n){
		if(x<=l&&r<=y){
			lf[k]=rt[k]=v[k]=0;
			mk[k]=1; return;
		}
		pushdown(k,l,r);
		int mid=(l+r)>>1;
		if(mid>=x) full(x,y,k<<1,l,mid);
		if(mid<y) full(x,y,k<<1|1,mid+1,r);
		pushup(k,l,r);
	}
}using namespace sumtree;
int main(){
	scanf("%d%d",&n,&m);
	build();
	for(int i=1;i<=m;i++){
		int op,x,y;
		scanf("%d",&op);
		if(op==1){
			scanf("%d",&y);
			if((x=find(y))==-1) puts("0");
			else {
				printf("%d\n",x);
				full(x,x+y-1);
			}
		} else {
			scanf("%d%d",&x,&y);
			clear(x,x+y-1);
		}
	}
	return 0;
}

关于线段树有很多后续知识,如线段树合并,线段树分裂,可持久化线段树(主席树)等,学习千万不能停止脚步。

同时,线段树的题目千变万化,建议多练练线段树的题。

祝大家学习愉快!

发布了11 篇原创文章 · 获赞 24 · 访问量 629

猜你喜欢

转载自blog.csdn.net/KonnyWen/article/details/104221911