线段树建立与维护

目录

简述线段树:

一.基础线段树操作

1.线段树建立

2.任意区间访问

3.单点修改(修改目标区间内一个元素的值)

二.线段树进阶操作1

1.pushdown操作

2.区间修改

 3.区间查询(修改后)

三.线段树进阶操作2

未完待续


闲话:

在学完基础算法和基本数据结构后算是真正的进入竞赛范围了吧。

最直观的感觉就是现在接触的算法和数据结构更加复杂和难以理解。虽然基础算法和数据结构还并没有非常熟练的使用和理解,但也必须先硬着头皮往前走。

简述线段树:

一种叶子节点是基本数据,其他节点为为其左右孩子的运算结果(如和积商等)的数据结构。

比如要求一段数组在某区间上的和。最简单的方法就是循环遍历整个区间得到和。但是这种方时间复杂度相对高,为O(n),如果数组区间很大,很容易超时。

但我们还学过一种算法,就是前缀和。这个算法通过O(n)的预处理,可以O(1)的得到数组任意区间上的和。如果只是得到区间和的话,线段树的O(logn)复杂度似乎还比不上。但是,如果我们需要频繁修改区间内某个元素的值,前缀和则需要重新处理一遍数组,才能查询,非常麻烦。但是如果我们建立了一颗线段树,我们则可以O(logn)的操作。

一.基础线段树操作

1.线段树建立

如图数组就可以构建这样的一颗线段树,线段树上每个非叶子节点的值为左右孩子的和。

常用一个索引为节点编号的数组来存储每个节点的值。

比如我们有一个编号为idx的节点,根据二叉树基本知识,可以得到他的左右孩子的编号分别为idx*2+1,和idx*2+2。

由此编号为索引可以把每一个节点都存储在一个数组内。因为这个数组的元素比较复杂,常用结构体表示——因为线段树经常会进行加乘操作,所得数据会非常大。常用long long声明变量

struct node {
	ll sum;
	ll l;//记录该节点表示区间的左坐标
	ll r;//右坐标
	ll lz_tag = 0;
}tree[1000001];

因为线段树的节点值都是从叶子节点向上得到的。我们又得到了数组的索引对应条件。而由图和逻辑可以看出,从根节点往下,每个子节点的值都是其双亲节点对应区间的一半(因为我们从下往上构造,每个双亲节点都是两个子节点的和,所以反过来每个子节点都是双亲节点对应区间和的一半)。所以由二叉树的后序遍历,我们可以得到整棵线段树。

void  segtree_creat(ll l, ll r, ll idx) {
	tree[idx].l = l;
	tree[idx].r = r;
	if (l == r) {//到达叶子节点,填充目标数组的每个元素值
		tree[idx].sum = arr[l];
		return;
	}
	int mid = (l + r) >> 1;//这里乘除建议都使用位运算加快速度
	ll left = idx*2 + 1;//得到索引填充数组
	ll right = idx * 2 + 2;
	segtree_creat(l, mid, left);
	segtree_creat(mid + 1, r, right);
//简述这里的递归过程,就是每次都分原区间为两半知道l==r不可分了即到达最底层的叶子节点
	tree[idx].sum = tree[left].sum + tree[right].sum;
	//因为递归的递已经结束,从这里开始归的同时填充每个双亲节点的值
	//双亲节点的值就是两个子节点的和
	return;
}

这步操作我们就建立好了一颗线段树,很明显,每次将区间分割一半,知道只剩一个元素开始回归的时间复杂度为O(logn)。

接下来我们就可以对其进行操作了。

2.任意区间访问

 还是这个例子。

如果我们要求[0-2]的和,很明显,线段树的第二个节点就是我们需要的值,也就是我们的节点坐标被完全包括在了目标区间[0-2]内,那么我们可以直接返回这个节点的值,这很好理解。

但是如果我们要求[0-3]区间的和呢?我们可以看到[0-3]被分为两个部分了, [0-2]的[0-2] 和 [3-5]的[3-3]。是简单的1号节点代表的[0-2]区间的值加上2号节点吗。这里就是线段树查询思想的难点了。

首先,我们可以直接得到[0-2]的值,也就是1号节点的值。但是[3-3]怎么得到?我们可以用搜索的思想来理解——很明显[3-3]在根节点[0-5]的右侧区域[3-5],我们就向[3-5]搜索[3-3]。因为每个双亲节点都以其区间的中间为界限分为左右子节点,所以我们又要判断[3-3]在[3-5]的哪一半区间,很明显在[3-5]的左边区间[3-4]所以我们就向2号节点[3-5]的左边区间[3-4]搜索[3-3]。到了5号节点[3-4]我们再次判断目标区间在5号节点的那一边,很明显还是左边,所以我们向5号节点[3-4]的左孩子[3-3]搜索终于我们得到了[3-3]。

注意,接下来是线段树思想的核心也是难点:

这里的“得到”的条件,不是刚好搜索到这个点,如果我们的目标区间里有多个元素呢?这里的条件是我们节点代表的区间刚好被完全包括在目标区间里。

思考一下,为什么我们把[0-3]区间分成了[0-2]

计算机怎么会知道我们要这么分,我分成[0-1]+[2-3]不也能得到结果吗?

这里的答案是,计算机不知道要这么分。但是如果目标区间跨了根节点的中点,比如像目标区间[0-3]和当前节点[0-5]这样目标区间跨了节点区间的两边。计算机不知道要怎么分解区间,那么当目标区间跨了节点区间的两边时,向两边搜索,如果搜索的过程发现目标区间在当前节点区间的中点左边,我们就向当前节点区间的左半边区间搜索,反之就向右半边搜索。

只要搜索到了一个节点的区间被完全包括在了目标区间里,如搜索到了[3-4]被完全包括在[3-5]那么我们就返回这个节点对应区间的和就行。

整个搜索过程得到目标区间的值的过程就好像拼图。我搜索到了一个被完全包括在目标区间的节点的值就把他拼到总和sum上,如果又搜索到了一个被完全包括在目标区间的节点的值,再将他拼到sum上,直到整个目标区间被这样的子区间填满那么搜索就结束了。此时sum就是目标区间的和。

向[0-5]的数组里求解[2-3]的值

刚开始目标区间[2-3]在[0-5]的两侧,那么我们向两边搜索,左边为黄色箭头,右边为蓝色箭头。

左边:

1.先搜索到了1 号[0-2]节点,再判断出此时目标区间[2-3]在[0-2]的右半边,所以再向1号节点[0-2]的右半边搜索。到达4号节点[2-2]。再次判断,发现4号节点[2-2]完全包括在目标区间[2-3]里。所以返回这个节点的值。左边搜索结束得到一个目标子区间的值。

右边:

先搜索到2号节点[3-5]区间,此时判断出目标区间[2-3]在[3-5]的左半边,所以向左半边[3-4]搜索。到达[3-4]判断出此时目标区间[2-3]在[3-4]的左半边,再次向[3-4]的左半边搜索,到达11号节点[3-3]判断出此时11号节点的区间被完全包含在目标区间[2-3]里返回。右边的搜索结束。

其实实际上的题目数据量很大,搜索分支会非常多,不可能一个一个分析,但只要理解的这个思想就能写成代码。

 (无lazy标记版)

void  segtree_search(ll l, ll r, ll idx) {//查找线段树中l~r的和

	if (l <= tree[idx].l && r >= tree[idx].r)//当前节点的区间完全在目标区间内,搜索结束
	{
		s += (tree[idx].sum);
		return;
	}
	
	ll left = idx * 2 + 1;
	ll right = idx * 2 + 2;
	ll mid = (tree[idx].l + tree[idx].r) >> 1;

	if (r <= mid) segtree_search(l, r, left);//目标区域整个在左,搜左
	else if (l >= mid + 1)
		segtree_search(l, r, right);//目标区域整个在右边,搜有
	else {
		segtree_search(l, r, left);
		segtree_search(l, r, right);
	}
	//目标区域跨了两边,总和为两边搜索到的值

}

 还可以写成这样,思路是一样的,不过值由函数返回。

int segtree_search(int l, int r, int idx) {//查找线段树中l~r的和
	
	if (l <= tree[idx].l && r >= tree[idx].r)//当前查找的区间在目标区间内,搜索结束
		return tree[idx].sum;
	
	
	int left = idx * 2 + 1;
	int right = idx * 2 + 2;
	int sum = 0;
	int mid = (tree[idx].l + tree[idx].r) >> 1;
	if (r <= mid)sum += segtree_search(l, r, left);//目标区域整个在左,搜左
	else if (l >= mid + 1)sum += segtree_search(l, r, right);//目标区域整个在右边,搜有
	else sum += (segtree_search(l, r, left) + segtree_search(l, r, right));
	//目标区域跨了两边,总和为两边搜索到的值
	return sum;
}

3.单点修改(修改目标区间内一个元素的值)

从我们创建线段树的思路来看,叶子节点为我们整个数组的每个元素,我们要修改单点只需要在遍历线段树到所要修改的叶子节点时返回修改值,并将搜索路径上的所有节点都加上修改值即可

void  segtree_point(ll pos, ll k, ll idx) {//

	tree[idx].sum += (k-arr[pos]);//每个路径节点增加变化量
	if (tree[idx].l == tree[idx].r && pos == tree[idx].l)//到达叶子节点,且为修改点
		return;

	ll left = idx * 2 + 1;
	ll right = idx * 2 + 2;

	ll mid = (tree[idx].l + tree[idx].r) >> 1;
	if (pos >= mid + 1) {//目标点在右半边,搜索,右半边
		segtree_point(pos, k, right);
	}
	else if (pos <= mid) {//向左半边搜索
		segtree_point(pos, k, left);
	}
}

二.线段树进阶操作1

前面的简单操作让我们可以很快的得到某一区间的值并且可以很快的改变某些点的值。

但是如果我们需要对整个区间进行操作该如何实现?比如让原数组的某一区间每个元素增加某个值?你可能会想,还是按搜索的思想,沿路径搜索到了被包含的区间为止都加上当前区间该增加的值,但是,修改的区间可能和查询的区间不同,比如因为如果对于1~4这个区间,你把1~3区间+1,相当于把节点1~2和3标记,但是如果你查询2~4时,你会发现你加的时没有标记的2节点和没有标记的3~4节点加上去。这样就只能得到错误的结果。

所以为了解决这个问题,线段树有个很神奇也很巧妙的概念叫pushdown操作(lazy标记)

1.pushdown操作

这个操作比较不容易理解,简述一下他的作用:

我们在对区间进行修改的时候,当然是要从区间包含叶子节点的开始把各自的路径都修改一遍最后pushup到根节点。这里的pushup相对好理解,就是从线段树的最下方叶子节点不断地更新双亲节点的值等于更新后的子节点的和。但是怎么可能每个点都沿着其路径修改一遍,这样的复杂度就变成O(nlogn)了(有n个节点,每个节点沿路修改的复杂度为O(logn))。所以我们引出pushdown操作,他的思想也就是,我们要修改一个区间内的所有元素,不需要一次性全操作完,我只要给他打上一个lazy标记,当我查询的时候路过这个节点,发现这个节点有lazy,说明我要对这个节点进行修改,此时我就修改这个节点的值,并且把标记下传给两个子节点。当下次查询到有标记的子节点时顺便修改该节点的值。这个修改过程是在查询的过程中以O(1)复杂度顺带完成的所以不影响修改区间各个元素后查询时的时间复杂度。

void push_down(ll idx) {
	if (tree[idx].lazy != 0) {
		ll tag = tree[idx].lazy;
		ll left = idx * 2 + 1;
		ll right = idx * 2 + 2;

		tree[idx].lazy = 0;//父标记归0
		tree[left].lazy += tag;//孩子们标记lz
		tree[right].lazy += tag;
		tree[left].sum += tag * (tree[left].r - tree[left].l + 1);
		tree[right].sum +=  tag * (tree[right].r - tree[right].l + 1);//更新修改后子节点 的值
	}
}

2.区间修改

 [0-5]的数组里修改[2-4]的值

像查询那样我们先判断目标区间和当前节点的关系,向对应方向搜索,当我们搜索到了[2-2],因为这个区间只有一个元素2,将其区间内每个元素加上k的话该节点的值变化为

tree[idx].sum += k * (tree[idx].r - tree[idx].l + 1)

其中括号内为该区间的长度,也就是1  修改完该节点的值后打上lazy标记,表示他的子节点是需要修改的,但是我只修改了这一个节点。同时该范围搜索结束开始返回,因为这一个节点更新了,返回的途中还要沿路更新路上的节点。也就是pushup操作,双亲节点的值更新为子节点的和。

void push_up(ll idx) {
	ll left = (idx * 2) + 1;
	ll right = (idx * 2) + 2;
	tree[idx].sum = tree[left].sum + tree[right].sum;
}

而目前为止其实只改变了搜索到区间的上半部分,以下并没有修改,但我们打上了一个lazy标记,当查询的时候发现这个标记,就更新这个节点,并且再次向下传递。

void segtree_add(ll l, ll r, ll idx, ll k) {
	if (l <= tree[idx].l && r >= tree[idx].r)//当前查找的区间在目标区间内
	{
		tree[idx].sum += k * (tree[idx].r - tree[idx].l + 1);
		tree[idx].lazy += k;
		return;
	}
	push_down(idx);//更新子节点
	ll left = idx * 2 + 1;
	ll right = idx * 2 + 2;
	ll mid = (tree[idx].r + tree[idx].l) >> 1;
	if (r <= mid)
		segtree_add(l, r, left, k);//目标区域整个在左,搜左
	else if (l >= mid + 1)
		segtree_add(l, r, right, k);//目标区域整个在右边,搜有
	else {//目标区域跨了两边,总和为两边搜索到的值
		segtree_add(l, r, left, k);
		segtree_add(l, r, right, k);
	}
	push_up(idx);//更新父节点
	return;
}

 3.区间查询(修改后)

因为将区间修改后,有部分需要更新值的节点没有更新,而是打上了标记,所以在修改之后,我们查询时要同样使用pushdown操作

①是为判断查询时该节点有无标记

②是为了将标记传递下去。

并且pushdown更新完节点之后pushup更新双亲节点。(其实可以省略,因为查询时判断标记是从上至下修改,双亲节点先更新再更新子节点)

void  segtree_search(ll l, ll r, ll idx) {//查找线段树中l~r的和

	if (l <= tree[idx].l && r >= tree[idx].r)//当前查找的区间在目标区间内,搜索结束
	{
		s += (tree[idx].sum);
		return;
	}

	push_down(idx);
	ll left = idx * 2 + 1;
	ll right = idx * 2 + 2;

	ll mid = (tree[idx].l + tree[idx].r) >> 1;
	if (r <= mid) segtree_search(l, r, left);//目标区域整个在左,搜左
	else if (l >= mid + 1)
		segtree_search(l, r, right);//目标区域整个在右边,搜有
	else {
		segtree_search(l, r, left);
		segtree_search(l, r, right);
	}
	//目标区域跨了两边,总和为两边搜索到的值

}

基本操作已经结束

P3372 【模板】线段树 1 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3372

三.线段树进阶操作2

未完待续

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值