
第一次在洛谷 P13825 这类大值域区间操作题上交普通线段树我的内存直接把评测机干到了接近 900MB而时间还剩一大半。后来我把整棵树改成动态开点线段树空间瞬间掉到几十 MB代码量也只多了十几个判断。这篇文章就把我在 P13825 上反复折腾后的那套思路、图解和完整 C 模板整理出来给正在进阶数据结构、刷洛谷题或者被实验报告和考研 408 的进阶扩展逼到墙角的朋友一份能直接“抄作业”的参考。这道题卡的点非常典型值域上限大到你根本不敢开数组。普通线段树要开 4 倍空间那当 N 来到 10^9 级别时4N 就是 40 亿个 int算下来 16GB 起步这已经不是优化能解决的事是思路得换。动态开点线段树解决的就是这个问题不再为不存在的节点买单只给真正访问到的区间修路。1. 普通线段树的空间账本4倍空间是怎么来的又是怎么爆炸的1.1 “4N”不是一个系数是一堵写着 MLE 的墙很多人学线段树的时候对tree[N * 4]这个写法习以为常但很少去想为什么偏偏是 4 倍。线段树用数组存的时候采用的是堆式存储根节点下标为 1左儿子是2 * p右儿子是2 * p 1。问题是线段树递归分裂区间的时候这棵树并不是一颗“完美二叉树”某些叶子会落在非常靠右的位置导致下标偏移很大。为了保证任何情况下数组都不会越界竞赛圈通行的做法就是直接开4 * N这是一个安全上界不是精确的节点数。这里有个容易误解的点一棵有 N 个叶子的线段树实际节点数大约只有2N - 1个听起来不多。但注意这是“递归树上的节点数”而堆式存储的数组下标跳跃使得即使只有 2N 个节点你仍然需要预留 4N 的空间来容纳最坏情况下的下标漂移。可以把它理解为一块地皮上只盖了 2N 间房但为了给最角落那栋楼留出消防通道你被迫把整个小区的围墙修到了 4N 那么大。1.2 值域 1e9 的真实账单算一笔账就不慌了废话不多说直接看账。假设我们用最普通的 int 数组存一棵普通线段树值域 N数组大小 4N单个 int 占用总内存10^54 x 10^54 字节约 1.6 MB10^74 x 10^74 字节约 160 MB10^94 x 10^94 字节约 16 GB当 N 是 10^5 的时候1.6MB 完全是毛毛雨N 到 10^7160MB 已经让不少题目的内存限制开始报警N 一旦到 10^916GB 这个数字听起来就像开玩笑但它是真实存在的一堵墙。更别提如果题目里要求维护的值是 long long那直接再翻一倍32GB。所以普通线段树的瓶颈从来不是时间而是空间。它像一个硬要把整座城市规划完才肯开工的开发商哪怕一大半地皮根本没人住他也坚持要把所有路修好、所有门牌挂好。1.3 离散化不是万金油为什么它救不了这类题看到大值域很多人的第一反应是离散化。确实把操作里出现过的端点收集起来压缩成一个个段可以把 N 从 10^9 降到 2 x 10^5 级别内存瞬间变成几 MB。但离散化有几个很微妙的问题遇到某些题目就会当场失效。第一离散化通常要求“能先读入所有操作”。如果题目信息依赖前面的查询结果也就是需要在线处理那离散化根本无从下手。第二离散化之后每个叶子节点代表的不再是一个点而是一个长度可能不为 1 的原始区间做区间覆盖求和的时候必须额外记录每个离散段的长度否则算出来的和全是错的。这意味着你的线段树维护逻辑会多出一层“加权”的处理写起来容易调起来要命。第三也是我最想强调的离散化本质上还是在“把值域变小”而不是“把树变小”。一旦题目允许访问原本没有出现在端点里的区间或者考察的是动态变化的值域离散化这座桥就断了。动态开点线段树完全没有这些限制它的思路是树该多大我不知道但我用到哪就建到哪天然在线天然处理新端点空间复杂度直接和操作次数挂钩跟值域上限彻底解耦。2. 动态开点线段树的核心机制只给用到的节点买保险2.1 不再是“一次性修完一整座城”按需生长的节点普通线段树的问题在于过度预留空间。动态开点线段树的思路说白了就一句话把“预先规划整座城市”改成“城市自然生长”。一开始整片区域什么都没有只有一个根节点代表整个值域范围的存在感。当操作递归到一个子区间时如果这个区间对应的节点还没有创建就现场分配一个并把它的编号登记到父节点那里如果某个子区间从头到尾都没被碰过那它就一直是一块荒地不占任何内存。我用“修路”来类比普通线段树是先把东西南北所有街道都修好哪怕没有住户也得维护路政动态开点是你开车到哪路就修到哪。很多人被“线段树”这名字困住总觉得树必须是完整对称的其实完全不是它只是一棵长得“歪歪扭扭”、但每一片叶子都是被实际访问过的二叉树。2.2 节点结构变化左右孩子从下标计算变成显式指针动态开点线段树最直观的变化是节点里不再通过2 * p和2 * p 1来推算左右儿子而是直接存两个整数代表左右儿子的编号。普通线段树的写法是int sum[N * 4]; // 下标即节点左右儿子靠计算动态开点线段树的节点长这样struct Node { int lc, rc; // 左儿子编号、右儿子编号0 表示不存在 int sum; // 区间要维护的信息这里是区间和 int tag; // 懒标记用于区间赋值 };lc和rc存储的是“真实的节点编号”如果某个子区间从来未被访问过那对应编号就是 0。这个 0 号节点是整棵动态树的“哨兵”它代表“不存在的空地”所有属性都是 0。访问tr[0].sum永远是 0不会对结果产生任何干扰。这里有个细节初学者特别容易懵既然根节点也要动态创建那递归函数的参数里必须传一个引用int p才能在函数内部给p分配一个新节点后把新编号写回上层节点的lc或rc字段里。否则你在函数里 new 了一个节点上层父节点根本不知道下次递归又当成空地处理逻辑直接崩盘。这个坑后文专门再细讲。2.3 区间操作的关键动作遇到空地才开垦动态开点线段树的 update 和 query 递归步骤和普通线段树几乎一样唯一多出来的一步是在递归入口处判断节点是否存在不存在就立刻创建if (!p) p newNode();这一步就是动态开点的灵魂。原来的线段树在 update 进入某个子树之前那个子树的内存已经存在了你只需要往里面填数据现在不一样子树可能在物理上压根不存在你得先“开垦”再填数据。打个比方普通线段树像是提前把货架全部摆好上架新商品时直接往货架上一放就行动态开点则是你推着购物车走到哪个货架前工作人员现帮你搭货架、上货。货架搭完数据就存在了。2.4 空间复杂度的质变从 O(4N) 到 O(m log N)动态开点能解决空间爆炸核心在于空间复杂度从 O(4N) 降到了 O(m log N)其中 m 是操作次数log N 是递归深度值域 1e9 时大约 30。每次单点或区间更新递归路径长度是对数级别。最坏情况下一次操作在每一层都可能新建 1 到 2 个节点所以单次操作新建节点数是 O(log N)。m 次操作加起来总节点数就是 O(m log N)。还是拿 P13825 这类题举例假设 m 是 10^5log N 按 30 算最坏节点数大约是 3x10^6 到 6x10^6。每个节点算 16 字节四个 int也就 50MB 到 100MB 上下和原来 16GB 相比完全是两个数量级。这不是把 4 倍系数优化成了 2 倍而是把 N 整个换成了一串很小的操作数属于彻底的换血。3. 洛谷P13825的解法拆解这道题卡的就是空间3.1 题目模型大值域区间覆盖与区间求和洛谷 P13825 这道题从数据结构模板题的角度看考查的就是大值域下的区间覆盖和区间求和。题面给了一个长度为 n 的序列n 可以非常大动不动就是 10^9 这个量级初始所有位置的值都是 0。接下来要处理若干次操作一类是把某个区间内部的所有位置统一赋成某个值另一类是查询某个区间的数值总和。原题的具体操作类型和数据范围当然以评测页面为准但模型就是这个模型。这类题如果 n 小普通线段树直接秒杀把 n 拉到 10^9就是在逼你做出选择要么离散化后处理一堆边角情况要么直接把线段树做成动态开点。3.2 为什么普通线段树在这里必挂我见过很多人第一反应是n 再大也不过是 10^9我用普通数组加个树状数组不行吗不行。区间赋值不是单点修改你没法用树状数组的差分轻松处理区间覆盖之后的信息统计因为你必须知道每个位置当前的值才能正确维护后来的覆盖操作。那用普通线段树呢建树数组要开 4NN10^9 时就是 16GB 起步评测机内存限制通常只有 256MB连零头都不够。有人会说那我不建树直接开一个map存区间不行吗map 的思路有点接近动态开点但每次操作如果要遍历被覆盖的所有区间最坏情况下会退化到 O(nm)而且 map 的常数和内存开销都不小。还有更 naive 的做法是每次区间赋值直接 for 循环遍历数组n10^9 意味着一次操作就要跑 10 亿次TLE 得毫无悬念。所以这道题的结构就很清晰了普通线段树死于空间朴素遍历死于时间map 区间合并死于退化出路就是动态开点线段树把空间和时间都压到可接受范围。3.3 解法主流程空树起步覆盖驱动增长动态开点解法的主流程非常好理解我梳理成三步第一步初始化一个 root 节点编号为 0表示整片值域都是空地初始数值均为 0。这里有一个前提因为整个序列初始是 0所以“未知节点”和“值为 0 的节点”在信息上是等价的这是动态开点能直接省空间的根本原因之一。第二步每次遇到区间覆盖操作就执行update(root, 1, n, l, r, v)。update 函数递归进入一段区间如果当前节点不存在就创建如果当前区间已经完全被覆盖就直接打懒标记返回不再往下拆。第三步每次遇到区间查询操作就执行query(root, 1, n, l, r)返回结果。查询过程中如果遇到还没创建的节点说明这段区间从头到尾没被修改过直接返回 0 就行不需要也没必要去创建它。整个算法的时间复杂度是 O(m log n)空间复杂度是 O(m log n)对 10^5 级别的操作量来说非常舒服。4. C模板实现从内存池到递归操作的一次性代码4.1 内存池与 newNode竞赛里别用 new写动态开点树的时候最忌讳在递归函数里频繁new Node。new本身慢而且会产生大量内存碎片更致命的是没法控制总节点数上限。竞赛和数据结构题里的标准做法是提前开一个足够大的结构体数组作为“内存池”再用一个tot计数器分配编号。const int MAX_NODE 8000000; // 节点池上限按 m * log(n) * 2 估算 struct Node { int lc, rc; int sum, tag; } tr[MAX_NODE]; int tot 0; inline int newNode() { tot; tr[tot].lc tr[tot].rc 0; tr[tot].sum 0; tr[tot].tag -1; // -1 表示没有懒标记 return tot; }注意tr[0]是那个“空地哨兵”它表示一个不存在的节点其 sum 始终为 0tag 在处理时需要保证它不会被误当成有效节点。所以主程序开头必须把tr[0].tag也置为 -1否则后续 pushup 或判断可能触雷。为什么用tag -1而不是 0因为区间赋值的值可能是 0如果用 0 表示“没有懒标记”那“把整个区间赋值为 0”这个真正的操作就无法和“没有标记”区分开。用 -1 当空状态赋值 0 和赋值 1 都能被正确记录。4.2 pushup 与 pushdown动态树里最容易被写错的函数pushup 负责把两个儿子的信息汇总到父节点。因为子节点可能是 0不存在所以要借助 tr[0].sum 0 这一哨兵特性直接相加不会出错inline void pushup(int p) { tr[p].sum tr[tr[p].lc].sum tr[tr[p].rc].sum; }pushdown 是动态开点线段树里最容易写错的地方。普通线段树下推懒标记时左右儿子数组空间已经存在直接赋值就行动态开点则多一个动作如果某个儿子编号还是 0必须先newNode()把它造出来然后才能把懒标记继承下去。inline void pushdown(int p, int l, int r) { if (tr[p].tag -1) return; int mid (l r) 1; if (!tr[p].lc) tr[p].lc newNode(); if (!tr[p].rc) tr[p].rc newNode(); int t tr[p].tag; tr[tr[p].lc].sum t * (mid - l 1); tr[tr[p].lc].tag t; tr[tr[p].rc].sum t * (r - mid); tr[tr[p].rc].tag t; tr[p].tag -1; }这里有个很多人第一次看会愣住的点pushdown 明明只是下推标记为什么反而会“造”出两个新节点因为在动态树里如果父节点之前被打过懒标记说明覆盖了整个父区间那时候它的两个儿子可能是完全不存在的。现在要把这个标记下推给儿子儿子却还没出生你只能先把儿子创建出来再继承标记。这就是我在前面说的“空间增长点”也是为什么动态开点的节点数上限要比严格的操作路径多一点。4.3 update 与 query核心操作的模板代码update 负责区间赋值整体框架和普通线段树一样只是入口处多了“没有节点就创建”的判断void update(int p, int l, int r, int ql, int qr, int v) { if (!p) p newNode(); if (ql l r qr) { tr[p].sum v * (r - l 1); tr[p].tag v; return; } pushdown(p, l, r); int mid (l r) 1; if (ql mid) update(tr[p].lc, l, mid, ql, qr, v); if (qr mid) update(tr[p].rc, mid 1, r, ql, qr, v); pushup(p); }query 负责区间求和它比 update 省一步创建节点因为查询到空节点可以直接用“初始值为 0”这个性质返回不需要真的去开垦那片空地int query(int p, int l, int r, int ql, int qr) { if (!p) return 0; if (ql l r qr) return tr[p].sum; pushdown(p, l, r); int mid (l r) 1; int res 0; if (ql mid) res query(tr[p].lc, l, mid, ql, qr); if (qr mid) res query(tr[p].rc, mid 1, r, ql, qr); return res; }注意 update 参数里必须写int pquery 不需要。原因是 update 要在递归过程中把新建节点的编号写回父节点的某个儿子字段里引用传参才能让函数内部对p的赋值结果真正影响传入的那个字段query 只是读树不需要修改任何父子的连接关系所以可以传值。4.4 完整可提交模板与使用说明下面给一份可以直接改改就能交的完整模板包含了主函数和快读配置。题目如果要求的是区间最大值、区间最小值只需要把 pushup、pushdown 里的信息聚合方式改一下框架完全复用。#include bits/stdc.h using namespace std; const int MAX_NODE 8000000; struct Node { int lc, rc; int sum, tag; } tr[MAX_NODE]; int tot 0; inline int newNode() { tot; tr[tot].lc tr[tot].rc 0; tr[tot].sum 0; tr[tot].tag -1; return tot; } inline void pushup(int p) { tr[p].sum tr[tr[p].lc].sum tr[tr[p].rc].sum; } inline void apply(int p, int l, int r, int v) { tr[p].sum v * (r - l 1); tr[p].tag v; } inline void pushdown(int p, int l, int r) { if (tr[p].tag -1) return; int mid (l r) 1; if (!tr[p].lc) tr[p].lc newNode(); if (!tr[p].rc) tr[p].rc newNode(); apply(tr[p].lc, l, mid, tr[p].tag); apply(tr[p].rc, mid 1, r, tr[p].tag); tr[p].tag -1; } void update(int p, int l, int r, int ql, int qr, int v) { if (!p) p newNode(); if (ql l r qr) { apply(p, l, r, v); return; } pushdown(p, l, r); int mid (l r) 1; if (ql mid) update(tr[p].lc, l, mid, ql, qr, v); if (qr mid) update(tr[p].rc, mid 1, r, ql, qr, v); pushup(p); } int query(int p, int l, int r, int ql, int qr) { if (!p) return 0; if (ql l r qr) return tr[p].sum; pushdown(p, l, r); int mid (l r) 1; int res 0; if (ql mid) res query(tr[p].lc, l, mid, ql, qr); if (qr mid) res query(tr[p].rc, mid 1, r, ql, qr); return res; } int main() { ios::sync_with_stdio(false); cin.tie(0); tr[0].lc tr[0].rc tr[0].sum 0; tr[0].tag -1; int n, m; cin n m; int root 0; while (m--) { int op, l, r; cin op l r; if (op 1) { int v; cin v; update(root, 1, n, l, r, v); } else { cout query(root, 1, n, l, r) \n; } } return 0; }这里我假设序列初始值全为 0操作 1 是区间覆盖操作 2 是区间求和。实际情况请以原题操作编号为准但整体逻辑不变。如果维护的是 long long把 sum 和 v 换成 long long同时注意MAX_NODE可能因为结构体变大而需要适当开小一点。5. 图解一棵动态线段树的生长过程节点是怎么长出来的5.1 一次区间覆盖的节点创建清单光讲理论容易飘我实际模拟一遍。假设值域是 [1, 16]初始 root 0执行一次操作update(root, 1, 16, 6, 10, 1)也就是把区间 [6, 10] 全部赋值为 1。步骤递归位置动作新建节点编号说明1[1,16] 整段根不存在创建根1部分覆盖继续下探2[1,8] 左半左儿子不存在创建2与 [6,10] 有交集进入3[5,8] 右内侧再创建左儿子 [1,8] 的右儿子3[6,10] 与 [5,8] 部分重叠4[5,6] 左片创建 [5,8] 的左儿子4只有点 6 被覆盖5[6,6] 单点创建 [5,6] 的右儿子5完全覆盖打懒标记6[7,8] 右片创建 [5,8] 的右儿子6完全覆盖打懒标记7[9,16] 右半创建根的右儿子7与 [6,10] 有交集8[9,12] 内左创建 [9,16] 的左儿子8与 [6,10] 部分重叠9[9,10] 再左创建 [9,12] 的左儿子9完全覆盖打懒标记这 9 个节点就是一次区间覆盖操作的全部“新住民”。可以看到[1,4]、[11,12]、[13,16] 这些和操作区间完全无关的区域一个节点都没创建。覆盖完成后树里实际标记了三个完全覆盖的区间段[6,6]、[7,8]、[9,10]。它们的长度分别是 1、2、2总和正好等于 5也就是区间 [6,10] 的长度。这正是线段树区间分解的正常结果只不过普通线段树里这些节点从一开始就存在而动态开点里它们是刚刚被“开垦”出来的。5.2 查询也会“造”节点一个反直觉的事实很多人以为动态开点只在 update 时创建节点query 只是读数据不应该改变树的结构。但用了上面这份模板query 在遇到带懒标记且没有完全覆盖当前区间的时候会调用 pushdown而 pushdown 会创建子节点。举个例子上面的树里节点 5 表示 [6,6]它的 tag 1但 lc 和 rc 都是 0。如果你执行query(root, 1, 16, 6, 6)在递归到 [6,6] 之前必须先确保 [6,6] 的祖先们没有挂着的懒标记。如果 [5,6] 有 tag 但没有子节点pushdown 就会现场创建 [5,6] 的两个儿子 [5,5] 和 [6,6]虽然 [6,6] 可能本来快存在也可能不存在。所以你会发现查询多了以后内存占用也可能继续增长。这不是 bug是“懒标记下推”的必然结果。解决办法有两种一是接受这种增长只要节点池估算时留足余量二是改写成 query 不 pushdown而是在递归参数里携带父节点懒标记的叠加信息这么做省空间但代码复杂很多。竞赛里我建议先用前者简单不易错。5.3 时间复杂度验证为什么是 O(m log n)动态开点线段树的时间复杂度推导很简单。值域长度为 n每次操作从根出发最多下降 log n 层每一层做常数次判断和赋值所以单次操作是 O(log n)。m 次操作就是 O(m log n)。n 是 10^9log n 大约 30m 是 10^5那么总计算量大约 3x10^6 次递归调用对 C 来说毫无压力。相比朴素遍历的 O(nm)这已经不是一个量级的差距了。空间方面m 次操作最坏建 O(m log n) 个节点也就是 3x10^6 到 6x10^6 的量级。每个节点 4 个 int16 字节最大也就 100MB 左右在 256MB 的限制内稳稳的。当然这是最坏情况实际题目里因为整段覆盖直接打标返回新建节点量往往远低于上限。6. 实战踩坑与调优笔记这些坑我替你踩过了6.1 内存池开多大才不 MLE 也不 RE这是动笔写代码前必须先算清楚的问题。MAX_NODE开小了运行到一半直接 Segmentation Fault开大了即使没有 RE内存占用也可能超限。最常用的估算是操作次数 x (log2(值域) 1) x 2。如果 m 10^5log2(1e9) 约 30那么 10^5 x 31 x 2 6.2 x 10^6。我在模板里写 8 x 10^6 是比较保守的。如果题目里查询量极大且 pushdown 频繁触发子节点创建建议再乘 1.5 到 2 倍开到 10^7 更保险但这时结构体如果是 16 字节就是 160MB要在内存限制 256MB 的题里自己权衡。还有一个细节如果你把 sum 换成 long long结构体变成 24 字节lc、rc、sum、tag 中 tag 如果还是 int8x10^6 个节点就是 192MB已经偏大。这时要么降低节点池大小要么把 tag 改成 int 而 sum 用 long long 仍可但结构体大小要重新算。总之内存池大小不是拍脑袋定的而是根据操作次数和值域上限推出的宁可按公式算也不要瞎猜。6.2 引用传参动态开点最容易翻车的细节int p这三个字符是我见过动态开点线段树翻车率最高的地方。如果你把 update 的参数写成int p那p newNode()只是在函数内部把局部变量 p 从 0 改成了新编号一旦函数返回这个改动就消失了。父节点的lc和rc字段仍然保持 0下次递归到这个区域时又认为它是空地重新创建节点逻辑混乱数据全错。有一个非常典型的症状单次操作创建大量重复节点、内存飞快涨。如果你发现内存涨得比预期快很多先检查所有 update 函数的节点参是否都是引用。query 不需要引用因为它不改树的连接关系但 update 必须引用这是硬性要求。6.3 多测试点的清空陷阱如果题目有多组测试数据动态开点的“清空”比普通线段树更阴间。普通线段树清空可以 memset动态开点如果也对整个 tr 数组 memset在节点池很大的时候会白白消耗大量时间而且没必要。正确的做法是把计数器tot重置为 0同时只重置tr[0]。因为下一次运行所有节点都会从newNode()重新分配原本的旧数据根本不会被读取除非你把回头访问tr[0]。但tr[0]是全局哨兵它的 sum 和 tag 必须清干净否则 pushup 时tr[0].sum可能带着上一组数据的残留值。tr[0].lc tr[0].rc tr[0].sum 0; tr[0].tag -1;这两行放在每组数据的开头是动态开点模板最容易忽略却最要命的细节。6.4 一点调优心得与选型建议最后聊点我对动态开点线段树在实际题目中选型和优化的体会。如果题目允许离线处理而且所有操作端点事先可知静态离散化 普通线段树往往比动态开点更快因为静态数组访问比结构体字段访问的缓存命中率高不少代码也更简单。可一旦题目有在线查询的需求或者值域大到连离散化都显得别扭时动态开点就是更稳的选择。在性能调优上我常用的几个小手段是把 newNode、pushup、pushdown 标记为inline减少函数调用开销用ios::sync_with_stdio(false)关闭 C 风格 IO 同步如果递归深度担心爆栈可以把递归层数压到 log n 级别1e9 值域只有 30 层完全不用担心。还有一点如果你发现某题数据特别毒查询极其频繁导致查询时 pushdown 疯狂创建节点可以考虑把 query 改成“不下推懒标记查询时把路径上的 tag 累积进答案”的写法。这个优化能大幅减少查询引发的节点创建但实现代码量会上升不少。我个人的建议是把基础模板跑通吃透之后再按需去优化这种细节不要一上来就追求最复杂写法那样只会给自己制造一堆调试障碍。