适合入门的线段树基础

线段树其实不难,如果讲解时能用到生动形象的例子,那么谁都可以快速入门线段树,这里我费劲心血查遍了网上大部分关于线段树的博客,通过自己感悟总结,呕心沥血写下这篇博客~

来年的今天我就可以轻松让我的学弟学妹快速线段树了~哈哈



1.综述

什么是线段树?

线段树是一种二叉搜索树,与区间树相似,它将一个区间划分成一些单元区间,每个单元区间对应线段树中的一个叶结点。

为什么要用到这种数据结构?

请你考虑一下这个问题
有一个数组 arr[10000000],我需要实现下面两个操作
1.区间查询:求下标从L–R区间内的元素的和是多少。
2.单点更新:实现更新某一下标对应元素的值。
以上两个步骤会交叉进行多次。

那么以我们现有的知识,会对上面问题进行这样的操作

  1. 区间查询:用循环计算L–R区间内的元素累加和。时间复杂度O(n)。
  2. 单点更新:只需要对应arr[idx] = val(要修改的值)即可。时间复杂度O(1)

那么有什么办法可以降低区间查询的时间复杂度呢?

思考一下
我们可以新建一个前缀和数组sum[n],来记录arr[0]-arr[n]的和,当然这个操作很简单

这样的话,区间查询操作就变成了sum[R] - sum[L-1]了,表示的是L到R区间内的元素和,看起来是变成了O(1)。

但是这个时候如果我们需要更新某个下标对应的值,其后面所有的前缀和也需要发生改变

我们发现,单点更新时间复杂度又变成了O(n)!!!

看来,鱼和熊掌不可兼得,不能将两者都维持到一个很低的复杂度上。那么,我想知道有没有一种方法,可以将整体的时间复杂度维持到一个比线性更快的水平上呢?
当然,答案就是线段树~
线段树查询和更新操作可以将复杂度降到O(logn),是不是感觉快多了呢,下面就让我好好说一下“线段树”为何这么香!

使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。而未优化的空间复杂度为2N,实际应用时一般还要开4N的数组以免越界,因此有时需要离散化让空间压缩。

2.基本原理

线段树基本思想:二分

我们已经知道了,线段是是一颗二叉搜索树。二叉树我们知道,从根节点开始下面每个节点都会分出来两个儿子节点,也就是说根节点是它下面所有节点的老大,而下面每个节点也是从他分出来所有节点的老大。

线段树的每个节点都管理者一段区间,也就是他下面的儿子,而每个节点又存储着他两个儿子节点的和,这下你是不是明白了呢?如果要查询arr数组一段区间的和,我们可以通过查询构建的线段树,找到管理这段区间的父节点,他存储的值就是我们需要求的和。
线段树的结构应该是这样的
假设我们有一个arr[]数组,从a[1]开始
在这里插入图片描述

图中的二叉树根节点从1开始

观察这棵线段树我们发现,arr数组是按照下标顺序存进这颗树的叶节点的,

根节点管理所有节点所以管理区间是 1-13 ,他存储的是所有叶节点也就是arr数组下标1 - 13的和。

根节点的左儿子管理左半部分的节点所以管理区间是1 - 7,他存储的是数组下标从1 - 7的和

根节点的右儿子管理右半部分的节点,管理区间是8-13,他存储的是下标8-13的和。

每个节点分为左右两个儿子节点,他们把父亲管理的节点分成两半,一人一半分别管理,互不干扰很是和谐。直到叶节点,叶节点管理的左右区间左边界和右边界相等,所以到他为止,他没有儿子了。没办法生了

说道这里你有没有明白建线段树的原理了呢?

2.1构建线段树

根据二叉树的结构特性,若每个父亲节点的编号i,他的左右孩子的编号分别是2i和2i+1

根节点从1开始管理的是数组arr 1-n这个区间的和,有了儿子之后,把这个区间分成两半,左半边给左儿子管,右半边给右儿子管,这样一直分下去,直到分不动了,到达叶子节点,他管理的是一个点的值,也存储的就是arr里这个点下标的值,到这里为止开始向上延伸,因为每个节点存储的都是他两个儿子节点的和,每个节点都递归下去,直到叶子节点,开始返回

const int  maxn = 13;
int arr[maxn];
struct p
{
//l、r表示管理的左右区间边界,mid是区间的中点,w是管理区间的宽度,
//ans是这段区间的综合,flag作为标记用,下面会讲为什么需要标记
   int l,r,mid,w,ans,flag;
}tree[4*maxn];  
//通常情况下arr数组有n个元素时,我们建的树数组会在2n-4n之间,保险起见会开四倍原数据空间       
void push_up(int node)//向上更新,父亲节点存的值是两个儿子节点存的值的和
{
	tree[node].ans = tree[node<<1].ans + tree[node<<1|1].ans;
}
void bulid_tree(int l,int r,int node) //l区间左边界r是右边界,node树到达的这个点
{
	tree[node] = {l,r,(r+l)>>1,r-l+1,0,0};//先初始化一下
	if(l==r)//如果到达了叶子节点,我们赋一下值就可以返回了
	{
		tree[node].ans = arr[l];
		return;
	}
	bulid_tree(l,tree[node].mid,node<<1);//建左儿子树
	bulid_tree(tree[node].mid+1,r,node<<1|1);//建右儿子树
	push_up(node);//每建一对左右儿子树就可以把值向上延伸,
}
int main()
{
	for (int i=1;i<=13;i++)
		arr[i] = i;
	bulid_tree(1,13,1);
	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';
	cout<<endl;
	return 0;
}

建树结果为

91 28 63 10 18 27 36 3 7 11 7 17 10 23 13 1 2 3 4 5 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0

一般情况下空节点我们会用0进行填充,我早在初始化的时候就已经填充了
这里大家可以查一下不用结构体数组,如何写线段树,思考一下结构体有什么好处呢?

在这里插入图片描述
我没有算完,但是已经可以观察出这个树的样子了,你学会了吗?

3.实现和基本操作

现在,我们已经把线段树构建好了,就存放在一个结构体数组中
那么,我们现在想要实现一个操作——单点更新,我要把arr[idx]的值赋为val

3.1单点更新

假设我们要把arr[5]=7,在这颗线段树中,每层只有一个节点包含[5]这个点,我们可以通过判断,每次都选择包含[5]这个点的分支,最后我们肯定可以走到[5]这个叶节点

修改叶节点的值之后,在回溯的过程中,把修改的值向上延伸,也就是上面的push_up操作,就可以把更新过的值传递上去

搜索路径是先到底,底部更新,然后返回时每层更新。

通过这个思路,我们写出来代码

void update(int idx,int val,int node)  //idx为下标,val为修改后的值,node是当前节点
{
	//如果当前节点管理的左边界和右边界相等,说明到达了目的叶子节点
	if(tree[node].l==tree[node].r)
	{
		tree[node].ans = val;
		arr[tree[node].l] = val;
		return;
	}
	//这一步判断只进入包含目标的节点
	if(idx<=tree[node].mid)  update(idx,val,node<<1);
	else                     update(idx,val,node<<1|1);
	//回溯向上更新,因为一直递归到叶子节点,才会更新,然后回溯的时候会从下面一层一层的更新上来
	push_up(node);
}

我利用之前建好的树,又添加了如下几行

	update(5,7,1);  //把下标5-7这一段的值改为 1
	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';

打印结果为

93 30 63 10 20 27 36 3 7 13 7 17 10 23 13 1 2 3 4 7 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0

可以发现,根节点刚好比原来的根节点多了2,代码运行时正确的

3.2区间查询

区间查询,查询什么呢?

查询原数组这一段区间所有元素的和,当然,我们知道,我们已经把这个数组建立在一个线段树中,而这棵线段树每一个节点又存储着一段区间的和,如果我们要查询的区间和树种一个节点管理的区间相匹配,那么我们可以直接把这个节点的值当做查询的结果

但是,如果这个节点管理的区间只是需要查询区间的一部分怎么办呢?
思考一下,线段树每个节点往下延伸的时候都是把一段区间从中间分成了两份,左半边给了左儿子,如果要左儿子管理的区间包含于要查询的区间,那么剩下的一段区间肯定是右儿子管理的(我们要查询的区间是连续的),先把左儿子管理区间的值存起来,那么我们把剩下需要查询的区间从右儿子开始,如此重复~

根据这个思想,我们写一下代码

int  query (int l,int r,int node)
{
	//如果我们需要查询的区间里包含当前节点管理的区间,
	//那么直接返回这个节点存储的值即可
	if(l<=tree[node].l&&r>=tree[node].r)
		return tree[node].ans;
	int sum =0;
	//这里需要思考一下,进入左右区间的条件是互不影响的,
	if(l<=tree[node].mid)    sum+=query(l,r,node<<1);
	if(r>tree[node].mid)  	 sum+=query(l,r,node<<1|1);
	return sum;
}

我们练习上面的两段代码在加上测试的主函数

#include <iostream>
#include <cmath>
using namespace std;
typedef long long ll;
const int INF=0x3f3f3f3f;
const int  maxn = 13;
int arr[maxn];
struct p
{
int l,r,mid,w,ans,flag;
}tree[4*maxn];  
//通常情况下arr数组有n个元素时,我们建的树数组会在2n-4n之间,保险起见会开四倍原数据空间       
void push_up(int node)//向上更新,父亲节点存的值是两个儿子节点存的值的和
{
	tree[node].ans = tree[node<<1].ans + tree[node<<1|1].ans;
}
void bulid_tree(int l,int r,int node) //l区间左边界r是右边界,node树到达的这个点
{
	tree[node] = {l,r,(r+l)>>1,r-l+1,0,0};//先初始化一下
	if(l==r)//如果到达了叶子节点,我们赋一下值就可以返回了
	{
		tree[node].ans = arr[l];
		return;
	}
	bulid_tree(l,tree[node].mid,node<<1);//建左儿子树
	bulid_tree(tree[node].mid+1,r,node<<1|1);//建右儿子树
	push_up(node);//每建一对左右儿子树就可以把值向上延伸,
}
void update(int idx,int val,int node)  //idx为下标,val为修改后的值,node是当前节点
{
	//如果当前节点管理的左边界和右边界相等,说明到达了目的叶子节点
	if(tree[node].l==tree[node].r)
	{
		tree[node].ans = val;
		arr[tree[node].l] = val;
		return;
	}
	//这一步判断只进入包含目标的节点
	if(idx<=tree[node].mid)  update(idx,val,node<<1);
	else                     update(idx,val,node<<1|1);
	//回溯向上更新,因为一直递归到叶子节点,才会更新,然后回溯的时候会从下面一层一层的更新上来
	push_up(node);
}
int  query (int l,int r,int node)
{
	//如果我们需要查询的区间里包含当前节点管理的区间,
	//那么直接返回这个节点存储的值即可
	if(l<=tree[node].l&&r>=tree[node].r)
		return tree[node].ans;
	int sum =0;
	//这里需要思考一下,进入左右区间的条件是互不影响的,
	if(l<=tree[node].mid)    sum+=query(l,r,node<<1);
	if(r>tree[node].mid)  	 sum+=query(l,r,node<<1|1);
	return sum;
}
int main()
{
	for (int i=1;i<=13;i++)
		arr[i] = i;
	
	bulid_tree(1,13,1);//建树

	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';//打印树
	cout<<endl;
	
	cout<<query(3,6,1)<<endl;//查询区间3-6元素的和

	update(5,7,1);//单点更新arr[5] = 7

	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';//打印树
	cout<<endl;

	cout<<query(3,6,1)<<endl;//查询区间3-6
	return 0;
}

输出结果为

91 28 63 10 18 27 36 3 7 11 7 17 10 23 13 1 2 3 4 5 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0
18
93 30 63 10 20 27 36 3 7 13 7 17 10 23 13 1 2 3 4 7 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0
20

好了,到现在为止,我们已经完美解决了用线段树存储一个数组单点修改和区间查询的操作,那么问题又来了:

如果我们要修改原来数组一段区间的值怎么办?

3.3区间修改

很显然,单点修改是区间修改的一个子问题,即区间长度为1时进行的区间修改操作。
但是如果要进行区间操作还使用单点操作的代码,不是不可行,时间复杂度会非常高,最高可以达到(n*logn)!

所以我们引入一个“lazy tag”(懒标记)标记。

我习惯用flag进行标记,这个因人而异~

一般称这个标记为懒惰标记,因为这个标记你用到的时候他才会动,不用他就不动~

思考一下,在进行区间标记时,如果当前这个节点管理的区间是我们需要进行修改的区间的子区间,那么我们是否可以只修改这一个节点,假设这个节点管理的区间长度是5,那么我们可以直接把这个节点存的值改为5*val(val为改变后的值),然后对此节点进行标记,说明这个节点已经改过了

当我们需要查询这个节点包含的子区间的值时,返回的这个节点的值就是修改后的值了

你是否发现了问题?
如果我们需要查询修改过的这个节点下下面的节点,因为只修改了这一个节点,下面没有动,所以下面节点的值还是原来的值,是这样的吧

这时我们就需要懒标记动一动了!

可以知道每次进行区间查询的时候,都是从根节点出发,如果我们要进入的节点经过判断后发现被标记过,那么我们可以把这个标记向下延伸下去,修改下面这两个子节点的值,这样来,我么你需要查询到哪,标记就走到哪!

是不是特别懒呢~这样的时间复杂度就降下来了

先看一下代码

void push_down(int node)
{
	if(tree[node].flag)//如果这个节点被标记了,那么开始向下延伸
	{
		tree[node<<1].flag   = tree[node].flag;
		tree[node<<1|1].flag = tree[node].flag;
		tree[node<<1].ans 	 = (tree[node].w - tree[node].mid)*tree[node].flag;
		tree[node<<1].ans 	 = tree[node].mid * tree[node].flag;
	}
}
void update(int l,int r,int val,int node)//l-r为需要修改的区间,val是改变后的值node是当前节点
{
	//如果当前节点管理的区间是需要修改区间的子区间,那么我们直接修改当前节点,并做标记,不在继续向下修改
	if(l<=tree[node].l&&r>=tree[node].r)
	{
		tree[node].ans  = tree[node].w * val;//修改后的值为这段区间的和
		tree[node].flag = val;
		return;
	}
	push_down(node);//标记向下延伸,这里有可能之前已经有标记了,所以需要记录
	if(l<=tree[node].mid)   update(l,r,val,node<<1);
	if(r>tree[node].mid) 					update(l,r,val,node<<1|1);
	push_up(node);
}

好,我直接把所有代码贴出来,方便大家测试

#include <iostream>
#include <cmath>
using namespace std;
typedef long long ll;
const int INF=0x3f3f3f3f;
const int  maxn = 13;
int arr[maxn];
struct p
{
int l,r,mid,w,ans,flag;
}tree[4*maxn];  
//通常情况下arr数组有n个元素时,我们建的树数组会在2n-4n之间,保险起见会开四倍原数据空间       
void push_up(int node)//向上更新,父亲节点存的值是两个儿子节点存的值的和
{
	tree[node].ans = tree[node<<1].ans + tree[node<<1|1].ans;
}
void push_down(int node)
{
	if(tree[node].flag)//如果这个节点被标记了,那么开始向下延伸
	{
		tree[node<<1].flag   = tree[node].flag;
		tree[node<<1|1].flag = tree[node].flag;
		tree[node<<1].ans 	 = (tree[node].w - tree[node].mid)*tree[node].flag;
		tree[node<<1|1].ans  = tree[node].mid * tree[node].flag;
	}
}
void bulid_tree(int l,int r,int node) //l区间左边界r是右边界,node树到达的这个点
{
	tree[node] = {l,r,(r+l)>>1,r-l+1,0,0};//先初始化一下
	if(l==r)//如果到达了叶子节点,我们赋一下值就可以返回了
	{
		tree[node].ans = arr[l];
		return;
	}
	bulid_tree(l,tree[node].mid,node<<1);//建左儿子树
	bulid_tree(tree[node].mid+1,r,node<<1|1);//建右儿子树
	push_up(node);//每建一对左右儿子树就可以把值向上延伸,
}
void update(int idx,int val,int node)  //idx为下标,val为修改后的值,node是当前节点
{
	//如果当前节点管理的左边界和右边界相等,说明到达了目的叶子节点
	if(tree[node].l==tree[node].r)
	{
		tree[node].ans    = val;
		arr[tree[node].l] = val;
		return;
	}
	//这一步判断只进入包含目标的节点
	if(idx<=tree[node].mid)  update(idx,val,node<<1);
	else                     update(idx,val,node<<1|1);
	//回溯向上更新,因为一直递归到叶子节点,才会更新,然后回溯的时候会从下面一层一层的更新上来
	push_up(node);
}
void update(int l,int r,int val,int node)//l-r为需要修改的区间,val是改变后的值node是当前节点
{
	//如果当前节点管理的区间是需要修改区间的子区间,那么我们直接修改当前节点,并做标记,不在继续向下修改
	if(l<=tree[node].l&&r>=tree[node].r)
	{
		tree[node].ans  = tree[node].w * val;//修改后的值为这段区间的和
		tree[node].flag = val;
		return;
	}
	push_down(node);//标记向下延伸,这里有可能之前已经有标记了,所以需要记录
	if(l<=tree[node].mid)   update(l,r,val,node<<1);
	if(r>tree[node].mid) 	update(l,r,val,node<<1|1);
	push_up(node); //向上延伸
}
int  query (int l,int r,int node)
{
	//如果我们需要查询的区间里包含当前节点管理的区间,
	//那么直接返回这个节点存储的值即可
	if(l<=tree[node].l&&r>=tree[node].r)
		return tree[node].ans;
	int sum =0;
	push_down(node);//标记向下延伸
	//这里需要思考一下,进入左右区间的条件是互不影响的,
	if(l<=tree[node].mid)    sum+=query(l,r,node<<1);
	if(r>tree[node].mid)  	 sum+=query(l,r,node<<1|1);
	return sum;
}
int main()
{
	for (int i=1;i<=13;i++)
		arr[i] = i;
	bulid_tree(1,13,1);//建立线段树

	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';//打印线段树
	cout<<endl;
	
	cout<<query(2,5,1)<<endl;  //查询区间2 - 5

	update(2,5,1,1);//修改区间2 - 5 的值为1

	for(int i=1;i<=4*13;i++)
	cout<<tree[i].ans<<' ';//再次打印线段树
	cout<<endl;

	cout<<query(2,5,1)<<endl;//再次查询区间2 - 5

	return 0;
}

运行结果是

91 28 63 10 18 27 36 3 7 11 7 17 10 23 13 1 2 3 4 5 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0 0
14
81 18 63 4 14 27 36 2 2 7 7 17 10 23 13 1 1 3 4 1 6 0 0 8 9 0 0 11 12 0 0 0 0 0 0 0 0 0 0 0 
0 0 0 0 0 0 0 0 0 0 0 0
4

我没有用很多的示例图演示,这里建议大家还是动手画一画,自己写一个数组,简画出来线段树结构,然后进行各种操作,配合代码,很快就能理解了!

以上方法是用递归实现的线段树,其实还可以使用循环实现线段树操作,个人喜欢多用一些递归,因为递归的思想在算法中是很重要的一部分。

4.总结

1、 线段树是二叉树,且必定是平衡二叉树,但不一定是完全二叉树。

2、 对于区间[a,b],令mid=(a+b)/2,则其左子树为[a,mid],右子树为[mid+1,b],当a==b时,该区间为线段树的叶子,无需继续往下划分。

3、 线段树虽然不是完全二叉树,但是可以用完全二叉树的方式去构造并存储它,只是最后一层可能存在某些叶子与叶子之间出现“空叶子”,这个无需理会,同样给空叶子按顺序编号,在遍历线段树时当判断到a==b时就认为到了叶子,“空叶子”永远也不会遍历到。

4、 之所以要用完全二叉树的方式去存储线段树,是为了提高在插入线段和搜索时的效率。用p2,p2+1的索引方式检索p的左右子树要比指针快得多。

5、线段树的精髓是,能不往下搜索,就不要往下搜索,尽可能利用子树的根的信息去获取整棵子树的信息。如果在插入线段或检索特征值时,每次都非要搜索到叶子,还不如直接建一棵普通树更来得方便。

当我们要处理的数据十分庞大时,需要运用离散化的操作,这里不做赘述

5.模板题

HDU1166敌兵布阵
POJ3468A Simple Problem with Integers
POJ2528Mayor’s posters

至于题解的话,上面这个模板就是题解啦嘿嘿

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
线段树是一种用来解决区间查询问题的数据结构。在CSND的线段树入门指南中,介绍了线段树的基本原理和实现方法,并且提供了进阶内容来扩展应用。 线段树的基本原理是将待查询的区间划分为若干个较小的子区间,并将每个子区间的信息预处理保存在树节点中。通过在树上的查询和更新操作,可以有效地解决区间最值、区间修改、区间合并等问题。 在入门阶段,CSND的指南首先介绍了线段树的基本结构和构建方法。通过递归思想和分治策略,可以将一个区间划分为两个子区间,并依次构建子区间的线段树,最终构建出整个区间的线段树。通过优化构建过程,如使用线性时间复杂度的构建方法,可以提高线段树的构建效率。 在进阶阶段,CSND的指南介绍了线段树的应用扩展。例如,可以使用线段树解决静态区间最值查询问题,即在一个不可修改的区间中快速计算最大或最小值。另外,还可以使用线段树解决动态区间修改问题,即可以在区间内进行元素的插入、删除、更新等操作,并支持快速的查询操作。 此外,CSND的指南还介绍了线段树的一些常见优化技巧,如懒惰标记、矩阵树状数组等。这些优化方法可以进一步提高线段树的查询和更新效率,适用于一些特殊的应用场景。 总的来说,通过CSND的线段树入门进阶指南,我们可以全面了解线段树的基本原理和常见应用,并学会使用线段树解决各种区间查询问题。这对于算法竞赛、数据结构设计等领域都具有重要的实用价值。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值