# P7476 「C.E.L.U-02」苦涩 ## 题目背景 回想起自己的过往的人生,YQH 觉...

Créé le : 30 juillet 2026

Répondu en utilisant GPT-5.6 Thinking par Chat01

Question

P7476 「C.E.L.U-02」苦涩

题目背景

回想起自己的过往的人生,YQH 觉得心中充满了苦涩。如果人生能再来一次,我一定会少做一些傻事,少真香几次,然后大胆地去追寻自己的爱。可惜没有这样一个机会了。

题目描述

在 YQH 的梦中,他看到自己过去的记忆正在不断浮现在自己脑中。这些记忆带给他的是满满的苦涩。他想要强行忘记一些来减轻自己的苦涩。
YQH 的脑中可以被分成 nn 个片区,每个片区相当于一个存放记忆的可重集,初始为空。他将进行 mm 次这三种操作:
操作 1:区间 lrl\sim r 的片区中都浮现了一个苦涩值为 kk 的记忆。
操作 2:YQH 开始清理 lrl\sim r 片区的记忆。如果一个片区 k[l,r]k\in[l,r]kk 中苦涩值最大的记忆与 lrl\sim r 片区中苦涩值最大的记忆相等,则将这个苦涩值最大的记忆忘记。如果在同一个片区有多个相同的苦涩值最大的记忆,则只忘记一个。如果这些片区内没有记忆,则无视。
操作 3:YQH 想知道,lrl\sim r 片区中苦涩值最大的记忆的苦涩值是多少,如果不存在,输出-1

输入格式

第一行两个数,n,mn,m
接下来 mm 行,第一个数代表操作种类 opop,对于操作 1,有三个数 l,r,kl,r,k,对于操作 2 或 3,有两个数 l,rl,r

输出格式

对于每个操作 3 输出一行,代表答案。

输入输出样例 #1

输入 #1

text
5 4 1 1 3 2 1 2 4 3 2 3 3 3 1 3

输出 #1

3

输入输出样例 #2

输入 #2

text
6 6 1 1 6 2 1 3 3 2 1 3 4 3 2 3 4 3 3 3 3 4 4

输出 #2

text
2 2

说明/提示

样例解释

样例解释一

下面为各操作之后 YQH 的大脑的状态:
第一次操作:{2},{2},{2},,\{2\},\{2\},\{2\},\varnothing,\varnothing
第二次操作:{2},{2,3},{2,3},{3},\{2\},\{2,3\},\{2,3\},\{3\},\varnothing
第三次操作:{2},{2,3},{2},{3},\{2\},\{2,3\},\{2\},\{3\},\varnothing
第四次操作询问 区间 131\sim 3 的最大值,所以答案是 33

样例解释二

下面为各操作之后 YQH 的大脑的状态:
第一次操作:{2},{2},{2},{2},{2},{2}\{2\},\{2\},\{2\},\{2\},\{2\},\{2\}
第二次操作:{2},{2},{2,2},{2},{2},{2}\{2\},\{2\},\{2,2\},\{2\},\{2\},\{2\}
第三次操作:{2},{2},{2,2,3},{2,3},{2},{2}\{2\},\{2\},\{2,2,3\},\{2,3\},\{2\},\{2\}
第四次操作:{2},{2},{2,2},{2},{2},{2}\{2\},\{2\},\{2,2\},\{2\},\{2\},\{2\}
第五次操作询问 33 的最大值,所以答案是 22
第六次操作询问 44 的最大值,所以答案是 22

数据范围

Subtasknm特殊性质
1(10pts)1(10pts)103\leq10^3103\le10^3\diagdown
2(20pts)2(20pts)5×104\leq5\times10^45×104\leq5\times10^4没有操作 2
3(10pts)3(10pts)5×104\leq5\times10^45×104\leq5\times10^4操作 2 中 l=rl=r
4(20pts)4(20pts)5×104\leq5\times10^45×104\leq5\times10^4\diagdown
5(20pts)5(20pts)2×105\leq2\times10^52×105\leq2\times10^5操作 2 中 l=rl=r
6(20pts)6(20pts)2×105\leq2\times10^52×105\leq2\times10^5\diagdown

对于 100%100\% 的数据,n,m2×105,k109n,m\le2\times10^5,k\le10^9

闲话:众所周知校内模拟赛很喜欢搬题,于是我们的 @CYJian 就放了 T1 签,T2 难,T3 签,T4 中,T5 签,然后这个蒟蒻只会 T5。而且还想了 1.5h1.5h。然后 T1 的贪心以为是假的所以就去梭哈最难的 T2 了。最终分数显然易得。

根据我们的 d** 同学所使用的方法——观察数据范围推测算法时空复杂度,然后由于 T1 数据范围 1.5×1051.5\times 10^5,以为要上数据结构,最后写了 4.69KB4.69KB244244 行的代码,拿了 0pts0pts,于是被老师喷了:那我开 1GB1GB 你是不是还要考虑可持久化一下。

解题思路

你猜用什么算法喵

以下讨论时间复杂度中的 nn 并不代表题目中的 nn

但是这道题目用以上方法就不难瞅出大概是 O(nlogn)\mathcal{O}(n \log n)O(nlog2n)\mathcal{O}(n \log^2 n) 或者 O(nn)\mathcal{O}(n \sqrt{n}) 的算法。当然,如果你像某位卡常代师可以把 8e98e9 的复杂度卡到 5e85e8 的话,可以考虑考虑 log3\log^3 做法。

但是一眼 O(nlogn)\mathcal{O}(n \log n) 的算法大概率是没有的,毕竟你区间操作带只 O(log)\mathcal{O}(\log),而且删除操作一看就很难 O(log)\mathcal{O}(\log) 删完。如果您可以 O(nlogn)\mathcal{O}(n \log n) 过掉可以告诉我,让我膜拜 ds 大神啊。

所以考虑 O(nn)\mathcal{O}(n \sqrt{n}) 的算法。区间操作很容易想到分块或者莫队,但是区间 [l,r)[l, r) 跟区间 [l,r][l, r] 似乎没有什么联系,所以只能考虑分块,但是显然每个块至少需要排序吧。所以复杂度为 O(nnlogn)\mathcal{O}(n \sqrt{n} \log n),大概率过不了了。

最后只剩下 O(nlog2n)\mathcal{O}(n \log^2 n) 的算法(O(n)\mathcal{O}(n) 算法一眼不可做)。

看到区间操作首先想差分、数据结构、分块、莫队对吧,但是差分肯定不行,分块之前说明过时间复杂度有问题,莫队正确性有问题。所以只剩下数据结构。那我们再列举一下数据结构:线段树、树状数组、平衡树……剩下的应该没用吧。平衡树支持插入、删除操作,但是区间插入、删除似乎还是有点困难。树状数组看着不太行的样子。酱紫就只剩下线段树了。

WTF,XX线段树怎么维护呢?当然肯定是求助「神」啦。「神」告诉你可以使用线段树套堆,于是你恍然大悟。但是你并不会做,于是「神」给你了一份代码。

CODE:

cpp
#include <bits/stdc++.h> using namespace std; #define int long long const int N = 2e5 + 10; struct node { int val, maxx = -1; priority_queue<int> q; } tree[N << 2]; inline void push_up(int k) { tree[k].maxx = max(tree[2 * k].maxx, tree[2 * k + 1].maxx); if (!tree[k].q.empty()) { tree[k].maxx = max(tree[k].maxx, tree[k].q.top()); } } inline void update(int k, int l, int r, int x, int y, int z) { if (l == x && r == y) { tree[k].q.push(z); tree[k].maxx = max(tree[k].maxx, z); return; } int mid = (l + r) >> 1; if (y <= mid) { update(2 * k, l, mid, x, y, z); } else if (x > mid) { update(2 * k + 1, mid + 1, r, x, y, z); } else { update(2 * k, l, mid, x, mid, z); update(2 * k + 1, mid + 1, r, mid + 1, y, z); } push_up(k); } inline void erase(int k, int l, int r, int x, int y, int z) { if (y < l || x > r || tree[k].maxx < z) { return; } if (!tree[k].q.empty() && tree[k].q.top() == z) { tree[k].q.pop(); if (l < x) { update(k, l, r, l, x - 1, z); } if (r > y) { update(k, l, r, y + 1, r, z); } if (l < r) { push_up(k); //。。。改成函数总是好的。 } else { //你猜是不是只剩下 l = r 的情况了 tree[k].maxx = -1; if (!tree[k].q.empty()) { tree[k].maxx = tree[k].q.top(); } } return; } int mid = (l + r) >> 1; erase(2 * k, l, mid, x, y, z); erase(2 * k + 1, mid + 1, r, x, y, z); push_up(k); } inline int query(int k, int l, int r, int x, int y) { if (l == x && r == y) { return tree[k].maxx; } int mid = (l + r) >> 1; int res = -1; if (!tree[k].q.empty()) { res = tree[k].q.top(); } if (y <= mid) { res = max(res, query(2 * k, l, mid, x, y)); } else if (x > mid) { res = max(res, query(2 * k + 1, mid + 1, r, x, y)); } else { res = max({res, query(2 * k, l, mid, x, mid), query(2 * k + 1, mid + 1, r, mid + 1, y)}); } return res; } signed main() { ios::sync_with_stdio(false); ios_base::sync_with_stdio(false); cin.tie(0), cout.tie(0); int n, m; cin >> n >> m; while (m--) { int op, l, r; cin >> op >> l >> r; if (op == 1) { int k; cin >> k; update(1, 1, n, l, r, k); } else if (op == 2) { int x = query(1, 1, n, l, r); if (x != -1) { erase(1, 1, n, l, r, x); } } else { cout << query(1, 1, n, l, r) << "\n"; } } return 0; }

线段树套堆思路分析

在你的苦苦哀求下,「神」告诉了你具体思路。

温馨提醒:这道题的线段树与普通线段树有一点点区别,且在观赏「神」的思路的时候请看完一个板块再思考,因为有些问题可能会在其他操作中讲到。

「神」说其他题解中说这个思路为标记永久化

[1,n][1, n] 每一个位置都有一个可重集合(就是堆)。

  • 对于操作 11,即在区间 [l,r][l, r] 中每一个堆都插入一个数 kk。那么在线段树每一个点上维护一个堆,代表这个点所管辖的区间中有哪些数。那么在线段树中找到区间 [l,r][l, r] 直接在这个点所维护的堆中插入 kk 即可。

  • 对于操作 33,即查询区间 [l,r][l, r] 中所有堆中最大数。那么线段树上每一个点再维护一变量 maxx\text{maxx},表示这个点所维护的区间中的所有堆中的最大数。查询的时候直接返回即可。

  • 对于操作 22,即把区间 [l,r][l, r] 中每一个堆都删除一个 [l,r][l, r] 的所有堆中的最大数。

显然操作 22 最难处理。那我们可以先手动添加一个操作 33,设返回的答案为 xx,那我们只需要在区间 [l,r][l, r] 中每一个堆里面删除一个 xx。那么我们设线段树上第 pp 个点的堆中的最大值为 vv

  • v>xv > x 时,堆中可能存在 xx,继续递归处理子区间。
  • v=xv = x 时,堆中最大数就是 xx,直接弹出就好。
  • v<xv < x 时,堆中不可能存在 xx,结束递归。

那么 v>xv > x 的情况最后一定会变成等于或者小于的情况,所以其实我们只用讨论 v=xv = x 时该怎么办就好,但是很显然直接弹出。但是就当你激动地准备提交代码时,惊奇的发现样例过不了,于是你再次请求「神」帮你调试代码,但是「神」告诉你还有一种情况。你诧异至极,顿时如梦初醒。

v=xv = x 时,假设 pp 所维护的区间为 [l1,r1][l_1, r_1],操作区间为 [l,r][l, r],显然 [l,r][l1,r1][l, r] \subset [l_1, r_1],是不是发现问题了?你其实把 [l1,l)(r,r1][l_1, l) \cup (r, r_1]xx 都给删除了,不好,怎么办?直接在这两个区间里面用操作 11 加回去就好了,就是这么暴力。

总时间复杂度为 O(qlog2n)\mathcal{O}(q \log^2 n)

为什么是 O(log2n)\mathcal{O}(\log^2 n) 呢?插入和查询时就不用多说,只需要聊聊删除操作。首先线段树的深度为 logn\log n,并且每次删除都可能会有两个区间的插入操作。但是你会发现中间的那个区间就不用更新了。于是就相当于递归左儿子和右儿子且你遍历的深度越大,那么儿子的深度就越小,平摊一下就差不多总深度为 logn\log n,再算上优先队列的 logn\log nlog2n\log^2 n 了。

注意:

  1. 别忘了 maxx\text{maxx} 在插入、删除操作的时候需要更新。插入的时候只需要再取 max(maxx,k)\max(\rm maxx, k)。在删除操作中,v=xv = x 时需要将 maxx\rm maxx 重新赋值成堆顶元素再取左右儿子最大值。
  2. 注意取堆顶元素时要先判断堆是否为空。
  3. 如果堆为空那么这个点的答案为 max(1,maxxl,maxxr)\max(-1, \rm maxx_l, maxx_r)

具体实现还是看上面的代码吧。突然感觉这道紫还挺水的,我赛时怎么没场切呢。

如果有错别字可以在讨论区威胁笔者修改。如果有疑问可以绑架作者并打破砂锅问到底。


以前从来没见过这种题和这种线段树,分析一下这种题目的本质是什么,如何举一反三应对类似的“线段树标记永久化套数据结构”的题目。

Réflexion

Analyzing structural bounds

I’m refining the concern: interval structure may limit costly splits. A stored segment can split only when it spans both query boundaries, and those segments form an ancestor chain, potentially explaining an amortized logarithmic bound.

Worked for 2m 17s

Réponse

这题的核心并不是“线段树里放一个堆”这么简单,而是:

把每次区间插入看成一个带权区间对象,用线段树永久保存这些对象;每个位置的状态,由根到叶路径上所有对象共同决定。

操作 2 又恰好具有“删除全局极值、相同值副本不可区分、区间删去后仍是若干区间”的性质,因此可以对区间对象进行剪切。


一、先把题目换一种语言

对每个位置 ii,维护一个可重集 AiA_i

操作 1:

i[l,r],AiAi{k}\forall i\in[l,r],\quad A_i\leftarrow A_i\uplus\{k\}

可以把它理解成创建了一个“区间记忆对象”:

e=(l,r,k)e=(l,r,k)

它在区间 [l,r][l,r] 内的每个位置贡献一个 kk

于是,位置 ii 的可重集,实际上就是:

Ai={ ⁣{kei[le,re]} ⁣}A_i=\{\!\{k_e\mid i\in [l_e,r_e]\}\!\}

也就是说,每个位置的元素来自所有覆盖它的区间对象。

操作 3 就是:

maxi[l,r]maxAi\max_{i\in[l,r]}\max A_i

操作 2 则是:

  1. 求出 x=maxi[l,r]maxAix=\max_{i\in[l,r]}\max A_i
  2. 对每个满足 maxAi=x\max A_i=x 的位置,删除一个 xx

所以这题本质上是:

动态维护一批带权区间,每个点拥有所有覆盖它的区间权值;支持查询一段位置中的最大权值,并从目标区间内剪掉一层最大权值。


二、为什么普通懒标记不好做

假设在区间 [1,n][1,n] 上依次插入:

5, 5, 3, 85,\ 5,\ 3,\ 8

如果只维护一个普通的最大值懒标记 88,那么删除一个 88 后,需要知道下一个最大值是 55

因此不能只保存一个数,必须保存所有尚未删除的区间标记,并且保留重数。

自然就需要一个可重集合。由于只关心最大值,并且删除的也是最大值,所以优先队列正合适。

但如果采用普通懒标记下传:

  • 一个节点中可能积累很多区间标记;
  • 一旦需要局部操作,就要把整个堆复制或下传给左右儿子;
  • 复杂度和空间都会失控。

所以这里不下传标记,而是采用“标记永久化”。


三、标记永久化到底是什么

普通懒标记的想法是:

标记暂时放在高层节点,以后有需要再推给儿子。

标记永久化的想法是:

这个标记本来就代表整个节点区间,因此永远放在这个节点,不再下传。查询时主动统计沿途的所有标记。

注意它和“可持久化数据结构”没有关系。

“永久”指的是标记不向下推,不是保存历史版本。


1. 区间插入的存储方式

在线段树中,将区间 [l,r][l,r] 分解成 O(logn)O(\log n) 个线段树节点。

例如:

[3,10]=[3,4][5,8][9,10][3,10]=[3,4]\cup[5,8]\cup[9,10]

对于每个被完整覆盖的节点 uu,将 kk 插入节点自己的堆 HuH_u

堆里的一个元素 kk 表示:

节点 uu 对应区间 IuI_u 中的每个位置,都拥有一个 kk

于是对任意叶子 ii,它的真实可重集为:

Ai=u 在根到 i 的路径上HuA_i=\biguplus_{u\text{ 在根到 }i\text{ 的路径上}}H_u

这是整套做法最重要的不变量。


2. 节点维护什么

对每个线段树节点 uu,维护:

  • HuH_u:直接存储在该节点的区间标记;
  • top(Hu)\operatorname{top}(H_u):节点自身标记中的最大值;
  • mxumx_u:节点子树内出现过的最大值。

有:

mxu=max(top(Hu),mxleft(u),mxright(u))mx_u= \max\left( \operatorname{top}(H_u), mx_{\mathrm{left}(u)}, mx_{\mathrm{right}(u)} \right)

其中空堆和空子树的最大值视为 1-1

要注意:

mxumx_u 只统计存放在 uu 及其后代节点中的标记,不统计祖先节点的标记。

祖先标记需要在查询递归过程中额外考虑。


四、为什么区间查询能够直接做

查询区间 Q=[l,r]Q=[l,r]

递归经过节点 uu 时,HuH_u 中的每个标记都覆盖整个 IuI_u,所以只要:

IuQI_u\cap Q\ne\varnothing

节点堆顶就可能成为答案。

如果某个儿子的区间被查询区间完整包含,就可以直接使用儿子的 mxmx

因此查询过程实际上统计了两类信息:

  1. 查询路径上的祖先永久标记;
  2. 完整包含在线段树查询分解中的子树信息。

这也是代码里为什么先取:

cpp
res = tree[k].q.top();

再递归查询儿子的原因。


五、操作 2 为什么是整题的精华

首先查询:

x=maxi[L,R]maxAix=\max_{i\in[L,R]}\max A_i

然后需要对所有满足局部最大值为 xx 的位置删除一个 xx

考虑线段树节点 uu,对应区间 IuI_u


情况一:mxu<xmx_u<x

说明节点子树中根本没有 xx,直接跳过。

cpp
if (tree[k].maxx < x) return;

情况二:节点自己的堆顶等于 xx

即:

top(Hu)=x\operatorname{top}(H_u)=x

这个 xx 是一个覆盖整个 IuI_u 的区间标记。

对于任意:

pIu[L,R]p\in I_u\cap[L,R]

由于节点标记给位置 pp 提供了一个 xx,所以:

maxApx\max A_p\ge x

另一方面,xx 是整个查询区间的最大值,所以:

maxApx\max A_p\le x

因此:

maxAp=x\max A_p=x

也就是说,交集中的每个位置都正好应该删除一个 xx

于是可以直接:

cpp
tree[u].heap.pop();

它会从整个 IuI_u 删除一层 xx


但删除范围太大了

真正需要删除的只是:

Iu[L,R]I_u\cap[L,R]

但弹出节点标记会影响整个 IuI_u

因此把不该删除的部分补回来:

Iu[L,R]I_u\setminus[L,R]

因为两个区间作差最多得到两个区间:

Iu[L,R]=[IuL,L1][R+1,IuR]I_u\setminus[L,R] = [I_u^L,L-1]\cup[R+1,I_u^R]

所以分别在左侧、右侧重新插入一个 xx

这就是代码中的:

cpp
if (l < x) update(k, l, r, l, x - 1, z); if (r > y) update(k, l, r, y + 1, r, z);

本质是:

先把整块标记删除,再把查询区间之外的部分补回去。

这可以称为“区间对象剪切”。


六、为什么可以随便删除这个 xx

某个位置可能同时有多个值为 xx 的记忆,例如:

Ai={2,5,5,5}A_i=\{2,5,5,5\}

题目只要求删除一个 55,并不关心删除的是哪一次操作产生的 55

因此所有相同权值的副本都是不可区分的。

这意味着:

只要从该位置覆盖到的任意一个 xx 标记中删掉一份,结果可重集就是正确的。

代码有时删除节点自身的 xx,有时递归到后代删除另一个 xx,都没有关系。

如果题目给每个记忆一个编号,并要求删除特定编号的记忆,这个做法就不能直接成立。


七、一个容易混淆的细节

原题解中提到:

节点堆顶 v>xv>x 时继续向下递归。

严格来说,如果这里的 vv 指的是节点自己堆的堆顶,并且节点区间与查询区间相交,那么:

v>xv>x

其实不可能发生。

因为节点自己的标记覆盖整个节点区间,只要它和查询区间有交集,交集中的位置就拥有一个 v>xv>x,这会与 xx 是查询区间最大值矛盾。

真正可能大于 xx 的是:

mxumx_u

因为 mxumx_u 统计整个节点区间,较大的值可能只出现在查询区间之外的后代中。

所以更准确的分类是:

  • mxu<xmx_u<x:直接剪枝;
  • top(Hu)=x\operatorname{top}(H_u)=x:删除当前永久标记;
  • top(Hu)<x\operatorname{top}(H_u)<x:目标 xx 只可能位于后代,递归;
  • top(Hu)>x\operatorname{top}(H_u)>x:对相交节点不可能。

八、复杂度需要更严谨地理解

原题解说删除操作单次是 O(log2n)O(\log^2 n),这并不是严格的最坏情况。

例如:

  • 对每个位置单独插入一个 11
  • 然后对整个区间执行一次删除。

这一次删除会访问所有叶子,单次复杂度可以达到 O(n)O(n)

正确的理解是:

总复杂度是均摊的 O(mlog2n)O(m\log^2 n),而不是每次删除都严格 O(log2n)O(\log^2 n)


为什么总复杂度仍然正确

每次操作 1 会产生 O(logn)O(\log n) 个堆元素。

删除过程中,弹出的节点两两不具有祖先后代关系,因为在某个节点弹出后就直接返回,不再递归它的后代。

所以被弹出节点对应的区间互不相交。

对于固定查询区间 [L,R][L,R]

  • 最多只有一个被弹出节点跨过左边界 LL
  • 最多只有一个被弹出节点跨过右边界 RR
  • 其余被弹出的节点都完整包含在 [L,R][L,R] 中,不需要补回。

因此每次删除最多产生两个需要补回的区间。

每个补回区间在线段树上拆成 O(logn)O(\log n) 个新堆元素,所以整个操作序列中创建的堆元素总数为:

O(mlogn)O(m\log n)

每个堆元素:

  • 被插入一次;
  • 最多被弹出一次;
  • 单次堆操作为 O(logm)O(\log m)

再考虑线段树递归和维护,整体可以控制在:

O(mlognlogm)O(m\log n\log m)

n,mn,m 同阶时通常写作:

O(mlog2n)O(m\log^2 n)

空间复杂度为:

O(mlogn)O(m\log n)

九、这种题应该如何识别

遇到下面几个特征时,可以考虑“标记永久化套数据结构”。

特征 1:一次操作创建一个区间对象

例如:

  • 给区间中每个位置添加一个权值;
  • 给区间中每个位置添加一种颜色;
  • 给区间中每个位置添加一个任务;
  • 一个权值为 ww 的区间开始生效。

重点是:

更新不是简单修改一个数,而是创建了一个以后可能需要单独删除、统计的对象。


特征 2:每个点的状态由所有覆盖它的对象决定

例如:

f(i)=max{we:iIe}f(i)=\max\{w_e:i\in I_e\}

或者:

f(i)=min{we:iIe}f(i)=\min\{w_e:i\in I_e\}

又或者需要知道覆盖点 ii 的对象数量、颜色集合等。

这类问题也称为动态区间 stabbing:

给定一个位置,查询所有覆盖这个位置的区间对象。


特征 3:对象不能压缩成普通懒标记

如果只需要区间加、区间和,可以把所有操作压缩成一个加法标记。

但如果需要:

  • 删除其中一个最大值;
  • 保留重复值数量;
  • 查询次大值;
  • 按编号删除对象;
  • 查询第 kk 大;

就不能只保存一个标量,必须维护对象集合。

此时节点内需要套数据结构。


特征 4:查询信息可以从局部摘要合并

本题查询的是最大值:

max(左侧答案,右侧答案,当前节点标记)\max(\text{左侧答案},\text{右侧答案},\text{当前节点标记})

最大值具有很好的可合并性。

类似适用的还有:

  • 最小值;
  • 按位或;
  • 按位与;
  • 是否存在;
  • 某些可结合的摘要。

但如果查询的是:

imaxAi\sum_i \max A_i

就没有这么简单。

因为区间对象可能覆盖许多位置,不能只用一个节点最大值计算所有点的最大值之和。


特征 5:局部删除后,对象支持低复杂度切割

本题一个区间减去另一个区间,最多剩两个区间:

[a,b][l,r][a,b]\setminus[l,r]

最多是:

[a,l1],[r+1,b][a,l-1],\quad [r+1,b]

因此可以删除整块,再补回左右两块。

这是本题能做的一个关键几何条件。

如果删除后剩余集合会变成大量碎片,这种方法就很容易失控。


十、设计这类数据结构的一套固定流程

以后遇到类似问题,可以按以下顺序思考。

第一步:把更新写成“对象”

不要先想线段树节点怎么维护。

先问:

一次更新究竟创建了什么对象?

本题创建:

(支持区间,权值)(\text{支持区间},\text{权值})

即:

([l,r],k)([l,r],k)

第二步:写出单点状态

问:

位置 ii 的答案,由哪些对象决定?

本题:

Ai={ ⁣{ke:iIe} ⁣}A_i=\{\!\{k_e:i\in I_e\}\!\}

然后:

f(i)=maxAif(i)=\max A_i

第三步:考虑对象的线段树规范分解

区间对象可以拆成 O(logn)O(\log n) 个线段树节点。

于是约定:

每个对象只存放在其规范分解得到的节点中。

不要把它继续下传到所有叶子。


第四步:选择节点内部数据结构

根据需要选择:

需求节点内部结构
只插入、取最大、删最大大根堆
只插入、取最小、删最小小根堆
任意值删除multiset
按对象编号删除双堆懒删除或哈希计数
查询排名、前驱后继平衡树
权值离散,查询计数、第 kk树状数组、权值线段树
需要撤销回滚栈、可撤销结构

本题只需要删除最大值,因此堆已经足够。


第五步:明确节点摘要

节点自身的数据结构只表示“直接挂在节点上的对象”。

还要维护整个子树的摘要:

summaryu=merge(localu,summarylc,summaryrc)summary_u= merge(local_u,summary_{lc},summary_{rc})

本题为:

mxu=max(topu,mxlc,mxrc)mx_u=\max(top_u,mx_{lc},mx_{rc})

第六步:查询时处理祖先贡献

永久标记不会下传,因此查询一个叶子或子区间时,不能只看目标节点。

必须考虑所有祖先节点上的永久标记。

典型方式有两种:

  1. 查询递归时不断把祖先贡献合入答案;
  2. 维护一个 carry 参数,表示根到当前节点积累的信息。

第七步:破坏性操作先确定目标值

本题操作 2 不直接递归删除,而是先查询得到:

xx

有了这个全局极值,才可以强力剪枝。

这是一个很常见的套路:

先查询目标极值,再进行带目标值的定向修改。

类似结构还经常用于:

  • 删除区间最大值;
  • 修改所有等于区间最小值的位置;
  • 找到最早冲突位置再局部修复;
  • 对达到某个阈值的节点递归处理。

第八步:寻找“整块处理”的充分条件

本题的充分条件是:

top(Hu)=xtop(H_u)=x

因为该标记覆盖整个节点区间,可以整块删除。

数据结构优化经常依赖这种思想:

找到一个条件,一旦满足,就不再递归到叶子,而是对整个节点一次完成操作。

这和线段树 Beats 有一点精神上的相似性,但机制不同:

  • Beats 依靠最大值、次大值等统计量判断整块修改;
  • 本题依靠永久区间对象,直接删除整个节点标记。

第九步:处理整块操作的副作用

整块删除影响了查询区间之外的位置。

于是把对象在补集上的部分恢复。

本质是:

新对象支持集=旧支持集修改区间\text{新对象支持集} = \text{旧支持集}\setminus\text{修改区间}

如果这个集合仍能由常数个同类对象表示,做法就很有希望。


十一、哪些变形还能用

变形一:查询最小值,删除一个最小值

把大根堆换成小根堆,maxx 换成 minn,思路基本相同。


变形二:支持按值删除指定的 xx

如果要从区间内所有包含 xx 的位置删除一个 xx,仍可能采用类似剪切。

但节点内不能只依赖堆顶,需要判断某个值是否存在并删除,通常改用:

  • multiset
  • 值到次数的映射;
  • 离散化后的权值数据结构。

复杂度会更高。


变形三:每个区间对象带唯一编号,支持撤销某次插入

节点堆中保存:

(权值,编号)(\text{权值},\text{编号})

删除指定编号时,可以使用:

  • 主堆;
  • 删除堆;
  • 两堆堆顶相同时同时弹出。

这是经典懒删除。

不过如果对象被操作 2 剪切过,还需要维护编号对应的多个区间碎片,问题会明显复杂。


变形四:只查询单点最大值

这会更简单。

区间对象仍放在线段树规范节点中,查询位置 ii 时只需沿根到叶路径取所有堆顶最大值。

不需要维护子树 maxx


变形五:动态区间集合,查询某点被哪些区间覆盖

这其实是同一模型。

每个节点存放完整覆盖该节点的区间对象;单点查询扫描根到叶路径上的所有节点数据结构。

区别只是内部容器和查询内容不同。


十二、哪些变形会直接破坏这个做法

1. 删除每个位置自己的最大值

如果操作变成:

对区间中每个非空位置,删除它自己的最大值。

那么不同位置删除的值可能不同:

5,7,2,9,5,7,2,9,\ldots

不再有一个统一目标 xx,无法使用 maxx < x 剪枝,也很难整块删除同一标记。


2. 相同值副本有身份区别

如果必须删除特定来源的 xx,就不能任意选择一个覆盖节点的 xx 弹出。

本题成立依赖:

相同权值的副本在可重集中完全不可区分。


3. 查询区间内所有点最大值之和

本题只查询:

maximaxAi\max_i \max A_i

最大值的最大值仍然是最大值。

但:

imaxAi\sum_i\max A_i

需要知道每个永久标记具体影响了多少位置、不同标记之间如何覆盖,节点只维护一个 maxx 远远不够。


4. 删除集合不是区间

如果删除的是:

  • 任意点集;
  • 按某个复杂条件筛出的点;
  • 大量离散位置;

那么一个区间对象被剪切后可能变成很多碎片,永久标记结构会快速膨胀。


5. 区间操作有严格时间顺序

本题每个位置维护的是可重集,插入顺序没有意义。

如果操作变成字符串拼接、函数复合等非交换操作,那么根到叶路径上的标记需要按时间顺序组合,普通堆无法处理。


十三、最推荐的直观模型:覆盖图层

可以把每次操作 1 想象成在数轴上铺了一条图层:

  • 横向范围是 [l,r][l,r]
  • 图层高度是 kk
  • 同一个高度允许有多层。

某个位置的最大苦涩值,就是该位置最高的图层高度。

操作 3:

看查询区间中出现的最高图层。

操作 2:

找到查询区间中的最高高度 xx,然后从所有能看到高度 xx 的位置撕掉一层 xx

线段树节点的堆,就是存放“完整横跨这个节点区间”的图层。

当要只撕掉图层中间的一部分时:

  1. 先把整条节点图层拿掉;
  2. 把左边没撕的部分铺回去;
  3. 把右边没撕的部分铺回去。

这正是代码的全部本质。


十四、最后总结成一句判定口诀

看到一题同时满足:

区间创建对象,单点由覆盖对象决定;对象不能压成单标记,查询可以合并摘要;修改针对统一极值,相同对象可互换;局部修改后支持集仍能拆成少量区间。

就应该考虑:

线段树规范分解+标记永久化+节点内数据结构+区间对象剪切\boxed{\text{线段树规范分解}+\text{标记永久化}+\text{节点内数据结构}+\text{区间对象剪切}}

而这题中:

  • 规范分解解决区间插入;
  • 永久标记避免大堆下传;
  • 优先队列维护重复最大值;
  • maxx 负责子树剪枝和查询;
  • “弹出整块,再补回两边”解决局部删除;
  • 全局最大值 xx 保证整块删除的正确性;
  • 相同值副本不可区分保证删除哪一份都可以。

Partager cette Q&R