UOJ170,Picks loves segment tree VIII,光看名字就知道又是一道线段树题。但这个系列能一路出到第八题,考的东西肯定不只是区间和区间最值那么单纯。这道题把懒标记这个知识点几乎压榨到了极致——区间加、区间赋值、区间求和、区间最大值一拥而上,任何一个标记的优先级没想明白,最终答案就会错得莫名其妙。
当年我在训练营里刷这道题,调了整整一个晚上才把样例跑对,第二天讲题复盘,发现同组十来个人里有一大半都栽在同一个地方:pushdown 里 set 和 add 的处理顺序。今天这篇文章就把这套东西从头捋一遍,包括标记合并规则怎么推导、每段代码为什么长这样、以及我自己踩过的几个坑。如果你正在攻线段树进阶,或者刷题刷到“多懒标记”这一关,这篇应该能帮你省下不少走弯路的时间。
1. 思路拆解:这题到底在考什么
1.1 表面是区间操作,内核是懒标记优先级
先快速对齐一下题面。这道题一般要求维护一个长度为 n 的数组,支持四种操作:区间加一个值、区间赋值成一个值、查询区间和、查询区间最大值。这套操作单独拎出来任何一个,基础线段树都能轻松应付,最多就是加一个懒标记往下传的事。但把它们放在一起之后,一个节点上可能同时压着两种甚至两种以上的标记,麻烦就来了。
典型的场景是这样:我先给某个大区间做了一个区间加,标记还没往下推,紧接着又给这个大区间的一个子区间做了赋值操作。那么碰撞点就出现了——父节点身上挂着“加了一个 v”的标记,而子区间现在要被整体赋成另一个值,这两件事到底谁覆盖谁?
答案取决于操作发生的先后。如果赋值操作发生在加法之后,那么赋值应该把之前那些加法的效果整个盖掉。否则就会出现一个很滑稽的结果:我先给你的区间加了 5,后来让你整体变成 100,最后算出来单个元素却是 105。这显然不对,105 的意思是“旧值加 5 之后再变成 100 的 5 被保留了”,但我们想要的是一次彻底的重置。
所以这道题真正要考的,是你能不能给一堆懒标记定义出清晰且自洽的合并语义,并在 pushdown 时严格按这个语义执行。代码本身并不长,难的是把每一步之间的逻辑关系理顺。
1.2 别急着写代码,先想清楚标记合并规则
很多新手拿到这道题的第一反应是:我给节点加一个set标记,再来一个add标记,pushdown 的时候两个都往下传不就行了?真这么简单的话,这题就不会挂在 UOJ 上让那么多人半夜抓耳挠腮了。
问题出在“两个都往下传”这句话上。假设父节点的标记状态是set = 100, add = 5,含义是这个区间先被赋成了 100,然后又整体加了 5。把这个标记传给儿子节点时,儿子的旧标记应该怎么处理?一个常见的错误是:儿子的add直接加上 5,但儿子的set标记还被保留着。害,如果儿子之前身上也有set = 50, add = 7,这一通操作下来不就变成了“先设 50、再加 7、再设 100、再加 5”四层变换大杂烩了吗?逻辑完全搅在一起。
正确的做法是分两种情况讨论。第一,父节点有set标记,说明父节点曾经对整段区间做过一次赋值操作。那么在这次赋值发生时,儿子节点身上不管存着多少历史标记,全部作废。赋值之后,再统一加一个add。所以儿子节点的最终标记状态应该是set = 父节点.set, add = 父节点.add。第二,父节点只有add标记,没有set,那说明父节点区间只是在原有基础上整体偏移,儿子的set标记可以保留,add标记累加即可。
这套规则一开始可能觉得绕,其实用生活类比一下就通了:set相当于“清空整张画布重新画”,add相当于“给画布上所有颜色统一调亮一个亮度”。一旦你重新画了底稿,之前的调亮效果自然就没了。只有没重画的时候,调亮才会叠加到旧画上。
想通了这一点,后面的代码实现就是一马平川。如果这个优先级没想明白,pushdown 里每写一行代码都是地雷。
2. 核心难点:set 和 add 两个标记的博弈
2.1 节点里到底该存哪些状态
动手写代码之前,先把节点结构定义清楚。这一题我用的节点信息包括四个字段:sum表示区间和,mx表示区间最大值,add表示加法懒标记,setv表示赋值懒标记。
setv这里有个 trick:它需要一个特殊值来表示“当前节点没有赋值标记”。因为真实的赋值操作可能把区间赋成任意整数,如果直接用 0 当“无标记”,刚好遇到赋值成 0 的操作就完蛋了。我习惯用一个极大值,比如const ll INF = (1LL << 60)来当哨兵。只要题目保证操作数值的绝对值远小于这个值,就不会发生冲突。
#include <bits/stdc++.h> using namespace std; typedef long long ll; const int MAXN = 200005; const ll INF = (1LL << 60); struct Node { ll sum, mx; ll add; // 加法懒标记 ll setv; // 赋值懒标记,等于 INF 表示没有赋值 } tr[MAXN << 2]; ll a[MAXN]; void pushup(int p) { tr[p].sum = tr[p << 1].sum + tr[p << 1 | 1].sum; tr[p].mx = max(tr[p << 1].mx, tr[p << 1 | 1].mx); }pushup很简单,左右儿子合并即可。不过要注意,tr[p]自己的懒标记是什么不影响pushup的合并,因为tr[p].sum和tr[p].mx存的是已经包含当前节点懒标记作用之后的值,合并时直接用即可。
2.2 合并规则为什么是这样
现在推导两个更新操作在节点上的作用方式。这个推导过程是我觉得整道题最关键的地方,强烈建议你自己在草稿纸上也推一遍,别看完了就以为会了。
先说区间加操作。对某个完全被覆盖的节点执行区间加 v 时,这个节点的sum要加v * 区间长度,mx要加 v,然后把这个 v 累加到add标记上。如果节点之前没有setv,那很好,标记就只是add变大了一点。如果节点之前有setv,说明这个节点现在记录的是“先被赋成 setv,然后又经历了一些加法”。此时区间加应该继续叠加在加法上,而setv保持不变。
void add_update(int p, int l, int r, int ql, int qr, ll v) { if (ql <= l && r <= qr) { tr[p].add += v; tr[p].sum += (ll)(r - l + 1) * v; tr[p].mx += v; return; } pushdown(p, l, r); int mid = (l + r) >> 1; if (ql <= mid) add_update(p << 1, l, mid, ql, qr, v); if (qr > mid) add_update(p << 1 | 1, mid + 1, r, ql, qr, v); pushup(p); }再说区间赋值操作。这个更干脆——既然整个区间都被重置成一个新值,那么这个节点之前积压的add和setv统统没有意义了。所以执行赋值时,直接设setv = v,同时清空add,并且把sum改成v * 区间长度,mx改成 v。这里的“清空 add”是必写的,漏掉的话就会出现“赋值之后还残留旧加法”的灵异现象。
void set_update(int p, int l, int r, int ql, int qr, ll v) { if (ql <= l && r <= qr) { tr[p].setv = v; tr[p].add = 0; // 这一步清零非常重要 tr[p].sum = (ll)(r - l + 1) * v; tr[p].mx = v; return; } pushdown(p, l, r); int mid = (l + r) >> 1; if (ql <= mid) set_update(p << 1, l, mid, ql, qr, v); if (qr > mid) set_update(p << 1 | 1, mid + 1, r, ql, qr, v); pushup(p); }注意:add_update里我没有清空setv,因为“先 set 再 add”的语义是合法的。而set_update里必须清空add,因为一个新建的set会覆盖掉之前所有的加法历史。这一对操作看起来对称,实际上完全不对称,恰恰是这题最容易写错的地方。
2.3 pushdown 的顺序就是生命线
pushdown是整个线段树的发动机,也是最容易翻车的地方。前面说过,如果父节点存在setv,那么子节点所有的旧标记都要作废,整体变成“父节点 setv 的值 + 父节点 add 的偏置”;如果父节点没有setv,那只需要把add累加到子节点上。
所以正确的pushdown顺序是:先处理setv,把子节点的setv覆盖掉、add清零,再叠加父节点的add。如果先处理add再处理setv,就会把父节点的加法错误地应用到“set 之前的旧世界”上,最后 set 又把 add 的效果覆盖掉,加法直接丢失。
void pushdown(int p, int l, int r) { int mid = (l + r) >> 1; int lc = p << 1, rc = p << 1 | 1; if (tr[p].setv != INF) { ll v = tr[p].setv; tr[lc].setv = v; tr[lc].add = 0; // 清掉儿子之前积累的 add tr[lc].sum = (ll)(mid - l + 1) * v; tr[lc].mx = v; tr[rc].setv = v; tr[rc].add = 0; tr[rc].sum = (ll)(r - mid) * v; tr[rc].mx = v; } if (tr[p].add != 0) { ll v = tr[p].add; tr[lc].add += v; tr[lc].sum += (ll)(mid - l + 1) * v; tr[lc].mx += v; tr[rc].add += v; tr[rc].sum += (ll)(r - mid) * v; tr[rc].mx += v; } tr[p].add = 0; tr[p].setv = INF; }这段代码里最容易被忽略的是tr[lc].add = 0这一行。很多新手在 set 分支里只改了setv、sum、mx,忘了把儿子旧的add清零,结果就是儿子节点里残留下了“设定值之前的偏移量”,下面更新的数据全被污染。
你可以把这个模式记成一句口诀:赋值清零,加法叠加;下推先赋值,后加偏置。每次写 pushdown 之前默念一遍,能少踩一大半的坑。
3. 完整实现:从 build 到 query 一步步写
3.1 建树与初始化
建树的时候,每个节点的setv都要初始化为INF,add初始化为 0。叶子节点直接读入数组值,内部节点靠pushup合并。
void build(int p, int l, int r) { tr[p].add = 0; tr[p].setv = INF; if (l == r) { tr[p].sum = tr[p].mx = a[l]; return; } int mid = (l + r) >> 1; build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r); pushup(p); }这里有个容易踩的细节:如果是在多组测试数据里跑,每次新建Node结构体数组时,setv默认值不是安全的INF。我习惯在build里显式赋值,或者每组数据前用循环把setv置成INF,避免上一组数据的残留标记影响这一组。尤其是用结构体数组而不是vector的时候,这种“脏数据”问题特别隐蔽。
3.2 查询操作的实现
查询区间和、区间最大值时,如果当前节点被查询区间完全覆盖,直接返回sum或mx。否则需要先pushdown,把父节点积压的懒标记传给儿子,再递归查询左右子树。
ll query_sum(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tr[p].sum; pushdown(p, l, r); int mid = (l + r) >> 1; ll res = 0; if (ql <= mid) res += query_sum(p << 1, l, mid, ql, qr); if (qr > mid) res += query_sum(p << 1 | 1, mid + 1, r, ql, qr); return res; } ll query_max(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tr[p].mx; pushdown(p, l, r); int mid = (l + r) >> 1; ll res = -INF; if (ql <= mid) res = max(res, query_max(p << 1, l, mid, ql, qr)); if (qr > mid) res = max(res, query_max(p << 1 | 1, mid + 1, r, ql, qr)); return res; }还有一个容易忽略的点:查询时如果父节点有懒标记但你没 pushdown,儿子节点里存的sum和mx可能是完全没有应用父节点标记的旧值,这时候查出来的结果就会比正确值小或者大。所以只要之后还要往下走,就必须先pushdown,哪怕add是 0、setv是INF也不会有额外开销问题,代码里我加了非零判断,能省一点是一点。
3.3 完整题解串起来的流程
把上面四段拼在一起,就是一个完整可提交的线段树。实际做题时输入大概是 n 个初始元素和 q 次操作,每次操作根据 op 的类型调用不同函数。这里给一个简单主流程示例:
int main() { int n, q; scanf("%d%d", &n, &q); for (int i = 1; i <= n; i++) scanf("%lld", &a[i]); build(1, 1, n); while (q--) { int op, l, r; scanf("%d%d%d", &op, &l, &r); if (op == 1) { // 区间加 ll v; scanf("%lld", &v); add_update(1, 1, n, l, r, v); } else if (op == 2) { // 区间赋值 ll v; scanf("%lld", &v); set_update(1, 1, n, l, r, v); } else if (op == 3) { // 查询区间和 printf("%lld\n", query_sum(1, 1, n, l, r)); } else { // 查询区间最大值 printf("%lld\n", query_max(1, 1, n, l, r)); } } return 0; }注意所有与区间长度相乘的地方都要先把区间长度转成long long,因为(r - l + 1) * v如果左边是 int,右边是long long,虽然 C++ 会做隐式提升,但如果你在一个项目里开了比较严格的编译选项或者恰好区间长度是 int、v 接近1e18,就可能有溢出风险。稳妥起见,所有乘法的左操作数都显式转ll。
4. 实战排查:新人最容易踩的四个坑
4.1 set 之后 add 没清空:赋值操作“灵气附体”
这是我见过最多的情况。症状是:对某个区间赋值后,再对这个区间的子区间做加法,结果答案里凭空多出了一个更早时候的 add 偏移量。
比如初始全是 0,先对 [1,5] 加 100,然后对 [1,5] 赋值为 7。如果set_update里没清add,节点的状态会变成setv = 7, add = 100。之后一旦 pushdown,子节点先被设成 7 又被加上 100,最后得到 107。这显然不对,因为赋值操作应该把之前的 +100 完全抹掉。
排查方法很简单:在set_update里设一个断点,检查节点旧的add是否为 0。一旦发现赋值时add不是 0,基本就可以确定是这里少了一行清零。
4.2 pushdown 顺序写反导致加法丢失或错乱
如果你在 pushdown 里先处理add再处理setv,会得到两种典型错误。第一种情况,父节点同时有setv和add,先传add再传setv,最终儿子节点的add被清掉、setv被覆盖,父节点原本要叠加的加法全部丢失。第二种情况,父节点只有setv没有add,顺序反了倒没什么影响,这就导致错误很难在小样例里第一时间暴露。
我的排查经验是:造一组“故意为难”的数据,比如先对区间赋值,再对同一个区间做几次累加,接着马上查询子区间。如果查询结果比预估值少了一个加数,优先怀疑 pushdown 顺序。这种问题靠肉眼盯代码很难看出来,用对数器对拍是最快的定位方式。
4.3 边界值与数据类型处理
setv的哨兵值选不好会带来一系列隐性问题。我之前用0x3f3f3f3f当哨兵,结果某道题操作值最大能到1e9,差点撞上。后来干脆统一用1LL << 60,只要题面数值范围在1e18以下就绝对安全。还有如果题目要求查询区间最大值,初始化res = -INF时也要用足够小的负数,不能用 0,否则全负序列查询最大值会直接错。
另一个常见问题是long long溢出。当 n 高达2e5,而且一个区间被反复add,sum累加值可能会超过int范围,所以所有存数值的字段必须是long long。我的习惯是:和值有关的量全部long long,只在操作类型和区间端点用int,一刀切最省心。
4.4 一个通用的对拍调试法
写线段树题,我不会只靠样例判断正确性。样例能过说明不了任何问题,大概率只是没覆盖到标记交互的边界。我的习惯是写三个文件:一个暴力模拟的bf.cpp,一个线段树版本的seg.cpp,一个随机数据生成器gen.cpp。然后写一个无限循环脚本,每次生成小规模随机数据,分别跑两份代码,比对输出。
数据生成器里要刻意让操作集中在同一个区间上,这样更容易触发标记堆积。比如l, r经常随机成整个区间的子集,并且交替执行赋值和加法操作。小数据规模比如 n = 10, q = 100 的时候,暴力程序跑得飞快,对拍几百组就能把大多数逻辑问题揪出来。
对拍脚本虽然写起来简单,但它是省时间的神器。尤其是这类“样例看不出错、大数据又不知道对不对”的懒标记题,没有对拍的话几乎只能靠玄学调参,有了对拍之后定位问题基本就是十分钟内的事。
我自己后来回过头看这道题,最大的收获反而不是那几行 pushdown 代码,而是养成了一个习惯:处理任何一种“带优先级的多懒标记”问题,先拿纸笔定义清楚每个标记的语义,再考虑怎么合并、怎么下推。线段树的代码技巧翻来覆去就那么几个,真正拉开差距的是思路的清晰程度。如果哪天遇到一道题,操作里多了区间乘,你再回头看这里就会更有感触——无非是把“乘法优先、加法后置”的优先级再排一遍,节点里多维护一个mul标记而已。理清了这道题的套路,后面再难的线段树变种也都能稳住阵脚。