线段树(Segment Tree)几乎是算法竞赛最常用的数据结构了,它主要用于维护区间信息(要求满足结合律)。与树状数组相比,它可以实现 O(logn) 的区间修改,还可以同时支持多种操作(加、乘),更具通用性。
接下来我们用这道模板题为例,看看线段树是怎么维护区间和这一信息的。
P3372 【模板】线段树 1
线段树的建立
线段树是一棵平衡二叉树。母结点代表整个区间的和,越往下区间越小。注意,线段树的每个节点都对应一条线段(区间),但并不保证所有的线段(区间)都是线段树的节点,这两者应当区分开。
如果有一个数组[1,2,3,4,5],那么它对应的线段树大概长这个样子:
$$ 每个节点x的左右子节点分别是 2 * x 和 2 * x + 1 $$
$$ 不论 x 是奇数还是偶数,2 * x都是偶数,那么 2 * x + 1就等于 (2 * x) | 1 $$
$$ 乘以二相当于左移一位,即 << 1 $$
$$ 而左移运算 << 的优先级高于按位或运算符 | $$
$$ 因此对于先执行左移运算后执行按位或运算的情况,我们不需要加括号改变优先级 $$
$$ 因此 x 的子节点可以表示为 x << 1 和 x << 1 | 1 $$
#define ls x << 1
#define rs x << 1 | 1
//ls:表示左子节点,rs:表示右子节点(leftson,rightson)
如何从数组建立一棵线段树?我们可以考虑递归地进行。
void build(int x, int l, int r) {
if (l == r) {
//到达叶子节点
return void(tree[x] = A[l]);
//用数组中的数据赋值
}
int mid = l + r >> 1;
build(ls, l, mid);
build(rs, mid + 1, r);
//先建立左右子节点
tree[x] = tree[ls] + tree[rs];
//该节点的值等于左右子节点的值之和
}
这里用一张gif展现上述的过程:
区间修改
在讲区间修改前,要先引入一个“懒标记”(或延迟标记)的概念。懒标记是线段树的精髓所在。对于区间修改,朴素的想法是用递归的方式一层层修改(类似于线段树的建立),但这样的时间复杂度比较高。使用懒标记后,对于那些正好是线段树节点的区间,我们不继续递归下去,而是打上一个标记,将来要用到它的子区间的时候,再向下传递。
void update(int l, int r, int value, int x = 1, int cl = 1, int cr = n) {
// 给A[l] ~ A[r] 都加上 value
if (cl > r || cr < l) {
//区间无交集
return;
//剪枝
}
else if (cl >= l && cr <= r) {
//当前节点对应的区间包含在目标区间中
tree[x] += value * (cr - cl + 1);
//更新当前区间的值
if (cr > cl) {
//不是叶子节点
mark[x] += value;
//给当前区间打上标记
}
} else {
//与目标区间有交集,但不包含于其中
int mid = l + ((r - l) >> 1);
mark[ls] += mark[x];
//标记向下传递
mark[rs] += mark[x];
tree[ls] += mark[x] * (mid - cl + 1);
//往下更新一层
tree[rs] += mark[x] * (cr - mid);
mark[x] = 0;//消除标记
update(l, r, value, ls, cl, mid);
//递归地往下寻找
update(l, r, value, rs, mid + 1, cr);
tree[x] = tree[ls] + tree[rs];
//根据子节点更新当前节点的值
}
}
更新时,我们是从最大的区间开始,递归向下处理。注意到,任何区间都是线段树上某些节点的并集。于是我们记目标区间为 [L,R] ,当前区间为 [CL,CR] , 当前节点为 X ,我们会遇到三种情况:
1. 当前区间与目标区间没有交集:
这时直接结束递归。
2.当前区间被包括在目标区间里:
这时可以更新当前区间,别忘了乘上区间长度:
tree[x] += (cr - cl + 1) * value;