线段树入门(Segment Tree)
线段树引入
题目描述:
已知一个数列,你需要进行下面两种操作:1.将某区间每一个数加上x
2.求出某区间每一个数的和
输入格式:
第一行包含两个整数N、M,分别表示该数列数字的个数和操作的总个数。第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。
接下来M行每行包含3或4个整数,表示一个操作,具体如下:
操作1: 格式:1 x k 含义:将位置x上的数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和
输出格式:
输出包含若干行整数,即为所有操作2的结果。
咋一看:这不是数组模拟一下就可以嘛?签到题.jpg
我们来看看数据范围:
时空限制:1000ms, 128M
数据规模:
对于30%的数据:N<=8,M<=10
对于70%的数据:N<=1000,M<=10000
对于100%的数据:N<=100000,M<=100000
(数据已经过加强^_^,保证在int64/long long数据范围内)
对于数组模拟,我们只能拿大概 30% 的分,然而这是ACM-ICPC不是NOIP,不存在部分分的说法。对于 100% 的数据,我们怎么才能对于每次操作 & 查询 做到 O(log2n) 的复杂度呢?我们引入线段树这一数据结构。
线段树介绍
线段树之所以被称为「树」,因为它有明显的树(二叉树)的特征(如下图,不懂的左转数据结构课)。线段树是用来处理一类区间问题的数据结构。
对于非叶子节点,它表示整个序列中的一段连续子区间;
对于每个叶子节点,都表示序列中单个元素的信息。子节点不断向自己的父亲节点传递信息(pushUp),而非叶子节点是它每一个子节点信息的聚合。
显而易见线段树是一个分块的思想(优雅的暴力–分块 未更待续)使得查询和更新达到O(log2n)的时间复杂度。分块用一句话概括为:通过将序列划分为有穷个小块,对于要查询的一段区间,我们总是可以用K个整块+x个单元素块来组合成要求的区间。
However,线段树O(log2n)虽然比分块暴力O(n1/2) 快,但它只能维护带有结合律的信息,如区间max/min,sum/xor之类,不带结合律的信息就不能维护。
线段树的构造与实现
1、建树
上文说了,线段树基于二叉树,所以,对于一个非叶子节点 i,他的左右孩子分别是 2*i 与 2*i+1。根据我们线段树要维护的对象,我们可以写成:
#include <bits/stdc++.h>
using namespace std;
#define lson l, m, rt << 1
#define rson m + 1, r, rt << 1 | 1
void build(int l, int r, int rt) {
if (l == r) {
sum[rt] = a[r]; // 如果是叶子节点,则赋初始值
minn[rt] = a[r];
return;
}
int m = (l + r) / 2;
build(lson); // 递归建左子树
build(rson);
pushUp(rt); // 下文解释
}
2、维护
上文说了,我们需要把叶子节点的信息向它的父亲节点传送(pushUp),让父亲节点拥有孩子的最新版消息,我们要根据要维护的对象来创建相应的数组。
void pushUp(int rt) {
sum[rt] = sum[rt << 1] + sum[rt << 1 | 1];
// sum数组 维护的是该区间的区间和
minn[rt] = min(minn[rt << 1], minn[rt << 1 | 1]);
// minn数组 维护的是该区间的区间最小值,最大值同理
}
pushUp 操作是为了维护父子节点间的逻辑关系。当我们递归建树时,对于每个节点我们应该调用一次pushUp操作让该节点的父节点更新消息。
要注意的是:在计算机系统中递归是先向最底层递归,当遇到 return 后返回递归栈的上一层,即从底往上回溯,所以建树时pushUp
的操作应该是在调用 build(lson)
和build(rson)
之后。
3、单点修改的区间查询
对于这个图,如果我们想要查询 [5, 9]
这个区间,怎么办呢?
显然易见:分块思想。如图,区间[5, 9]
可以拆分成区间[5, 5] + [6, 8] + [9, 9]
。这与上文所说的维护的对象需要符合结合律对应。对于连续的一段区间,我们不用递归到叶子节点就知道了所求的信息,利用这一点,我们成功的降低了时间复杂度。
/*********************************************
[L, R] 为要求的区间
[l, r]为我们当前递归到的区间
rt 为根节点
**********************************************/
int query(int L, int R, int l, int r, int rt) {
if (L <= l && r <= R) // 如果区间[l,r]被[L,R]包含
return sum[rt]; // 直接返回[l,r]节点的值
int m = (l + r) >> 1;
ll ans = 0;
if (L <= m) // 如果不被包含,则将区间[l,r]拆为左右子树计算
ans += query(L, R, lson);
if (R > m)
ans += query(L, R, rson);
return ans;
}
4、单点修改
在后文我们要学习区间修改,在学习区间修改前我们要先学一下单点修改,毕竟单点修改就是区间长度为1的区间修改。
单点修改无非就是找到叶子节点然后修改当前叶子节点的值再更新一下那些父亲节点就好了很简单的,直接看代码。
/*********************************************
index, num 为将 index 位置的数加上 num
[l, r] 为我们当前递归到的区间
rt 为根节点
**********************************************/
void updata(int index, int num, int l, int r, int rt) {
if (l == r) { // 如果是叶子节点则修改 & 返回上层栈
sum[rt] += num;
return;
}
int m = (l + r) >> 1;
if (index <= m) // 类似二分递归左右子树
updata(index, num, lson);
else
updata(index, num, rson);
pushUp(rt); // 别忘了更新父节点的信息~
}
到这里为止单点修改 && 区间查询就完了,单重点还在后头qaq,耐心往下看please。
5、区间修改 & 区间查询
如果我们将文章开头例题的操作改成:
操作1: 格式:1 x y k 含义:将区间[x,y]内每个数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和
如果我们将他的区间修改拆成对区间内的每个点进行单点修改,会有什么后果呢?
对于单点修改的时间复杂度是O(log2n),那对于一次操作的区间的时间复杂度是O(nlog2n),那N次区间修改的操作就是O(n2log2n), 显然超时了。这时我们需要将单次区间操作的时间复杂度降成O(log2n)才能使总体时间复杂度达到理想的状态。有什么办法将时间复杂度降低呢?
(1)从分块思想谈区间修改
为了让大家不用往前翻看分块思想是什么,这里再重复说下:
通过将整个序列分为有穷个小块,对于要查询的一段区间,总是可以整合成k个所分块与m个单个元素的信息的并(0 <= k, m <=logn)
还是这张图
假设我们现在要修改区间[5,10]
,那么对于单个元素[5, 5]
,我们就只修改他本身,对于 [6, 10]
,因为他有父亲节点包含了全部,所以我们修改一次就可以修改这整个区间。
(2)lazy tag正确的使用方式
lazy tag
的作用是存储每次,每个节点要更新的值(delta 增量),这是一个传递式记录,就是它是可以由上往下传递的。
对于一个区间修改,那么标记在这个区间所有节点的最近公共祖先节点上
我们采取这种方式时,我们需要在每次查询&修改时 pushdown
一次。pushdown
: 与pushUp
类似的思想:因为修改的信息存储在父亲节点,所以要 pushdown
将信息推到叶子节点上。那么哪时候pushdown
呢? pushUp
我们发生在递归结束开始回溯时,那么显而易见 pushdown
就是放在开始递归前啦。
void pushDown(int rt, int ln, int rn) { // ln、rn为左右长度
if (lazy[rt]) {
// lazy[rt] 的左右孩子都加上它
lazy[rt << 1] += lazy[rt];
lazy[rt << 1 | 1] += lazy[rt];
// 如果是维护区间最值
minn[rt << 1] += lazy[rt];
minn[rt << 1 | 1] += lazy[rt];
// 如果是维护区间和,则要乘上区间长度
sum[rt << 1] += (ll)ln * lazy[rt];
sum[rt << 1 | 1] += (ll)rn * lazy[rt];
lazy[rt] = 0;
}
}
void updata(int L, int R, int num, int l, int r, int rt) {
if (L <= l && r <= R) {
lazy[rt] += num;
sum[rt] += (ll)num * (r - l + 1); // 如果是区间和要乘上长度
min[rt] += num; // 如果是最小值就直接改变啦
return;
}
int m = (l + r) >> 1;
pushDown(rt, m - l + 1, r - m);
if (L <= m)
updata(L, R, num, lson);
if (R > m)
updata(L, R, num, rson);
pushUp(rt);
}
区间查询其实和区间修改差不多啦,嘤嘤嘤不想重复了看代码:
ll query(int L, int R, int l, int r, int rt) {
if (L <= l && r <= R)
return sum[rt]; // 这里不是修改变成返回一个值
int m = (l + r) >> 1;
pushDown(rt, m - l + 1, r - m);
ll ans = 0;
if (L <= m)
ans += query(L, R, lson);
// ans = min(ans, query(L, R, lson));
if (R > m)
ans += query(L, R, rson);
// ans = min(ans, query(L, R, rson));
return ans;
}
附上例题:线段树模板题
常数巨大嘤嘤嘤,我太蒻了。
后续有不妥的地方再修改
完结撒花