DAY 04
线段树应用
CSP 暑期集训 · Segment Tree Advanced Applications
🔁
线段树回顾
建/查/改 核心操作速览
🏷️
懒标记 Lazy Tag
区间修改 · pushdown 核心机制
🏷️
双懒标记
区间乘+区间加 · 标记优先级
📊
常见应用
区间最值 · 最大子段和 · 属性速查
✏️ 📎
🧠 线段树核心概念(Day02 回顾)
线段树是一棵完全二叉树,每个节点维护一个区间 [l, r] 的信息。
• 根节点编号 1,维护整个区间 [1, n]
• 节点 u 的左儿子编号 2u,右儿子 2u+1
• 叶子节点满足 l = r,维护单个元素 a[l]
[1,8] [1,4] [5,8] [1,2] [3,4] [5,6] [7,8] 1 2 3 4 5 6 7 8 根节点 内部节点 叶子节点
三大核心操作回顾
// ===== 建树 build:递归构建,叶子赋初值,内部节点合并 ===== void build(int u, int l, int r) { if (l == r) { tree[u] = a[l]; return; } // 叶子节点直接赋值 int mid = (l + r) / 2; build(2*u, l, mid); // 递归建左子树 build(2*u+1, mid+1, r); // 递归建右子树 tree[u] = tree[2*u] + tree[2*u+1]; // pushup:区间和 = 左 + 右 } // ===== 单点修改 update:定位到叶子,沿途更新 ===== void update(int u, int l, int r, int pos, long long v) { if (l == r) { tree[u] += v; return; } int mid = (l + r) / 2; if (pos <= mid) update(2*u, l, mid, pos, v); // 目标在左半 else update(2*u+1, mid+1, r, pos, v); // 目标在右半 tree[u] = tree[2*u] + tree[2*u+1]; // 回溯时更新父节点 } // ===== 区间查询 query:区间完全包含则返回,否则拆分 ===== long long query(int u, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[u]; // 完全包含,直接返回 int mid = (l + r) / 2; long long ans = 0; if (ql <= mid) ans += query(2*u, l, mid, ql, qr); // 左半有交集 if (qr > mid) ans += query(2*u+1, mid+1, r, ql, qr); // 右半有交集 return ans; }
建树:O(n)
单点修改:O(log n)
区间查询:O(log n)
💡 回顾要点:线段树高效的核心在于——每次操作将区间二分,树高仅 log n 层,所以每层只访问常数个节点,总复杂度 O(log n)。今天我们要解决的新问题是:区间修改也能 O(log n) 吗?
✏️
为什么线段树操作是 O(log n)?
📐 树高推导
设 n 个叶子节点的线段树高度为 h。
线段树是完全二叉树:第 k 层最多有 2^k 个节点。
叶子在第 ⌈log₂n⌉ 层(从0开始计数),故树高 h = ⌈log₂n⌉。

📐 单点修改的复杂度
从根节点出发,每层只选择左或右一个分支 → 每层访问 1 个节点。
总共经过 h 层 → 访问 h = ⌈log₂n⌉ 个节点。
每个节点做 O(1) 工作(pushup) → 总时间 O(log n)

📐 区间查询的复杂度(关键难点)
查询区间 [ql, qr] 在每层可能"分裂"为两个递归分支。
引理:每层最多有 4 个节点被部分覆盖(即需要继续递归)。

证明:在任意一层,所有被访问的节点区间是从左到右排列、互不相交的。
• 最左边的节点:左边界可能与 [ql, qr] 部分重叠(但右边界一定在 qr 左边)
• 最右边的节点:右边界可能与 [ql, qr] 部分重叠
• 中间的所有节点:要么完全在 [ql, qr] 内(直接返回),要么完全在外(不访问)
因此每层最多 2 个"部分覆盖"节点需要递归,另外最多 2 个"完全覆盖"节点直接返回。
每层最多访问 4 个节点 → 总时间 O(4 × log n) = O(log n)
查询 [3,7] 时各层访问情况(n=8) 第0层 [1,8] ← 部分覆盖,递归两路 第1层 [1,4] [5,8] ← 两个都部分覆盖 第2层 [1,2] [3,4] [5,6] [7,8] 不访问 完全覆盖✓ 完全覆盖✓ 部分 第3层 [7] [8] 完全覆盖✓ 不访问 = 部分覆盖(需递归) = 完全覆盖(直接返回) = 不在查询范围(不访问) 每层最多访问 4 个节点 → O(log n)
操作每层访问节点数层数总复杂度
单点修改1⌈log₂n⌉O(log n)
区间查询≤ 4⌈log₂n⌉O(log n)
建树n(全部)⌈log₂n⌉O(n)
关键认知:线段树的效率来自二分结构。无论查询区间多大,每层只 splits 成 O(1) 个分支, 所以总工作量被"锁死"在 O(log n)。这是后面懒标记 O(log n) 的基础。
✏️
🎯 新问题:区间修改 + 区间查询
给定长度为 n 的数列 a[],要求支持:
① 将区间 [l, r] 的每个元素都加上 v(区间修改)
② 查询区间 [l, r] 的元素之和(区间查询)
朴素做法:逐点修改

最直接的想法:对 [l, r] 中的每个位置逐一调用单点修改

// 朴素区间修改:逐一调用单点修改 for (int i = l; i <= r; i++) { update(1, 1, n, i, v); // 每次 O(log n),共调用 (r-l+1) 次 }
📐 朴素做法复杂度分析
设区间长度为 k = r - l + 1
每次单点修改:O(log n)
总时间:O(k × log n)

最坏情况:k = n(修改整个区间)→ O(n log n)
如果有 m 次操作 → 总计 O(m × n log n)
当 n = m = 10⁵ 时:10⁵ × 10⁵ × 17 ≈ 1.7 × 10¹¹ 次运算 → 严重超时!
朴素做法 vs 懒标记做法对比 ❌ 朴素:逐点修改 [2,7] 加3 a[2] a[3] a[4] a[5] a[6] a[7] 逐一修改 6 个叶子节点 涉及 6 × log 8 = 18 次节点访问 复杂度:O(n log n) ✅ 懒标记:整段打标记 节点[2,4] tag=3 节点[5,7] tag=3 仅在 O(log n) 个节点上打标记 仅涉及 log 8 = 3 次节点访问 复杂度:O(log n)
核心目标:将区间修改的复杂度从 O(n log n) 降低到 O(log n)
关键洞察:如果一个节点代表的区间完全被修改区间覆盖,就不需要继续向下递归了——把修改信息"暂存"在该节点上即可。
✏️
🏷️ 懒标记(Lazy Tag)思想
完全包含在修改区间内的节点:
① 直接更新该节点的 sum += v × 区间长度
② 打上懒标记 lazy += v(表示"子树中每个元素都需要加 v")
不再继续向下递归——等到查询/修改需要用到子节点时再下传(pushdown)
图解:对区间 [1,8] 的线段树,执行"将 [2,6] 加上 3"

初始数组 a = [1, 2, 3, 4, 5, 6, 7, 8],线段树维护区间和。

Step 1:修改前的线段树(区间和) [1,8] 36 [1,4] 10 [5,8] 26 [1,2] 3 [3,4] 7 [5,6] 11 [7,8] 15 [1]1 [2]2 [3]3 [4]4 [5]5 [6]6 [7]7 [8]8 修改区间 [2, 6],每个元素加 3
💡 观察:位置 2、3、4、5、6 需要加 3。在线段树上,[2,2]、[3,4]、[5,6] 这些节点可以整段覆盖,无需递归到叶子。
而位置 1 和 7、8 不在修改范围内,不受影响。
✏️
Step 2:执行 update(1,1,8, 2,6, 3) — 蓝色=访问路径,红色=打tag节点,绿色=不受影响 [1,8] 51 [1,4] 19 [5,8] 32 [1,2] 6 [3,4] 13 tag=3 [5,6] 17 tag=3 [7,8] 15 [1]1 [2]5 [3]3? [4]4? [5]5? [6]6? = 完全覆盖,打tag(不再递归子树) = 部分覆盖,需继续递归 = 不在修改范围,跳过 = 未更新的叶子 关键:[3,4] 和 [5,6] 被打上 tag=3,它们的子节点暂时不更新(虚线),等到查询时再 pushdown!
pushdown 机制的数学推导
📐 核心公式推导
设节点 u 维护区间 [l, r],懒标记 tag = v,区间中点 mid = (l+r)/2

含义:tag = v 表示"该区间的每个元素还需要加 v"(尚未传递给子节点)

pushdown 操作(将 u 的标记下传给左右儿子):

① 左儿子维护 [l, mid],共 (mid - l + 1) 个元素
   左儿子 sum 增加量 = v × (mid - l + 1)
   左儿子 lazy 累加 v

② 右儿子维护 [mid+1, r],共 (r - mid) 个元素
   右儿子 sum 增加量 = v × (r - mid)
   右儿子 lazy 累加 v

③ 清空当前节点标记:lazy[u] = 0

为什么 sum 增量 = tag × 区间长度?
tag = v 表示区间内每个元素都要加 v
区间共有 len 个元素 → 区间总和增加 v × len
这就是为什么区间和的修改可以直接在节点上完成!
💡 "懒"的本质:tag 是一个"待执行的批量修改指令"。我们把它存在节点上,只有当真正需要访问子节点时,才把这个指令"分摊"给左右儿子。这就是延迟传播的思想。
✏️
场景:上一步已对 [2,6] 加 3,现在查询 query(1,1,8, 3,5)

查询区间 [3,5] 的和。需要从根节点出发,途中遇到 tag 就 pushdown。
注意:如果节点被查询区间完全覆盖,则直接返回 tree[u],不需要 pushdown

查询 query(3,5) 时的执行路径(修正版) Step ① 到达根 [1,8],sum=51, tag=0 [3,5] 部分覆盖 [1,8] → 需递归。tag=0 无需 pushdown。mid=4 → 向左、向右都走 [1,8]tag=0 Step ② 到达 [1,4],sum=19, tag=0 [3,5] 部分覆盖 [1,4] → 需递归。tag=0 无需 pushdown。mid=2 → ql=3 > mid → 只向右 [1,4]tag=0 Step ③ 到达 [3,4],sum=13, tag=3 ← 有标记,但完全覆盖! [3,5] 完全覆盖 [3,4](3≤3 且 4≤5)→ 直接返回 tree[u]=13,不 pushdown! [3,4]返回13 Step ④ 回溯到根,向右到 [5,8],sum=32, tag=0 [3,5] 部分覆盖 [5,8] → 需递归。tag=0 无需 pushdown。mid=6 → ql=3 ≤ 6 → 只向左 [5,8]tag=0 Step ⑤ 到达 [5,6],sum=17, tag=3 → 部分覆盖 → pushdown!左[5] sum+=3→8 | 右[6] sum+=3→9 | tag清零 然后递归到 [5] 返回 8
📐 查询结果汇总
query(3, 5) = query(左路) + query(右路)
= 13([3,4] 完全覆盖,直接返回 sum)+ 8([5,6] pushdown 后递归到 [5] 返回 8)
= 21

验证:修改后 a = [1, 5, 6, 7, 8, 9, 7, 8],a[3]+a[4]+a[5] = 6+7+8 = 21
⚠️ 易错点:很多初学者以为"遇到有 tag 的节点就要先 pushdown"。
正确做法:先判断是否完全覆盖。如果 ql ≤ l && r ≤ qr,直接 return tree[u]!
因为 tree[u] 的值已经包含了 tag 的效果(打 tag 时就更新过 sum 了),不需要 pushdown 来"激活"。
关键认知:pushdown 只在"必须用到子节点信息"时才执行。
每次操作(修改/查询)从根到叶子最多经过 log n 层,每层最多触发常数次 pushdown,所以总复杂度仍为 O(log n)。
✏️
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; long long tree[4 * MAXN]; // 线段树数组,维护区间和 long long lazy[4 * MAXN]; // 懒标记数组,记录待下传的增量 int a[MAXN]; // 原始数组 // ===================== 建树 ===================== void build(int u, int l, int r) { lazy[u] = 0; // 初始化懒标记为 0 if (l == r) { tree[u] = a[l]; // 叶子节点:直接赋原始值 return; } int mid = (l + r) / 2; build(2 * u, l, mid); // 递归建左子树 build(2 * u + 1, mid + 1, r); // 递归建右子树 tree[u] = tree[2 * u] + tree[2 * u + 1]; // pushup:合并区间和 } // ===================== pushdown:懒标记下传 ===================== void pushdown(int u, int l, int r) { if (lazy[u]) { // 只有 tag 非零时才需要下传 int mid = (l + r) / 2; tree[2 * u] += lazy[u] * (mid - l + 1); // 左儿子区间和 += tag × 左半长度 lazy[2 * u] += lazy[u]; // 左儿子懒标记累加 tag tree[2 * u + 1] += lazy[u] * (r - mid); // 右儿子区间和 += tag × 右半长度 lazy[2 * u + 1] += lazy[u]; // 右儿子懒标记累加 tag lazy[u] = 0; // 标记已传递,清零 } } // ===================== 区间修改:[ql, qr] 每个元素加 v ===================== void update(int u, int l, int r, int ql, int qr, long long v) { if (ql <= l && r <= qr) { // 完全覆盖 → 打标记,不递归 tree[u] += v * (r - l + 1); // 区间和 += v × 区间长度 lazy[u] += v; // 懒标记累加 v return; } pushdown(u, l, r); // ⚠️ 关键!访问子节点前必须下传标记 int mid = (l + r) / 2; if (ql <= mid) update(2 * u, l, mid, ql, qr, v); // 左半有交集 if (qr > mid) update(2 * u + 1, mid + 1, r, ql, qr, v); // 右半有交集 tree[u] = tree[2 * u] + tree[2 * u + 1]; // pushup:回溯时更新当前节点 } // ===================== 区间查询 ===================== long long query(int u, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[u]; // 完全覆盖,直接返回 pushdown(u, l, r); // ⚠️ 查询前也要 pushdown! int mid = (l + r) / 2; long long ans = 0; if (ql <= mid) ans += query(2 * u, l, mid, ql, qr); if (qr > mid) ans += query(2 * u + 1, mid + 1, r, ql, qr); return ans; }
建树:O(n)
区间修改:O(log n)
区间查询:O(log n)
pushdown:O(1) 每次
三步口诀:pushdown → 递归 → pushup
修改和查询函数中,只要需要访问子节点,必须先 pushdown
忘记 pushdown 是初学者最常见的错误。
✏️
为什么加了懒标记,区间修改仍然是 O(log n)?
📐 关键观察:区间修改的访问模式
区间修改 update(ql, qr) 的递归过程与区间查询 query(ql, qr) 完全类似

① 如果当前节点 [l,r] 完全被 [ql,qr] 覆盖 → 打标记,O(1) 直接返回
② 否则 → pushdown(O(1)),然后递归到至多 2 个子节点

📐 与区间查询的对比
区间查询区间修改(懒标记)
完全覆盖直接返回 tree[u],O(1)打标记 tree[u]+=v×len, lazy[u]+=v,O(1)
部分覆盖pushdown + 递归pushdown + 递归
不覆盖不访问不访问

📐 结论
两者的访问模式完全相同!由前面的证明:每层最多访问 4 个节点。
每个节点做 O(1) 工作(pushdown / 打标记 / pushup 都是常数操作)。
树高 = ⌈log₂n⌉ → 总时间 O(log n)

📐 pushdown 的"分摊"分析
疑问:pushdown 会不会导致连锁反应,让复杂度爆炸?
答:不会!每次 pushdown 只处理当前节点的 tag(O(1)),且清零后不会重复处理。
一次操作中,沿路径最多 log n 个节点需要 pushdown → 额外开销 O(log n)。
总开销 = 访问节点 O(log n) + pushdown O(log n) + pushup O(log n) = O(log n)
区间修改 vs 区间查询:每层访问模式完全一致 query(ql, qr) 访问路径 第0层:1个节点(根) → 部分覆盖 → 递归 第1层:≤2个节点 → 完全覆盖的直接返回,部分的递归 第k层:≤4个节点 → 每层O(1) × log n层 = O(log n) update(ql, qr, v) 访问路径 第0层:1个节点(根) → 部分覆盖 → pushdown + 递归 第1层:≤2个节点 → 完全覆盖的打标记返回,部分的递归 第k层:≤4个节点 → 每层O(1) × log n层 = O(log n)
一句话总结:懒标记没有改变线段树的访问模式,只是把"完全覆盖"时的操作从"读取"变成了"打标记"。 因此复杂度保持 O(log n) 不变。
✏️
洛谷 P3372 【模板】线段树 1
给定数列 a[1..n],支持两种操作:
① 1 x y k:将区间 [x, y] 每个数加上 k
② 2 x y:查询区间 [x, y] 的元素之和
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; long long tree[4 * MAXN], lazy[4 * MAXN]; int a[MAXN]; void build(int u, int l, int r) { lazy[u] = 0; if (l == r) { tree[u] = a[l]; return; } int mid = (l + r) / 2; build(2 * u, l, mid); build(2 * u + 1, mid + 1, r); tree[u] = tree[2 * u] + tree[2 * u + 1]; } void pushdown(int u, int l, int r) { if (lazy[u]) { int mid = (l + r) / 2; tree[2 * u] += lazy[u] * (mid - l + 1); lazy[2 * u] += lazy[u]; tree[2 * u + 1] += lazy[u] * (r - mid); lazy[2 * u + 1] += lazy[u]; lazy[u] = 0; } } void update(int u, int l, int r, int ql, int qr, long long v) { if (ql <= l && r <= qr) { tree[u] += v * (r - l + 1); lazy[u] += v; return; } pushdown(u, l, r); int mid = (l + r) / 2; if (ql <= mid) update(2 * u, l, mid, ql, qr, v); if (qr > mid) update(2 * u + 1, mid + 1, r, ql, qr, v); tree[u] = tree[2 * u] + tree[2 * u + 1]; } long long query(int u, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[u]; pushdown(u, l, r); int mid = (l + r) / 2; long long ans = 0; if (ql <= mid) ans += query(2 * u, l, mid, ql, qr); if (qr > mid) ans += query(2 * u + 1, mid + 1, r, ql, qr); return ans; } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; i++) scanf("%d", &a[i]); build(1, 1, n); // 建树 for (int i = 0; i < m; i++) { int op, x, y; long long k; scanf("%d", &op); if (op == 1) { scanf("%d%d%lld", &x, &y, &k); update(1, 1, n, x, y, k); // 区间加 } else { scanf("%d%d", &x, &y); printf("%lld\n", query(1, 1, n, x, y)); // 区间求和 } } return 0; }
✏️
执行模拟:n=8, a=[1,2,3,4,5,6,7,8]
操作序列模拟(展示数据结构状态变化) 操作1:update [2,6] +3 访问: [1,8]→[1,4]→[1,2]→[2,2](叶子+3) | [3,4](整段tag=3) | [5,8]→[5,6](整段tag=3) a → [1,5,6,7,8,9,7,8] sum([1,8])=51 操作2:query [1,4] 路径: [1,8]→[1,4](整段覆盖,sum=19,直接返回) 注:[1,4]无tag,不需pushdown 结果 = 19 ✅ 验证: 1+5+6+7 = 19 操作3:query [3,6] [3,4](tag=3,完全覆盖,返回13) | [5,6](tag=3,pushdown→[5]=8,[6]=9,返回sum=17) 结果 = 13+17 = 30 ✅ 验证: 6+7+8+9 = 30 操作4:update [4,7] +2 注意:[5,6]的tag在操作3中被pushdown清零了,需重新处理。[3,4]的tag在操作3中未触发pushdown仍保留
⚠️ 常见错误总结
❌ 错误1:忘记 pushdown
update 或 query 递归到子节点前,没有先调用 pushdown。
后果:子节点的值不包含父节点累积的修改,查询结果错误。
规则:只要要访问子节点(递归进入左右儿子),就必须先 pushdown。
❌ 错误2:pushdown 后忘记 pushup
update 函数中,递归修改子节点后,忘记用 tree[u] = tree[2*u] + tree[2*u+1] 更新父节点。
后果:父节点的 sum 过时,后续查询出错。
规则:update 递归返回后,必须 pushup。
❌ 错误3:数组开太小
线段树数组应开 4 × MAXN。如果只开 2 × MAXN 或 3 × MAXN,可能越界。
原因:n 个叶子的完全二叉树最多有 4n 个节点(当 n 不是 2 的幂时)。
❌ 错误4:long long 溢出
当 v × (r-l+1) 超出 int 范围时会溢出。tree 和 lazy 数组应使用 long long。
经验:CSP 竞赛中,线段树的 sum 和 lazy 一律用 long long。
❌ 错误5:在完全覆盖时还做 pushdown
query 中如果节点被完全覆盖就直接 return,不需要 pushdown。
虽然多做一次 pushdown 不会导致错误答案,但会浪费时间,且可能让初学者逻辑混乱。
💡 调试技巧:如果答案不对,在 pushdown 前后打印 tag 和 sum 值,检查是否正确传递。也可以手写小数据模拟。
✏️
🎯 问题场景
给定数列 a[],需要支持三种操作:
① 区间乘法:a[l..r] 每个数乘以 v
② 区间加法:a[l..r] 每个数加上 v
③ 区间求和:查询 a[l..r] 的和(对 p 取模)
核心难点:两个懒标记的优先级
关键规则:乘法优先
每个节点维护的信息始终表示为:
实际值 = 存储值 × mul标记 + add标记

即对于一个区间 [l, r] 中的元素 a[i]:
a[i]的实际值 = a[i] × mul + add(mul 和 add 是该节点到根路径上所有标记累积的结果)
⚙️ 先搞懂代码中的 l, r, ql, qr
这四个参数在每次递归中都会出现,理解它们是读懂线段树代码的关键!

l, r当前递归到的节点所代表的区间范围。例如根节点是 [1, n],其左儿子是 [1, mid],右儿子是 [mid+1, n]。
ql, qr用户要求的查询/修改的目标区间。在整个递归过程中保持不变

核心判断:当前节点与目标区间的关系
ql <= l && r <= qr → 当前节点 完全被目标区间包含 → ✅ 直接更新/返回,打标记即可
② 否则 → 当前节点只被目标区间部分覆盖 → ⚠️ 必须先 pushdown,再递归进子节点

记忆:完全包含→打标记不走;部分覆盖→先下传再递归
标记复合规则(核心公式推导)
📐 当一个节点已有标记 (mul, add) 时:
节点实际含义:区间内每个元素 a[i] → a[i] × mul + add
区间和:sum = (Σa[i]) × mul + add × len

📐 情况一:新到来一个乘法操作 "× new_mul"
原变换:a[i] → a[i] × mul + add
再乘 new_mul:a[i] → (a[i] × mul + add) × new_mul = a[i] × (mul × new_mul) + (add × new_mul)
所以新标记:mul' = mul × new_mul,add' = add × new_mul

📐 情况二:新到来一个加法操作 "+ new_add"
原变换:a[i] → a[i] × mul + add
再加 new_add:a[i] → a[i] × mul + add + new_add
所以新标记:mul' = mul,add' = add + new_add
💡 记忆口诀:"乘法优先,乘法改变一切"
• 乘法到来时:mul 和 add 都要乘以 new_mul(因为 add 本身也被乘了)
• 加法到来时:只有 add 加上 new_add(mul 不受影响)
• 可以理解为:每个节点维护的是 y = mul·x + add 这个一次函数
pushdown 顺序:先乘后加
📐 pushdown 的严格顺序
对子节点 v 下传父节点 u 的标记 (mul[u], add[u]):

第一步:下传 mul
sum[v] = sum[v] × mul[u]   (子节点的区间和也要乘 mul)
add[v] = add[v] × mul[u]   (子节点的 add 标记也要乘 mul)
mul[v] = mul[v] × mul[u]

第二步:下传 add
sum[v] = sum[v] + add[u] × len[v]   (区间和加上 add × 区间长度)
add[v] = add[v] + add[u]

第三步:清零
mul[u] = 1,add[u] = 0
❌ 常见错误
mul 初始值设为 0:mul 是乘法单位元,必须是 1
先下传 add 再下传 mul:顺序反了会导致 add 被重复作用
sum 没有同步更新:pushdown 时必须同时更新 sum[v]、add[v]、mul[v]
忘记取模:乘法和加法都要对 p 取模!
❌ 另一个常见错误:pushdown 中 add 和 mul 下传顺序混淆
错误写法:先 add 后 mul → add[u] 先作用到 sum[v] 上,然后又被 mul[u] 乘了 → 多加了
正确写法:必须先 mul 后 add,因为 mul 要作用于"原始的 sum 和 add"
✏️
🎯 问题:初始数组 a = [1, 2, 3],模数 p = 1000000007
依次执行:① 区间 [1,3] ×2 ② 区间 [1,3] +3 ③ 区间 [1,3] ×4
观察标记如何复合——不需要提前 pushdown,标记自然累积!
逐步推演:标记的复合过程
📐 初始状态
根节点(覆盖 [1,3]):mul = 1, add = 0, sum = 1+2+3 = 6
实际含义:a[i] → a[i] × 1 + 0 = a[i](恒等变换)

📐 操作①:[1,3] ×2(乘法操作)
当前节点完全被 [1,3] 包含(ql≤l && r≤qr 成立),直接更新标记:
• sum = 6 × 2 = 12
• mul = 1 × 2 = 2
• add = 0 × 2 = 0(add 也被乘了!)
验证:实际值 = a[i]×2 + 0 → [2, 4, 6],sum = 12 ✓

📐 操作②:[1,3] +3(加法操作)
当前节点完全被 [1,3] 包含,直接更新标记:
• sum = 12 + 3×3 = 21
• mul = 2(不变)
• add = 0 + 3 = 3
验证:实际值 = a[i]×2 + 3 → [5, 7, 9],sum = 21 ✓

📐 操作③:[1,3] ×4(再次乘法操作)
当前节点完全被 [1,3] 包含,直接更新标记:
• sum = 21 × 4 = 84
• mul = 2 × 4 = 8
• add = 3 × 4 = 12(add 又被乘了!)
验证:实际值 = a[i]×8 + 12 → [20, 28, 36],sum = 84 ✓

✅ 最终结果
标记 (mul=8, add=12),即 a[i] 最终 = 原始a[i] × 8 + 12
a[1]=1×8+12=20, a[2]=2×8+12=28, a[3]=3×8+12=36
整个过程没有一次 pushdown!标记像复合函数一样自然叠加。
什么时候才需要 pushdown?
📐 pushdown 的触发条件
只有当当前节点被目标区间"部分覆盖"时才需要 pushdown

具体来说:当 ql <= l && r <= qr 不成立时,
说明需要递归进入子节点处理,此时必须先把当前节点的标记下传给子节点,
否则子节点上的旧标记会和父节点的新标记冲突。

📐 举例:如果操作②只加 [1,2]
根节点 [1,3] 被 [1,2] 部分覆盖(3 不在目标区间内)
→ 先 pushdown 将 (mul=2, add=0) 下传给左右儿子
→ 递归进入左儿子 [1,2]:完全被包含,打 add=3 标记
→ 不进入右儿子 [3,3]:不在目标区间内
→ 回溯时 pushup 更新根节点的 sum
💡 总结口诀:
完全包含 → 直接打标记,不 pushdown
部分覆盖 → 先 pushdown 清空当前标记,再递归子节点
完全无关 → 直接 return,什么都不做
标记就像"欠债":完全包含时"记一笔账",需要拆分时才"还债"(pushdown)
❌ 常见误解
"每次操作前都要先 pushdown" —— 错!
只有当你要进入子节点时才需要 pushdown。如果当前节点被完全包含,直接修改标记即可,这正是懒标记"懒"的精髓
✏️
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; long long a[MAXN], tree[4*MAXN]; // 区间和 long long ml[4*MAXN], ad[4*MAXN]; // ml=乘法标记, ad=加法标记 int n, m, p; // p 为取模的质 // ===== 建树 ===== void build(int u, int l, int r) { ml[u] = 1; ad[u] = 0; // mul 初始为 1(乘法单位元),add 初始为 0 if (l == r) { tree[u] = a[l] % p; return; } int mid = (l + r) / 2; build(2*u, l, mid); build(2*u+1, mid+1, r); tree[u] = (tree[2*u] + tree[2*u+1]) % p; // pushup } // ===== pushdown:先乘后加,严格顺序! ===== void pushdown(int u, int l, int r) { int mid = (l + r) / 2; // 第一步:下传 mul —— 子节点的 sum 和 add 都要乘 mul[u] tree[2*u] = tree[2*u] * ml[u] % p; // 左儿子区间和 × mul tree[2*u+1] = tree[2*u+1] * ml[u] % p; // 右儿子区间和 × mul ml[2*u] = ml[2*u] * ml[u] % p; // 左儿子 mul 标记复合 ml[2*u+1] = ml[2*u+1] * ml[u] % p; // 右儿子 mul 标记复合 ad[2*u] = ad[2*u] * ml[u] % p; // 左儿子 add 也要 × mul ad[2*u+1] = ad[2*u+1] * ml[u] % p; // 右儿子 add 也要 × mul // 第二步:下传 add —— 子节点的 sum 加上 add × 区间长度 tree[2*u] = (tree[2*u] + ad[u] * (mid - l + 1)) % p; tree[2*u+1] = (tree[2*u+1] + ad[u] * (r - mid)) % p; ad[2*u] = (ad[2*u] + ad[u]) % p; // 左儿子 add 标记复合 ad[2*u+1] = (ad[2*u+1] + ad[u]) % p; // 右儿子 add 标记复合 // 第三步:清零父节点标记 ml[u] = 1; ad[u] = 0; }
// ===== 区间乘法修改 ===== void updateMul(int u, int l, int r, int ql, int qr, long long v) { if (ql <= l && r <= qr) { tree[u] = tree[u] * v % p; // 区间和 × v ml[u] = ml[u] * v % p; // mul 标记复合 ad[u] = ad[u] * v % p; // add 也要 × v(加法标记被乘法改变了!) return; } pushdown(u, l, r); int mid = (l + r) / 2; if (ql <= mid) updateMul(2*u, l, mid, ql, qr, v); if (qr > mid) updateMul(2*u+1, mid+1, r, ql, qr, v); tree[u] = (tree[2*u] + tree[2*u+1]) % p; // pushup } // ===== 区间加法修改 ===== void updateAdd(int u, int l, int r, int ql, int qr, long long v) { if (ql <= l && r <= qr) { tree[u] = (tree[u] + v * (r - l + 1)) % p; // 区间和 += v × 长度 ad[u] = (ad[u] + v) % p; // add 标记 += v return; } pushdown(u, l, r); int mid = (l + r) / 2; if (ql <= mid) updateAdd(2*u, l, mid, ql, qr, v); if (qr > mid) updateAdd(2*u+1, mid+1, r, ql, qr, v); tree[u] = (tree[2*u] + tree[2*u+1]) % p; // pushup } // ===== 区间查询 ===== long long query(int u, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[u]; pushdown(u, l, r); int mid = (l + r) / 2; long long ans = 0; if (ql <= mid) ans = (ans + query(2*u, l, mid, ql, qr)) % p; if (qr > mid) ans = (ans + query(2*u+1, mid+1, r, ql, qr)) % p; return ans; } // ===== 主函数 ===== int main() { scanf("%d%d%d", &n, &m, &p); for (int i = 1; i <= n; i++) scanf("%lld", &a[i]); build(1, 1, n); while (m--) { int op; scanf("%d", &op); if (op == 1) { // 区间乘法 int l, r; long long v; scanf("%d%d%lld", &l, &r, &v); updateMul(1, 1, n, l, r, v % p); } else if (op == 2) { // 区间加法 int l, r; long long v; scanf("%d%d%lld", &l, &r, &v); updateAdd(1, 1, n, l, r, v % p); } else { // 区间求和 int l, r; scanf("%d%d", &l, &r); printf("%lld\n", query(1, 1, n, l, r)); } } return 0; }
💡 关键点回顾:
ml 初始为 1(乘法单位元),ad 初始为 0(加法单位元)
② pushdown 中必须先处理 mul 再处理 add,否则 add 会被重复作用
③ 区间乘法时,ad 也要 × v(因为 ad 本身也被乘了)
④ 所有运算都要对 p 取模,注意 long long 防止溢出
✏️
三种维护属性对比
属性结合律分配律懒标记单位元
区间和 sum✓(乘/加均可)✓ 区间加/乘0
区间最值 max/min✓(区间加)✓ 区间加-∞ / +∞
区间 GCD不支持区间加0
各类 pushup 公式汇总
📐 区间和
tree[u] = tree[2u] + tree[2u+1]

📐 区间最值
tree[u] = max(tree[2u], tree[2u+1]) 或 min(tree[2u], tree[2u+1])

📐 区间 GCD
tree[u] = __gcd(tree[2u], tree[2u+1])

📐 区间异或
tree[u] = tree[2u] ^ tree[2u+1] (单位元为 0)
💡 "线段树能维护什么"的判断标准:
① 操作是否满足结合律?→ 决定了能否分治合并(pushup)
② 操作是否有分配律?→ 决定了能否用懒标记批量更新
③ 操作的单位元是什么?→ 查询时的初始值
满足①就一定能用线段树;满足①+②就能用懒标记优化区间修改
常见变体题型
区间最大子段和(P4513 小白逛公园)
维护四个值:pre(最大前缀和)、suf(最大后缀和)、sum(区间和)、ans(最大子段和)
pushup 时合并:ans = max(左.ans, 右.ans, 左.suf + 右.pre)
区间历史最值
在懒标记基础上额外维护"历史最大值"标记,pushdown 时同时传递历史最值
区间赋值 + 区间加组合
赋值可以看作"先清零再加"。两个标记的优先级:赋值标记优先于加法标记
(有赋值标记时,加法标记被覆盖为 0)
区间开方(P4145 上帝造题的七分钟)
关键性质:一个数开方几次后就变成 0 或 1,不再变化
做法:维护区间 max,当 max ≤ 1 时跳过;否则暴力下传到叶子逐个开方
✏️
🎯 问题
给定数列 a[],支持:① 单点修改 a[pos]=val ② 查询 gcd(a[l], a[l+1], ..., a[r])
为什么 GCD 可以用线段树维护?
📐 GCD 的结合律证明
定理:gcd(a, b, c) = gcd(gcd(a, b), c) = gcd(a, gcd(b, c))

证明:设 d | a, d | b, d | c(d 是公因子),则:
• d | gcd(a,b)(因为 d 同时整除 a 和 b)
• 所以 d | gcd(gcd(a,b), c)
• 反之亦然。因此两边的最大公因子相等。

📐 线段树合并方式
因为 GCD 满足结合律,所以可以像 sum 一样分治合并:
tree[u] = gcd(tree[2u], tree[2u+1])
特殊值:gcd(x, 0) = x,所以查询时初始值设为 0。

📐 与 sum 线段树的对比
sum 线段树GCD 线段树
pushuptree[u] = tree[2u] + tree[2u+1]tree[u] = __gcd(tree[2u], tree[2u+1])
查询初始值ans = 0(加法单位元)ans = 0(gcd单位元:gcd(x,0)=x)
合并操作ans += 左 + 右ans = __gcd(ans, 左); ans = __gcd(ans, 右)
懒标记支持区间加不支持区间加!(gcd无分配律)
代码实现(逐行注释)
// pushup:用 gcd 合并 tree[u] = __gcd(tree[2 * u], tree[2 * u + 1]); // 区间 GCD 查询 long long queryGCD(int u, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[u]; // 完全覆盖直接返回 int mid = (l + r) / 2; long long ans = 0; // gcd(x, 0) = x,初始为0(单位元) if (ql <= mid) ans = __gcd(ans, queryGCD(2*u, l, mid, ql, qr)); if (qr > mid) ans = __gcd(ans, queryGCD(2*u+1, mid+1, r, ql, qr)); return ans; } // 单点修改 void updatePoint(int u, int l, int r, int pos, long long val) { if (l == r) { tree[u] = val; return; } // 叶子直接赋值 int mid = (l + r) / 2; if (pos <= mid) updatePoint(2*u, l, mid, pos, val); else updatePoint(2*u+1, mid+1, r, pos, val); tree[u] = __gcd(tree[2*u], tree[2*u+1]); // pushup }
区间 GCD 线段树:a = [12, 8, 6, 4] [1,4] gcd=2 [1,2] gcd=4 [3,4] gcd=2 12a[1] 8a[2] 6a[3] 4a[4] gcd(12,8)=4 gcd(6,4)=2 gcd(4,2)=2
📐 复杂度分析
单点修改:O(log n)(与普通线段树相同)
区间查询:O(log n × log(max_val))
• 线段树访问 O(log n) 个节点
• 每次 __gcd 运算 O(log(max_val))(辗转相除法)
• 总时间:O(log n × log(max_val))

例:n=10⁵, max_val=10⁹ → log n ≈ 17, log(max_val) ≈ 30 → 17×30 ≈ 510 次运算/查询
💡 进阶技巧:区间加 + 区间 GCD 查询 = 差分 + 线段树
性质:gcd(a[l], ..., a[r]) = gcd(a[l], a[l+1]-a[l], a[l+2]-a[l+1], ..., a[r]-a[r-1])
即原数组的 GCD = a[l] 与差分数组区间 GCD 的 GCD。
差分后,区间加变成了两次单点修改!这样就避免了 GCD 不支持区间加的问题。
对应练习题
P1890 gcd区间(普及):纯区间 GCD 查询,用线段树/ST表均可,是本题最直接的练手题
P10463(普及+/提高−):在差分数组上同时支持查区间 GCD 和区间加,是进阶技巧的实战应用
✏️

🌐 在线处理 vs 离线处理

在数据结构题中,"在线"和"离线"是两种根本不同的处理范式。线段树天然支持在线,但有些场景离线反而更简单。

🔹 核心定义
在线处理离线处理
处理方式 每读入一个操作就立即处理并输出,不依赖后续操作 读完所有操作,可以重新排序、分批处理后统一输出
输入依赖 第 i 个操作的输入可能依赖第 i-1 个操作的答案(如加密/解码) 所有操作一开始就全部已知,无依赖关系
输出时机 处理完一个操作就输出一个答案 所有操作处理完后,按原始顺序输出
典型工具 线段树、树状数组、平衡树等动态数据结构 CDQ分治、整体二分、莫队、分块
🔹 什么时候必须在线?
📐 "强制在线"题的特征
题目会说:"答案对 last_ans 取模后才能得到真实的 l, r"

例如:int l = read() % last_ans, r = read() % last_ans;

这意味着你必须先回答上一个查询,才能解码下一个查询的参数。
这种题无法离线——因为你根本不知道"所有操作"是什么。
🔹 什么时候离线更好?
💡 离线可以降低问题维度的典型场景
场景 1:区间第 k 小
• 在线:主席树(可持久化线段树),空间 O(n log n),比较复杂
• 离线:整体二分,把"值域二分"和"查询分治"合在一起,代码更简洁

场景 2:二维偏序(如"每个点左下角有多少点")
• 在线:树套树(线段树套平衡树),常数大
• 离线:CDQ 分治,按第一维排序后分治处理第二维,只需一个树状数组

场景 3:多组区间查询(无修改)
• 在线:线段树每次 O(log n)
• 离线:莫队 / 分块,利用相邻查询的相似性,均摊 O(√n)
🔹 线段树的定位
💡 线段树 = 在线数据结构的核心代表

线段树的每次修改/查询都是 O(log n),天然适合在线处理。
如果题目没有"强制在线"的限制,你可以选择:
• 用线段树在线做(通用、直观)
• 用 CDQ/整体二分/莫队离线做(可能更简单或更快)

CSP 中 90% 的线段树题都是在线的,因为线段树本身就是为在线设计的。离线技巧(CDQ、整体二分)更多出现在提高+/省选难度。
对比维度线段树(在线)CDQ 分治(离线)整体二分(离线)
是否依赖后续操作❌ 不依赖✅ 需要所有操作✅ 需要所有操作
能否处理强制在线✅ 可以❌ 不行❌ 不行
典型复杂度O(n log n)O(n log² n)O(n log n)
实现难度中等较高较高
CSP 出现频率⭐⭐⭐ 高频⭐ 低频⭐ 低频
对应练习题
P3834 可持久化线段树(主席树)(提高):区间第 k 小的在线做法,对比整体二分的离线做法
P4390 [CQOI2016] 矩阵查询(提高+/省选−):CDQ 分治的经典入门题,体会离线如何降维
✏️
问题与思路
经典问题:矩形面积并
给定 n 个矩形,求它们覆盖的总面积(重叠部分只算一次)。
📐 算法步骤(逐步推导)
① 将每个矩形 (x₁, y₁, x₂, y₂) 拆成两个事件:
   在 x = x₁ 处:y 轴区间 [y₁, y₂] 覆盖次数 +1
   在 x = x₂ 处:y 轴区间 [y₁, y₂] 覆盖次数 -1

② 将所有事件按 x 坐标从小到大排序

③ 从左到右扫描(想象一条竖线从左移到右):
   每遇到一个事件,用线段树更新 y 轴的覆盖情况
   线段树需要支持:区间加/减 + 查询"覆盖长度"

④ 相邻两个事件 xᵢ, xᵢ₊₁ 之间:
面积 += (y轴被覆盖的总长度) × (xᵢ₊₁ - xᵢ)
扫描线思想:两个矩形的面积并 y x 矩形A [2,5]×[3,8] 矩形B [5,9]×[2,7] 重叠 扫描线 x=2(+) x=5(+) x=5(-) x=9(-)
关键:线段树维护"覆盖长度"
📐 线段树特殊设计
这里的线段树维护 y 轴的覆盖信息,与普通线段树不同:
• 每个叶子节点代表 y 轴上的一个单位区间 [i, i+1]
• 维护 cnt[u]:该区间被覆盖的次数
• 维护 len[u]:该区间中被覆盖的长度(cnt > 0 的部分)

pushup 规则
• 如果 cnt[u] > 0:该区间全部被覆盖 → len[u] = 区间总长度
• 如果 cnt[u] = 0:len[u] = len[左儿子] + len[右儿子]

📐 为什么不需要 pushdown?
因为 cnt 是"区间加"操作(+1/-1),而且查询的是整棵树的 len[root]。
我们永远只需要知道根节点的"覆盖总长度",不需要查任意子区间。
所以 cnt 只增不减地在路径上累积,不需要下传!这就是"扫描线线段树"的精妙之处。
📐 复杂度分析
• 事件数 = 2n(每个矩形产生 2 个事件)
• 排序事件:O(n log n)
• 每个事件:线段树区间修改 O(log Y),Y 是 y 轴范围
• 总时间:O(n log n + n log Y) = O(n log n)
• 空间:O(n)(线段树 + 事件数组)
完整代码实现
#include <bits/stdc++.h> using namespace std; const int MAXN = 200005; long long len[4*MAXN]; // 被覆盖的长度 int cnt[4*MAXN]; // 覆盖次数 int y[2*MAXN], yn; // 离散化后的 y 坐标 struct Event { int x, y1, y2, type; // type=1 左边界(+1),type=-1 右边界(-1) bool operator<(const Event& o) const { return x < o.x; } } ev[2*MAXN]; // ===== pushup:根据 cnt 更新 len ===== void pushup(int u, int l, int r) { if (cnt[u] > 0) len[u] = y[r+1] - y[l]; // 全部被覆盖 else if (l == r) len[u] = 0; // 叶子节点且未被覆盖 else len[u] = len[2*u] + len[2*u+1]; // 合并子区间 } // ===== 区间修改:[ql, qr] 覆盖次数 +v ===== void update(int u, int l, int r, int ql, int qr, int v) { if (ql <= l && r <= qr) { cnt[u] += v; pushup(u, l, r); return; } int mid = (l + r) / 2; if (ql <= mid) update(2*u, l, mid, ql, qr, v); if (qr > mid) update(2*u+1, mid+1, r, ql, qr, v); pushup(u, l, r); } int main() { int n; scanf("%d", &n); for (int i = 1; i <= n; i++) { int x1, y1, x2, y2; scanf("%d%d%d%d", &x1, &y1, &x2, &y2); ev[2*i-1] = {x1, y1, y2, 1}; // 左边界:+1 ev[2*i] = {x2, y1, y2, -1}; // 右边界:-1 y[2*i-1] = y1; y[2*i] = y2; } // y 坐标离散化 sort(y+1, y+2*n+1); yn = unique(y+1, y+2*n+1) - y - 1; // 事件按 x 排序 sort(ev+1, ev+2*n+1); long long ans = 0; for (int i = 1; i <= 2*n; i++) { if (i > 1) ans += len[1] * (long long)(ev[i].x - ev[i-1].x); // 将 y1, y2 转为离散化后的排名 int ql = lower_bound(y+1, y+yn+1, ev[i].y1) - y; int qr = lower_bound(y+1, y+yn+1, ev[i].y2) - y - 1; if (ql <= qr) update(1, 1, yn-1, ql, qr, ev[i].type); } printf("%lld\n", ans); return 0; }
💡 代码要点
叶子节点对应 y 轴的一个单位区间:第 i 个叶子代表 [y[i], y[i+1]]
不需要 pushdown:因为只查根节点的 len[1],不需要下传 cnt
update 中 ql, qr 是离散化后的排名:用 lower_bound 转换
面积累加:相邻事件之间,面积 += len[1] × Δx
💡 注意:y 坐标可能很大,需要离散化。将所有出现过的 y 坐标排序去重,用排名代替原值。
对应练习题
P5490 【模板】扫描线(提高):矩形面积并模板题,直接套用上述算法
P1856 [IOI1998] 矩形周长(提高):矩形周长并,在面积并基础上维护竖边/横边数量
P1884 [USACO12OPEN] Overplanting(普及+/提高−):矩形面积并变体,数据范围较小可做练习
✏️

🌳 动态开点线段树

当值域很大(如 10⁹)但操作次数较少时,预分配 4n 空间会爆内存。动态开点线段树通过"用到哪开到哪"解决这个问题。

🔹 核心思想
📐 从"预分配"到"按需分配"
普通线段树
• 预分配 tree[4 * MAXN],无论是否用到都占空间
• 值域 n = 10⁹ 时需要 4 × 10⁹ 个节点 → 爆内存

动态开点线段树
• 不预分配,用指针/数组模拟动态分配
• 只创建实际访问到的节点
• 值域 10⁹,操作 10⁵ 次 → 最多 10⁵ × log(10⁹) ≈ 3 × 10⁶ 个节点 ✅
🔹 代码实现
动态开点线段树模板(单点修改 + 区间查询)
const int MAXN = 1e5 + 5;  // 操作次数
const int MAXNODE = MAXN * 30;  // 最多节点数(操作数 × 树高)

struct Node {
    int ls, rs;     // 左右儿子编号(不是 2*u, 2*u+1)
    int sum;         // 维护的信息
} tree[MAXNODE];

int root = 0, cnt = 0;  // 根节点编号,节点计数器

// 新建节点
int newNode() {
    ++cnt;
    tree[cnt].ls = tree[cnt].rs = tree[cnt].sum = 0;
    return cnt;
}

// pushup(和普通线段树一样)
void pushup(int u) {
    tree[u].sum = tree[tree[u].ls].sum + tree[tree[u].rs].sum;
}

// 单点修改:在值域 [l, r] 的线段树中,给位置 pos 加 v
void update(int &u, int l, int r, int pos, int v) {
    if (!u) u = newNode();  // 如果节点不存在,动态创建
    if (l == r) {
        tree[u].sum += v;
        return;
    }
    int mid = l + (r - l) / 2;
    if (pos <= mid) update(tree[u].ls, l, mid, pos, v);
    else update(tree[u].rs, mid + 1, r, pos, v);
    pushup(u);
}

// 区间查询
int query(int u, int l, int r, int ql, int qr) {
    if (!u) return 0;  // 节点不存在,返回 0
    if (ql <= l && r <= qr) return tree[u].sum;
    int mid = l + (r - l) / 2, res = 0;
    if (ql <= mid) res += query(tree[u].ls, l, mid, ql, qr);
    if (qr > mid) res += query(tree[u].rs, mid + 1, r, ql, qr);
    return res;
}
🔹 对比:普通 vs 动态开点
对比维度普通线段树动态开点线段树
节点编号 u 的左儿子 = 2u,右儿子 = 2u+1 u 的左儿子 = tree[u].ls,右儿子 = tree[u].rs
空间分配 预分配 4n 用到哪开到哪(最多 m × log n)
适用场景 值域 n ≤ 10⁶ 值域 n ≤ 10⁹,操作次数 m ≤ 10⁵
代码差异 数组开 4 × MAXN 需要 ls, rs 指针 + newNode 函数
时间复杂度 O(log n) O(log n)(相同)
🔹 模板题解析:P1908 逆序对
📐 题目与思路
题意:给定长度为 n 的序列,求逆序对数量(i < j 且 a[i] > a[j])

数据范围:n ≤ 5 × 10⁵,a[i] ≤ 10⁹
• 值域 10⁹ → 普通线段树需要 4 × 10⁹ 空间 → 爆内存
• 动态开点:操作 n 次,每次 O(log 10⁹) ≈ 30 个节点 → 最多 1.5 × 10⁷ 节点 ✅

算法:从右往左扫描,用线段树维护"已插入的数的分布"
• 插入 a[i] 时,查询 [1, a[i]-1] 的区间和(比 a[i] 小的数的个数)→ 贡献到答案
• 然后将 a[i] 插入线段树(单点 +1)

注意:a[i] 可能为 0,需要整体 +1 避免 lower_bound 出错
P1908 逆序对(动态开点线段树解法)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5e5 + 5;
const int MAXNODE = MAXN * 30;

struct Node { int ls, rs, sum; } tree[MAXNODE];
int root = 0, cnt = 0;

int newNode() { tree[++cnt] = {0, 0, 0}; return cnt; }
void pushup(int u) { tree[u].sum = tree[tree[u].ls].sum + tree[tree[u].rs].sum; }

void update(int &u, int l, int r, int pos, int v) {
    if (!u) u = newNode();
    if (l == r) { tree[u].sum += v; return; }
    int mid = l + (r - l) / 2;
    if (pos <= mid) update(tree[u].ls, l, mid, pos, v);
    else update(tree[u].rs, mid + 1, r, pos, v);
    pushup(u);
}

int query(int u, int l, int r, int ql, int qr) {
    if (!u || ql > qr) return 0;
    if (ql <= l && r <= qr) return tree[u].sum;
    int mid = l + (r - l) / 2, res = 0;
    if (ql <= mid) res += query(tree[u].ls, l, mid, ql, qr);
    if (qr > mid) res += query(tree[u].rs, mid + 1, r, ql, qr);
    return res;
}

int a[MAXN];
int main() {
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);

    long long ans = 0;
    for (int i = n; i >= 1; i--) {
        ans += query(root, 1, 1e9, 1, a[i] - 1);  // 查询比 a[i] 小的数的个数
        update(root, 1, 1e9, a[i], 1);  // 插入 a[i]
    }
    printf("%lld\n", ans);
    return 0;
}
💡 替代方案:这道题也可以用离散化 + 树状数组,代码更短。但动态开点线段树的优势是不需要离散化,直接处理原始值域。
对应练习题
P1908 逆序对(普及+/提高−):动态开点线段树入门题
P3369 【模板】普通平衡树(提高):动态开点维护数的出现次数
P4556 线段树合并(提高+/省选−):动态开点 + 线段树合并
🔹 第一部分:线段树基础应用回顾
知识点适用场景核心操作时间复杂度关键易错点
基础线段树单点修改 + 区间查询pushup 合并O(log n)数组4×MAXN
懒标记区间修改 + 区间查询pushdown 延迟下传O(log n)忘记pushdown/pushup
双懒标记区间乘+加mul/add标记复合O(log n)mul初始为1,先乘后加
区间 GCDGCD 查询__gcd 合并O(log n · log M)不支持区间加,需差分转化
区间最值最大/最小值max/min + lazyO(log n)区间加可直接作用于最值
扫描线矩形面积并离散化 + 线段树维护覆盖O(n log n)cnt计数,无需pushdown
核心规律:线段树能维护的信息必须满足结合律(如 sum, max, min, gcd, xor 等)。
只需要修改 pushup 的合并方式 + 对应的 pushdown(如果需要懒标记),就能适配不同问题!
🔹 第二部分:线段树 vs 其他算法——横向对比

线段树不是万能的——不同数据结构有各自的"甜点区"。

数据结构核心能力时间空间在线/离线实现难度
树状数组 单点加 + 前缀和 O(log n) O(n) 在线 ⭐ 极简
线段树 区间修改 + 区间查询(sum/max/min/gcd) O(log n) O(n) 在线 ⭐⭐ 中等
ST 表 静态 RMQ(无修改) 查询 O(1) O(n log n) 在线 ⭐⭐ 中等
分块 区间操作(万能但慢) O(√n) O(n) 在线 ⭐ 简单
FHQ Treap 区间翻转、分裂合并(文艺平衡树) O(log n) O(n) 在线 ⭐⭐⭐ 较难
李超线段树 维护直线/线段集合,查询最大/最小值 O(log n) O(n) 在线 ⭐⭐⭐ 较难
莫队 区间查询(无修改) O(n√n) O(n) 离线 ⭐⭐ 中等
CDQ 分治 多维偏序问题 O(n log² n) O(n) 离线 ⭐⭐⭐ 较难
✏️

🎯 场景选型与高级变体

🔹 第三部分:具体场景选型指南
💡 如何根据场景选择数据结构?
场景 1:数据范围 n ≤ 10⁵,需要区间修改 + 区间查询
线段树(首选):O(n log n),通用性强,在线支持
分块(备选):O(n√n),实现简单,但常数大

场景 2:数据范围 n ≤ 10⁷,只有单点修改 + 前缀和查询
树状数组(首选):代码极简,常数小,O(n log n)
线段树(过重):功能过剩,代码复杂

场景 3:数据范围 n ≤ 10⁶,只有查询无修改(静态RMQ)
ST 表(首选):查询 O(1),建表 O(n log n)
线段树(过重):查询 O(log n),不如 ST 表快

场景 4:需要维护区间翻转、区间平移(如文艺平衡树)
FHQ Treap(首选):基于随机化的平衡树,支持分裂合并
Splay(备选):经典平衡树,但常数大、难写
线段树(不适合):无法高效处理区间翻转

场景 5:维护若干直线,查询某 x 处的最大/最小 y 值
李超线段树(首选):专门处理直线/线段覆盖问题
普通线段树(不适合):需要额外处理直线交点

场景 6:区间第 k 小(强制在线)
主席树(可持久化线段树):保留历史版本,在线查询
整体二分(离线):如果可以离线,代码更简洁

场景 7:值域很大(如 10⁹),但操作次数少(≤ 10⁵)
动态开点线段树:不预分配 4n 空间,用到哪开到哪
离散化 + 普通线段树:先离散化值域再用普通线段树
🔹 第四部分:线段树的高级变体(拓展视野)
变体核心思想解决什么问题典型题目
动态开点线段树 不预分配 4n 空间,用到哪开到哪 值域很大(如 10⁹)但操作次数少 P1908 逆序对(值域 10⁹)
P3369 普通平衡树
可持久化线段树
(主席树)
每次修改生成新版本,保留历史版本 区间第 k 小、可持久化数据结构 P3834 主席树
P3919 可持久化数组
李超线段树 维护直线集合,查询某 x 处的最值 直线/线段覆盖问题 P4097 [SDOI2013] 线段
P4254 Blue Mary 开公司
线段树合并 两棵线段树对应节点合并 树上问题(如子树信息合并) P4556 线段树合并
P3605 升序数
标记永久化 懒标记不下传,查询时累加 避免 pushdown 的常数,适合可持久化 P3373 线段树 3(标记永久化优化)
🎯 线段树的独特价值

1. 通用性最强:只要能维护结合律信息,线段树都能做
2. 扩展性最强:懒标记、动态开点、可持久化、李超、标记永久化……
3. 在线能力:天然支持强制在线题,不需要提前知道所有操作
4. 思维训练:分治思想、递归思维、懒标记延迟更新——这些是算法核心能力
📐 学习路径建议
CSP 阶段(当前):掌握基础线段树 + 懒标记 + 双懒标记
→ 能解决 80% 的线段树题目

提高+/省选阶段
• 动态开点 → 处理值域很大的问题
• 可持久化 → 区间第 k 小、历史版本查询
• 李超线段树 → 直线覆盖问题
• 线段树合并 → 树上问题
• 标记永久化 → 优化常数

这些变体的核心思想都是"分治"——把大区间分成小区间,递归处理后再合并。掌握了基础线段树,学高级变体会很自然。
✏️
📝 做题顺序建议
先完成基础模板题,再挑战进阶变形,最后尝试综合应用题。
编号题目知识点难度
1P3372 【模板】线段树 1区间加 + 区间和(懒标记基础模板)普及+/提高−
2P3374 【模板】树状数组 1单点加 + 区间和(树状数组对比)普及+/提高−
3P1816 忠诚区间最小值(RMQ 线段树/ST表)普及
4P1198 [JSOI2008] 最大数线段树 + 动态插入 + 区间最大值普及+/提高−
5P1531 I Hate It单点修改 + 区间最大值普及+/提高−
6P3373 【模板】线段树 2双懒标记(乘法+加法)普及+/提高−
7P2023 [AHOI2009] 维护序列双懒标记(P3373 双倍经验)普及+/提高−
8P1253 扶苏的问题区间赋值 + 区间加 + 区间最大值普及+/提高−
9P1890 gcd区间区间 GCD 查询(线段树/ST表)普及
10P1558 色板游戏区间赋值 + 位运算(颜色压缩)普及+/提高−
11P1438 无聊的数列差分 + 线段树(区间修改转化)普及+/提高−
12P4145 上帝造题的七分钟 2区间开方 + 区间求和(势能分析)提高
13P4513 小白逛公园区间最大子段和(维护4个值)提高
14P5490 【模板】扫描线扫描线 + 线段树(矩形面积并)提高
📌 做题顺序建议(由易到难):
第一梯队(基础巩固):① P3372(懒标记模板)→ ② P1816(区间最值)→ ③ P1531(单点修改+区间最值)→ ④ P1198(动态建树)
第二梯队(核心进阶):⑤ P3373(双标记模板)→ ⑥ P2023(双标记练习)→ ⑦ P1253(多标记组合)→ ⑧ P1890(区间GCD)
第三梯队(综合应用):⑨ P1558(位运算)→ ⑩ P1438(差分转化)→ ⑪ P4145(势能分析)→ ⑫ P4513(子段和)→ ⑬ P5490(扫描线)
🔑 做题检查清单:
□ pushdown 是否在访问子节点前调用?
□ update 递归返回后是否 pushup?
□ 数组大小是否 4×MAXN?
□ 是否使用了 long long?
□ 边界条件 l==r 是否正确处理?
🎉
Day 04 完成!
懒标记 Lazy Tag + pushdown 机制 + 双懒标记
区间 GCD + 常见应用速查
pushdown 是线段树的灵魂,务必熟练手写!
线段树应用 懒标记 Lazy Tag 双懒标记 其他应用 pushdown · 区间修改 mul/add标记 · 优先级 GCD · 常见应用
Day 05 预告:最小生成树与Tarjan算法 🔥
记住:线段树的本质是分治 + 信息合并,掌握这个思想,万变不离其宗。
✏️ 🧹