截至本题解开始撰写,我的代码同时为 [P11695 / 昼寝](https://www.luogu....

創建於:2026年8月20日

使用 GPT-5.6 Thinking 回答 Chat01

問題

截至本题解开始撰写,我的代码同时为 P11695 / 昼寝 及其完全强数据版 P12082 / Frühlingsbeginn / 立春 的最优解。

本题的思路足够精妙,但我认为,自主实现本题代码是想到思路之后远比其更大的障碍。因此,本文作为一篇题解,虽不得不花些笔墨复述一遍前人早已阐述完备的思路,但我想把重点放在实现上。


思路

我们应当意识到一个事实:当题目的数据以某种非常规的方式给出时,我们第一时间要想的不一定是将其化为常规形式,而是基于这种方式进行思考。

题目将所有区间以左闭右开的方式给出,就是在告诉我们:** 不要**将其化为闭区间,而是利用左闭右开区间的性质,去除某些思想上的障碍。

我们不妨假设询问区间 [l,r)[l,r) 包含一个单位区间 [p,p+1)[p,p+1)。这样,我们将所有可填入区间 [l,r)[l,r) 的区间 [s,t)[s,t) 分为三类:

  1. [s,t)[p,p+1)[s,t)\supseteq [p,p+1)
  2. [s,t)[l,p)[s,t)\subseteq [l,p)
  3. [s,t)[p+1,r)[s,t)\subseteq [p+1,r)

首先考虑第一类。由于所有第一类区间都包含 [p,p+1)[p,p+1),那么我们收集所有满足 sltrs\ge l\land t\le r 的区间 [s,t)[s,t),得到它们的并集,其显然是一个区间,不妨将其表示为 [c,d)[c,d)

听起来很简单对吧?这是二维偏序,写去吧。能写,但是显然太不方便了。有没有什么方便一点的办法?有。

我们发现,用“一次计算”得到 [c,d)[c,d) 相当难。不过,可以拆成“两次计算”。

第一次,求 cc。它是最的使得操作区间 [c,e)[c,e) 存在的 cc,其中 e(p,r]e\in(p,r]

第二次,求 dd。它是最的使得操作区间 [f,d)[f,d) 存在的 dd,其中 f[l,p]f\in[l,p]

这两次计算好像和之前的没有什么区别。事实上,这种方法可以求出正确的 [c,d)[c,d),而且这种方法在不考虑时间维度的情况下可以直接使用线段树二分求解。

实现后面再说,现在讨论第二类。假设对于 [l,r)[l,r),已经求出了其对应的 [c,d)[c,d)。现在需要做的是把 [l,c)[l,c) 填满。不难想到一种暴力的检查方法:令 ggll,按左端点升序枚举所有的 [c,d)[c,d)。若 g<cg<c,停止枚举;否则 gmax(g,d)g\leftarrow\max(g,d),继续枚举。由此得到一个新的区间 [l,g)[l,g),显然,gcg\ge c[l,r)[l,r) 能被填满的一个必要不充分条件。

实际上,我们可以发现第三类和第二类的处理方法是相同的。将上面的文字改写一遍:现在需要做的是把 [d,r)[d,r) 填满。不难想到一种暴力的检查方法:令 hhrr,按右端点降序枚举所有的 [c,d)[c,d)。若 h>dh>d,停止枚举;否则 hmin(h,c)h\leftarrow\min(h,c),继续枚举。由此得到一个新的区间 [h,r)[h,r),显然,hdh\le d[l,r)[l,r) 能被填满的一个必要不充分条件。

但是有个问题。每一个 [c,d)[c,d) 的存在时间是一个区间。但是再想一想,每个询问都占据单独的一个单位时间,而且,第二类和第三类的区间枚举顺序分别是相同的……有了!

在处理第一类区间的时候,我们用到了线段树。而现在,每一个 [c,d)[c,d) 所影响的询问在时间维度上也是一个区间!而再看一看我们对第二类区间和第三类区间干了什么:区间取 max\max 和区间取 min\min。这也是线段树可以轻松实现的东西。所以,本质上,对第二类区间和第三类区间的处理体现了并行思想。

所以如何使用上面的性质和方法?最好的办法是令 pp[l,r)[l,r) 的中点,随后不断向下分治 [l,p)[l,p)[p+1,r)[p+1,r)

由此,本题解法的理论部分终于完成。然而,当你点开本题的提交记录,看到一个个 7KB 以上的 AC 提交记录时,你才会意识到:

实现,才是本题最大的挑战

实现

通过上述的思路,不难发现本题的核心数据结构就是线段树。接下来将对每棵线段树分别解释细节。

第一类区间的第一次计算:

第一次,求 cc。它是最的使得操作区间 [c,e)[c,e) 存在的 cc,其中 e(p,r]e\in(p,r]

该操作需要一棵支持单点插入、单点删除和区间最值查询的线段树,其操作范围为 [l,p][l,p],修改时在左端点处插入右端点位置。显然,在分治进行至 [l,r)[l,r) 时,没有必要管外面的区间,这样只会徒增线段树的递归层数。为了让右端点尽可能地塞进查询区间,供查询的右端点位置需要尽可能小。

这棵树的每个叶子都是一个。本题推荐使用 multiset 实现,可以方便地使用 *ms.begin() 查询目前堆内的最小值,并将其存储入线段树本身的数据节点,上传数据时使用两侧节点的最小值。

注意,本题的线段树二分与常规情况不同,存在两个限制(查询位置限制和最小值限制),不一定能通过只走一边得到结果,在无法得到结果时需要两边都走。必须在必要的时刻终止递归。

第一类区间的第二次计算:

第二次,求 dd。它是最的使得操作区间 [f,d)[f,d) 存在的 dd,其中 f[l,p]f\in[l,p]

该操作需要一棵支持单点插入、单点删除和区间最值查询的线段树,其操作范围为 [p+1,r][p+1,r],修改时在右端点处插入左端点位置。为了让左端点尽可能地塞进查询区间,供查询的左端点位置需要尽可能大。

这棵树的每个叶子都是一个。使用 multiset 实现,可以使用 *ms.rbegin() 查询目前堆内的最大值,并将其存储入线段树本身的数据节点,上传数据时使用两侧节点的最大值。

第一类区间的两次计算可以共用堆序列。

第二类区间,需要一棵支持单点插入、单点删除、区间取 max\max区间求 min\min (并不实际查区间 min\min,但要求结构意义上支持)的线段树,其操作范围,无论何时,均为 [1,m][1,m]。其不变的值域和区间操作决定了每次分治暴力 build 的不可行性,因此懒标记需要同时承载清除数据和 max\max 操作数据。上传数据时使用两侧节点的最小值。下传标记时,不下传至没有有效节点或没有必要继续下传的位置(由前面提到的节点内存储区间 min\min 实现)。

将所有包含 [p,p+1)[p,p+1) 的查询区间和所有包含在 [l,p)[l,p) 内的操作区间按左端点升序排序,枚举左端点 ii先加入左端点为 ii 的查询(放在对应的时间坐标上),使用左端点为 ii 的操作在时间坐标上区间取 max\max。进行完一轮次的操作后,若线段树中存在无法继续被操作的节点,则暴力递归至每个满足要求的节点,查询是否满足要求,并删除对应节点。均摊分析可得其时间复杂度在可接受范围内。

第三类区间,需要一棵支持单点插入、单点删除、区间取 min\min区间求 max\max (并不实际查区间 max\max,但要求结构意义上支持)的线段树,其操作范围,无论何时,均为 [1,m][1,m]。上传数据时使用两侧节点的最大值。下传标记时,不下传至没有有效节点或没有必要继续下传的位置

将所有包含 [p,p+1)[p,p+1) 的查询区间和所有包含在 [p+1,r)[p+1,r) 内的操作区间按右端点降序排序,枚举右端点 ii先加入右端点为 ii 的查询,使用右端点为 ii 的操作在时间坐标上区间取 min\min。进行完一轮次的操作后,若线段树中存在无法继续被操作的节点,则暴力递归至每个满足要求的节点,查询是否满足要求,并删除对应节点。同样的,均摊分析可得其时间复杂度在可接受范围内。

四棵线段树的内部实现均有所不同,为了调试方便,建议写成四个不同的类。我曾尝试令四棵线段树共用节点,然后被无边的调试折磨,最终删掉所有代码重写并使用对拍寻找 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'; } }

:::深度解释题解,我看不懂

思考

先给你一个“抓手”:这题不是在动态维护“能否选出某个神奇子集”,而是在离线维护“当前所有合法小区间能否把询问区间无缝铺满”。 因为只要某个区间伸出询问 [l,r)[l,r) 外,它绝不可能被选;而所有完全包含于 [l,r)[l,r) 的区间即使全选,也不会多覆盖出去。所以“存在一个子集并恰好覆盖”其实等价于“所有当前存在且包含于询问中的区间,它们的并是否等于 [l,r)[l,r)”。这一步把“选子集”的干扰彻底去掉了。题目本身确实就是动态插入、删除区间并做这种判定。

Worked for 1m 20s

回答

这篇题解真正难懂的地方,不是“四棵线段树”,而是作者把 三个不同维度的思想同时压在了一起

  • 空间维:在数轴上分治;
  • 时间维:插入的区间只在一段时间内存在;
  • 覆盖维:判断很多区间能不能无缝连成询问区间。

把这三层拆开以后,代码会清楚很多。下面我尽量不按原题解的叙述顺序,而是从“为什么算法一定长成这个样子”讲。


1. 先把题意重新理解一遍

询问:

当前存在的区间中,能否选择一个子集,使它们的并恰好为 [l,r)[l,r)

注意一个很重要的等价转化。

如果某个区间不是 [l,r)[l,r) 的子区间,例如

[s,t)⊈[l,r),[s,t)\not\subseteq[l,r),

那它一定不能选,因为一选,并集就跑到询问区间外面了。

因此真正有资格参与询问的,只有

ls<trl\le s<t\le r

的区间。

而对于这些区间,我们根本没必要纠结“选哪个子集”:

把所有合法区间全部选上,一定是最优的。

因为它们全部在 [l,r)[l,r) 内,多选不会把并集扩出 [l,r)[l,r)

所以询问等价于:

当前所有满足 [s,t)[l,r) 的区间的并,是否恰好是 [l,r)\boxed{ \text{当前所有满足 }[s,t)\subseteq[l,r)\text{ 的区间的并,是否恰好是 }[l,r) }

这也是其他公开题解采用的基本转化。(Luogu)


2. 如果没有动态修改,这题其实是什么?

先彻底忘掉插入删除。

假设现在有一些区间,我们问:

[l,r)[l,r)

能否被其中完全包含于它的区间铺满。

经典贪心是:

text
g = l 不断找左端点 <= g 的区间, 让 g 尽可能往右扩展。 最后看 g >= r。

比如:

text
询问:[2, 10) 区间: [2,4) [3,7) [6,8) [7,10)

过程:

text
g = 2 [2,4) => g = 4 [3,7) => g = 7 [6,8) => g = 8 [7,10) => g = 10

于是成功。

为什么要求区间是左闭右开非常舒服?

因为:

[2,4)[4,7)=[2,7)[2,4)\cup[4,7)=[2,7)

端点正好相等就能接上。

完全不需要考虑什么 +1-1


3. 但动态情况下,这个贪心不好直接做

现在每个插入区间还有一个“生命周期”。

例如:

text
第 3 次操作:插入 [2,7) 第 15 次操作:删除它

那它实际上是一个三维对象:

([2,7),[3,15))([2,7), [3,15))

意思是:

空间上覆盖 [2,7)[2,7),时间上在第 3 到第 15 次操作之间存在。

所以一个询问可以看成:

(l,r,t)(l,r,t)

即:

在时间 tt,能不能覆盖空间区间 [l,r)[l,r)

直接做就会变成很恶心的“空间 + 时间”二维动态问题。

于是这题最漂亮的一步来了:

对空间做分治。


4. solve(opr,qry,L,R) 到底在分治什么?

代码:

cpp
void solve(vector<Operation> &opr, vector<Query> &qry, int l = 1, int r = n)

这里的 [l,r] 不是某一个询问。

它是:

当前正在处理的“空间坐标范围”。

cpp
mid = (l + r) >> 1;

关键不是点 mid,而是单位区间

[mid,mid+1)\boxed{[mid,mid+1)}

我们把所有区间分成三类。

左边

cpp
if (i.r <= mid) lo.emplace_back(i);

[s,t)[l,mid)[s,t)\subseteq [l,mid)

它完全在核心单位区间左边。

右边

cpp
else if (i.l > mid) ro.emplace_back(i);

它完全在

[mid+1,r)[mid+1,r)

这一侧。

跨中点

其余:

cpp
else mo.emplace_back(i);

满足

smid<ts\le mid<t

于是它必定包含

[mid,mid+1).[mid,mid+1).

询问也是完全一样的分类。

因此 mq 中的所有询问,都满足

qlmid<qr.q_l\le mid<q_r.

换句话说:

mq 中每个询问都包含 [mid,mid+1)\boxed{ mq\text{ 中每个询问都包含 }[mid,mid+1) }

这就是整个算法的核心。

类似的公开题解通常称这一结构为对数轴进行分治/猫树式分治。(Luogu)


5. 为什么跨中点询问可以在这里一次性解决?

考虑:

Q=[ql,qr)Q=[q_l,q_r)

并且它包含

[mid,mid+1).[mid,mid+1).

想把整个 QQ 覆盖掉,那么单位区间

[mid,mid+1)[mid,mid+1)

当然也必须有人覆盖。

而谁能覆盖它?

只有 mo

text
跨 mid 的操作区间。

所以:

一个跨 mid 的询问,必须至少使用一个跨 mid 的操作区间\boxed{\text{一个跨 mid 的询问,必须至少使用一个跨 mid 的操作区间}}

如果:

cpp
mo.empty()

那么所有 mq 直接失败:

cpp
for (auto &i : mq) ret[i.id] = false;

这就是这里:

cpp
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; }

6. 最关键的观察:所有跨中点区间的并一定还是一个区间

假设当前询问是:

Q=[2,10)Q=[2,10)

取:

mid=6.mid=6.

当前存在并且包含在 QQ 里的跨中点操作有:

[3,8),[5,9),[4,7).[3,8),\quad [5,9),\quad [4,7).

画出来:

text
2 3 4 5 6 7 8 9 10 |-------|---|---|---|---|---|---|---| [-----------) [-----------) [-----------) ^ [6,7)

所有这些区间都有一个共同部分:

[6,7).[6,7).

所以它们互相一定连通。

因此它们的并必然是:

[c,d)[c,d)

其中:

c=mins,d=maxt.c=\min s,\qquad d=\max t.

不会出现:

text
[c, x) 空洞 [y, d)

这种情况。

因为所有区间都共同覆盖中间那一格。

这是为什么算法只需要求两个数:

c, d\boxed{c,\ d}

而不需要真的维护整个并集。


7. 整个询问现在就变成了三个部分

有:

Q=[ql,qr)Q=[q_l,q_r)

跨中点区间已经形成核心:

[c,d).[c,d).

于是只剩:

text
q_l c mid d q_r |----------------|========|========|---------------| 左侧 核心 右侧

因此整个询问成功,当且仅当:

① 跨中点区间得到 [c,d)

而且它确实存在。

② 左侧能铺到核心

[ql,c)[q_l,c)

能被左半边操作铺满。

③ 右侧能从核心铺到终点

[d,qr)[d,q_r)

能被右半边操作铺满。

因此:

Q 成功    左边接上核心右边接上核心\boxed{ Q\text{ 成功} \iff \text{左边接上核心} \land \text{右边接上核心} }

这就是代码所谓的四个 Part:

text
Part 1: 求 d Part 2: 求 c Part 3: 判断左边能否接到 c Part 4: 判断右边能否接到 d

8. 第一棵线段树 Seg1:求核心右端点 dd

这是最好理解的一棵。

我们只讨论当前 mo,即所有跨中点操作。

考虑一个询问:

[ql,qr).[q_l,q_r).

一个操作:

[s,t)[s,t)

能够参与这个询问,需要:

sql,tqr.s\ge q_l,\qquad t\le q_r.

我们想让核心的右端点尽可能大,所以要:

maxt\boxed{\max t}

满足:

sql,tqr.s\ge q_l,\quad t\le q_r.

换一个角度

按照操作的右端点 tt 建线段树。

对每一个右端点 tt,维护:

At=max{s[s,t) 当前存在}.A_t = \max\{s\mid [s,t)\text{ 当前存在}\}.

那么询问就是:

找最大的 tqrt\le q_r,使得

Atql.A_t\ge q_l.

这是不是一个标准的线段树二分?

所以 Seg1

cpp
tr[x] = max(...)

叶子 p 代表右端点 p

叶子中的:

cpp
ms[p]

存:

text
所有右端点为 p 的区间的左端点

因此:

cpp
*ms[p].rbegin()

就是最大的左端点。


为什么找最大的左端点?

因为我们只需要知道:

有没有一个左端点 ≥ ql

显然,只看最大的那个就够了。

如果:

maxs<ql\max s <q_l

所有区间都不合法。

否则至少一个合法。


bisect() 就非常好懂了

cpp
int bisect(int lb, int rb, int x, int l, int r) { if (tr[x] < lb or l > rb) return 0;

这里:

text
lb = q_l rb = q_r

若:

cpp
tr[x] < q_l

说明当前整棵子树所有候选区间的左端点都太靠左。

不可能。

而:

cpp
l > q_r

说明右端点已经超过询问范围。

也不可能。

然后:

cpp
int res = bisect(lb, rb, x << 1 | 1, mid + 1, r);

先搜右儿子。

为什么?

因为要:

最大的右端点\boxed{\text{最大的右端点}}

所以尽量往右。

最后得到:

cpp
oqry[mq[pq].id].rb

这里的 rb 实际就是:

d\boxed d

不是询问原来的右端点。


9. 第二棵 Seg2:完全对称,求 cc

现在要求:

c=minsc=\min s

同时必须存在:

[s,t)[ql,qr).[s,t)\subseteq[q_l,q_r).

所以对于每个左端点 ss,维护:

Bs=min{t[s,t) 当前存在}.B_s = \min\{t\mid[s,t)\text{ 当前存在}\}.

为什么取最小右端点?

因为要判断:

tqr.\exists t\le q_r.

只需要知道最小值。

如果:

mint>qr\min t>q_r

那所有都不合法。

否则至少有一个合法。

所以询问变成:

minsql,Bsqr\boxed{ \min s\ge q_l,\quad B_s\le q_r }

于是 Seg2

cpp
tr[x] = min(...)

并从左往右二分。

最终:

cpp
oqry[id].lb

就是:

c\boxed c

10. 但别忘了:区间会被删除

所以 Seg1 / Seg2 不是静态的。

这就是代码中的时间扫描线。

每个操作:

cpp
Operation { typ, l, r, t, t2 };

真正表达的是:

text
空间:[l,r) 存在时间:[t,t2)

例如:

text
第 5 次:插入 [3,8) 第 20 次:删除它

那么:

cpp
l = 3 r = 8 t = 5 t2 = 20

11. main() 里那个诡异的 typ=-1 是干什么?

看:

cpp
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; }

假设第 x 次操作插入了某区间。

删除它时做两件事:

原来的插入记录

把:

cpp
t2 = 删除时间

于是原记录表示完整生命周期。

再复制一个 typ=-1

这份记录专门充当:

text
删除事件

因此 mo 里实际上同时有:

text
typ = 1 插入事件 typ = -1 删除事件

12. Part 1 / Part 2 为什么需要两份事件?

看:

cpp
sort(mo.begin(), mo.end(), [&](Operation &x, Operation &y) { return (x.typ == 1 ? x.t : x.t2) < (y.typ == 1 ? y.t : y.t2); });

它们按照:

text
事件发生时间

排序。

然后同时扫描询问:

cpp
sort(mq.begin(), mq.end(), [&](Query &x, Query &y) { return x.t < y.t; });

于是扫描到询问时间 TT 时:

text
已经执行: 所有 T 之前的插入; 所有 T 之前的删除。

线段树里恰好就是:

时刻 T 当前存在的跨中点区间\boxed{\text{时刻 }T\text{ 当前存在的跨中点区间}}

这就是为什么 Part 1 / Part 2 可以回答动态询问。


13. 至此,最容易的两棵树结束

对于每个跨中点询问,我们已经得到了:

text
核心区间 [c,d)

代码里:

cpp
oqry[id].lb = c; oqry[id].rb = d;

现在真正变态的部分来了:

Seg3 / Seg4。


14. Seg3 到底在求什么?

它只解决:

[ql,c) 能否被左边区间铺满\boxed{ [q_l,c)\text{ 能否被左边区间铺满} }

回到最普通的静态贪心。

对于一个询问,我们设:

g=ql.g=q_l.

g 表示:

从询问左端点开始,目前已经连续覆盖到哪里。

比如:

text
Q = [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

于是:

gcg\ge c

左半边成功。


15. 真正的问题是:有最多几十万个询问

如果每个询问各跑一次贪心,炸掉。

作者于是做了一件非常漂亮的事情:

把所有询问的贪心同时跑。

这就是题解所谓:

并行\boxed{\text{并行}}

16. 如何同时跑所有询问?

我们按照空间左端点

i=L,L+1,,midi=L,L+1,\dots,mid

从左向右扫描。

假设某个询问:

Q=[ql,qr)Q=[q_l,q_r)

那么只有左端点满足:

sqls\ge q_l

的左侧操作才能包含在询问中。

怎么办?

非常简单:

当扫描到 i=qli=q_l 时,才把这个询问加入数据结构。

这就是:

cpp
while (pq != mq.size() and mq[pq].l == i) seg_exl.insert(mq[pq].t, i), pq++;

加入时设置:

g=ql=i.g=q_l=i.

17. 这里突然出现了“时间轴线段树”

这是整个题最反直觉的一步。

Seg3 的线段树下标不是空间坐标。

而是:

1,2,,m 的操作时间\boxed{1,2,\dots,m\text{ 的操作时间}}

也就是说叶子 tt 表示:

tt 个时刻的那个询问,目前覆盖到哪里。

如果时刻 100 有询问:

[3,20)[3,20)

当扫描到空间坐标 3:

cpp
seg_exl.insert(100, 3);

等价于:

text
时间轴第 100 个叶子: g_100 = 3

18. 一个操作怎么影响这些询问?

比如空间操作:

[i,R)[i,R)

它存在于时间:

[t1,t2].[t_1,t_2].

现在我们正好扫描到了它的左端点 ii

如果一个询问目前已经覆盖到了 ii,那么这个区间就可以接上:

gmax(g,R).g\leftarrow\max(g,R).

而这个操作仅在时间:

[t1,t2][t_1,t_2]

存在。

所以对所有这些询问,统一进行:

gtmax(gt,R),t[t1,t2]\boxed{ g_t\leftarrow\max(g_t,R), \qquad t\in[t_1,t_2] }

是不是恰好就是:

时间轴上的区间 chmax

所以:

cpp
seg_exl.fwd(lo[po].r, lo[po].t, lo[po].t2);

本质是:

text
对于这个操作存在期间的所有询问, 把“当前能覆盖到的位置”对 R 取 max。

公开题解也通常把这一部分描述为:在时间轴上线段树维护每个询问当前向右延伸到的位置,扫描操作左端点时对其生命周期做区间 chmax。(Luogu)


19. 你这时候应该会有一个重大疑问

等等!

操作:

[i,R)[i,R)

明明只有在:

gig\ge i

的时候才能接上。

可是代码:

cpp
seg_exl.fwd(...)

为什么直接对整个时间区间 chmax

没有检查:

cpp
g >= i

啊?


20. 这正是 Seg3 最精妙的地方

答案是:

所有仍留在线段树里的询问,都自动满足 gi\boxed{\text{所有仍留在线段树里的询问,都自动满足 }g\ge i}

为什么?

因为每扫完位置 ii,代码执行:

cpp
seg_exl.purg(i);

它会删除:

g=ig=i

而且已经再也无法前进的询问。


考虑一个失败询问

例如:

text
当前扫描 i=4 某询问 g=4

已经处理完所有左端点为 4 的操作之后,它仍然:

g=4.g=4.

那意味着:

所有能够从 4 接出去的区间,都试过了,但还是走不了。

下一步扫描的是:

i=5.i=5.

左端点为 5 的区间显然也救不了它,因为:

g=4<5.g=4<5.

中间已经有洞了。

所以这个询问以后永远不可能成功。

可以立即删除。


21. 因此维持了一个极漂亮的不变量

进入扫描位置 ii 时:

所有仍存活的询问都有 gi\boxed{ \text{所有仍存活的询问都有 }g\ge i }

证明是归纳:

扫描完 i1i-1 后,所有:

g=i1g=i-1

的询问都被删了。

剩下的整数 gg 必然:

gi.g\ge i.

所以处理左端点为 ii 的操作时:

cpp
range_chmax(R)

根本不用再判断:

cpp
g >= i

因为活着的天然全部满足。

这就是整题最值得理解的地方之一。


22. 一个具体例子

询问:

[2,10)[2,10)

假设核心左端点:

c=6.c=6.

左侧操作:

[2,4),[4,7).[2,4),\quad[4,7).

都在该询问时刻存在。


扫描 i=2i=2

加入询问:

text
g=2

看到:

[2,4)[2,4)

于是:

g4.g\gets4.

此时:

cpp
purg(2)

但:

g=42g=4\ne2

所以活着。


扫描 i=3i=3

没有事。

因为:

g=4>3.g=4>3.

扫描 i=4i=4

看到:

[4,7)[4,7)

于是:

g7.g\gets7.

现在:

7c=6.7\ge c=6.

左边已经成功。


23. 那为什么 purge 时才判断是否成功?

代码:

cpp
ret[qid[l]] &= (oqry[qid[l]].lb <= tr[1]);

其中:

text
lb = c tr[1] = 当前无法继续前进的位置 g

因此判断:

cg.c\le g.

即:

gc\boxed{g\ge c}

如果成立,虽然这个贪心现在走不动了,但已经碰到核心了。

够了。

如果:

g<cg<c

那说明在碰到核心前就断掉了。

失败。


24. 为什么有些询问从来没有被 purge 也没关系?

假设一直扩:

g>mid.g>mid.

那么扫描:

cpp
for (int i=l; i<=mid; i++)

结束以后它还活着。

但是核心的左端点一定:

cmidc\le mid

因为核心区间跨 mid

所以:

g>midc.g>mid\ge c.

必定已经成功。

因此让:

cpp
ret[id]

继续保持 true 就行。


25. Seg3 内部到底维护什么?

终于可以看代码。

cpp
int tr[N << 2], tag[N << 2];

对于一个时间轴节点:

tr[x]tr[x]

表示:

这个时间区间内,所有活跃询问的最小 g\boxed{ \text{这个时间区间内,所有活跃询问的最小 }g }

为什么维护最小?

因为当前扫描空间位置 ii

我们最关心:

有没有某个询问已经卡在 ii

只要:

ming=i\min g=i

就存在卡住的询问。


26. 0 表示什么?

cpp
tr[x] == 0

表示:

这个时间区间没有当前正在处理的询问。

所以合并两个儿子的时候不能普通:

cpp
min(left,right)

否则:

text
min(0, 7)=0

会错误地把“空”当成最小值。

于是它写:

cpp
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]);

这段看着很抽象,其实只是:

text
两边都有: min(left,right) 只有左边: left 只有右边: right 都没有: 0

a ^ b 在这里只是利用“至少一个为 0”。

可读性非常差,但确实省代码。


27. update() 为什么这样剪枝?

cpp
if (!tr[x] or tr[x] >= v) return;

操作是:

gmax(g,v).g\gets\max(g,v).

如果:

cpp
tr[x] == 0

没有询问。

不管。

如果:

mingv\min g\ge v

那这一整段中的每个 gg 都:

gv.g\ge v.

取 max 完全没有变化。

所以也可以直接返回。


28. 更奇怪的是这一句

cpp
if (l >= lb and r <= rb) { tr[x] = tag[x] = v; return; }

你可能会想:

明明是 chmax,怎么可以直接把 tr[x] 赋值为 v

子树里可能本来有 10、20 啊。

注意:

tr[x] 不是说所有元素都等于它。

它只维护:

min\boxed{\min}

假设原来:

text
3 8 20

做:

text
chmax 7

变成:

text
7 8 20

新的最小值确实:

7.7.

所以:

cpp
tr[x] = 7; tag[x] = 7;

含义不是:

整棵子树赋值 7。

而是:

整棵子树以后需要执行 chmax(7)

因此 tag 是:

下界标记\boxed{\text{下界标记}}

不是普通赋值 lazy tag。


29. psh() 也就懂了

cpp
if (tr[x << 1] and tr[x << 1] < tag[x]) tr[x << 1] = tag[x << 1] = tag[x];

含义:

如果左儿子的最小值:

<tr[x] 的 chmax 下界<tr[x]\text{ 的 chmax 下界}

才需要更新。

本质就是:

gmax(g,tag)g\gets\max(g,\text{tag})

30. purge() 为什么能暴力递归?

看:

cpp
if (tr[x] != tr[1]) return;

根节点:

tr[1]tr[1]

是所有询问中最小的 gg

当前调用:

cpp
purg(i)

之前已经保证:

tr[1]=i.tr[1]=i.

所以 purge() 只会进入:

子树最小值也是 ii 的地方。

最终找到所有:

g=ig=i

的叶子并删除。

看似会暴力递归很多节点,但每个询问只会真正被删除一次,因此可以做均摊分析;相关公开分析给出的整体复杂度约为 O(nlogn+mlog2n)O(n\log n+m\log^2n)。(Luogu)


31. tag=-1 又是什么鬼?

因为 Seg3 的时间轴范围永远是:

[1,m].[1,m].

每一次空间分治节点都重新:

cpp
seg_exl.init();

如果真写:

cpp
build(1,1,m);

那么一次就是:

O(m).O(m).

分治里做很多次,直接死。

所以作者用了:

懒清空\boxed{\text{懒清空}}

tag=-1 表示:

这个节点下面的数据应被视为全部清空。

所以:

cpp
void build() { tr[1] = 0; tag[1] = -1; }

只清根。

以后真正访问某条路径时再:

cpp
psh()

把“清空”懒惰地下传。

这是原文所谓:

懒标记需要同时承载清除数据和 max 操作数据。

于是 tag 三种状态:

text
-1 : 需要清空 0 : 没有 lazy tag >0 : 待执行 chmax(tag)

理解 Seg3 时,这一点也非常关键。


32. Seg4 完全是 Seg3 的镜像

左边我们是:

text
从左往右扫描 当前覆盖右端点 g range chmax 维护最小 g

右边则是:

text
从右往左扫描 当前覆盖左端点 h range chmin 维护最大 h

考虑:

Q=[ql,qr)Q=[q_l,q_r)

核心:

[c,d).[c,d).

我们需要验证:

[d,qr)[d,q_r)

能否填满。

初始化:

h=qr.h=q_r.

含义:

当前已经可以从 hh 连续覆盖到 qrq_r

遇到右侧区间:

[L,i)[L,i)

如果它能够接上:

ih,i\ge h,

则:

hmin(h,L).h\gets\min(h,L).

最后只需要:

hd.h\le d.

33. 为什么 Seg4 扫描右端点?

代码:

cpp
for (int i = r; i > mid; i--)

从右向左。

询问右端点是 ii 时加入:

cpp
seg_exr.insert(mq[pq].t, i);

也就是:

h=qr.h=q_r.

然后对右端点恰为 ii 的操作:

cpp
seg_exr.bkwd(lo, t, t2);

执行:

hmin(h,L).h\gets\min(h,L).

仍然利用同样的不变量:

所有仍活着的询问都已经满足可以接当前右端点 ii

如果某个询问处理完所有右端点为 ii 的操作以后仍:

h=i,h=i,

那后面只会看到右端点更小的区间。

它们不可能再接上。

于是:

cpp
seg_exr.purg(i);

删掉。

并判断:

cpp
ret[id] &= (oqry[id].rb >= tr[1]);

即:

dh.d\ge h.

等价于:

hd.h\le d.

右半边成功。


34. 所以四棵树到底分别干什么?

这是整份代码最值得记的一张表:

结构下标是什么每个位置存什么作用
Seg1空间右端点当前区间的最大左端点求核心右端点 dd
Seg2空间左端点当前区间的最小右端点求核心左端点 cc
Seg3时间询问当前能向右覆盖到的 gg判断左边能否接到 cc
Seg4时间询问当前能向左覆盖到的 hh判断右边能否接到 dd

如果这一张表完全理解,这题已经懂了 80%。


35. 再来看 solve(),现在应该完全是顺的

整个函数实际上就是:

cpp
solve(当前操作, 当前询问, 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”的那一层被真正回答\boxed{ 每个询问只会在“它第一次跨过当前 mid”的那一层被真正回答 }

例如询问:

[17,23)[17,23)

在根节点可能完全位于左侧,于是一路递归。

直到某一层的 mid 落进:

[17,23)[17,23)

它才进入:

cpp
mq

然后一次性解决。


36. 为什么只递归 lo/lqro/rq

因为 mq 已经回答完了。

mo 也不需要再下传。

mo 是:

当前这一层跨中点的操作。

以后处理完全左侧的询问时,它不可能合法,因为它伸出了左侧询问。

完全右侧同理。

所以:

cpp
solve(lo, lq, l, mid); solve(ro, rq, mid + 1, r);

即可。


37. 再用一张图总结一次整个算法

假设:

Q=[2,12),mid=7.Q=[2,12),\qquad mid=7.

跨中点操作:

text
[4----------10) [5----------------11) [6-----9)

它们的并:

[4,11).[4,11).

所以:

text
c = 4 d = 11

整个询问:

text
Q: [2--------------------------------12) 跨中点核心: [4------------------11) 缺的: [2-----4) [11--12)

现在:

Seg1 + Seg2

负责得到:

text
[c,d)=[4,11)

Seg3

检查:

text
能否从 2 一路接到 >=4

Seg4

检查:

text
能否从 12 一路反着接到 <=11

如果都成立:

text
左边 + 核心 + 右边

就是:

[2,12).[2,12).

38. 原题解中最容易让人误解的一段,我建议这样改写

原文说:

ggll,按左端点升序枚举所有的 [c,d)[c,d)。若 g<cg<c,停止枚举……

这里符号重复使用,特别容易把人绕死。

更清楚的写法应该是:

假设当前枚举的操作区间为:

[x,y).[x,y).

维护当前连续覆盖到:

g.g.

按照 xx 递增枚举:

x>gx>g

则出现空洞,停止;

  • 否则
gmax(g,y).g\leftarrow\max(g,y).

最后检查:

gc.g\ge c.

这样就清晰很多。


39. 为什么半开区间在这里真的很重要?

例如:

[2,5),[5,8).[2,5),[5,8).

它们:

[2,5)[5,8)=[2,8).[2,5)\cup[5,8)=[2,8).

所以状态直接维护:

g=5g=5

就意味着:

下一段允许左端点等于 5。

条件简单地是:

xg.x\le g.

如果转成各种闭区间后,代码里就会充斥:

cpp
r+1 l-1 g+1

虽然理论等价,但更容易把这个并行贪心的本质遮住。


40. qid 那段也给你翻译一下

它被作者一变量两用。

对于询问操作:

cpp
qid[i] = qry.size();

表示:

text
第 i 次操作对应第几个询问

对于插入操作:

cpp
qid[i] = -1 - opr.size();

负数编码:

text
第 i 次插入,对应 opr 数组中的哪个元素

删除时:

cpp
opr[-qid[x] - 1]

就把这个下标解出来。

所以:

cpp
qid

这个名字其实取得挺糟糕。

更可读的代码应该拆成:

cpp
insert_id[] query_id[]

41. oqry 又为什么存在?

cpp
qry

solve() 里会不断:

cpp
sort(...)

而且被拆到:

text
lq/mq/rq

中。

但我们需要:

text
根据永久 id 找到这个询问的 c,d。

所以:

cpp
oqry = qry;

保存一份不动的原始数组。

于是:

cpp
oqry[id].lb oqry[id].rb

永远分别保存:

c,d.c,d.

42. ret 为什么初始为 true?

cpp
ret[qid[i]] = true;

因为对于一个跨中点询问,最后实际上检查两个条件:

LeftOKRightOK.\text{LeftOK}\land\text{RightOK}.

所以最方便就是:

cpp
ret = true; ret &= LeftOK; ret &= RightOK;

Seg3:

cpp
ret[id] &= (c <= g);

Seg4:

cpp
ret[id] &= (d >= h);

最终就是:

(gc)(hd)\boxed{ (g\ge c)\land(h\le d) }

43. 为什么不存在跨中点合法区间时也会正确判 N?

如果某个询问根本找不到合法 mo

那么:

Seg1:

text
d = 0

Seg2:

text
c = inf

左侧最多只能覆盖到 mid,所以最终不可能:

g.g\ge\infty.

Seg3 会将:

cpp
ret &= false;

右边也类似。

所以代码并没有专门:

cpp
if (c == inf) ret=false;

它是让后续判定自然失败。


44. 为什么 Part 3 / Part 4 只处理 typ == 1

你可能注意到了:

cpp
if (lo[po].typ == 1) seg_exl.fwd(...);

删除记录:

cpp
typ == -1

竟然直接无视。

原因是 Seg1 / Seg2 和 Seg3 / Seg4 对时间的处理方法完全不同。

Seg1 / Seg2

正在“按时间扫描”。

所以必须有:

text
插入事件 删除事件

才能动态维护当前集合。

Seg3 / Seg4

它们已经直接拿到了操作的整个生命周期:

cpp
[t,t2]

一个操作直接对应:

cpp
range_chmax(t,t2,...)

即可。

根本不需要删除事件。

所以只使用:

cpp
typ == 1

的原始记录。

这是一个非常容易看代码看懵的细节。


45. 至于为什么 Segment Tree 3/4 要“暴力 purge”

这其实是均摊思想。

假设现在根的最小值为:

i.i.

我们必须把所有:

g=ig=i

的询问找出来。

最坏看起来可能很多。

但每找到一个叶子,就:

cpp
tr[x] = 0;

永久删除这个询问。

一个询问:

一层分治中最多被 purge 一次\boxed{\text{一层分治中最多被 purge 一次}}

所以暴力下降到叶子的总次数可以摊到询问身上。

这也是为什么“线段树节点里只维护最小值/最大值”就足够了,不需要保存一个询问集合。


46. 整题真正的算法本质

如果让我不用任何代码,用一句稍长的话描述这题,我会说:

对空间区间分治。对于第一次跨越分治中点的询问,所有跨中点且合法的操作必然形成一个连续核心区间 [c,d)[c,d);用时间扫描线加两棵空间线段树求出 c,dc,d。随后将左右两侧普通的区间覆盖贪心对所有询问并行执行:按空间端点扫描,把每个询问的当前覆盖边界放在“时间轴线段树”上,每个操作根据自己的生命周期进行区间 chmax/chmin,并及时删除已经无法继续扩展的询问。

这就是整道题。


47. 四棵树最简记忆法

不要记代码。

只记:

问题Seg1核心最多往右到哪?Seg2核心最多往左到哪?Seg3询问左端能否向右走到核心?Seg4询问右端能否向左走到核心?\boxed{ \begin{array}{c|c} \text{树} & \text{问题}\\ \hline Seg1 & \text{核心最多往右到哪?}\\ Seg2 & \text{核心最多往左到哪?}\\ Seg3 & \text{询问左端能否向右走到核心?}\\ Seg4 & \text{询问右端能否向左走到核心?} \end{array} }

前两棵:

时间扫描,树建在空间上。

后两棵:

空间扫描,树建在时间上。

我认为这八个字是理解本题最重要的总结:

前两棵:时间扫空间\boxed{ \text{前两棵:时间扫空间} } 后两棵:空间扫时间\boxed{ \text{后两棵:空间扫时间} }

整个“精妙感”就在这里。


48. 最后给你一条推荐的理解顺序

如果你准备真正把这题学会,不要直接啃那 7KB 代码。建议按这个顺序重新看:

  1. 先完全不考虑动态,会写“区间能否铺满”的贪心。
  2. 理解空间分治后,一个跨中点询问为什么变成 左侧+[c,d)+右侧.\text{左侧}+[c,d)+\text{右侧}.
  3. 理解为什么跨中点区间一定形成连续的 [c,d)[c,d)
  4. 单独理解 Seg1 如何求 dd
  5. 对称推出 Seg2 如何求 cc
  6. 先不看 Seg3 代码,理解“很多询问一起跑覆盖贪心”。
  7. 理解最关键不变量:扫描到 ii 时,所有仍存活询问都有 gi.g\ge i.
  8. 最后再看 Seg3tr/tag/purge
  9. Seg4 不重新学习,直接把 Seg3 全部左右翻转。

这份实现的公开分析通常给出约

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

的总复杂度,足以解释为什么需要这种相当重的离线分治和线段树组合,而不能对每个询问独立做覆盖贪心。(Luogu)

如果只挑一个最值得继续深挖的部分,就是 Seg3。因为一旦你真的理解“为什么 range chmax 不检查 g>=i 仍然正确”,这题基本就从“完全看不懂”变成“只是代码长”。

分享這個問答