# P7476 「C.E.L.U-02」苦涩 ## 题目背景 回想起自己的过往的人生,YQH 觉...
創建於:2026年7月30日
使用 GPT-5.6 Thinking 回答 Chat01
創建於:2026年7月30日
使用 GPT-5.6 Thinking 回答 Chat01
回想起自己的过往的人生,YQH 觉得心中充满了苦涩。如果人生能再来一次,我一定会少做一些傻事,少真香几次,然后大胆地去追寻自己的爱。可惜没有这样一个机会了。
在 YQH 的梦中,他看到自己过去的记忆正在不断浮现在自己脑中。这些记忆带给他的是满满的苦涩。他想要强行忘记一些来减轻自己的苦涩。
YQH 的脑中可以被分成 个片区,每个片区相当于一个存放记忆的可重集,初始为空。他将进行 次这三种操作:
操作 1:区间 的片区中都浮现了一个苦涩值为 的记忆。
操作 2:YQH 开始清理 片区的记忆。如果一个片区 且 中苦涩值最大的记忆与 片区中苦涩值最大的记忆相等,则将这个苦涩值最大的记忆忘记。如果在同一个片区有多个相同的苦涩值最大的记忆,则只忘记一个。如果这些片区内没有记忆,则无视。
操作 3:YQH 想知道, 片区中苦涩值最大的记忆的苦涩值是多少,如果不存在,输出-1。
第一行两个数,。
接下来 行,第一个数代表操作种类 ,对于操作 1,有三个数 ,对于操作 2 或 3,有两个数 。
对于每个操作 3 输出一行,代表答案。
text5 4 1 1 3 2 1 2 4 3 2 3 3 3 1 3
3
text6 6 1 1 6 2 1 3 3 2 1 3 4 3 2 3 4 3 3 3 3 4 4
text2 2
样例解释一
下面为各操作之后 YQH 的大脑的状态:
第一次操作:
第二次操作:
第三次操作:
第四次操作询问 区间 的最大值,所以答案是 。
样例解释二
下面为各操作之后 YQH 的大脑的状态:
第一次操作:
第二次操作:
第三次操作:
第四次操作:
第五次操作询问 的最大值,所以答案是 。
第六次操作询问 的最大值,所以答案是 。
| Subtask | n | m | 特殊性质 |
|---|---|---|---|
| 没有操作 2 | |||
| 操作 2 中 | |||
| 操作 2 中 | |||
对于 的数据,
闲话:众所周知校内模拟赛很喜欢搬题,于是我们的 @CYJian 就放了 T1 签,T2 难,T3 签,T4 中,T5 签,然后这个蒟蒻只会 T5。而且还想了 。然后 T1 的贪心以为是假的所以就去梭哈最难的 T2 了。最终分数显然易得。
根据我们的 d** 同学所使用的方法——观察数据范围推测算法时空复杂度,然后由于 T1 数据范围 ,以为要上数据结构,最后写了 、 行的代码,拿了 ,于是被老师喷了:那我开 你是不是还要考虑可持久化一下。
以下讨论时间复杂度中的 并不代表题目中的 。
但是这道题目用以上方法就不难瞅出大概是 、 或者 的算法。当然,如果你像某位卡常代师可以把 的复杂度卡到 的话,可以考虑考虑 做法。
但是一眼 的算法大概率是没有的,毕竟你区间操作带只 ,而且删除操作一看就很难 删完。如果您可以 过掉可以告诉我,让我膜拜 ds 大神啊。
所以考虑 的算法。区间操作很容易想到分块或者莫队,但是区间 跟区间 似乎没有什么联系,所以只能考虑分块,但是显然每个块至少需要排序吧。所以复杂度为 ,大概率过不了了。
最后只剩下 的算法( 算法一眼不可做)。
看到区间操作首先想差分、数据结构、分块、莫队对吧,但是差分肯定不行,分块之前说明过时间复杂度有问题,莫队正确性有问题。所以只剩下数据结构。那我们再列举一下数据结构:线段树、树状数组、平衡树……剩下的应该没用吧。平衡树支持插入、删除操作,但是区间插入、删除似乎还是有点困难。树状数组看着不太行的样子。酱紫就只剩下线段树了。
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; }
在你的苦苦哀求下,「神」告诉了你具体思路。
温馨提醒:这道题的线段树与普通线段树有一点点区别,且在观赏「神」的思路的时候请看完一个板块再思考,因为有些问题可能会在其他操作中讲到。
「神」说其他题解中说这个思路为标记永久化。
每一个位置都有一个可重集合(就是堆)。
对于操作 ,即在区间 中每一个堆都插入一个数 。那么在线段树每一个点上维护一个堆,代表这个点所管辖的区间中有哪些数。那么在线段树中找到区间 直接在这个点所维护的堆中插入 即可。
对于操作 ,即查询区间 中所有堆中最大数。那么线段树上每一个点再维护一变量 ,表示这个点所维护的区间中的所有堆中的最大数。查询的时候直接返回即可。
对于操作 ,即把区间 中每一个堆都删除一个 的所有堆中的最大数。
显然操作 最难处理。那我们可以先手动添加一个操作 ,设返回的答案为 ,那我们只需要在区间 中每一个堆里面删除一个 。那么我们设线段树上第 个点的堆中的最大值为 。
那么 的情况最后一定会变成等于或者小于的情况,所以其实我们只用讨论 时该怎么办就好,但是很显然直接弹出。但是就当你激动地准备提交代码时,惊奇的发现样例过不了,于是你再次请求「神」帮你调试代码,但是「神」告诉你还有一种情况。你诧异至极,顿时如梦初醒。
当 时,假设 所维护的区间为 ,操作区间为 ,显然 ,是不是发现问题了?你其实把 的 都给删除了,不好,怎么办?直接在这两个区间里面用操作 加回去就好了,就是这么暴力。
总时间复杂度为 。
为什么是 呢?插入和查询时就不用多说,只需要聊聊删除操作。首先线段树的深度为 ,并且每次删除都可能会有两个区间的插入操作。但是你会发现中间的那个区间就不用更新了。于是就相当于递归左儿子和右儿子且你遍历的深度越大,那么儿子的深度就越小,平摊一下就差不多总深度为 ,再算上优先队列的 就 了。
注意:
具体实现还是看上面的代码吧。突然感觉这道紫还挺水的,我赛时怎么没场切呢。
如果有错别字可以在讨论区威胁笔者修改。如果有疑问可以绑架作者并打破砂锅问到底。
以前从来没见过这种题和这种线段树,分析一下这种题目的本质是什么,如何举一反三应对类似的“线段树标记永久化套数据结构”的题目。
这题的核心并不是“线段树里放一个堆”这么简单,而是:
把每次区间插入看成一个带权区间对象,用线段树永久保存这些对象;每个位置的状态,由根到叶路径上所有对象共同决定。
操作 2 又恰好具有“删除全局极值、相同值副本不可区分、区间删去后仍是若干区间”的性质,因此可以对区间对象进行剪切。
对每个位置 ,维护一个可重集 。
操作 1:
可以把它理解成创建了一个“区间记忆对象”:
它在区间 内的每个位置贡献一个 。
于是,位置 的可重集,实际上就是:
也就是说,每个位置的元素来自所有覆盖它的区间对象。
操作 3 就是:
操作 2 则是:
所以这题本质上是:
动态维护一批带权区间,每个点拥有所有覆盖它的区间权值;支持查询一段位置中的最大权值,并从目标区间内剪掉一层最大权值。
假设在区间 上依次插入:
如果只维护一个普通的最大值懒标记 ,那么删除一个 后,需要知道下一个最大值是 。
因此不能只保存一个数,必须保存所有尚未删除的区间标记,并且保留重数。
自然就需要一个可重集合。由于只关心最大值,并且删除的也是最大值,所以优先队列正合适。
但如果采用普通懒标记下传:
所以这里不下传标记,而是采用“标记永久化”。
普通懒标记的想法是:
标记暂时放在高层节点,以后有需要再推给儿子。
标记永久化的想法是:
这个标记本来就代表整个节点区间,因此永远放在这个节点,不再下传。查询时主动统计沿途的所有标记。
注意它和“可持久化数据结构”没有关系。
“永久”指的是标记不向下推,不是保存历史版本。
在线段树中,将区间 分解成 个线段树节点。
例如:
对于每个被完整覆盖的节点 ,将 插入节点自己的堆 。
堆里的一个元素 表示:
节点 对应区间 中的每个位置,都拥有一个 。
于是对任意叶子 ,它的真实可重集为:
这是整套做法最重要的不变量。
对每个线段树节点 ,维护:
有:
其中空堆和空子树的最大值视为 。
要注意:
只统计存放在 及其后代节点中的标记,不统计祖先节点的标记。
祖先标记需要在查询递归过程中额外考虑。
查询区间 。
递归经过节点 时, 中的每个标记都覆盖整个 ,所以只要:
节点堆顶就可能成为答案。
如果某个儿子的区间被查询区间完整包含,就可以直接使用儿子的 。
因此查询过程实际上统计了两类信息:
这也是代码里为什么先取:
cppres = tree[k].q.top();
再递归查询儿子的原因。
首先查询:
然后需要对所有满足局部最大值为 的位置删除一个 。
考虑线段树节点 ,对应区间 。
说明节点子树中根本没有 ,直接跳过。
cppif (tree[k].maxx < x) return;
即:
这个 是一个覆盖整个 的区间标记。
对于任意:
由于节点标记给位置 提供了一个 ,所以:
另一方面, 是整个查询区间的最大值,所以:
因此:
也就是说,交集中的每个位置都正好应该删除一个 。
于是可以直接:
cpptree[u].heap.pop();
它会从整个 删除一层 。
真正需要删除的只是:
但弹出节点标记会影响整个 。
因此把不该删除的部分补回来:
因为两个区间作差最多得到两个区间:
所以分别在左侧、右侧重新插入一个 。
这就是代码中的:
cppif (l < x) update(k, l, r, l, x - 1, z); if (r > y) update(k, l, r, y + 1, r, z);
本质是:
先把整块标记删除,再把查询区间之外的部分补回去。
这可以称为“区间对象剪切”。
某个位置可能同时有多个值为 的记忆,例如:
题目只要求删除一个 ,并不关心删除的是哪一次操作产生的 。
因此所有相同权值的副本都是不可区分的。
这意味着:
只要从该位置覆盖到的任意一个 标记中删掉一份,结果可重集就是正确的。
代码有时删除节点自身的 ,有时递归到后代删除另一个 ,都没有关系。
如果题目给每个记忆一个编号,并要求删除特定编号的记忆,这个做法就不能直接成立。
原题解中提到:
节点堆顶 时继续向下递归。
严格来说,如果这里的 指的是节点自己堆的堆顶,并且节点区间与查询区间相交,那么:
其实不可能发生。
因为节点自己的标记覆盖整个节点区间,只要它和查询区间有交集,交集中的位置就拥有一个 ,这会与 是查询区间最大值矛盾。
真正可能大于 的是:
因为 统计整个节点区间,较大的值可能只出现在查询区间之外的后代中。
所以更准确的分类是:
原题解说删除操作单次是 ,这并不是严格的最坏情况。
例如:
这一次删除会访问所有叶子,单次复杂度可以达到 。
正确的理解是:
总复杂度是均摊的 ,而不是每次删除都严格 。
每次操作 1 会产生 个堆元素。
删除过程中,弹出的节点两两不具有祖先后代关系,因为在某个节点弹出后就直接返回,不再递归它的后代。
所以被弹出节点对应的区间互不相交。
对于固定查询区间 :
因此每次删除最多产生两个需要补回的区间。
每个补回区间在线段树上拆成 个新堆元素,所以整个操作序列中创建的堆元素总数为:
每个堆元素:
再考虑线段树递归和维护,整体可以控制在:
在 同阶时通常写作:
空间复杂度为:
遇到下面几个特征时,可以考虑“标记永久化套数据结构”。
例如:
重点是:
更新不是简单修改一个数,而是创建了一个以后可能需要单独删除、统计的对象。
例如:
或者:
又或者需要知道覆盖点 的对象数量、颜色集合等。
这类问题也称为动态区间 stabbing:
给定一个位置,查询所有覆盖这个位置的区间对象。
如果只需要区间加、区间和,可以把所有操作压缩成一个加法标记。
但如果需要:
就不能只保存一个标量,必须维护对象集合。
此时节点内需要套数据结构。
本题查询的是最大值:
最大值具有很好的可合并性。
类似适用的还有:
但如果查询的是:
就没有这么简单。
因为区间对象可能覆盖许多位置,不能只用一个节点最大值计算所有点的最大值之和。
本题一个区间减去另一个区间,最多剩两个区间:
最多是:
因此可以删除整块,再补回左右两块。
这是本题能做的一个关键几何条件。
如果删除后剩余集合会变成大量碎片,这种方法就很容易失控。
以后遇到类似问题,可以按以下顺序思考。
不要先想线段树节点怎么维护。
先问:
一次更新究竟创建了什么对象?
本题创建:
即:
问:
位置 的答案,由哪些对象决定?
本题:
然后:
区间对象可以拆成 个线段树节点。
于是约定:
每个对象只存放在其规范分解得到的节点中。
不要把它继续下传到所有叶子。
根据需要选择:
| 需求 | 节点内部结构 |
|---|---|
| 只插入、取最大、删最大 | 大根堆 |
| 只插入、取最小、删最小 | 小根堆 |
| 任意值删除 | multiset |
| 按对象编号删除 | 双堆懒删除或哈希计数 |
| 查询排名、前驱后继 | 平衡树 |
| 权值离散,查询计数、第 大 | 树状数组、权值线段树 |
| 需要撤销 | 回滚栈、可撤销结构 |
本题只需要删除最大值,因此堆已经足够。
节点自身的数据结构只表示“直接挂在节点上的对象”。
还要维护整个子树的摘要:
本题为:
永久标记不会下传,因此查询一个叶子或子区间时,不能只看目标节点。
必须考虑所有祖先节点上的永久标记。
典型方式有两种:
carry 参数,表示根到当前节点积累的信息。本题操作 2 不直接递归删除,而是先查询得到:
有了这个全局极值,才可以强力剪枝。
这是一个很常见的套路:
先查询目标极值,再进行带目标值的定向修改。
类似结构还经常用于:
本题的充分条件是:
因为该标记覆盖整个节点区间,可以整块删除。
数据结构优化经常依赖这种思想:
找到一个条件,一旦满足,就不再递归到叶子,而是对整个节点一次完成操作。
这和线段树 Beats 有一点精神上的相似性,但机制不同:
整块删除影响了查询区间之外的位置。
于是把对象在补集上的部分恢复。
本质是:
如果这个集合仍能由常数个同类对象表示,做法就很有希望。
把大根堆换成小根堆,maxx 换成 minn,思路基本相同。
如果要从区间内所有包含 的位置删除一个 ,仍可能采用类似剪切。
但节点内不能只依赖堆顶,需要判断某个值是否存在并删除,通常改用:
multiset;复杂度会更高。
节点堆中保存:
删除指定编号时,可以使用:
这是经典懒删除。
不过如果对象被操作 2 剪切过,还需要维护编号对应的多个区间碎片,问题会明显复杂。
这会更简单。
区间对象仍放在线段树规范节点中,查询位置 时只需沿根到叶路径取所有堆顶最大值。
不需要维护子树 maxx。
这其实是同一模型。
每个节点存放完整覆盖该节点的区间对象;单点查询扫描根到叶路径上的所有节点数据结构。
区别只是内部容器和查询内容不同。
如果操作变成:
对区间中每个非空位置,删除它自己的最大值。
那么不同位置删除的值可能不同:
不再有一个统一目标 ,无法使用 maxx < x 剪枝,也很难整块删除同一标记。
如果必须删除特定来源的 ,就不能任意选择一个覆盖节点的 弹出。
本题成立依赖:
相同权值的副本在可重集中完全不可区分。
本题只查询:
最大值的最大值仍然是最大值。
但:
需要知道每个永久标记具体影响了多少位置、不同标记之间如何覆盖,节点只维护一个 maxx 远远不够。
如果删除的是:
那么一个区间对象被剪切后可能变成很多碎片,永久标记结构会快速膨胀。
本题每个位置维护的是可重集,插入顺序没有意义。
如果操作变成字符串拼接、函数复合等非交换操作,那么根到叶路径上的标记需要按时间顺序组合,普通堆无法处理。
可以把每次操作 1 想象成在数轴上铺了一条图层:
某个位置的最大苦涩值,就是该位置最高的图层高度。
操作 3:
看查询区间中出现的最高图层。
操作 2:
找到查询区间中的最高高度 ,然后从所有能看到高度 的位置撕掉一层 。
线段树节点的堆,就是存放“完整横跨这个节点区间”的图层。
当要只撕掉图层中间的一部分时:
这正是代码的全部本质。
看到一题同时满足:
区间创建对象,单点由覆盖对象决定;对象不能压成单标记,查询可以合并摘要;修改针对统一极值,相同对象可互换;局部修改后支持集仍能拆成少量区间。
就应该考虑:
而这题中:
maxx 负责子树剪枝和查询;