一、线段树的相关概念
①定义
什么是线段树呢?首先默认很多同学已经知道树这个结构了。也就是说,我们要用树的每一个节点去存下每一段数,我觉得线段树的名字由来大概如此吧!我们可以看下的图
上面的图就是线段树上的每一个节点所存的数代表的含义,比如一个线段的左编号为l,右编号为r,那么这个线段所代表的就是$\sum_ {i=l}^{r}nums[i]$,最终呈现的一颗树如下图。
而蓝色标记的数字代表树上节点的编号,这个是在二叉树里面的编号,所以可以知道每一个节点左儿子和右儿子的编号,假设当前的编号k, 那么他的左儿子为:2k(k << 1),右儿子为:2k+1(k<<1|1),这里加红的地方大家可以注意一下,因为一般来说,位运算能比较快的去遍历一整颗树,所以在后面的代码全部由位运算代替左右儿子的遍历。
②好处
首先呢,由于线段树是一个二叉树,因此它可以让时间复杂度为$O(N)$的代码降低为$log_2(N)$的时间复杂度,在数据大的时候,很有可能就是这样的时间差距,带来更多的收益。那么线段树到底会解决什么样的问题,那么复杂的一棵树,为什么我一定要拿来用呢?有几个点:如果我们多次修改某个区间,求区间和又或者是多次让某个区间变成一个数,类似这样的问题是可以用线段树的。当然这些问题是可以用线段数组来写的,但是有的时候我们会不得不用线段树,比如多次将一个区间变成同一个数。当然线段树的很多功能可以通过树状数组来代替,如果能够使用树状数组写的题目,最好能够优先选择树状数组。所以为什么还要去学习线段树呢?我觉得在刚开始我一直很畏惧去学,但是经过一天的学习之后我发现,学习完线段树,不仅是多了一个解决问题的手段,多学了一个数据结构,更多的是对递归,分治的思想又加深了。所以建议uu们都可以去学习这样的一颗复杂但是优美的树!
二、线段树的基本操作(1)
①建树
毫无疑问对于这样的一个数据结构来说,我们肯定是得先要把它建出来。这样建树的过程其实在树的学习中,大家肯定都是会的,而因为线段树的定义不同,因此我们要在每次递归回溯的过程,让节点所代表的值加上他左子树和右子树的值,才最终代表某个节点是由下面子树的和而构建来的,具体代码如下:
void build_tree(int k, int l, int r) {
if(l == r) {
sum[k] = nums[l];
return;
}
int m = (l + r) >> 1; //取l,r的中点
//递归左子树,然后k<<1相当于2*k
build_tree(k << 1, l, m);
//递归右子树,然后k << 1 | 1相当于2*k+1
build_tree(k << 1 | 1, m + 1, r);
//进行区间的求和累积(左子树和右子树的和)
sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
②修改单个点的值
如果题目要求让某个点加上一个val值的话,很显然,在更新一整颗树的时候,每一个线段带有该点都必须一起的进行更新,然后在更新的过程的时候,会有一个必要条件,也就是更新点的下标一定是会被包含在每次递归的左右区间之内的,所以有如下的代码。
//进行单个数的加值
void add(int k, int l, int r, int x, int val) {
//改变的数根据递归肯定会在[l, r]的区间之内
sum[k] += val;
if(l == r) return;
int m = (l + r) >> 1;
//要改变的数在左子树
if(x <= m) {
add(k << 1, l, m, x, val);
}
//要改变的数在右子树
else {
add(k << 1 | 1, m + 1, r, x, val);
}
}
完成上面的操作之后,就可以让每一颗树进行对应的更新了,因此如果在次基础上,如果我们要查询一段区间和的时候,我们其实只需要递归的去计算每个区间的和就好了
③针对只修改了单个点值的区间查询
这里可以注意一下标题,只针对修改了单个点值的区间查询,因为在这个前提下的计算和之后的计算有着不同的地方,所以先看一个点,再慢慢推进到后面的点。这里的操作还是以下图为例子:
因为对于这里的递归会有很多的不理解的地方,在这稍作提示。如果我们在做一个区间计算的时候,如果计算的区间刚好在左子树,那么我们最后只需要递归左子树的和就好了,比如在编号为①的时候,我们要查找$[1,2]$区间的和,那么其实只需要到编号为②的地方去计算,右子树同理。那么如果我们跨了区间怎么半呢?比如在上图我要查找$[2,4]$区间的和呢,也很简单,只不过就是左子树的区间$[2,3]$和右子树的区间$[3,4]$两个计算的结果加在一起,具体很多的注释加在代码内,建议读者可以用一些例子理解这个递归式子。而下面第一行注释的lazy标记是后面要介绍的内容!
//计算某个区间的和,不含有lazy标记
int cal(int k, int l, int r, int x, int y) {
if(l == x && r == y) {
return sum[k];
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
return cal(k << 1, l, m, x, y);
}
//区间完全在右子树
else if(x > m) {
return cal(k << 1 | 1, m + 1, r, x, y);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
return cal(k << 1, l, m, x, m) +
cal(k << 1 | 1, m + 1, r, m + 1, y);
}
}
④例题模板1
例题1入口
这个例题将我们所说的结合起来就可以写了的,当然他是树状数组的一个板子,之所以用线段树去写,只是为了加深对线段树的熟悉度罢了。
⑤AC全部代码
#include<bits/stdc++.h>
using namespace std;
//开辟其他数组的时候最好为4n让数组不会产生越界的情况
int nums[500005]; //数据的存储
int sum[2000010]; //区间和的存储
int lz[2000010]; //lazy标记的存储
int n, m;
//进行线段树的构建
void build_tree(int k, int l, int r) {
if(l == r) {
sum[k] = nums[l];
return;
}
int m = (l + r) >> 1; //取l,r的中点
//递归左子树,然后k<<1相当于2*k
build_tree(k << 1, l, m);
//递归右子树,然后k << 1 | 1相当于2*k+1
build_tree(k << 1 | 1, m + 1, r);
//进行区间的求和累积(左子树和右子树的和)
sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
//进行单个数的加值
void add(int k, int l, int r, int x, int val) {
//改变的数根据递归肯定会在[l, r]的区间之内
sum[k] += val;
if(l == r) return;
int m = (l + r) >> 1;
//要改变的数在左子树
if(x <= m) {
add(k << 1, l, m, x, val);
}
//要改变的数在右子树
else {
add(k << 1 | 1, m + 1, r, x, val);
}
}
//计算某个区间的和,不含有laze标记
int cal(int k, int l, int r, int x, int y) {
if(l == x && r == y) {
return sum[k];
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
return cal(k << 1, l, m, x, y);
}
//区间完全在右子树
else if(x > m) {
return cal(k << 1 | 1, m + 1, r, x, y);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
return cal(k << 1, l, m, x, m) +
cal(k << 1 | 1, m + 1, r, m + 1, y);
}
}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) {
cin >> nums[i];
}
build_tree(1, 1, n);
for(int i = 1; i <=m; i++) {
int cz, x, y;
cin >> cz >> x >> y;
if(cz == 1) {
add(1, 1, n, x, y);
}
else {
cout << cal(1, 1, n, x, y) << endl;
}
}
}
三、线段树的基本操作(2)不下传懒惰标记版本
在上面的操作,我们只是进行了对线段树中的某一个值进行一个加减的过程,那么很显然不能体现线段树的好处。那么如果我们对一整个区间去进行操作呢?我们先看一下下面的引入:
①引入懒惰标记
如图所示,左边是我们最开始要构建的一颗树,右边是加入了懒惰标记的时候构建的一颗树,在这个地方我们看到了在两个地方我们由红色标记了一个东西,这就是我们的懒惰标记。由它的名字我们可以知道,就是我们可以偷懒,让程序不用去把所有的值进行修改,就可以得到我们最终的答案。也就是图中的下标为3,4的地方的并没有进行加1操作,但是他们的和是加上了2的,这是怎么偷懒的呢?通俗来说,就是假设我们计算一个区间,如果线段树的那一段已经恰好完全包含了这个区间,正如[3,4]的区间被恰好包含在编号为3的子树上,那么我们就直接给他打上一个+1的标记,代表其实它的儿子是都需要加1的,但是由于我已经包含了他们,因此我们不再需要继续往下递归,因为它们的老爸已经把他们相加的结果记录好了,这个操作实现的代码如下:
void insert(int k, int l, int r, int x, int y, long long val) {
//如果刚好区域被包含在[l,r]的区间内
if(l == x && r == y) {
//给恰好包含要求子数做上lazy标记,表示后面不用继续递归加数
lz[k] += val;
return;
}
//先让能够满足所求区间为总区间的子区间的加上对应的区间和
sum[k] += (y - x + 1) * val;
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
insert(k << 1, l, m, x, y, val);
}
//区间完全在右子树
else if(x > m) {
insert(k << 1 | 1, m + 1, r, x, y, val);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
insert(k << 1, l, m, x, m, val);
insert(k << 1 | 1, m + 1, r, m + 1, y, val);
}
}
②区间的查询
好了那么我们要查询一个区间怎么办呢?首先这里由于我们是这里先介绍不把懒惰标记下传的写法,因此,下面的方法与后面把懒惰标记下传的方法会有些不同的地方,大家注意看一下区别。
回归正题,如果我们要查找一段区间,但是由于懒惰坐标并没有下传,这就会导致一个什么事情呢,就是在我们查询的时候,有一些孩子的值是并没有得到更新的,这就会导致,他们所存的sum值是没有得到更新的,还需要去加上之前积累的懒惰标记的值才行,这里还是以上图为例子。
假设我们要查找的就是$[4,4]$这个区间呢?我们可以看到本身4下标的这个数字是要加1的,但是很遗憾的是,我们的第一步操作并没有达到我们想要满足的效果。那么我们可以关注一下,从下标为4的那个地方往上看,是不是可以看到一个懒惰标记,这个代表了什么呢?不就是代表了它上方如果存在懒惰标记的话,那么就代表因为太懒,本来它本身是需要加上懒惰标记的值但是没有加上,那么我们只需要把他上方所有积累的懒惰标记加起来,然后最后在计算本身的时候加上$(\sum{lz}*length(区间的长度))$就好了。这个地方会有点难理解为什么要加上上方所有的和,不急,可以在用一个图为例子:
假设在前面图的基础上让区间[1,4]都加上了一个1,很显然,因为恰好包含,除了根节点挂上一个+1的懒惰标记,其他地方都不会变化,那如果此时我们还要计算[4,4]的值呢?很显然我们要加上两个懒惰标记的值,因为4被连续加上了两次1。也就是说当我们不把懒惰标记下传的时候,我们是需要计算两部分的值才能最终确定一个区间的和。一个就是懒惰标记的积累和,另外就是本身的子树累计的和,代码如下,大家可以结合代码去走一遍图:
//进行区间的查询
//函数的意思:在[l,r]上的[x,y]区间所有数的和
//p代表当前下标的lazy值
int query(int k, int l, int r, int x, int y, int p) {
//先进行lazy值的累加
p += lz[k];
//如果恰好包含
if(l == x && r == y) {
return p * (y - x + 1) + sum[k];
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
return query(k << 1, l, m, x, y, p);
}
//区间完全在右子树
else if(x > m) {
return query(k << 1 | 1, m + 1, r, x, y, p);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
return query(k << 1, l, m, x, m, p) +
query(k << 1 | 1, m + 1, r, m + 1, y, p);
}
}
③例题模板2
题目2入口
④AC完整代码
#include<bits/stdc++.h>
using namespace std;
//开辟其他数组的时候最好为4n让数组不会产生越界的情况
int nums[100001]; //数据的存储
long long sum[400010]; //区间和的存储
long long lz[400010]; //lazy标记的存储
int n, m;
//进行线段树的构建
void build_tree(int k, int l, int r) {
if(l == r) {
sum[k] = nums[l];
return;
}
int m = (l + r) >> 1; //取l,r的中点
//递归左子树,然后k<<1相当于2*k
build_tree(k << 1, l, m);
//递归右子树,然后k << 1 | 1相当于2*k+1
build_tree(k << 1 | 1, m + 1, r);
//进行区间的求和累积(左子树和右子树的和)
sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
//进行区间的加值
//函数意思代表:在[l,r]上的[x,y]区间给每个数加上一个val值
void insert(int k, int l, int r, int x, int y, long long val) {
//如果刚好区域被包含在[l,r]的区间内
if(l == x && r == y) {
//给恰好包含要求子数做上lazy标记,表示后面不用继续递归加数
lz[k] += val;
return;
}
//先让能够满足所求区间为总区间的子区间的加上对应的区间和
sum[k] += (y - x + 1) * val;
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
insert(k << 1, l, m, x, y, val);
}
//区间完全在右子树
else if(x > m) {
insert(k << 1 | 1, m + 1, r, x, y, val);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
insert(k << 1, l, m, x, m, val);
insert(k << 1 | 1, m + 1, r, m + 1, y, val);
}
}
//进行区间的查询
//函数的意思:在[l,r]上的[x,y]区间所有数的和
//p代表当前下标的lazy值
long long query(int k, int l, int r, int x, int y, long long p) {
//先进行lazy值的累加
p += lz[k];
//如果恰好包含
if(l == x && r == y) {
return p * (y - x + 1) + sum[k];
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
return query(k << 1, l, m, x, y, p);
}
//区间完全在右子树
else if(x > m) {
return query(k << 1 | 1, m + 1, r, x, y, p);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
return query(k << 1, l, m, x, m, p) +
query(k << 1 | 1, m + 1, r, m + 1, y, p);
}
}
int main() {
scanf("%d%d", &n, &m);
for(int i = 1; i <= n; i++) {
scanf("%d", &nums[i]);
}
build_tree(1, 1, n);
for(int i = 1; i <=m; i++) {
int cz;
scanf("%d", &cz);
if(cz == 1) {
int x,y;
long long k;
scanf("%d%d%lld", &x, &y, &k);
insert(1, 1, n, x, y, k);
}
else {
int x,y;
scanf("%d%d", &x, &y);
printf("%lld\n", query(1, 1, n, x, y, 0));
}
}
}
四、线段树的基本操作(2)下传懒惰标记版本
①引言
这里其实就是说会根据很多人的不同习惯有很多不同的写法,所以仅供大家参考目前我在慢慢习惯的一个写法。
下传懒惰标记其实就是说,我们不要去管计算的时候,一个区间上面的懒惰标记,假设还是如图
如果我们还是要去计算[4,4]的值,我们不妨在访问和为9的那个线段的时候,直接把它的懒惰标记传给它的左右孩子,然后它本身的懒惰标记变成0。这样的话,我们计算值的时候就不用管上面懒惰标记的和了,因为都是0。那么我们其实就是在计算本层的一个值加上本身就应该加上的懒惰标记的值,其实和之前的方法就是一个过程了相当于,就是思维上稍微有点不同。比如在这个地方,因为计算的时候有可能会把懒惰标记下传,这样的过程会导致什么呢?会导致上方的值因为懒惰标记的下传,上面的值得不到一个更新,因此很简单的思路就是。在递归回溯的过程中,让上方的值不断的进行一个更新,也就是和我们文章最初开头的一种方式是一样的了!
②插入的操作(懒惰标记下传)
//进行区间的加值
//函数意思代表:在[l,r]上的[x,y]区间给每个数加上一个val值
void insert(int k, int l, int r, int x, int y, long long val) {
//如果刚好区域被包含在[l,r]的区间内
if(l == x && r == y) {
//给恰好包含要求子数做上lazy标记,表示后面不用继续递归加数
lz[k] += val;
return;
}
//如果此处有标记,向下传
if(lz[k]) {
lz[k << 1] += lz[k];
lz[k << 1 | 1] += lz[k];
lz[k] = 0; //记得懒惰标记的清0
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
insert(k << 1, l, m, x, y, val);
}
//区间完全在右子树
else if(x > m) {
insert(k << 1 | 1, m + 1, r, x, y, val);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
insert(k << 1, l, m, x, m, val);
insert(k << 1 | 1, m + 1, r, m + 1, y, val);
}
//最后递归回溯的时候不断更新上面的值
sum[k] = sum[k << 1] + (m - l + 1) * lz[k << 1]
+ sum[k << 1 | 1] + (r - m) * lz[k << 1 | 1];
}
②查询的操作(懒惰标记下传)
//进行区间的查询
//函数的意思:在[l,r]上的[x,y]区间所有数的和
long long query(int k, int l, int r, int x, int y) {
//如果恰好包含
if(l == x && r == y) {
return sum[k] + lz[k] * (r - l + 1);
}
//如果此处有标记,向下传
if(lz[k]) {
lz[k << 1] += lz[k];
lz[k << 1 | 1] += lz[k];
lz[k] = 0; //记得懒惰标记的清0
}
int m = (l + r) >> 1;
long long ret = 0;
//区间完全在左子树
if(y <= m) {
ret = query(k << 1, l, m, x, y);
}
//区间完全在右子树
else if(x > m) {
ret = query(k << 1 | 1, m + 1, r, x, y);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
ret = query(k << 1, l, m, x, m) +
query(k << 1 | 1, m + 1, r, m + 1, y);
}
//最后递归回溯的时候不断更新上面的值
sum[k] = sum[k << 1] + (m - l + 1) * lz[k << 1]
+ sum[k << 1 | 1] + (r - m) * lz[k << 1 | 1];
return ret;
}
这里为什么要另外设计一个ret变量对结果进行保存呢?对比不下传的方法,我们可以看到,本次的方法会边回溯边更新答案,因此在我们回溯的过程中,我们才会得到正确的答案,因此回溯的过程要把区间的左右记录下来,最后完成区间和的更新才是我们最终的答案。而方法二为什么不用这个呢?就是因为方法二是不断的往下去更新,然后遇到可以不用更新的地方就直接回溯了,但是我们发现在向下递归的时候,就已经对答案进行了更新,也就是方法三的一个逆过程!
③AC代码
因为题目还是方法二的,因此这里直接贴上不同版本的代码:
#include<bits/stdc++.h>
using namespace std;
//开辟其他数组的时候最好为4n让数组不会产生越界的情况
long long nums[100001]; //数据的存储
long long sum[400010]; //区间和的存储
long long lz[400010]; //lazy标记的存储
int n, m;
//进行线段树的构建
void build_tree(int k, int l, int r) {
if(l == r) {
sum[k] = nums[l];
return;
}
int m = (l + r) >> 1; //取l,r的中点
//递归左子树,然后k<<1相当于2*k
build_tree(k << 1, l, m);
//递归右子树,然后k << 1 | 1相当于2*k+1
build_tree(k << 1 | 1, m + 1, r);
//进行区间的求和累积(左子树和右子树的和)
sum[k] = sum[k << 1] + sum[k << 1 | 1];
}
//进行区间的加值
//函数意思代表:在[l,r]上的[x,y]区间给每个数加上一个val值
void insert(int k, int l, int r, int x, int y, long long val) {
//如果刚好区域被包含在[l,r]的区间内
if(l == x && r == y) {
//给恰好包含要求子数做上lazy标记,表示后面不用继续递归加数
lz[k] += val;
return;
}
//如果此处有标记,向下传
if(lz[k]) {
lz[k << 1] += lz[k];
lz[k << 1 | 1] += lz[k];
lz[k] = 0; //记得懒惰标记的清0
}
int m = (l + r) >> 1;
//区间完全在左子树
if(y <= m) {
insert(k << 1, l, m, x, y, val);
}
//区间完全在右子树
else if(x > m) {
insert(k << 1 | 1, m + 1, r, x, y, val);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
insert(k << 1, l, m, x, m, val);
insert(k << 1 | 1, m + 1, r, m + 1, y, val);
}
sum[k] = sum[k << 1] + (m - l + 1) * lz[k << 1]
+ sum[k << 1 | 1] + (r - m) * lz[k << 1 | 1];
}
//进行区间的查询
//函数的意思:在[l,r]上的[x,y]区间所有数的和
long long query(int k, int l, int r, int x, int y) {
//如果恰好包含
if(l == x && r == y) {
return sum[k] + lz[k] * (r - l + 1);
}
//如果此处有标记,向下传
if(lz[k]) {
lz[k << 1] += lz[k];
lz[k << 1 | 1] += lz[k];
lz[k] = 0; //记得懒惰标记的清0
}
int m = (l + r) >> 1;
long long ret = 0;
//区间完全在左子树
if(y <= m) {
ret = query(k << 1, l, m, x, y);
}
//区间完全在右子树
else if(x > m) {
ret = query(k << 1 | 1, m + 1, r, x, y);
}
//区间跨越了左右子树
else {
//合并左右子树的结果
ret = query(k << 1, l, m, x, m) +
query(k << 1 | 1, m + 1, r, m + 1, y);
}
sum[k] = sum[k << 1] + (m - l + 1) * lz[k << 1]
+ sum[k << 1 | 1] + (r - m) * lz[k << 1 | 1];
return ret;
}
int main() {
cin >> n >> m;
for(int i = 1; i <= n; i++) {
cin >> nums[i];
}
build_tree(1, 1, n);
for(int i = 1; i <=m; i++) {
int cz, x, y;
long long k;
cin >> cz;
if(cz == 1) {
cin >> x >> y >> k;
insert(1, 1, n, x, y, k);
}
else {
cin >> x >> y;
cout << query(1, 1, n, x, y) << endl;
}
}
}
五、总结
总而言之,线段树会有很多不同的写法,本文仅提供目前学到的这种,uu们可以找到自己习惯的写法,然后经常复习一下,我觉得对线段树就会慢慢不陌生了!!!
参考文章:
感谢各大佬写的博客,发的视频的帮助!
本文章的问题也希望读者们指出!