线段树详解

致歉:嗯... 由于数学竞赛原因,本人很久没有更新了,望洛谷的粉丝朋友们多多谅解。这一期就不做漫画了,我们出个博文讲讲线段树。感谢粉丝们对我长期以往的支持与鼓励,谢谢!

OK,我们言归正传,来讲一讲线段树吧!

首先,看一道例题,洛谷的 P3372,【线段树1】。主要看一下精华部分。

如题,已知一个数列,你需要维护下面两种操作:

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

题目要求我们维护这两种操作。我们首先可以用暴力的思路解决。

暴力解法:

      解决操作1,很简单,开一个数组,对于给出的区间,for 一下,直接加上 k 就完事了。              解决操作2,更简单,要么暴力累加,要么来个前缀和,稍微优化一下。

所以,用暴力解决很简单,下面看一下代码。

//P3372 【模板】线段树1  暴力解法
#include <iostream>
#include <cstdio>
using namespace std;
const int MAXN = 1e5 + 5;
int n, m;
int number[MAXN];

int main () {
	ios::sync_with_stdio(false);
	cin.tie(0); cout.tie(0); //以上两行是关闭同步,让速度更快,大家要是不知道,不用没关系

	cin >> n >> m;
	for (int i = 1;i <= n; ++i) 
		cin >> number[i];
	for (int i = 1;i <= m; ++i) {
		int opt; cin >> opt; //这里的 opt 指的是操作前的提示符 (1, 2)
		if (opt == 1) {
			int x, y, k; cin >> x >> y >> k;
			for (int j = x;j <= y; ++j) {
				number[j] += k; //实现1操作
			}
		}
		else if (opt == 2) {
			int x, y; cin >> x >> y;
			int sum = 0;
			for (int j = x;j <= y; ++j) {
				sum += number[j]; //实现2操作
			}
			cout << sum << '\n'; //使用关闭同步,就不能用 endl 了
		}
	}
	return 0;
}

洛谷测评机:您老真是勇气可嘉 (回去挨打吧你)

TLE 的原因就不用说了。 O(n^2) 的时间复杂度,配上 1e5 的数据,100% 超时。 

重点来了,线段树的基本想法:

        我们刚刚已经公开处刑了这位用暴力求解的孩子,没错,爆时间了。那有没有一种方法,能够在短时间内维护这两种操作?有,线段树!

        线段树本身不难理解。正所谓 “线段树”,它是一颗树。而树上的节点呢,都是一段区间。就以这道题为例,构建一个“线段树”。我们假设原数组num有八个格,数值分别为[5, 9, 4, 1, 8, 8, 8, 8] 。 观察上面的树。图中绿色的中括号,是它的节点所代表的区间。图中节点上的数字,就是它所代表的的区间的和。这么说我怕有些人会看不懂,那么举个例子。看到图中左上角的节点,它节点上写的数字是19,这个19 表达的就是从数组 num[1]  到  num[4] 的和 (5 + 9 + 4 + 1 = 19)。

线段树的应用:

我们现在清楚了线段树的每个部分都在表达什么,进一步,我们去探究线段树如何去应用在这道题目上。

首先看第 2 种操作,注意是第二种 :比如说我们想知道 [1, 5] 这个区间的和,那我们无需用 for 循环一遍一遍地做,直接把代表区间 [1, 4] 和 [5] 的节点加起来,结果就是 19 + 8 = 27 。这样,就可以把时间复杂度直接减到 O (log~n),岂不美哉!

接着看第 1 种操作:看到上图。比如,我们想把 [1, 4] 这个区间的数值都加上 1,我们先把图中的 [1], [2], [3], [4] 都加上1,紧接着,[1], [2], [3], [4] 再分别把自己的父节点加上1:[1, 2] 加上2 ;    [3 ,4] 加上2 ;然后,[1, 4] 加上4,[1, 8] 加上4。这就完整地完成了一次 1 操作。大家如果不太理解,可以联系线段树的基本想法部分手绘理解一下。

重头戏——线段树的实现:


我们首先看到操作 2 的实现。

         注意是操作 2!我们要实现的是一个区间求和操作。但是,我们在线段树里,就可以理解为一个“查询”操作。为啥呢?还是上文那个例子,我们要求区间 [1, 5] 的和。就要先知道 [1, 5] 是由 [1, 4] 和 [5] 组合而成的,才能把两个区间加起来得到答案。那,怎么知道 [1, 5] 是谁和谁组合而成的呢?请看下图。

 图片中的字有些小,请各位放大使用。上图所示,便是操作 2 的流程。我们注意到,图中有两种行走方式:向左下走和向右下走。那么,我们为了方便说明,就定义往左下走,就是去往 left_son ,往右下走,就是去往 right_son 。我们宏定义一下:

#define mid ( (l + r) / 2 )
#define left_son u * 2, l, mid
#define right_son u * 2 + 1, mid + 1, r

有些同学可能会抱怨:唉,又开始看不懂了。没关系,这段代码现在看不懂没事,待会放到代码里,你就知道起什么作用了。 


我们再看到操作 1 的实现。

        操作 1 放到后面讲可能好一点,并不是它难。我们实现的是修改操作。根据上文“线段树的应用”,我们得知,操作 1 其实就像条链子一样,一直不断往上加。如果不太理解,我们看图。

        同上文,假设我们要把区间 [1, 4] 加上 1。

字比较小,咱还是放大食用一下。


好,我们现在就到了代码实现的部分了。

我们先实现两个函数,叫做 push_down 和 push_up 。

push_down:这个函数可以理解为是向左儿子和右儿子去走,并且在走完的地方打上标记。

push_up:这个函数可以理解为向上回溯,那么有些代码和思路中是不需要他的。但是我们这个思路就需要。

我们先把它们实现了再说!

/*                            push_down 的模板代码                        */
void push_down (int u, int l, int r) { //u是本节点的下标。l和r分别是查询的左区间和右区间
    tree[u] += vis[u] * (r - l + 1);
    vis[u * 2] += vis[u]; //向左儿子走
    vis[u * 2 + 1] += vis[u]; //向右儿子走
    vis[u] = 0; //清空标记
}
/*                            push_up 的模板代码                        */
void push_up (int u, int l, int r) {
    push_down (u, l, r);
    push_down (left_son); //去往左儿子
    push_down (right_son); //去往右儿子
    tree[u] = tree[u * 2] + tree[u * 2 + 1]; //一步步往上更新加和
}

我们看到代码,应该不难理解。tree 是构建树的数组这些都好理解。但是我相信大家一定对这个叫做 vis 的数组抱有疑问。下面,我们就来讲讲 vis 数组。

       vis 数组的作用:我们的 vis 数组是一个懒标记  (Lazy~ Tag) 数组,也有的地方翻译成“延迟标记”(听起来高端,实际上啥也不是)。好了,回顾一下整个过程:区间更新时,我们递归查找目标区间的子区间,过程中不断维护当前节点的区间和,每找到一个目标区间的子区间(有可能只有1个点)被完全覆盖到时停止往下递归。最妙的地方在于我们每次不必要把懒标记下传到底,只需传到管理目标区间的子区间的节点就行,下次再有更新的时候,我们再随着更新递归传递标记就好了。这样,时间复杂度会大大提升有了上文这些准备,主要的代码来了!

对于操作 2,区间查询。 实现一个 query查找函数。我们需要几个参数。分别是 u,~ l,~ r,~ L,~ R ,分别表示本节点的下标、当前节点的左区间、当前节点的右区间、查询的左区间、查询的右区间。

query Code

long long query (int u, int l, int r, int L, int R) { //上文所提到的参数
	push_down (u, l, r); //向下查询
	if (L <= l and r <= R) { //如果本区间完全属于要查询的区间
		return tree[u]; //直接返回答案
	}
	long long answer = 0; //定义输出答案
	if (L <= mid) { //判断左区间是否合法
		answer += query (left_son, L, R); //去右儿子
	}
	if (R > mid) { //判断右区间是否合法
		answer += query (right_son, L, R); //去左儿子 
	}
	return answer; //返回最终的答案
}

以上的代码应该很好理解,我们主要来看下面的 update 函数。

update 函数主要实现的就是加一个数字 k。我们需要的参数有 u,~l,~r,~L,~,R~,~value ,其中的 value 就是输入的 k 。其他的上文说过不过多解释,详见代码注释。

void update (int u, int l, int r, int L, int R, int value) { //上文所述的一些系数
    push_down (u, l, r); //往下走
    if (L <= l and r <= R) { //跟上个函数一样,先判断是否全部属于
        vis[u] += value; //更新懒标记
        return ;
    }
    if (L <= mid) {
        update (left_son, L, R, value);
    }
    if (R > mid) {
        update (right_son, L, R, value);
    } //同上
    push_up (u, l, r); //可以理解为回溯
    return ;
}

我们实现了这两个函数之后,就把他们融入我们的大程序吧!

我觉得我的代码还算好懂吧...

#include <iostream>
#include <cstdio>
#define MAXN 100010
#define mid ((l + r) / 2)
#define left_son u * 2, l, mid //宏定义我们的左儿子和右儿子
#define right_son u * 2 + 1, mid + 1, r
using namespace std;
int n, m;
long long tree[MAXN * 8], vis[MAXN * 8];


void push_down (int u, int l, int r) { //u是本节点的下标。l和r分别是查询的左区间和右区间
    tree[u] += vis[u] * (r - l + 1);
    vis[u * 2] += vis[u]; //向左儿子走
    vis[u * 2 + 1] += vis[u]; //向右儿子走
    vis[u] = 0; //清空标记
}

void push_up (int u, int l, int r) {
    push_down (u, l, r);
    push_down (left_son); //去往左儿子
    push_down (right_son); //去往右儿子
    tree[u] = tree[u * 2] + tree[u * 2 + 1]; //一步步往上更新加和
}

long long query (int u, int l, int r, int L, int R) { //上文所提到的参数
	push_down (u, l, r); //向下查询
	if (L <= l and r <= R) { //如果本区间完全属于要查询的区间
		return tree[u]; //直接返回答案
	}
	long long answer = 0; //定义输出答案
	if (L <= mid) { //判断左区间是否合法
		answer += query (left_son, L, R); //去右儿子
	}
	if (R > mid) { //判断右区间是否合法
		answer += query (right_son, L, R); //去左儿子 
	}
	return answer; //返回最终的答案
}

void update (int u, int l, int r, int L, int R, int vlaue) { //上文所述的一些系数
    push_down (u, l, r); //往下走
    if (L <= l and r <= R) { //跟上个函数一样,先判断是否全部属于
        vis[u] += value; //更新懒标记
        return ;
    }
    if (L <= mid) {
        update (left_son, L, R, value);
    }
    if (R > mid) {
        update (right_son, L, R, value);
    } //同上
    push_up (u, l, r); //可以理解为回溯
    return ;
}

int main () {
	cin >> n >> m;
	for (int i = 1;i <= n; ++i) {
		long long number;
		cin >> number;
		update (1, 1, n, i, i, number); //最初输入数据的时候,也相当于在更新。我们就用更新函数建一下树。
	}
	for (int i = 1;i <= m; ++i) {
		int a; cin >> a;
		if (a == 1) {
			long long x, y, k;
			cin >> x >> y >> k;
			update (1, 1, n ,x, y, k); //对于操作1,调用update。
		}
		else {
			int x, y;
			cin >> x >> y;
			cout << query (1, 1, n, x, y) << endl; //对于操作2,调用query,并输出。
		}
	}
	return 0;
}

贴一下题目【模板】线段树1https://www.luogu.com.cn/problem/P3372嗯...这次讲解差不多就到这了。goodbye~

大家对我文章的意见可以直接在下方留言,或者在洛谷的帖子上说。

我还发起一个投票:下期是继续讲线段树的深入还是画漫画?大家可以投个票哈~

感谢观看~

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值