线段树 (Segment Tree)
线段树 (Segment Tree) 相较于树状数组,它更灵活,能解决更复杂的问题,当然代码也更长一些。
我们将从基础的单点更新、区间查询开始,然后讲解其最强大的特性——区间更新(懒惰标记)。
一、 核心思想:分治 (Divide and Conquer)
线段树的核心思想是分治。它将一个大的区间 [L, R]
不断地二分成两个子区间 [L, M]
和 [M+1, R]
(其中 M = (L+R)/2
),直到每个区间只包含一个元素。然后,通过合并子区间的信息来得到父区间的信息。
- 它是一棵完全二叉树。
- 每个节点代表原始数组的一个区间
[l, r]
。 - 根节点代表整个数组的区间,例如
[1, N]
。 - 叶子节点代表只包含单个元素的区间,例如
[i, i]
。 - 一个代表区间
[l, r]
的非叶子节点,其左子节点代表[l, mid]
,右子节点代表[mid+1, r]
,其中mid = (l+r)/2
。
这个结构使得我们可以在 O(log N) 的时间内,将任意一个查询区间 [ql, qr]
分解成树上 O(log N) 个节点所代表的区间的组合。
二、 树的存储
和堆一样,我们通常用一个数组来存储这棵树。如果根节点存储在数组索引 p
处:
- 其左子节点在
2 * p
(或p << 1
) - 其右子节点在
2 * p + 1
(或p << 1 | 1
)
为了保证不越界,这个数组的大小通常需要开到 4 * N
。
三、 基础线段树:单点更新,区间查询
我们以最经典的区间求和为例。
1. 结构定义
const int MAXN = 100005;
int N;
long long a[MAXN]; // 原始数组
long long tree[MAXN * 4]; // 线段树数组
2. push_up
操作
这是一个辅助函数,用来通过子节点的信息更新父节点。对于区间求和,父节点的值就是左右子节点的值相加。
void push_up(int p) {tree[p] = tree[p * 2] + tree[p * 2 + 1];
}
3. build
操作 (建树)
递归地将原始数组 a
的信息构建到 tree
数组中。
- 参数:
p
(当前节点在tree
数组中的索引),l
,r
(当前节点代表的区间)。 - 逻辑:
- 如果
l == r
,说明是叶子节点,直接存入a[l]
的值。 - 否则,递归地为左子树
[l, mid]
和右子树[mid+1, r]
建树,然后用push_up
合并信息。
- 如果
void build(int p, int l, int r) {if (l == r) {tree[p] = a[l];return;}int mid = l + (r - l) / 2;build(p * 2, l, mid);build(p * 2 + 1, mid + 1, r);push_up(p);
}
4. update
操作 (单点更新)
将 a[idx]
的值修改为 val
。
- 参数:
p, l, r
(同上),idx
(要修改的原始数组下标),val
(新的值)。 - 逻辑:
- 从根节点开始,递归地向下查找。
- 如果
l == r
,说明找到了代表a[idx]
的叶子节点,更新它的值。 - 否则,判断
idx
在左子区间还是右子区间,并递归下去。 - 关键:在递归返回后,调用
push_up
来更新路径上所有祖先节点的值。
void update(int p, int l, int r, int idx, int val) {if (l == r) {tree[p] = val;return;}int mid = l + (r - l) / 2;if (idx <= mid) {update(p * 2, l, mid, idx, val);} else {update(p * 2 + 1, mid + 1, r, idx, val);}push_up(p);
}
5. query
操作 (区间查询)
查询区间 [ql, qr]
的和。
- 参数:
p, l, r
(同上),ql, qr
(要查询的区间)。 - 逻辑:
- 完全覆盖: 如果当前节点代表的区间
[l, r]
被查询区间[ql, qr]
完全包含,直接返回当前节点的值tree[p]
。这是效率的关键。 - 部分覆盖: 如果当前区间与查询区间有交集,但不是完全覆盖。
- 递归地到左子树查询
[ql, qr]
与[l, mid]
的交集部分。 - 递归地到右子树查询
[ql, qr]
与[mid+1, r]
的交集部分。 - 将两边的结果合并(相加)。
- 递归地到左子树查询
- 无覆盖: 如果当前区间与查询区间没有交集,返回一个不影响结果的单位元(对于求和是0,求最大值是-∞)。
- 完全覆盖: 如果当前节点代表的区间
long long query(int p, int l, int r, int ql, int qr) {// Case 1: [l, r] is fully inside [ql, qr]if (ql <= l && r <= qr) {return tree[p];}int mid = l + (r - l) / 2;long long sum = 0;// Recurse into left child if there is overlapif (ql <= mid) {sum += query(p * 2, l, mid, ql, qr);}// Recurse into right child if there is overlapif (qr > mid) {sum += query(p * 2 + 1, mid + 1, r, ql, qr);}return sum;
}
四、 进阶线段树:懒惰标记 (Lazy Propagation)
这是线段树最强大的地方,它能高效处理区间更新问题(例如,给区间 [ul, ur]
里的每个数都加上 delta
)。
如果对区间 [ul, ur]
的每个数都进行一次单点更新,复杂度会退化到 O(N log N),无法接受。
懒惰标记的思想:当一次更新操作覆盖了一个节点所代表的整个区间时,我们不再继续向下更新,而是在这个节点上打一个“标记”(tag),表示“我的所有子孙节点都欠着一个更新操作”。然后我们立刻更新当前节点的值,然后就返回。
这个标记只有在将来有查询或更新操作需要经过这个节点并进入其子树时,才会被“下放”(push_down
)给它的直接子节点。
1. 新增结构
// ... tree 数组等不变 ...
long long lazy[MAXN * 4]; // 懒惰标记数组
2. push_down
操作
这是懒惰标记的核心。在访问一个节点的子节点之前,必须先执行 push_down
。
- 参数:
p
(当前节点索引),l
,r
(当前节点代表的区间)。 - 逻辑:
- 检查当前节点
p
是否有懒惰标记lazy[p]
。 - 如果有,将这个标记的效果应用到左右两个子节点的值上。
- 将标记传递给左右两个子节点的懒惰标记数组。
- 清除当前节点的懒惰标记。
- 检查当前节点
void push_down(int p, int l, int r) {if (lazy[p] != 0) { // 如果有标记int mid = l + (r - l) / 2;// 更新左子节点的值和懒惰标记tree[p * 2] += lazy[p] * (mid - l + 1);lazy[p * 2] += lazy[p];// 更新右子节点的值和懒惰标记tree[p * 2 + 1] += lazy[p] * (r - (mid + 1) + 1);lazy[p * 2 + 1] += lazy[p];// 清除当前节点的标记lazy[p] = 0;}
}
3. 修改后的 update
(区间更新)
void update(int p, int l, int r, int ul, int ur, int delta) {// Case 1: [l, r] is fully inside the update range [ul, ur]if (ul <= l && r <= ur) {tree[p] += (long long)delta * (r - l + 1); // 更新当前节点的值lazy[p] += delta; // 打上懒惰标记return;}// Before recursing, push down the lazy tagpush_down(p, l, r);int mid = l + (r - l) / 2;if (ul <= mid) {update(p * 2, l, mid, ul, ur, delta);}if (ur > mid) {update(p * 2 + 1, mid + 1, r, ul, ur, delta);}// After updating children, update selfpush_up(p);
}
4. 修改后的 query
查询操作也需要 push_down
,以确保在访问子节点前,它们的值是最新的。
long long query(int p, int l, int r, int ql, int qr) {if (ql <= l && r <= qr) {return tree[p];}// Before recursing, push down the lazy tagpush_down(p, l, r);int mid = l + (r - l) / 2;long long sum = 0;if (ql <= mid) {sum += query(p * 2, l, mid, ql, qr);}if (qr > mid) {sum += query(p * 2 + 1, mid + 1, r, ql, qr);}return sum;
}
五、 总结与对比
特性 | 树状数组 (BIT) | 线段树 |
---|---|---|
解决问题 | 单点更新, 前缀区间查询 | 单点/区间更新, 任意区间查询 |
功能 | 较局限,但可扩展(如差分) | 非常灵活,支持各种区间操作(求和、最值、GCD等) |
复杂度 | O(log N) | O(log N) |
常数 | 小,速度非常快 | 较大,比BIT慢2-4倍 |
代码长度 | 短,易于实现 | 较长,容易出错 |
空间复杂度 | O(N) | O(4N) |
何时使用?
- 如果问题只需要单点更新和区间(或前缀)求和/异或,并且追求极致的速度和简洁的代码,首选树状数组。
- 如果问题涉及区间更新,或者需要查询区间最大/最小值等不满足简单前缀和性质的操作,必须使用线段树。