第一次在洛谷 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^5 | 4 x 10^5 | 4 字节 | 约 1.6 MB |
| 10^7 | 4 x 10^7 | 4 字节 | 约 160 MB |
| 10^9 | 4 x 10^9 | 4 字节 | 约 16 GB |
当 N 是 10^5 的时候,1.6MB 完全是毛毛雨;N 到 10^7,160MB 已经让不少题目的内存限制开始报警;N 一旦到 10^9,16GB 这个数字听起来就像开玩笑,但它是真实存在的一堵墙。更别提如果题目里要求维护的值是 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^5,log 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,我用普通数组加个树状数组不行吗?不行。区间赋值不是单点修改,你没法用树状数组的差分轻松处理区间覆盖之后的信息统计,因为你必须知道每个位置当前的值才能正确维护后来的覆盖操作。
那用普通线段树呢?建树数组要开 4N,N=10^9 时就是 16GB 起步,评测机内存限制通常只有 256MB,连零头都不够。有人会说那我不建树,直接开一个map存区间不行吗?map 的思路有点接近动态开点,但每次操作如果要遍历被覆盖的所有区间,最坏情况下会退化到 O(nm),而且 map 的常数和内存开销都不小。还有更 naive 的做法是每次区间赋值直接 for 循环遍历数组,n=10^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 始终为 0,tag 在处理时需要保证它不会被误当成有效节点。所以主程序开头必须把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 &p,query 不需要。原因是 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^9,log n 大约 30;m 是 10^5,那么总计算量大约 3x10^6 次递归调用,对 C++ 来说毫无压力。相比朴素遍历的 O(nm),这已经不是一个量级的差距了。
空间方面,m 次操作最坏建 O(m log n) 个节点,也就是 3x10^6 到 6x10^6 的量级。每个节点 4 个 int,16 字节,最大也就 100MB 左右,在 256MB 的限制内稳稳的。当然,这是最坏情况,实际题目里因为整段覆盖直接打标返回,新建节点量往往远低于上限。
6. 实战踩坑与调优笔记:这些坑我替你踩过了
6.1 内存池开多大才不 MLE 也不 RE
这是动笔写代码前必须先算清楚的问题。MAX_NODE开小了,运行到一半直接 Segmentation Fault;开大了,即使没有 RE,内存占用也可能超限。
最常用的估算是操作次数 x (log2(值域) + 1) x 2。如果 m = 10^5,log2(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 如果还是 int),8x10^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 累积进答案”的写法。这个优化能大幅减少查询引发的节点创建,但实现代码量会上升不少。我个人的建议是把基础模板跑通吃透之后,再按需去优化这种细节,不要一上来就追求最复杂写法,那样只会给自己制造一堆调试障碍。