截至本题解开始撰写,我的代码同时为 [P11695 / 昼寝](https://www.luogu....
बनाया गया: 20 अगस्त 2026
का उपयोग करके उत्तर दिया गया GPT-5.6 Thinking द्वारा Chat01
बनाया गया: 20 अगस्त 2026
का उपयोग करके उत्तर दिया गया GPT-5.6 Thinking द्वारा Chat01
截至本题解开始撰写,我的代码同时为 P11695 / 昼寝 及其完全强数据版 P12082 / Frühlingsbeginn / 立春 的最优解。
本题的思路足够精妙,但我认为,自主实现本题代码是想到思路之后远比其更大的障碍。因此,本文作为一篇题解,虽不得不花些笔墨复述一遍前人早已阐述完备的思路,但我想把重点放在实现上。
我们应当意识到一个事实:当题目的数据以某种非常规的方式给出时,我们第一时间要想的不一定是将其化为常规形式,而是基于这种方式进行思考。
题目将所有区间以左闭右开的方式给出,就是在告诉我们:** 不要**将其化为闭区间,而是利用左闭右开区间的性质,去除某些思想上的障碍。
我们不妨假设询问区间 包含一个单位区间 。这样,我们将所有可填入区间 的区间 分为三类:
首先考虑第一类。由于所有第一类区间都包含 ,那么我们收集所有满足 的区间 ,得到它们的并集,其显然是一个区间,不妨将其表示为 。
听起来很简单对吧?这是二维偏序,写去吧。能写,但是显然太不方便了。有没有什么方便一点的办法?有。
我们发现,用“一次计算”得到 相当难。不过,可以拆成“两次计算”。
第一次,求 。它是最小的使得操作区间 存在的 ,其中 。
第二次,求 。它是最大的使得操作区间 存在的 ,其中 。
这两次计算好像和之前的没有什么区别。事实上,这种方法可以求出正确的 ,而且这种方法在不考虑时间维度的情况下可以直接使用线段树二分求解。
实现后面再说,现在讨论第二类。假设对于 ,已经求出了其对应的 。现在需要做的是把 填满。不难想到一种暴力的检查方法:令 为 ,按左端点升序枚举所有的 。若 ,停止枚举;否则 ,继续枚举。由此得到一个新的区间 ,显然, 是 能被填满的一个必要不充分条件。
实际上,我们可以发现第三类和第二类的处理方法是相同的。将上面的文字改写一遍:现在需要做的是把 填满。不难想到一种暴力的检查方法:令 为 ,按右端点降序枚举所有的 。若 ,停止枚举;否则 ,继续枚举。由此得到一个新的区间 ,显然, 是 能被填满的一个必要不充分条件。
但是有个问题。每一个 的存在时间是一个区间。但是再想一想,每个询问都占据单独的一个单位时间,而且,第二类和第三类的区间枚举顺序分别是相同的……有了!
在处理第一类区间的时候,我们用到了线段树。而现在,每一个 所影响的询问在时间维度上也是一个区间!而再看一看我们对第二类区间和第三类区间干了什么:区间取 和区间取 。这也是线段树可以轻松实现的东西。所以,本质上,对第二类区间和第三类区间的处理体现了并行思想。
所以如何使用上面的性质和方法?最好的办法是令 为 的中点,随后不断向下分治 和 。
由此,本题解法的理论部分终于完成。然而,当你点开本题的提交记录,看到一个个 7KB 以上的 AC 提交记录时,你才会意识到:
实现,才是本题最大的挑战。
通过上述的思路,不难发现本题的核心数据结构就是线段树。接下来将对每棵线段树分别解释细节。
第一类区间的第一次计算:
第一次,求 。它是最小的使得操作区间 存在的 ,其中 。
该操作需要一棵支持单点插入、单点删除和区间最小值查询的线段树,其操作范围为 ,修改时在左端点处插入右端点位置。显然,在分治进行至 时,没有必要管外面的区间,这样只会徒增线段树的递归层数。为了让右端点尽可能地塞进查询区间,供查询的右端点位置需要尽可能小。
这棵树的每个叶子都是一个堆。本题推荐使用 multiset 实现,可以方便地使用 *ms.begin() 查询目前堆内的最小值,并将其存储入线段树本身的数据节点,上传数据时使用两侧节点的最小值。
注意,本题的线段树二分与常规情况不同,存在两个限制(查询位置限制和最小值限制),不一定能通过只走一边得到结果,在无法得到结果时需要两边都走。必须在必要的时刻终止递归。
第一类区间的第二次计算:
第二次,求 。它是最大的使得操作区间 存在的 ,其中 。
该操作需要一棵支持单点插入、单点删除和区间最大值查询的线段树,其操作范围为 ,修改时在右端点处插入左端点位置。为了让左端点尽可能地塞进查询区间,供查询的左端点位置需要尽可能大。
这棵树的每个叶子都是一个堆。使用 multiset 实现,可以使用 *ms.rbegin() 查询目前堆内的最大值,并将其存储入线段树本身的数据节点,上传数据时使用两侧节点的最大值。
第一类区间的两次计算可以共用堆序列。
第二类区间,需要一棵支持单点插入、单点删除、区间取 和区间求 (并不实际查区间 ,但要求结构意义上支持)的线段树,其操作范围,无论何时,均为 。其不变的值域和区间操作决定了每次分治暴力 build 的不可行性,因此懒标记需要同时承载清除数据和 操作数据。上传数据时使用两侧节点的最小值。下传标记时,不下传至没有有效节点或没有必要继续下传的位置(由前面提到的节点内存储区间 实现)。
将所有包含 的查询区间和所有包含在 内的操作区间按左端点升序排序,枚举左端点 ,先加入左端点为 的查询(放在对应的时间坐标上),后使用左端点为 的操作在时间坐标上区间取 。进行完一轮次的操作后,若线段树中存在无法继续被操作的节点,则暴力递归至每个满足要求的节点,查询是否满足要求,并删除对应节点。均摊分析可得其时间复杂度在可接受范围内。
第三类区间,需要一棵支持单点插入、单点删除、区间取 和区间求 (并不实际查区间 ,但要求结构意义上支持)的线段树,其操作范围,无论何时,均为 。上传数据时使用两侧节点的最大值。下传标记时,不下传至没有有效节点或没有必要继续下传的位置。
将所有包含 的查询区间和所有包含在 内的操作区间按右端点降序排序,枚举右端点 ,先加入右端点为 的查询,后使用右端点为 的操作在时间坐标上区间取 。进行完一轮次的操作后,若线段树中存在无法继续被操作的节点,则暴力递归至每个满足要求的节点,查询是否满足要求,并删除对应节点。同样的,均摊分析可得其时间复杂度在可接受范围内。
四棵线段树的内部实现均有所不同,为了调试方便,建议写成四个不同的类。我曾尝试令四棵线段树共用节点,然后被无边的调试折磨,最终删掉所有代码重写并使用对拍寻找 hack 后才通过此题。
正如前面所说,此题的关键在于实现。若只是理解了思路,而没有真正上手,认真将四棵线段树全部实现一遍,并(在可能的长时间调试后)真正地独立通过此题,本题才能体现出它的最大价值。
以下给出一份能够通过题目但在某些细节上略有劣化的代码。
:::info[code]
c#include <algorithm> #include <cassert> #include <iostream> #include <set> #include <vector> using namespace std; const int N = 1e6 + 10, inf = 1e9; struct Operation { int typ, l, r, t, t2; }; struct Query { int l, r, t, lb, rb, id; }; int n, m, qid[N]; bool ret[N]; vector<Operation> opr; vector<Query> qry, oqry; multiset<int> ms[N]; /* Single point insert / erase Bisecting maximum x with maximum at least y */ class Seg1 { private: int tr[N << 2], clb, crb; void build(int x, int l, int r) { tr[x] = 0; if (l == r) { ms[l].clear(); return; } int mid = (l + r) >> 1; build(x << 1, l, mid); build(x << 1 | 1, mid + 1, r); } void update(int tar, int v, int x, int l, int r) { if (l == r) { v > 0 ? ms[l].emplace(v) : ms[l].erase(ms[l].find(-v)); tr[x] = (ms[l].size() ? *ms[l].rbegin() : 0); return; } int mid = (l + r) >> 1; if (tar <= mid) update(tar, v, x << 1, l, mid); else update(tar, v, x << 1 | 1, mid + 1, r); tr[x] = max(tr[x << 1], tr[x << 1 | 1]); } int bisect(int lb, int rb, int x, int l, int r) { if (tr[x] < lb or l > rb) return 0; // cout << lb << ' ' << rb << ' ' << x << ' ' << l << ' ' << r << '\n'; if (l == r) { return l; } int mid = (l + r) >> 1; int res = bisect(lb, rb, x << 1 | 1, mid + 1, r); if (res) return res; return bisect(lb, rb, x << 1, l, mid); } public: void init(int lb, int rb) { clb = lb, crb = rb; build(1, clb, crb); } void insert(int p, int v) { update(p, v, 1, clb, crb); } void erase(int p, int v) { update(p, -v, 1, clb, crb); } int find(int lb, int rb) { return bisect(lb, rb, 1, clb, crb); } }; /* Single point insert / erase Bisecting minimum x with minimum at most y */ class Seg2 { private: int tr[N << 2], clb, crb; void build(int x, int l, int r) { tr[x] = inf; if (l == r) { ms[l].clear(); return; } int mid = (l + r) >> 1; build(x << 1, l, mid); build(x << 1 | 1, mid + 1, r); } void update(int tar, int v, int x, int l, int r) { if (l == r) { v > 0 ? ms[l].emplace(v) : ms[l].erase(ms[l].find(-v)); tr[x] = (ms[l].size() ? *ms[l].begin() : inf); return; } int mid = (l + r) >> 1; if (tar <= mid) update(tar, v, x << 1, l, mid); else update(tar, v, x << 1 | 1, mid + 1, r); tr[x] = min(tr[x << 1], tr[x << 1 | 1]); } int bisect(int lb, int rb, int x, int l, int r) { if (tr[x] > rb or r < lb) return 0; if (l == r) { return l; } int mid = (l + r) >> 1; int res = bisect(lb, rb, x << 1, l, mid); if (res) return res; return bisect(lb, rb, x << 1 | 1, mid + 1, r); } public: void init(int lb, int rb) { clb = lb, crb = rb; build(1, clb, crb); } void insert(int p, int v) { update(p, v, 1, clb, crb); } void erase(int p, int v) { update(p, -v, 1, clb, crb); } int find(int lb, int rb) { return bisect(lb, rb, 1, clb, crb); } }; /* Range chmax Purge minimum Lazy init */ class Seg3 { private: int tr[N << 2], tag[N << 2]; void build() { tr[1] = 0; tag[1] = -1; } void psh(int x) { if (!~tag[x]) { if (tr[x << 1]) tr[x << 1] = 0, tag[x << 1] = -1; if (tr[x << 1 | 1]) tr[x << 1 | 1] = 0, tag[x << 1 | 1] = -1; tag[x] = 0; return; } if (tr[x << 1] and tr[x << 1] < tag[x]) tr[x << 1] = tag[x << 1] = tag[x]; if (tr[x << 1 | 1] and tr[x << 1 | 1] < tag[x]) tr[x << 1 | 1] = tag[x << 1 | 1] = tag[x]; tag[x] = 0; } void create(int tar, int v, int x = 1, int l = 1, int r = m) { if (l == r) { tr[x] = v; return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; if (tar <= mid) create(tar, v, x << 1, l, mid); else create(tar, v, x << 1 | 1, mid + 1, r); tr[x] = (tr[x << 1] and tr[x << 1 | 1] ? min(tr[x << 1], tr[x << 1 | 1]) : tr[x << 1] ^ tr[x << 1 | 1]); } void update(int lb, int rb, int v, int x = 1, int l = 1, int r = m) { if (!tr[x] or tr[x] >= v) return; if (l >= lb and r <= rb) { tr[x] = tag[x] = v; return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; if (lb <= mid) update(lb, rb, v, x << 1, l, mid); if (rb > mid) update(lb, rb, v, x << 1 | 1, mid + 1, r); tr[x] = (tr[x << 1] and tr[x << 1 | 1] ? min(tr[x << 1], tr[x << 1 | 1]) : tr[x << 1] ^ tr[x << 1 | 1]); } void purge(int x = 1, int l = 1, int r = m) { // cout << "purge " << x << ' ' << l << ' ' << r << ' ' << tr[x] << '\n'; if (tr[x] != tr[1]) return; if (l == r) { tr[x] = 0; if (qid[l] < 0) return; // cout << "purgel " << qid[l] << ' ' << tr[1] << '\n'; ret[qid[l]] &= (oqry[qid[l]].lb <= tr[1]); return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; purge(x << 1, l, mid); purge(x << 1 | 1, mid + 1, r); tr[x] = (tr[x << 1] and tr[x << 1 | 1] ? min(tr[x << 1], tr[x << 1 | 1]) : tr[x << 1] ^ tr[x << 1 | 1]); } public: void init() { build(); } void insert(int t, int l) { create(t, l); } void fwd(int v, int l, int r) { update(l, r, v); } void purg(int v) { if (tr[1] != v) return; purge(); assert(tr[1] != v); } }; /* Range chmin Purge maximum Lazy init */ class Seg4 { private: int tr[N << 2], tag[N << 2]; void build() { tr[1] = 0; tag[1] = -1; } void psh(int x) { if (!~tag[x]) { if (tr[x << 1]) tr[x << 1] = 0, tag[x << 1] = -1; if (tr[x << 1 | 1]) tr[x << 1 | 1] = 0, tag[x << 1 | 1] = -1; tag[x] = 0; return; } if (tr[x << 1] > tag[x]) tr[x << 1] = tag[x << 1] = tag[x]; if (tr[x << 1 | 1] > tag[x]) tr[x << 1 | 1] = tag[x << 1 | 1] = tag[x]; tag[x] = 0; } void create(int tar, int v, int x = 1, int l = 1, int r = m) { if (l == r) { tr[x] = v; return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; if (tar <= mid) create(tar, v, x << 1, l, mid); else create(tar, v, x << 1 | 1, mid + 1, r); tr[x] = max(tr[x << 1], tr[x << 1 | 1]); if (tr[x] != v) cerr << x << ' ' << tr[x] << ' ' << v << '\n'; assert(tr[x] >= v); } void update(int lb, int rb, int v, int x = 1, int l = 1, int r = m) { if (tr[x] <= v) return; if (l >= lb and r <= rb) { tr[x] = tag[x] = v; return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; if (lb <= mid) update(lb, rb, v, x << 1, l, mid); if (rb > mid) update(lb, rb, v, x << 1 | 1, mid + 1, r); tr[x] = max(tr[x << 1], tr[x << 1 | 1]); } void purge(int x = 1, int l = 1, int r = m) { if (tr[x] != tr[1]) return; if (l == r) { tr[x] = 0; if (qid[l] < 0) return; // cout << "purger " << qid[l] << ' ' << tr[1] << '\n'; ret[qid[l]] &= (oqry[qid[l]].rb >= tr[1]); return; } if (tag[x]) psh(x); int mid = (l + r) >> 1; purge(x << 1, l, mid); purge(x << 1 | 1, mid + 1, r); tr[x] = max(tr[x << 1], tr[x << 1 | 1]); } public: void init() { build(); } void insert(int t, int r) { create(t, r); } void bkwd(int v, int l, int r) { update(l, r, v); } void purg(int v) { if (tr[1] != v) return; purge(); assert(tr[1] != v); } }; Seg1 seg_rb; Seg2 seg_lb; Seg3 seg_exl; Seg4 seg_exr; void solve(vector<Operation> &opr, vector<Query> &qry, int l = 1, int r = n) { if (qry.empty()) return; if (opr.empty()) { for (auto &i : qry) ret[i.id] = false; return; } int mid = (l + r) >> 1; vector<Operation> lo, mo, ro; vector<Query> lq, mq, rq; for (auto &i : opr) { if (i.r <= mid) lo.emplace_back(i); else if (i.l > mid) ro.emplace_back(i); else mo.emplace_back(i); } for (auto &i : qry) { if (i.r <= mid) lq.emplace_back(i); else if (i.l > mid) rq.emplace_back(i); else mq.emplace_back(i); } if (mo.empty() or mq.empty()) { for (auto &i : mq) ret[i.id] = false; solve(lo, lq, l, mid); solve(ro, rq, mid + 1, r); return; } sort(mo.begin(), mo.end(), [&](Operation &x, Operation &y) { return (x.typ == 1 ? x.t : x.t2) < (y.typ == 1 ? y.t : y.t2); }); sort(mq.begin(), mq.end(), [&](Query &x, Query &y) { return x.t < y.t; }); // Part 1: Calculate Right Border seg_rb.init(l, r); int po = 0, pq = 0; while (po != mo.size() or pq != mq.size()) { if (pq == mq.size() or (po != mo.size() and (~mo[po].typ ? mo[po].t : mo[po].t2) < mq[pq].t)) { if (~mo[po].typ) seg_rb.insert(mo[po].r, mo[po].l); else seg_rb.erase(mo[po].r, mo[po].l); po++; continue; } oqry[mq[pq].id].rb = seg_rb.find(mq[pq].l, mq[pq].r); // cout << "rb " << mq[pq].id << ' ' << oqry[mq[pq].id].rb << '\n'; pq++; } // Part 2: Calculate Left Border seg_lb.init(l, r); po = pq = 0; while (po != mo.size() or pq != mq.size()) { if (pq == mq.size() or (po != mo.size() and (~mo[po].typ ? mo[po].t : mo[po].t2) < mq[pq].t)) { if (~mo[po].typ) seg_lb.insert(mo[po].l, mo[po].r); else seg_lb.erase(mo[po].l, mo[po].r); po++; continue; } oqry[mq[pq].id].lb = seg_lb.find(mq[pq].l, mq[pq].r); if (!oqry[mq[pq].id].lb) oqry[mq[pq].id].lb = inf; // cout << "lb " << mq[pq].id << ' ' << oqry[mq[pq].id].lb << '\n'; pq++; } // Part 3: Extend Left Border seg_exl.init(); sort(lo.begin(), lo.end(), [&](Operation &x, Operation &y) { return x.l < y.l; }); sort(mq.begin(), mq.end(), [&](Query &x, Query &y) { return x.l < y.l; }); po = pq = 0; for (int i = l; i <= mid; i++) { while (pq != mq.size() and mq[pq].l == i) /*cout << "insertl " << mq[pq].id << '\n', */ seg_exl.insert(mq[pq].t, i), pq++; while (po != lo.size() and lo[po].l == i) { if (lo[po].typ == 1) seg_exl.fwd(lo[po].r, lo[po].t, lo[po].t2); po++; } seg_exl.purg(i); } // Part 4: Extend Right Border seg_exr.init(); sort(ro.begin(), ro.end(), [&](Operation &x, Operation &y) { return x.r > y.r; }); sort(mq.begin(), mq.end(), [&](Query &x, Query &y) { return x.r > y.r; }); po = pq = 0; for (int i = r; i > mid; i--) { while (pq != mq.size() and mq[pq].r == i) /*cout << "insertr " << mq[pq].id << '\n',*/ seg_exr.insert(mq[pq].t, i), pq++; while (po != ro.size() and ro[po].r == i) { // cout << "bkwd " << ' ' << ro[po].l << ' ' << ro[po].t << ' ' << ro[po].t2 << '\n'; if (ro[po].typ == 1) seg_exr.bkwd(ro[po].l, ro[po].t, ro[po].t2); po++; } seg_exr.purg(i); } opr.clear(), qry.clear(), mo.clear(), mq.clear(); solve(lo, lq, l, mid); solve(ro, rq, mid + 1, r); } int main() { // freopen("input.in", "r", stdin); // freopen("output.out", "w", stdout); cin.tie(0); cout.tie(0); ios::sync_with_stdio(0); cin >> n >> m; for (int i = 1, op, x, y; i <= m; i++) { cin >> op >> x; if (op == 2) { qid[i] = -1 - (int)opr.size(); opr[-qid[x] - 1].t2 = i; opr.emplace_back(opr[-qid[x] - 1]); opr.back().typ = -1; continue; } cin >> y; if (op == 1) { qid[i] = -1 - (int)opr.size(); opr.push_back(Operation{1, x, y, i, m}); continue; } qid[i] = qry.size(); ret[qid[i]] = true; qry.push_back(Query{x, y, i, -1, -1, qid[i]}); } oqry = qry; solve(opr, qry); for (int i = 0; i < oqry.size(); i++) { cout << (ret[i] ? 'Y' : 'N') << '\n'; } }
:::深度解释题解,我看不懂
这篇题解真正难懂的地方,不是“四棵线段树”,而是作者把 三个不同维度的思想同时压在了一起:
把这三层拆开以后,代码会清楚很多。下面我尽量不按原题解的叙述顺序,而是从“为什么算法一定长成这个样子”讲。
询问:
当前存在的区间中,能否选择一个子集,使它们的并恰好为 ?
注意一个很重要的等价转化。
如果某个区间不是 的子区间,例如
那它一定不能选,因为一选,并集就跑到询问区间外面了。
因此真正有资格参与询问的,只有
的区间。
而对于这些区间,我们根本没必要纠结“选哪个子集”:
把所有合法区间全部选上,一定是最优的。
因为它们全部在 内,多选不会把并集扩出 。
所以询问等价于:
这也是其他公开题解采用的基本转化。(Luogu)
先彻底忘掉插入删除。
假设现在有一些区间,我们问:
能否被其中完全包含于它的区间铺满。
经典贪心是:
textg = l 不断找左端点 <= g 的区间, 让 g 尽可能往右扩展。 最后看 g >= r。
比如:
text询问:[2, 10) 区间: [2,4) [3,7) [6,8) [7,10)
过程:
textg = 2 [2,4) => g = 4 [3,7) => g = 7 [6,8) => g = 8 [7,10) => g = 10
于是成功。
为什么要求区间是左闭右开非常舒服?
因为:
端点正好相等就能接上。
完全不需要考虑什么 +1、-1。
现在每个插入区间还有一个“生命周期”。
例如:
text第 3 次操作:插入 [2,7) 第 15 次操作:删除它
那它实际上是一个三维对象:
意思是:
空间上覆盖 ,时间上在第 3 到第 15 次操作之间存在。
所以一个询问可以看成:
即:
在时间 ,能不能覆盖空间区间 。
直接做就会变成很恶心的“空间 + 时间”二维动态问题。
于是这题最漂亮的一步来了:
solve(opr,qry,L,R) 到底在分治什么?代码:
cppvoid solve(vector<Operation> &opr, vector<Query> &qry, int l = 1, int r = n)
这里的 [l,r] 不是某一个询问。
它是:
当前正在处理的“空间坐标范围”。
取
cppmid = (l + r) >> 1;
关键不是点 mid,而是单位区间
我们把所有区间分成三类。
cppif (i.r <= mid) lo.emplace_back(i);
即
它完全在核心单位区间左边。
cppelse if (i.l > mid) ro.emplace_back(i);
它完全在
这一侧。
其余:
cppelse mo.emplace_back(i);
满足
于是它必定包含
询问也是完全一样的分类。
因此 mq 中的所有询问,都满足
换句话说:
这就是整个算法的核心。
类似的公开题解通常称这一结构为对数轴进行分治/猫树式分治。(Luogu)
考虑:
并且它包含
想把整个 覆盖掉,那么单位区间
当然也必须有人覆盖。
而谁能覆盖它?
只有 mo:
text跨 mid 的操作区间。
所以:
如果:
cppmo.empty()
那么所有 mq 直接失败:
cppfor (auto &i : mq) ret[i.id] = false;
这就是这里:
cppif (mo.empty() or mq.empty()) { for (auto &i : mq) ret[i.id] = false; solve(lo, lq, l, mid); solve(ro, rq, mid + 1, r); return; }
假设当前询问是:
取:
当前存在并且包含在 里的跨中点操作有:
画出来:
text2 3 4 5 6 7 8 9 10 |-------|---|---|---|---|---|---|---| [-----------) [-----------) [-----------) ^ [6,7)
所有这些区间都有一个共同部分:
所以它们互相一定连通。
因此它们的并必然是:
其中:
不会出现:
text[c, x) 空洞 [y, d)
这种情况。
因为所有区间都共同覆盖中间那一格。
这是为什么算法只需要求两个数:
而不需要真的维护整个并集。
有:
跨中点区间已经形成核心:
于是只剩:
textq_l c mid d q_r |----------------|========|========|---------------| 左侧 核心 右侧
因此整个询问成功,当且仅当:
[c,d)而且它确实存在。
能被左半边操作铺满。
能被右半边操作铺满。
因此:
这就是代码所谓的四个 Part:
textPart 1: 求 d Part 2: 求 c Part 3: 判断左边能否接到 c Part 4: 判断右边能否接到 d
Seg1:求核心右端点 这是最好理解的一棵。
我们只讨论当前 mo,即所有跨中点操作。
考虑一个询问:
一个操作:
能够参与这个询问,需要:
我们想让核心的右端点尽可能大,所以要:
满足:
按照操作的右端点 建线段树。
对每一个右端点 ,维护:
那么询问就是:
找最大的 ,使得
这是不是一个标准的线段树二分?
所以 Seg1:
cpptr[x] = max(...)
叶子 p 代表右端点 p。
叶子中的:
cppms[p]
存:
text所有右端点为 p 的区间的左端点
因此:
cpp*ms[p].rbegin()
就是最大的左端点。
因为我们只需要知道:
有没有一个左端点 ≥
ql?
显然,只看最大的那个就够了。
如果:
所有区间都不合法。
否则至少一个合法。
bisect() 就非常好懂了cppint bisect(int lb, int rb, int x, int l, int r) { if (tr[x] < lb or l > rb) return 0;
这里:
textlb = q_l rb = q_r
若:
cpptr[x] < q_l
说明当前整棵子树所有候选区间的左端点都太靠左。
不可能。
而:
cppl > q_r
说明右端点已经超过询问范围。
也不可能。
然后:
cppint res = bisect(lb, rb, x << 1 | 1, mid + 1, r);
先搜右儿子。
为什么?
因为要:
所以尽量往右。
最后得到:
cppoqry[mq[pq].id].rb
这里的 rb 实际就是:
不是询问原来的右端点。
Seg2:完全对称,求 现在要求:
同时必须存在:
所以对于每个左端点 ,维护:
为什么取最小右端点?
因为要判断:
只需要知道最小值。
如果:
那所有都不合法。
否则至少有一个合法。
所以询问变成:
于是 Seg2:
cpptr[x] = min(...)
并从左往右二分。
最终:
cppoqry[id].lb
就是:
所以 Seg1 / Seg2 不是静态的。
这就是代码中的时间扫描线。
每个操作:
cppOperation { typ, l, r, t, t2 };
真正表达的是:
text空间:[l,r) 存在时间:[t,t2)
例如:
text第 5 次:插入 [3,8) 第 20 次:删除它
那么:
cppl = 3 r = 8 t = 5 t2 = 20
main() 里那个诡异的 typ=-1 是干什么?看:
cppif (op == 2) { qid[i] = -1 - (int)opr.size(); opr[-qid[x] - 1].t2 = i; opr.emplace_back(opr[-qid[x] - 1]); opr.back().typ = -1; continue; }
假设第 x 次操作插入了某区间。
删除它时做两件事:
把:
cppt2 = 删除时间
于是原记录表示完整生命周期。
typ=-1这份记录专门充当:
text删除事件
因此 mo 里实际上同时有:
texttyp = 1 插入事件 typ = -1 删除事件
看:
cppsort(mo.begin(), mo.end(), [&](Operation &x, Operation &y) { return (x.typ == 1 ? x.t : x.t2) < (y.typ == 1 ? y.t : y.t2); });
它们按照:
text事件发生时间
排序。
然后同时扫描询问:
cppsort(mq.begin(), mq.end(), [&](Query &x, Query &y) { return x.t < y.t; });
于是扫描到询问时间 时:
text已经执行: 所有 T 之前的插入; 所有 T 之前的删除。
线段树里恰好就是:
这就是为什么 Part 1 / Part 2 可以回答动态询问。
对于每个跨中点询问,我们已经得到了:
text核心区间 [c,d)
代码里:
cppoqry[id].lb = c; oqry[id].rb = d;
现在真正变态的部分来了:
它只解决:
回到最普通的静态贪心。
对于一个询问,我们设:
g 表示:
从询问左端点开始,目前已经连续覆盖到哪里。
比如:
textQ = [2,10) 核心 c = 6 左区间: [2,4) [4,5) [3,6)
过程:
text开始 g=2 看到 [2,4) => g=max(2,4)=4 看到 [3,6) 3 <= g => g=max(4,6)=6
于是:
左半边成功。
如果每个询问各跑一次贪心,炸掉。
作者于是做了一件非常漂亮的事情:
把所有询问的贪心同时跑。
这就是题解所谓:
我们按照空间左端点:
从左向右扫描。
假设某个询问:
那么只有左端点满足:
的左侧操作才能包含在询问中。
怎么办?
非常简单:
当扫描到 时,才把这个询问加入数据结构。
这就是:
cppwhile (pq != mq.size() and mq[pq].l == i) seg_exl.insert(mq[pq].t, i), pq++;
加入时设置:
这是整个题最反直觉的一步。
Seg3 的线段树下标不是空间坐标。
而是:
也就是说叶子 表示:
第 个时刻的那个询问,目前覆盖到哪里。
如果时刻 100 有询问:
当扫描到空间坐标 3:
cppseg_exl.insert(100, 3);
等价于:
text时间轴第 100 个叶子: g_100 = 3
比如空间操作:
它存在于时间:
现在我们正好扫描到了它的左端点 。
如果一个询问目前已经覆盖到了 ,那么这个区间就可以接上:
而这个操作仅在时间:
存在。
所以对所有这些询问,统一进行:
是不是恰好就是:
时间轴上的区间
chmax!
所以:
cppseg_exl.fwd(lo[po].r, lo[po].t, lo[po].t2);
本质是:
text对于这个操作存在期间的所有询问, 把“当前能覆盖到的位置”对 R 取 max。
公开题解也通常把这一部分描述为:在时间轴上线段树维护每个询问当前向右延伸到的位置,扫描操作左端点时对其生命周期做区间 chmax。(Luogu)
等等!
操作:
明明只有在:
的时候才能接上。
可是代码:
cppseg_exl.fwd(...)
为什么直接对整个时间区间 chmax?
没有检查:
cppg >= i
啊?
答案是:
为什么?
因为每扫完位置 ,代码执行:
cppseg_exl.purg(i);
它会删除:
而且已经再也无法前进的询问。
例如:
text当前扫描 i=4 某询问 g=4
已经处理完所有左端点为 4 的操作之后,它仍然:
那意味着:
所有能够从 4 接出去的区间,都试过了,但还是走不了。
下一步扫描的是:
左端点为 5 的区间显然也救不了它,因为:
中间已经有洞了。
所以这个询问以后永远不可能成功。
可以立即删除。
进入扫描位置 时:
证明是归纳:
扫描完 后,所有:
的询问都被删了。
剩下的整数 必然:
所以处理左端点为 的操作时:
cpprange_chmax(R)
根本不用再判断:
cppg >= i
因为活着的天然全部满足。
这就是整题最值得理解的地方之一。
询问:
假设核心左端点:
左侧操作:
都在该询问时刻存在。
扫描 。
加入询问:
textg=2
看到:
于是:
此时:
cpppurg(2)
但:
所以活着。
扫描 。
没有事。
因为:
扫描 。
看到:
于是:
现在:
左边已经成功。
purge 时才判断是否成功?代码:
cppret[qid[l]] &= (oqry[qid[l]].lb <= tr[1]);
其中:
textlb = c tr[1] = 当前无法继续前进的位置 g
因此判断:
即:
如果成立,虽然这个贪心现在走不动了,但已经碰到核心了。
够了。
如果:
那说明在碰到核心前就断掉了。
失败。
purge 也没关系?假设一直扩:
那么扫描:
cppfor (int i=l; i<=mid; i++)
结束以后它还活着。
但是核心的左端点一定:
因为核心区间跨 mid。
所以:
必定已经成功。
因此让:
cppret[id]
继续保持 true 就行。
终于可以看代码。
cppint tr[N << 2], tag[N << 2];
对于一个时间轴节点:
表示:
为什么维护最小?
因为当前扫描空间位置 。
我们最关心:
有没有某个询问已经卡在 ?
只要:
就存在卡住的询问。
0 表示什么?cpptr[x] == 0
表示:
这个时间区间没有当前正在处理的询问。
所以合并两个儿子的时候不能普通:
cppmin(left,right)
否则:
textmin(0, 7)=0
会错误地把“空”当成最小值。
于是它写:
cpptr[x] = (tr[x<<1] and tr[x<<1|1] ? min(tr[x<<1], tr[x<<1|1]) : tr[x<<1] ^ tr[x<<1|1]);
这段看着很抽象,其实只是:
text两边都有: min(left,right) 只有左边: left 只有右边: right 都没有: 0
a ^ b 在这里只是利用“至少一个为 0”。
可读性非常差,但确实省代码。
update() 为什么这样剪枝?cppif (!tr[x] or tr[x] >= v) return;
操作是:
如果:
cpptr[x] == 0
没有询问。
不管。
如果:
那这一整段中的每个 都:
取 max 完全没有变化。
所以也可以直接返回。
cppif (l >= lb and r <= rb) { tr[x] = tag[x] = v; return; }
你可能会想:
明明是
chmax,怎么可以直接把tr[x]赋值为v?子树里可能本来有 10、20 啊。
注意:
tr[x] 不是说所有元素都等于它。
它只维护:
假设原来:
text3 8 20
做:
textchmax 7
变成:
text7 8 20
新的最小值确实:
所以:
cpptr[x] = 7; tag[x] = 7;
含义不是:
整棵子树赋值 7。
而是:
整棵子树以后需要执行
chmax(7)。
因此 tag 是:
不是普通赋值 lazy tag。
psh() 也就懂了cppif (tr[x << 1] and tr[x << 1] < tag[x]) tr[x << 1] = tag[x << 1] = tag[x];
含义:
如果左儿子的最小值:
才需要更新。
本质就是:
purge() 为什么能暴力递归?看:
cppif (tr[x] != tr[1]) return;
根节点:
是所有询问中最小的 。
当前调用:
cpppurg(i)
之前已经保证:
所以 purge() 只会进入:
子树最小值也是 的地方。
最终找到所有:
的叶子并删除。
看似会暴力递归很多节点,但每个询问只会真正被删除一次,因此可以做均摊分析;相关公开分析给出的整体复杂度约为 。(Luogu)
tag=-1 又是什么鬼?因为 Seg3 的时间轴范围永远是:
每一次空间分治节点都重新:
cppseg_exl.init();
如果真写:
cppbuild(1,1,m);
那么一次就是:
分治里做很多次,直接死。
所以作者用了:
tag=-1 表示:
这个节点下面的数据应被视为全部清空。
所以:
cppvoid build() { tr[1] = 0; tag[1] = -1; }
只清根。
以后真正访问某条路径时再:
cpppsh()
把“清空”懒惰地下传。
这是原文所谓:
懒标记需要同时承载清除数据和 max 操作数据。
于是 tag 三种状态:
text-1 : 需要清空 0 : 没有 lazy tag >0 : 待执行 chmax(tag)
理解 Seg3 时,这一点也非常关键。
左边我们是:
text从左往右扫描 当前覆盖右端点 g range chmax 维护最小 g
右边则是:
text从右往左扫描 当前覆盖左端点 h range chmin 维护最大 h
考虑:
核心:
我们需要验证:
能否填满。
初始化:
含义:
当前已经可以从 连续覆盖到 。
遇到右侧区间:
如果它能够接上:
则:
最后只需要:
代码:
cppfor (int i = r; i > mid; i--)
从右向左。
询问右端点是 时加入:
cppseg_exr.insert(mq[pq].t, i);
也就是:
然后对右端点恰为 的操作:
cppseg_exr.bkwd(lo, t, t2);
执行:
仍然利用同样的不变量:
所有仍活着的询问都已经满足可以接当前右端点 。
如果某个询问处理完所有右端点为 的操作以后仍:
那后面只会看到右端点更小的区间。
它们不可能再接上。
于是:
cppseg_exr.purg(i);
删掉。
并判断:
cppret[id] &= (oqry[id].rb >= tr[1]);
即:
等价于:
右半边成功。
这是整份代码最值得记的一张表:
| 结构 | 下标是什么 | 每个位置存什么 | 作用 |
|---|---|---|---|
Seg1 | 空间右端点 | 当前区间的最大左端点 | 求核心右端点 |
Seg2 | 空间左端点 | 当前区间的最小右端点 | 求核心左端点 |
Seg3 | 时间 | 询问当前能向右覆盖到的 | 判断左边能否接到 |
Seg4 | 时间 | 询问当前能向左覆盖到的 | 判断右边能否接到 |
如果这一张表完全理解,这题已经懂了 80%。
solve(),现在应该完全是顺的整个函数实际上就是:
cppsolve(当前操作, 当前询问, L, R) { mid = (L+R)/2; 把操作分成: lo = 完全在左 mo = 跨 mid ro = 完全在右 把询问分成: lq = 完全在左 mq = 跨 mid rq = 完全在右 // mq 在当前层彻底解决 对 mq: Part 1: 用跨 mid 操作求 d Part 2: 用跨 mid 操作求 c Part 3: 用 lo 判断 [ql,c) 能否填满 Part 4: 用 ro 判断 [d,qr) 能否填满 // mq 已经不会再下传 solve(lo,lq,L,mid); solve(ro,rq,mid+1,R); }
注意:
例如询问:
在根节点可能完全位于左侧,于是一路递归。
直到某一层的 mid 落进:
它才进入:
cppmq
然后一次性解决。
lo/lq 和 ro/rq?因为 mq 已经回答完了。
而 mo 也不需要再下传。
mo 是:
当前这一层跨中点的操作。
以后处理完全左侧的询问时,它不可能合法,因为它伸出了左侧询问。
完全右侧同理。
所以:
cppsolve(lo, lq, l, mid); solve(ro, rq, mid + 1, r);
即可。
假设:
跨中点操作:
text[4----------10) [5----------------11) [6-----9)
它们的并:
所以:
textc = 4 d = 11
整个询问:
textQ: [2--------------------------------12) 跨中点核心: [4------------------11) 缺的: [2-----4) [11--12)
现在:
负责得到:
text[c,d)=[4,11)
检查:
text能否从 2 一路接到 >=4
检查:
text能否从 12 一路反着接到 <=11
如果都成立:
text左边 + 核心 + 右边
就是:
原文说:
令 为 ,按左端点升序枚举所有的 。若 ,停止枚举……
这里符号重复使用,特别容易把人绕死。
更清楚的写法应该是:
假设当前枚举的操作区间为:
维护当前连续覆盖到:
按照 递增枚举:
则出现空洞,停止;
最后检查:
这样就清晰很多。
例如:
它们:
所以状态直接维护:
就意味着:
下一段允许左端点等于 5。
条件简单地是:
如果转成各种闭区间后,代码里就会充斥:
cppr+1 l-1 g+1
虽然理论等价,但更容易把这个并行贪心的本质遮住。
qid 那段也给你翻译一下它被作者一变量两用。
对于询问操作:
cppqid[i] = qry.size();
表示:
text第 i 次操作对应第几个询问
对于插入操作:
cppqid[i] = -1 - opr.size();
负数编码:
text第 i 次插入,对应 opr 数组中的哪个元素
删除时:
cppopr[-qid[x] - 1]
就把这个下标解出来。
所以:
cppqid
这个名字其实取得挺糟糕。
更可读的代码应该拆成:
cppinsert_id[] query_id[]
oqry 又为什么存在?cppqry
在 solve() 里会不断:
cppsort(...)
而且被拆到:
textlq/mq/rq
中。
但我们需要:
text根据永久 id 找到这个询问的 c,d。
所以:
cppoqry = qry;
保存一份不动的原始数组。
于是:
cppoqry[id].lb oqry[id].rb
永远分别保存:
ret 为什么初始为 true?cppret[qid[i]] = true;
因为对于一个跨中点询问,最后实际上检查两个条件:
所以最方便就是:
cppret = true; ret &= LeftOK; ret &= RightOK;
Seg3:
cppret[id] &= (c <= g);
Seg4:
cppret[id] &= (d >= h);
最终就是:
如果某个询问根本找不到合法 mo。
那么:
Seg1:
textd = 0
Seg2:
textc = inf
左侧最多只能覆盖到 mid,所以最终不可能:
Seg3 会将:
cppret &= false;
右边也类似。
所以代码并没有专门:
cppif (c == inf) ret=false;
它是让后续判定自然失败。
typ == 1?你可能注意到了:
cppif (lo[po].typ == 1) seg_exl.fwd(...);
删除记录:
cpptyp == -1
竟然直接无视。
原因是 Seg1 / Seg2 和 Seg3 / Seg4 对时间的处理方法完全不同。
正在“按时间扫描”。
所以必须有:
text插入事件 删除事件
才能动态维护当前集合。
它们已经直接拿到了操作的整个生命周期:
cpp[t,t2]
一个操作直接对应:
cpprange_chmax(t,t2,...)
即可。
根本不需要删除事件。
所以只使用:
cpptyp == 1
的原始记录。
这是一个非常容易看代码看懵的细节。
这其实是均摊思想。
假设现在根的最小值为:
我们必须把所有:
的询问找出来。
最坏看起来可能很多。
但每找到一个叶子,就:
cpptr[x] = 0;
永久删除这个询问。
一个询问:
所以暴力下降到叶子的总次数可以摊到询问身上。
这也是为什么“线段树节点里只维护最小值/最大值”就足够了,不需要保存一个询问集合。
如果让我不用任何代码,用一句稍长的话描述这题,我会说:
对空间区间分治。对于第一次跨越分治中点的询问,所有跨中点且合法的操作必然形成一个连续核心区间 ;用时间扫描线加两棵空间线段树求出 。随后将左右两侧普通的区间覆盖贪心对所有询问并行执行:按空间端点扫描,把每个询问的当前覆盖边界放在“时间轴线段树”上,每个操作根据自己的生命周期进行区间
chmax/chmin,并及时删除已经无法继续扩展的询问。
这就是整道题。
不要记代码。
只记:
前两棵:
时间扫描,树建在空间上。
后两棵:
空间扫描,树建在时间上。
我认为这八个字是理解本题最重要的总结:
整个“精妙感”就在这里。
如果你准备真正把这题学会,不要直接啃那 7KB 代码。建议按这个顺序重新看:
Seg1 如何求 。Seg2 如何求 。Seg3 的 tr/tag/purge。Seg4 不重新学习,直接把 Seg3 全部左右翻转。这份实现的公开分析通常给出约
的总复杂度,足以解释为什么需要这种相当重的离线分治和线段树组合,而不能对每个询问独立做覆盖贪心。(Luogu)
如果只挑一个最值得继续深挖的部分,就是 Seg3。因为一旦你真的理解“为什么 range chmax 不检查 g>=i 仍然正确”,这题基本就从“完全看不懂”变成“只是代码长”。