✨AI 摘要deepseek-v4.1-flash
以7个数的区间最大值为例,从建树、查询、单点修改讲到区间修改与懒标记,逐步展示每个节点在操作中的变化。建树递归合并左右孩子取最大值,查询将区间拆成O(log n)个整块节点,单点修改只更新一条根到叶的路径。懒标记的核心是整段修改时只更新当前节点并记下欠账,需要下探时再通过pushdown交给孩子。结论是线段树通过整块取值和延迟下传实现高效区间操作,并指出数组开4倍、区间和乘长度、下探前pushdown等易错点。
线段树之前一直是半懂不懂的状态,尤其是懒标记:看题解的时候觉得懂了,自己一写就乱。这次干脆拿一个小例子从头推了一遍,把每一步树上发生了什么都画了出来,记在这里。
一句话总结
把区间一直对半劈开,每个节点存「这一段的答案」,父节点的答案由两个孩子拼出来。查一段、改一个数、给一段统一加一个数,都是 O(log n)。
先把树建出来
例子是 7 个数:5 1 4 9 2 7 3,下标从 1 开始,每个节点存这段区间的最大值。规则就一条:区间 [l, r] 从 mid = (l + r) / 2 劈开,左边 [l, mid],右边 [mid + 1, r],劈到只剩一个数为止。
画出来是这样,方括号里是节点编号,后面是区间和这段的最大值:
编号的规律,存数组全靠它
节点 p 的左孩子是 2p,右孩子是 2p+1,父节点是 p/2,所以整棵树直接塞进一个数组,不用指针。
数组要开 4 倍:编号不是连续用满的(上面 7 个数就用到了 13),开 4n 一定够。
建树就是递归下去,叶子直接放元素,回来的时候用两个孩子算自己:
void build(int p, int l, int r) {
if (l == r) { t[p] = a[l]; return; } // 叶子:直接存元素
int mid = (l + r) >> 1;
build(p << 1, l, mid); // 左孩子 2p
build(p << 1 | 1, mid + 1, r); // 右孩子 2p+1
t[p] = max(t[p << 1], t[p << 1 | 1]); // 合并:取两边最大值
}
查询:能整块拿就整块拿
从根往下走,每到一个节点只有三种情况:
- 这个节点的区间整段都在查询范围里:直接拿它存的值,不再往下走
- 和左孩子有交集:去左边
- 和右孩子有交集:去右边。两边都有就两边都去,最后取 max
比如查 [2, 6] 的最大值,高亮的是最后「整块拿」的节点:
结果是 max(1, 9, 7) = 9。[5] 和 [6] 都是整段拿的,下面的叶子根本没碰。不管查哪一段,最后都只会拆成 O(log n) 个这样的节点,这就是它快的原因。
int query(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return t[p]; // 当前区间被完全包含
int mid = (l + r) >> 1, res = INT_MIN;
if (ql <= mid) res = max(res, query(p << 1, l, mid, ql, qr));
if (qr > mid) res = max(res, query(p << 1 | 1, mid + 1, r, ql, qr));
return res;
}
单点修改:改叶子,回来的路上顺手更新
把第 4 个数从 9 改成 0。从根往下只走包含位置 4 的那一边:[1] → [2] → [5] → [11],把 [11] 改掉,然后回溯的时候一路往上重新算 max:
原来的最大值 9 没了,根节点跟着变成 7。整个过程只动了一条从根到叶子的路径。
区间修改和懒标记(最绕的一块)
现在要给一整段加上同一个数。拆成一个一个单点改的话,一次就要 O(n log n),比暴力还慢。
懒标记的想法其实就是偷懒:走到一个整段都要加的节点,只改它自己的值,然后在它身上记一笔「我下面的都还欠着 +v」,不往下走。等以后真的要往它下面走的时候,再把这笔账交给两个孩子,这一步叫 pushdown。
接着上面的数组(第 4 个数已经是 0 了)做两次区间加:
① [5, 7] 全部 +3:走到 [3](5~7)正好整段都在,[3] 的值 7 → 10,记上 tag = 3,到此为止。[6]、[7] 和下面的叶子都还是旧值。
② [5, 6] 全部 +2:又走到 [3],这次不是整段,得往下走了,走之前先把 [3] 欠的 +3 交下去:[6] 7 → 10,[7] 3 → 6,它俩各记上 tag 3,[3] 的 tag 清零。然后来到 [6](5~6),整段都在:10 → 12,tag 变成 3 + 2 = 5。
两次做完,树上是这样的(标着「旧值」的两个叶子,其实还欠着 [6] 身上的 +5):
③ 查 [6, 6]:走到 [6] 要继续往下,发现它身上有 tag 5,先交下去:[12] 2 → 7,[13] 7 → 12,然后到 [13] 拿到 12。核对一下:第 6 个数是 7,先 +3 再 +2,正好 12。
有了懒标记,就两条规矩
往下走之前,先 pushdown:查询、修改都一样
递归回来之后,再 pushup:用两个孩子重新算自己
pushdown 写出来就这几行(这里是区间和的版本,配合下面的 P3372):
// 给节点 p(区间长度 len)整体加上 v:更新自己的值,并把这次修改记在懒标记上
void addTag(int p, int len, ll v) {
sum[p] += v * len; // 区间和:每个元素都 +v,总和 +v*len
tag[p] += v;
}
// 下发懒标记:把欠着孩子的修改交给左右孩子,自己清零
void pushdown(int p, int l, int r) {
if (!tag[p]) return;
int mid = (l + r) >> 1;
addTag(p << 1, mid - l + 1, tag[p]);
addTag(p << 1 | 1, r - mid, tag[p]);
tag[p] = 0;
}
做了两道洛谷题
单点修改 + 区间最大值,就是前面那一套。U A B 是「A 的成绩比 B 低才改成 B」,所以叶子上是取 max,不是直接赋值。
P1531 完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;
int n, m, a[N], t[N << 2]; // 线段树开 4 倍空间
void build(int p, int l, int r) {
if (l == r) { t[p] = a[l]; return; } // 叶子:直接存元素
int mid = (l + r) >> 1;
build(p << 1, l, mid); // 左孩子 2p
build(p << 1 | 1, mid + 1, r); // 右孩子 2p+1
t[p] = max(t[p << 1], t[p << 1 | 1]); // 合并:取两边最大值
}
// 单点修改:位置 x 的成绩改成 max(原成绩, v)
void update(int p, int l, int r, int x, int v) {
if (l == r) { t[p] = max(t[p], v); return; }
int mid = (l + r) >> 1;
if (x <= mid) update(p << 1, l, mid, x, v);
else update(p << 1 | 1, mid + 1, r, x, v);
t[p] = max(t[p << 1], t[p << 1 | 1]); // 回溯时自底向上更新
}
// 区间查询 [ql, qr] 的最大值
int query(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return t[p]; // 当前区间被完全包含
int mid = (l + r) >> 1, res = INT_MIN;
if (ql <= mid) res = max(res, query(p << 1, l, mid, ql, qr));
if (qr > mid) res = max(res, query(p << 1 | 1, mid + 1, r, ql, qr));
return res;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
build(1, 1, n);
while (m--) {
char op[3]; int x, y;
scanf("%s%d%d", op, &x, &y);
if (op[0] == 'Q') {
if (x > y) swap(x, y); // 保险起见
printf("%d\n", query(1, 1, n, x, y));
} else {
update(1, 1, n, x, y);
}
}
return 0;
}
区间加 + 区间求和,懒标记的模板题。和最大值版本只差一个地方:
最容易错的一处
维护区间和时,一个节点整体 +v,它的值要加 v × 区间长度;维护最大值才是只加 v。
另外和会很大,记得开 long long。
P3372 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 100005;
int n, m;
ll a[N], sum[N << 2], tag[N << 2]; // tag 就是懒标记
void pushup(int p) { sum[p] = sum[p << 1] + sum[p << 1 | 1]; }
void build(int p, int l, int r) {
if (l == r) { sum[p] = a[l]; return; }
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
pushup(p);
}
// 给节点 p(区间长度 len)整体加上 v:更新自己的值,并把这次修改记在懒标记上
void addTag(int p, int len, ll v) {
sum[p] += v * len; // 区间和:每个元素都 +v,总和 +v*len
tag[p] += v;
}
// 下发懒标记:把欠着孩子的修改交给左右孩子,自己清零
void pushdown(int p, int l, int r) {
if (!tag[p]) return;
int mid = (l + r) >> 1;
addTag(p << 1, mid - l + 1, tag[p]);
addTag(p << 1 | 1, r - mid, tag[p]);
tag[p] = 0;
}
// 区间修改:[ql, qr] 每个数加 v
void modify(int p, int l, int r, int ql, int qr, ll v) {
if (ql <= l && r <= qr) { addTag(p, r - l + 1, v); return; } // 完全包含:偷个懒,不往下走
pushdown(p, l, r); // 要往下走了,先把旧账下发
int mid = (l + r) >> 1;
if (ql <= mid) modify(p << 1, l, mid, ql, qr, v);
if (qr > mid) modify(p << 1 | 1, mid + 1, r, ql, qr, v);
pushup(p);
}
// 区间查询:[ql, qr] 的和
ll query(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sum[p];
pushdown(p, l, r); // 查询往下走之前同样要下发
int mid = (l + r) >> 1;
ll res = 0;
if (ql <= mid) res += query(p << 1, l, mid, ql, qr);
if (qr > mid) res += query(p << 1 | 1, mid + 1, r, ql, qr);
return res;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);
build(1, 1, n);
while (m--) {
int op, x, y; ll k;
scanf("%d%d%d", &op, &x, &y);
if (op == 1) { scanf("%lld", &k); modify(1, 1, n, x, y, k); }
else printf("%lld\n", query(1, 1, n, x, y));
}
return 0;
}
两份代码都过了样例,也和暴力对拍过。
容易写错的地方
- 数组没开 4 倍
- 区间和忘了乘区间长度
- 往下走之前忘了 pushdown,回来之后忘了 pushup
- 区间和没开
long long
接下来打算做 P3373 【模板】线段树 2(加和乘两个标记,要想清楚先乘后加)和 P1253 扶苏的问题(区间赋值 + 区间加 + 区间最大值)。