刚刚接触线段树,感觉线段树的想法真厉害,整个结构非常的优美而且缜密,所以这里特别记录一下。在这里我主要根据例题来对线段树进行讲解,并且将代码自己重新写了一遍,这样会更加深入的了解线段树中一些细节,如果不对,还请指正。
第一次看到线段树1的时候,是看到了它和树状数组2(Fenwick Tree,Binary Indexed Tree)的比较,这两个感觉挺相似的,区别以后再介绍吧,毕竟我自己还有很多不理解。除了线段树、树状数组,还有权值线段树3、可持久化线段树3(Persistent Segment Tree,也叫总书记树、主席树或者函数式线段树)、zkw线段树4(也叫重口味树)等等,这些基本上都是线段树的变体,这些也以后再讲吧。
首先我们用一个题目来引出我们所要讲解的内容:
题目描述
如题,已知一个数列,你需要进行下面两种操作:
输入格式
第一行包含两个整数N、M,分别表示该数列数字的个数和操作的总个数。
第二行包含N个用空格分隔的整数,其中第i个数字表示数列第i项的初始值。
接下来M行每行包含3或4个整数,表示一个操作,具体如下:
操作1: 格式:1 x y k 含义:将区间[x,y]内每个数加上k
操作2: 格式:2 x y 含义:输出区间[x,y]内每个数的和
输出格式
输出包含若干行整数,即为所有操作2的结果。
输入样例
5 5
1 5 4 2 3
2 2 4
1 2 3 2
2 3 4
1 1 5 1
2 1 4
输出样例
11
8
20
题目来源:P3372 【模板】线段树 1
这个例子本身如果使用循环解法的话,操作1是需要 O ( m ) O(m) O(m)次的, m m m为需要修改的个数,而操作2如果用DP,时间复杂度最快也要 O ( n ) O(n) O(n),并且还需要额外数组来存和,这样对于数据量较大的时候是不理想的,为了解决上述的问题,因此产生了线段树这个概念。
用线段树可以将时间复杂度由 O ( n ) O(n) O(n)降到 O ( l o g n ) O(logn) O(logn),而且后面最多只需要加一个常数而已,这样就更加适合频繁的查询和更新数据了。那么现在就来讲讲如何构造这么一个线段树。
线段树本身是一个二叉平衡树,因此树的最大高度不会超过 O ( l o g n ) O(logn) O(logn),并且线段树的每个节点都存储着一个区间,代表这个节点存储着这个区间的信息,线段树的每个叶子节点代表单个数值,每个叶子节点也存储有区间,只不过是区间左边界和区间右边界相等的节点,因此线段树一定有n个叶子节点;而每个非叶子节点里面存储着代表该区间要维护的信息,可以是最大值、最小值、和等等。
根据上述描述可知,线段树的每个节点都会有这几个变量,左区间、右区间、维护的信息以及左孩子和右孩子,因此线段树的指针表示方法如下:
struct SegmentTree
{
int l, r; // 左右区间
int tag; // lazy tag,后面会介绍
Data val; // 数据
SegmentTree *left, *right; // 左右孩子
SegmentTree(int _l, int _r, int _val) :
l(_l), r(_r), val(_val), left(nullptr), right(nullptr) {}
};
然而,由于完全二叉树还可以用静态数组来进行表示,此时第 i 个节点的左孩子下标为 2i,右孩子为 2i+1。因此可以把线段树当成一颗完全二叉树放到数组里进行存储(虽然有些节点会一直用不上)。
线段树基本有三个操作:
首先,为了能快速访问节点的左孩子和右孩子,增加如下函数:
inline ll ls(ll p) { return p << 1; } // 左孩子
inline ll rs(ll p) { return p << 1 | 1; } // 右孩子
并且根据不同的题目要求,实现从叶子节点到父节点的回溯更新(用 push_up 表示),这里就以示例为准,每个节点的信息维护这个区间的和:
void push_up(ll p) {
ans[p] = ans[ls(p)] + ans[rs(p)]; // 回溯时,将信息往父节点传递
}
递归的构建子树,并且回溯的时候更新左子树和右子树
void build(ll p, ll l, ll r) {
tag[p] = 0; // 初始化tag数组
if (l == r) {
ans[p] = a[l];
return;
}
ll mid = (l+r) >> 1;
build(ls(p), l, mid);
build(rs(p), mid+1, r); // 递归建树
push_up(p); // 回溯节点
}
更新区间需要从末端开始更新,即从每个叶子节点开始更新,然后不断回溯更改父节点的维护的信息,以此来达到递归更新的效果。然而这样更新有个缺点就是:每次更新的时候都需要从叶子节点更新到父节点,假设现在有多个值需要更新,这些值根据线段树的结构可以分割成 m m m个区间,那么根据树的高度 n n n,则更新区间的时间复杂度就是 O ( l o g n + m ) O(logn+m) O(logn+m),而且每次更新都需要 O ( l o g n + m ) O(logn+m) O(logn+m)时间,因此,为了能更加节省时间,我们线段树的每个节点多维护一个叫做 Lazy Tag 的信息。
节点内维护 Lazy Tag 这样的更新方法叫做懒更新。为什么叫做懒更新,是因为:Lazy Tag 存储的是每一次更新操作所要修改的值。
懒更新的意思是:每此执行更新区间的操作的时候,假设某个节点的区间在需要更新的区间范围内,我们就直接更新该节点的值,并且用 Lazy Tag 去存储当前需要更新值,然后等到下次需要查询或者更新之前,我们先将之前需要进行更新的值通过 Lazy Tag 传递到子节点,并且对每个孩子节点进行更新。总结来说,就是我们先将需要更新的值保存下来,然而当下一次需要查询或者更新之前,将上一次需要更新的值再传递下去。(它的形式正好和回溯相反,所以我们这里用 push_down 来表示)
void func(ll p, ll l, ll r, ll k) {
tag[p] += k; // 加上从上向下传递的tag值,由于节点本身可能有tag,所以是"+="
ans[p] += k*(r-l+1); // 修改当前节点的信息
}
void push_down(ll p, ll l, ll r) {
ll mid = (l+r) >> 1;
func(ls(p), l, mid, tag[p]);
func(rs(p), mid+1, r, tag[p]); // 向左右孩子传递tag值,并更新信息
tag[p] = 0; // 重置当前节点的tag值
}
void update(ll nl, ll nr, ll l, ll r, ll p, ll k) {
// nl, nr 为要更新的区间的值
// l, r 为当前节点所在的区间
// p 为当前节点的下表
// k 为要修改的值
if (nl <= l && r <= nr) {
func(p, l, r, k); // 如果当前节点的区间在需要修改的区间内,则直接更新当前节点信息
return;
}
push_down(p, l, r); // 在更新孩子节点之前,要将tag值向下传递
ll mid = (l+r) >> 1;
if (nl <= mid) update(nl, nr, l, mid, ls(p), k);
if (nr > mid) update(nl, nr, mid+1, r, rs(p), k);
push_up(p); // 孩子更新了之后,父节点的值也要进行更新
}
查询区间就不多说了,也是递归回溯查询信息,需要注意的就是查询之前需要将 Lazy Tag 向子节点传递,防止子节点未更新
ll query(ll ql, ll qr, ll l, ll r, ll p) {
ll res = 0;
if (ql <= l && qr >= r) {
return ans[p];
}
push_down(p, l, r);
ll mid = (l+r) >> 1;
if (ql <= mid) res += query(ql, qr, l, mid, ls(p));
if (qr > mid) res += query(ql, qr, mid+1, r, rs(p));
return res;
}
具体代码实现如下,如有不对,还请指正
#include
#include
using namespace std;
#define MAXN 1000001
#define ll long long
inline ll ls(ll p) { return p << 1; } // 左孩子
inline ll rs(ll p) { return p << 1 | 1; } // 右孩子
unsigned ll a[MAXN], ans[MAXN<<2], tag[MAXN<<2];
void push_up(ll p) {
ans[p] = ans[ls(p)] + ans[rs(p)]; // 回溯时,将信息往父节点传递
}
void build(ll p, ll l, ll r) {
tag[p] = 0; // 初始化tag数组
if (l == r) {
ans[p] = a[l];
return;
}
ll mid = (l+r) >> 1;
build(ls(p), l, mid);
build(rs(p), mid+1, r); // 递归建树
push_up(p); // 回溯节点
}
void func(ll p, ll l, ll r, ll k) {
tag[p] += k; // 加上从上向下传递的tag值,由于节点本身可能有tag,所以是"+="
ans[p] += k*(r-l+1); // 修改当前节点的信息
}
void push_down(ll p, ll l, ll r) {
ll mid = (l+r) >> 1;
func(ls(p), l, mid, tag[p]);
func(rs(p), mid+1, r, tag[p]); // 向左右孩子传递tag值,并更新信息
tag[p] = 0; // 重置当前节点的tag值
}
void update(ll nl, ll nr, ll l, ll r, ll p, ll k) {
// nl, nr 为要更新的区间的值
// l, r 为当前节点所在的区间
// p 为当前节点的下表
// k 为要修改的值
if (nl <= l && r <= nr) {
func(p, l, r, k); // 如果当前节点的区间在需要修改的区间内,则直接更新当前节点信息
return;
}
push_down(p, l, r); // 在更新孩子节点之前,要将tag值向下传递
ll mid = (l+r) >> 1;
if (nl <= mid) update(nl, nr, l, mid, ls(p), k);
if (nr > mid) update(nl, nr, mid+1, r, rs(p), k);
push_up(p); // 孩子更新了之后,父节点的值也要进行更新
}
ll query(ll ql, ll qr, ll l, ll r, ll p) {
ll res = 0;
if (ql <= l && qr >= r) {
return ans[p];
}
push_down(p, l, r);
ll mid = (l+r) >> 1;
if (ql <= mid) res += query(ql, qr, l, mid, ls(p));
if (qr > mid) res += query(ql, qr, mid+1, r, rs(p));
return res;
}
int main() {
int N, M;
cin >> N >> M;
for (int i=1; i<=N; ++i) {
cin >> a[i];
}
build(1, 1, N);
ll b, c, d, e, f;
while(M--) {
ll cmd;
scanf("%lld", &cmd);
switch(cmd)
{
case 1:{
scanf("%lld %lld %lld", &b, &c, &d);
update(b, c, 1, N, 1, d);
break;
}
case 2:{
scanf("%lld %lld", &e, &f);
printf("%lld\n", query(e, f, 1, N, 1));
break;
}
}
}
return 0;
}
洛谷日报#4 浅谈线段树(Segment Tree) ↩︎
树状数组 ↩︎
洛谷日报#24 浅谈权值线段树到主席树 ↩︎ ↩︎
洛谷日报#35 线段树的扩展之浅谈zkw线段树 ↩︎