【算法】线段树详解

一.概述

在说线段树之前,我们先来了解一个问题

给你一串数组a,求一段区间[L,R]的和,该数组的值随时可以更新

传统的做法:
 每次查询某一区间的和,我们声明一个变量sum=0,然后令i从L枚举到R,依此加上a[i],但是这时候的查询时间复杂度为O(n)
原数组更新时,例如让第i个数改为5,我们只需要直接令a[i]=5就能完成更新,此时更新的时间复杂度为O(1)

前缀和做法:
 这时候很多人会想到直接声明一个数组sum,先预处理出前缀和,然后直接访问sum[R]-sum[L-1]就是所求值,此时一查询的时间复杂度为O(1)
 但是,如果原数组的数据更新呢?这时候直接用前缀和的知识来写,我们每更新一次数组,都要更新前缀和,时间复杂度达到O(n)

总结
无论是传统做法还是前缀和算法,查询或者更新总有一个时间复杂度达到O(n),当数据大点的时候,就容易超时。那么我们有没有一种算法,让查询和更新的时间复杂度都小于O(n)呢?这里就要介绍本次文章的主角——线段树

二.线段树

 线段树,是一个二叉搜索树,它的主要思想是将一个大的区间上的问题分解为左右两个小区间的问题求解,进而求解出大的区间上的答案。例如,我们想要求解区间长度为10上的问题,它构造出来的线段树如下图所示
【算法】线段树详解_第1张图片
 可以看出,每次线段树的深度最大不会超过⌊ log ⁡ ( n ) + 1 \log(n)+1 log(n)+1⌋,如此一来,其查询的时间复杂度为O( log ⁡ ( n ) \log(n) log(n)),其更新操作的时间复杂度也是O( log ⁡ ( n ) \log(n) log(n))

线段树的编写

假设有一串数组a,长度为[L, R],对于线段树的编写,有以下基本步骤

  1. 计算出区间的中点mid,将区间分为两个小区间[L, mid], [mid+1, R]
  2. 对两个区间递归分解,直到区间长度为1,得到这个区间的答案
  3. 依此回溯得到大区间的答案
  4. 最终得到[L, R]的答案

可能有的人看到这还是有点懵懂,接下来我们来根据一道例题来讲解

假设有一个数组a[]={1,2,3,4,5,6,7,8,9,10},我们要进行区间求和,接下来我们来构造线段树,先把区间分成这样一个形式
【算法】线段树详解_第2张图片
从下往上计算答案
【算法】线段树详解_第3张图片
计算第4层答案,有子元素的为子元素的和
【算法】线段树详解_第4张图片
第三层,第二层,第一层同理,最后得到下图
【算法】线段树详解_第5张图片
这样我们线段树的构造完成了!
那么,构造完线段树,我们都是怎么进行查询的呢?假设总的区间长度为[start, end],要查询的区间为[L, R]。
一般来说,查询的步骤如下

  1. 当前区间为[start, end]时,如果L<=start且R>=end,则返回当前区间的答案
  2. 否则计算出[start, end]的中点mid. 如果L<=mid,则向[start, mid]搜索答案,R>mid时向[mid+1, end]搜索答案
  3. 重复1,2步,直到将答案搜索到

例如,我们要求解[4,9]之间的区间和是多少时
1.因为[1,10]的中点为5,而4<5而9>5,所以我们向两边搜索答案,如下图,红色为判断过的,绿色为还未判断
【算法】线段树详解_第6张图片
在区间[1,5],中点mid=3,又因为L>mid, 但是R>mid,所以只向右孩子搜索
同理对区间[6,10],中点mid=8,因为Lmid所以向两个孩子都搜索,如下图
【算法】线段树详解_第7张图片
对[4,5],因为L<=4, R>=5,所以左边区间的答案就是[4,5]的值,值为9,对[6,8]也同理,得到值21,对[9,10]继续处理,最后在[9]的到值9

【算法】线段树详解_第8张图片
最后res=39,而4+5+……+9刚好等于39,答案没错,事实证明这样处理,查询得到的结果为对的!
对于更新操作,和查询操作差不多,只不过最后将值改变之后在往上回溯的到更新后的值,这里我就不多赘述了!

三.代码

根据上面的思想我们可以开始写代码了
构建线段树,假设我们数据都在num数组里,我们在tree数组里进行构建线段树

void build_tree(int node,int start,int end)//node为当前结点,对[start,end]进行构建
{
	if(start==end) {
		tree[node]=num[start];
	} else {
		int left_node = node * 2;
		int right_node = node * 2 + 1;
		int mid=(start+end)/2;
		build_tree(left_node,start,mid);
		build_tree(right_node,mid+1,end);
		tree[node]=tree[left_node]+tree[right_node];
	}
}

在线段树上的查询代码

int query_tree(int node,int start,int end,int L,int R)//数组长度[start,end],查询区间[L,R],node为当前结点
{
	if(R<start || L>end) {
		return 0;
	} else if(start == end) {
		return tree[node];
	} else if(L <= start && end <= R) {
		return tree[node];
	} else {
		int mid=(start + end) / 2;
		int left_node = node * 2;
		int right_node = node * 2 + 1;
		int sum_left = query_tree(left_node,start,mid,L,R);//找出左边的答案
		int sum_right = query_tree(right_node,mid+1,end,L,R);//找出右边的答案
		return sum_left+sum_right;//左右两边答案相加
	}
}

线段树的更新

void updata_tree(int node,int start,int end,int idx,int val)//在num[start,end]区间上,对num[idx]增加val后,线段树的更新,为单点更新
{
	if(start==end) {
		num[idx]+=val;
		tree[node]+=val;
	} else {
		int mid = (start + end) / 2;
		int left_node = node * 2;
		int right_node = node * 2 + 1;
		if(idx>=start && idx<=mid) {
			updata_tree(left_node,start,mid,idx,val);
		} else {
			updata_tree(right_node,mid+1,end,idx,val);
		}
		tree[node]=tree[left_node]+tree[right_node];
	}
}

构建,查询,更新线段树的代码至此就写完了,但是,这样就足够了吗?
其实不然,我们看看更新操作,对于单点更新来说这么写可以,如果我们要对区间进行更新的操作时,如果每次对每个点,更新一个数据线段树都要更新一遍,是否有太多的多余操作呢?
例如,我们如果对[4,9]区间上都加上1,根据上面,在进行到[4,5]的更新时。我们都是对4更新一次,然后往上回溯更新,再对5更新一次,再往上回溯,如图
【算法】线段树详解_第9张图片
这样操作,我们就反复对[4,5]区间进行多余的更新操作了
那么我们该怎么写才能避免这件事呢?这时候就要介绍一种lazytag(懒标签)对区间标记来减少操作的办法了

设一个数组add[], 如果我们在访问到区间[4.5]时,判断[4,5]在[4,9]里面时,我们直接对这个区间进行标记,因为[4,5]的结点为5,即令add[5]=1,同理在访问[6,8]区间时,令add[6]=1
之后我们在查询的时候访问到[4,5]区间的时候,直接往下更新答案
更新前【算法】线段树详解_第10张图片
更新后【算法】线段树详解_第11张图片
从而达到对操作的化简减少,实现性能的提升

代码如下
从下往上更新

//从上往下更新
#define LeftChild(x) 2*(x)
#define RightChild(x) 2*(x)+1
typedef long long LL;

//表示区间 [left,right] 都加上k
inline void f(LL node,LL left,LL right,LL k)
{
	tag[node] += k;
	tree[node] += k*(right-left+1);
}

inline void push_down(LL node,LL left,LL right)
{
	LL mid = (left+right)>>1;
	f(LeftChild(node),left,mid,tag[node]);
	f(RightChild(node),mid+1,right,tag[node]);
	tag[node]=0;//用完之后置为0
}

更新

void update(LL node,LL start, LL end, LL k, LL left, LL right)//区间更新,要用懒标记
{
	if(left<=start && right>=end) {
		tree[node]+=k*(end-start+1);
		tag[node]+=k;
	} else {
		push_down(node,start,end);//往下更新(之前的懒标记)
		LL mid = (start + end)>>1;
		if(left<=mid)update(LeftChild(node),start,mid,k,left,right);
		if(right>mid)update(RightChild(node),mid+1,end,k,left,right);
		push_up(node);
	}
}

查询

LL query(LL node,LL left,LL right,LL start,LL end)
{
	LL res = 0;
	if(left<=start && right>=end) {
		return tree[node];
	} else {
		LL mid = (start + end) >> 1;
		push_down(node,start,end);
		if(left<=mid)res+=query(LeftChild(node),left,right,start,mid);
		if(right>mid)res+=query(RightChild(node),left,right,mid+1,end);
		return res;
	}
}

四.总结

线段树的用处其实非常多的,我这里其实只举例了区间求和,其实还可以进行区间乘,除,减操作,还能对动态RMQ问题求解等等…
在线段树的编写中,如果涉及对某一区间修改时,注意对懒标记的使用,能够有效的提升性能,避免TLE
如果觉得我的文章对你有帮助,希望能够支持作者哦(点赞啊,收藏等
也欢迎大佬对我的问题进行批评哦!

你可能感兴趣的:(算法)