线段树,从入门到入坑(完整版)

前言

今年初的时候学线段树,凑了一篇博客(其实就是一堆题的题解),连线段树是什么都没讲明白,所以重新写了一篇。

线段树的引入

举个例子,我们现在有一个序列,想维护一段子区间的和,该怎么办呢?

你或许会说,可以暴力!把这个区间的数加起来就行了。

那么如果这个子区间里有1e5个数呢?

前缀和?

如果强制在线呢?

如果在维护区间和的同时维护最大值、并且支持区间修改呢?

我们有很多种办法维护区间问题,比如树状数组,线段树,分块。其中,线段树是较通用且直观的一种数据结构。

基础线段树

线段树入门

首先,我们有一个序列。

{ 1 , 1 , 4 , 5 , 1 , 4 } \left \{ 1,1,4,5,1,4 \right \} { 1,1,4,5,1,4}

我们利用二分的思想,用每一个节点表示一个区间,两个子节点表示左右两个子区间。

在这里插入图片描述
然后我们就可以在每个节点处维护一些信息。

注意:实际上,只有最下面一层的叶子节点才保存了实际的数字,其它的每个节点只保存着这个区间的信息(如区间和,区间最值等)

那么如何把子节点的信息传到父节点上呢?

我们要了解一个叫做 p u s h u p pushup pushup的操作。

void pushup(int x){
   
	tr[x].sum=tr[x*2].sum+tr[x*2+1].sum;
}

这个操作的意思就是:节点表示的区间和等于两个子节点所表示的区间之和。即下图:

在这里插入图片描述
有了这个操作,我们就可以递归的求出每一个节点所表示的信息。

在这里插入图片描述
这个建立线段树的过程可以看作是预处理信息,把数组的信息转移到线段树的叶子节点上,时间复杂度大概是 O ( n ) O(n) O(n)

事实上,还有另一种写法的线段树,不需要建树,但是需要 O ( n log ⁡ n ) O( n\log n) O(nlogn)的时间复杂度插入数据,我们会在权值线段树部分介绍这种写法。

建树代码

void build(int x,int l,int r){
   
	tr[x].l=l,tr[x].r=r;//节点表示区间的左右界
	if(l==r){
   
		//若l=r,说明这个节点是叶子节点,直接赋值
		tr[x].sum=a[l];//a是原数列
		return;
	}
	int mid=(l+r)/2;//mid表示左右子区间的间隔
	build(x*2,l,mid),build(x*2+1,mid+1,r);//递归建树
	//线段树是完全二叉树,左右子节点可以用x*2,x*2+1表示
	tr[x].sum=tr[x*2].sum+tr[x*2+1].sum;//pushup操作
}

区间查询

线段树可以在 O ( log ⁡ n ) O(\log n) O(logn)的时间复杂度下完成区间查询操作。

以刚刚的数列 { 1 , 1 , 4 , 5 , 1 , 4 } \left \{ 1,1,4,5,1,4 \right \} { 1,1,4,5,1,4}为例。

此时如果询问 [ 3 , 5 ] [3,5] [3,5]之间的区间和,我们该怎么办呢?

在这里插入图片描述
首先,如果直接查询 [ 4 , 6 ] [4,6] [4,6]的区间和,我们肯定是会的,直接输出10就行。

查询 [ 4 , 5 ] [4,5] [4,5]怎么办呢?

可以把 [ 4 , 6 ] [4,6] [4,6]拆成 [ 4 , 5 ] [4,5] [4,5] [ 6 , 6 ] [6,6] [6,6],然后输出 [ 4 , 5 ] [4,5] [4,5]的和。

那么 [ 3 , 5 ] [3,5] [3,5]就可以表示为 [ 3 , 3 ] [3,3] [3,3] [ 4 , 5 ] [4,5] [4,5]

在这里插入图片描述
所以无论我们查询多大的区间,都可以拆成一些(不超过 log ⁡ n \log n logn)预处理过的子区间,把这些子区间的区间和加起来,就是答案。

区间查询代码

int query(int x,int l,int r){
   
	//区间查询
	if(tr[x].l>=l&&tr[x].r>=r) return tr[x].sum;//如果该节点的区间被要查找的区间包括了,那么就不用继续找了,直接返回改节点的值就行了
	int mid=(tr[x].l+tr[x].r)/2;
	int sum=0;
	if(l<=mid) sum+=query(x*2,l,r);//如果当前节点在要查找区间左边界的右面,那么递归查找左子树
	if(r>mid) sum+=query(x*2+1,l,r);//如果当前节点在要查找区间右边界的左面,那么递归查找右子树
	return sum;//由此得出了该区间的值,返回即可
}

单点修改

单点修改比较简单,不断递归,定位到要找的节点,修改即可。

在这里插入图片描述
单点修改代码

void change(int now,int x,int k){
   
	//单点修改
	if(tr[now].l==tr[now].r){
   
		tr[now].sum=k;//如果找到了该节点,那么修改它
		return;
	}
	int mid=(tr[now].l+tr[now].r)/2;
	if(x<=mid) change(now*2,x,k);//如果要寻找的节点在当前节点的左侧,就递归左子树
	else change(now*2+1,x,k);//否则递归右子树
	tr[now].sum=tr[now*2].sum+tr[now*2+1].sum;//pushup操作,维护每个节点的sum值
}

线段树的存储

观察线段树,我们发现它是一个完全二叉树,可以用堆式储存法。

即把每个节点都存在一个数组里,因为是完全二叉树,所以两个子节点可以用 2 p 2p 2p 2 p + 1 2p+1 2p+1表示。

因为线段树大部分节点都不是用来存数字的,所以线段树所用的空间要比原数列的空间多很多,如图,只有红色的节点才是真正存数字的。

在这里插入图片描述

线段树大概要开四倍的空间,具体可以看OIwiki上的分析。

例题1:单点修改,区间查询

洛谷P3374

已知一个数列,进行下面两种操作:

  • 将某一个数加上 x x x
  • 求出某区间每一个数的和

题目分析

相当于模板题,可以尝试着敲一遍,这里提供代码。

AC代码

#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
struct node{
   
	int sum,l,r;//线段树节点的结构体
}tr[N*4];//线段树需要开四倍空间
int a[N];
inline void pushup(int x){
   
	tr[x].sum=tr[x*2].sum+tr[x*2+1].sum;
}
void build(int x,int l,int r){
   
	tr[x].l=l,tr[x].r=r;//节点表示区间的左右界
	if(l==r){
   
		//若l=r,说明这个节点是叶子节点,直接赋值
		tr[x].sum=a[l];//a是原数列
		return;
	}
	int mid=(l+r)/2;//mid表示左右子区间的间隔
	build(x*2,l,mid),build(x*2+1,mid+1,r);//递归建树
	//线段树是完全二叉树,左右子节点可以用x*2,x*2+1表示
	pushup(x);//pushup操作
}
int query(int x,int l,int r){
   
	//区间查询
	if(tr[x].l>=l&&tr[x].r<=r) return tr[x].sum;//如果该节点的区间被要查找的区间包括了,那么就不用继续找了,直接返回改节点的值就行了
	int mid=(tr[x].l+tr[x].r)/2;
	int sum=0;
	if(l<=mid) sum+=query(x*2,l,r);//如果当前节点在要查找区间左边界的右面,那么递归查找左子树
	if(r>mid) sum+=query(x*2+1,l,r);//如果当前节点在要查找区间右边界的左面,那么递归查找右子树
	return sum;//由此得出了该区间的值,返回即可
}
void change(int now,int x,int k){
   
	//单点修改
	if(tr[now].l==tr[now].r){
   
		tr[now].sum+=k;//如果找到了该节点,那么修改它
		return;
	}
	int mid=(tr[now].l+tr[now].r)/2;
	if(x<=mid) change(now*2,x,k);//如果要寻找的节点在当前节点的左侧,就递归左子树
	else change(now*2+1,x,k);//否则递归右子树
	pushup(now);//pushup操作,维护每个节点的sum值
}

int n,q;
int main(){
   
	cin>>n>>q;
	for(int i=1;i<=n;i++) cin>>a[i];
	build(1,1,n);//建树
	while(q--){
   
		int t,b,c;
		cin>>t>>b>>c;
		if(t==1) change(1,b,c);
		else cout<<query(1,b,c)<<endl;
	}
}

习题

学会了线段树最基础的部分,就可以做一些习题了,将在博客的最后提供题解和代码。

  1. JSOI2008 最大数
    线段树维护最大值的模板
  2. loj10123. Balanced Lineup
    RMQ问题,可以试试用线段树做

懒标记

下面请思考,怎么才能做到线段树的区间修改呢?

如果直接把区间遍历一遍,依次修改,复杂度会达到无法接受的 O ( n log ⁡ n ) O(n\log n) O(nlogn)

那么怎么能让区间修改的复杂度变小呢?

我们需要引入一个叫做“懒标记”的东西。

懒标记也叫延迟标记,顾名思义,我们再修改这个区间的时候给这个区间打上一个标记,这样就可以做到区间修改的 O ( log ⁡ n ) O(\log n) O(logn)的时间复杂度。

如图,如果要给 [ 4 , 6 ] [4,6] [4,6]每个数都加上 2 2 2,那么直接再代表着 [ 4 , 6 ] [4,6] [4,6]区间的结点打上 + 2 +2 +2的标记就行了。
在这里插入图片描述

pushdown操作

再想一个问题,在给 [ 4 , 6 ] [4,6] [4,6]区间打上懒标记后,我们如何查询 [ 4 , 5 ] [4,5] [4,5]的值?

如果我们直接查询到 [ 4 , 5 ] [4,5] [4,5]区间上,会发现根本就没有被加上过2。

为什么呢?

因为现在懒标记打在了 [ 4 , 6 ] [4,6] [4,6]区间上。而他的子节点压根没被修改过!

所以我们需要把懒标记向下传递。

这就有了一个操作,叫做pushdown,它可以把懒标记下传。

设想一下,如果我们要把懒标记下传,应该注意什么呢?

首先,要给子节点打上懒标记。

然后,我们要修改子节点上的值。

最后,不要忘记把这个节点的懒标记清空。

pushdown代码

inline void pushudown(int x){
   
	if(tr[x].add){
   
		//如果这个节点上有懒标记
		tr[2*x].add+=tr[x].add,tr[2*x+1].add+=tr[x].add;
		//把这个节点的懒标记给他的两个子节点
		tr[2*x].sum+=tr[x].add*(tr[2*x].r-tr[2*x].l+1);
		tr[2*x+1].sum+=tr[x].add*(tr[2*x+1].r-tr[2*x+1].l+1);
		//分别给它的两个子节点修改
		tr[x].add=0;
		//别忘了清空这个节点的懒标记
	}
}

区间修改

学会了懒标记,应该可以很轻松地写出区间修改的代码了。

区间修改的操作很像区间查询,也是查找能够覆盖住的子区间,然后给它打上懒标记。

区间查询代码

void update(int now,int l,int r,int k){
   
	if(l<=tr[now].l&&r>=tr[now].r){
   
		//如果查到子区间了
		tr[now].sum+=k*(tr[now].r-tr[now].l+1);//先修改这个区间
		tr[now].add+=k;//然后给它打上懒标记
		//注:这里一定要分清顺序,先修改,再标记!
	}
	else{
   
		//如果需要继续向下查询
		pushudown(now);//一定要先把懒标记向下传
		int mid=(tr[now].l+tr[now].r)/2;
		//这里很像区间查询
		if(l<=mid) update(now*2,l,r,k);
		if(r>mid) update(now*2+1,l,r,k);	
		//最后别忘了pushup一下
		pushup(now);
	}
}

例题2:区间修改,区间查询

洛谷P3372

已知一个数列,你需要进行下面两种操作:

  1. 将某区间每一个数加上 k k k
  2. 求出某区间每一个数的和。

题目分析

应用到区间修改,需要注意的一点是,在区间查询时,也需要下传懒标记,这样才能查询到真实的值。

AC代码

#include <bits/stdc++.h>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值