线段树小计+注意事项

考虑一个简单的问题
给定一个数组 a [ 0... n − 1 ] a[0 . . . n-1] a[0...n1],我们要对数组执行这样的操作:

(1)计算从下标 l l l r r r的元素的最小值,其中 0 < = l < = r < = n − 1 0 <= l <= r <= n-1 0<=l<=r<=n1

(2)修改数组指定元素的值 a [ i ] = x a[i] = x a[i]=x,其中 0 < = i < = n − 1 0 <= i <= n-1 0<=i<=n1

一个简单的方案是从 l l l r r r执行循环,计算给定区间的元素之和。更新值的时候,简单地令 a [ i ] = x a[i] = x a[i]=x。第一个操作花费 O ( n ) O(n) O(n)的时间,第二个操作花费 O ( 1 ) O(1) O(1)的时间。

第二个方案是创建另外一个数组来存储从下标 i i i开始的元素的最小值。这样一来,给定区间之和可以用 O ( 1 ) O(1) O(1)的时间计算,但是更新需要花费 O ( n ) O(n) O(n)的时间。这种方法适用于需要大量查询而更新操作较少的场景。

有没有一种方法,能够同时高效的完成区间查询和元素修改两种操作呢,这就是线段树了。

线段树概念

线段树是擅长处理区间的,形如下图的数据结构。线段树是一棵完美二叉树(PerfectBinaiy Tree)(所有的叶子的深度都相同,并且每个节点要么是叶子要么有2个儿子的树 ),树上的每个节点都维护一个区间。根维护的是整个区间,每个节点维护的是父亲的区间二等分后的其中一个子区间。当有 n n n个元素时,对区间的操作可以在 O ( log ⁡ n ) O(\log n) O(logn) 的时间内完成。
图来自白书
而线段树所提供的区间查询的功能取决于节点中存储的数据,如果节点中存储的是区间最小值,那么线段树就能查询区间最小值。如果节点存储区间和,那么线段树提供的就是区间和的查询。我们以求区间最小值(range minimum query RMQ)的线段树为例、

RMQ的构建–以叶子节点为2的整数幂为例

如下图,线段树的每个节点维护对应区间的最小值。在建树时,只需要按从下到上的顺序分别取左右儿子的值中的较小者就可以了。
在这里插入图片描述
代码如下,只需要将数组 a a a中的 n n n个元素依次 u p d a t e update update即可。

//a[i]=x,同时修改与其有关的所有根节点的值
void update(ll k, ll x) {
	k += n - 1;//线段树一共2n-1个节点,后n个节点为数组元素,叶子节点,将k转化为其在树中的位置。
	tree[k] = x;//修改值

	while (k > 0) {//0号节点是root节点
		k = (k - 1) / 2;//对于节点i,2i+1,2i+2是左右孩子,(i-1)/2是父亲

		//这一步决定了线段树的功能,是存储区间的最小值。
		tree[k] = min(tree[2 * k + 1], tree[2 * k + 2]);
	}
}

基于RMQ的更新操作

更新操作也就是上方的update代码,操作过程无异于构建树的过程。比如如果将 a [ 0 ] a[0] a[0]赋值为2
在这里插入图片描述

基于RMQ的查询操作

如果要求 a [ 1 ] . . . a [ 6 ] a[1]...a[6] a[1]...a[6]的最小值,我们只需要求下图中的三个节点的值的最小值即可。
在这里插入图片描述
像这样,即使査询的是一个比较大的区间,由于较靠上的节点对应较大的区间,通过这些区间就可以知道大部分值的最小值,从而只需要访问很少的节点就可以求得最小值。而这个对树的查询操作,需要一个递归的程序不断的访问子层的节点,对子层的节点进行如下判断:

  1. 如果所查询的区间和当前节点对应的区间完全没有交集,那么就返回一个不影响答案的值。比如求区间最小值的话,就返回一个很大的值,求区间和的话,就返回0.
  2. 如果所查询的区间完全包含了当前节点对应的区间,那么就返回当前节点的值。
  3. 以上两种情况都不满足的话,就对两个儿子递归处理,返回两个结果中的较小者。

这里的一个比较重要的问题是,我们如何知道当前节点对应的区间是where to where?用一个struct存储所有中间结点的起始位置、终止位置and 节点值吗?不用如此麻烦,具体看注释

// 求[a, b)的最小值
// k是节点的编号,1, r表示这个节点对应的是[1, r) 区间。
// 在外部调用时,用query(a, b)即可
ll query(ll a, ll b, ll k, ll l = 0, ll r = n) {

	//1st case: 如果所查询的区间和当前节点对应的区间完全没有交集。
	//tips:为什么是大于等于或者小于等于,因为[a,b)和[l,r)都是半开区间b,r都是不能取到的index。
	if (r <= a || l >= b) return inf;

	//2ed case:如果所查询的区间完全包含了当前节点对应的区间,那么就返回当前节点的值。
	if (l >= a && r <= b)return tree[k];

	//3rd case:以上两种情况都不满足的话,就对两个儿子递归处理,返回两个结果中的较小者。
	ll v1 = query(a, b, 2 * k + 1, l, (l + r) / 2);
	ll v2 = query(a, b, 2 * k + 2, (l + r) / 2, r);
	return min(v1, v2);
}

疑问?如果叶子节点不是2的整数幂呢

我们将叶子节点的数目补为2的整数幂,比如对于数组 [ 1 , 2 , 3 ] [1,2,3] [1,2,3],我们将其补位 [ 1 , 2 , 3 , 0 ] [1,2,3,0] [1,2,3,0],也就是多余位用0替代,然后 t r e e [ ] tree[] tree[]数组的大小为 2 n − 1 2n-1 2n1,有一部分中间结点,也可能会变成0,这样做依然是正确的。

void init(ll n_) {
	N = 1;
	//如果叶子节点不是2的整数幂,那么数组不止是2*n-1,需要4N-1,这里方便起见将叶子节点的数目记为2的整数幂
	//因此空节点都用0填补
	while (N < n_) N *= 2;
	for (ll i = 0; i < 2 * N - 1; i++) tree[i] = 0;
}

基于RMQ的复杂度分析

就本文开始描述的那样,无论是查询还是修改操作,都是比较高效的复杂度, 1 − − n 1--n 1n之间的 O ( log ⁡ n ) O(\log n) O(logn)。对于一般二叉树而言。很有可能发生退化,使得复杂度回升到 O ( n ) O(n) O(n),但是线段树不会进行区间的合并,或者增加,删除元素,因此不会出现退化的情况发生。

此外 n n n个元素的线段树的初始化的总的空间复杂度为 O ( n ) O(n) O(n),因为一共只有 2 n − 1 2n-1 2n1个节点。

拓展:基于稀疏表的RMQ

在 RMQ 的其他实现方法中,有一种叫做 Sparse Table 的方法较为常见。对于上述数列构建的 Sparse Table 如下表所示。

在这里插入图片描述
其中 t i , j t_{i,j} ti,j表示的是 a j , a j + 1 , . . . , a j + 2 i a_j,a_{j+1},...,a_{j+2^i} aj,aj+1,...,aj+2i,具体操作不详述,只需要知道他单次查询的效率比基于线段树的RMQ高,但是预处理的时间复杂度和空间复杂度都达到了 O ( n log ⁡ n ) O(n\log n) O(nlogn),而且无法高效的对值进行更新。

Tips

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

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

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

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

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

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值