CF1462F 这串编号一出来,老刷题人应该就猜到是 Codeforces 的题了。这道题挂在 Div. 3 的 F 位,实际难度没有想象中那么劝退,但它非常典型的体现了区间类问题的一个核心思维:题目表面问“最少删多少个区间”,背后考的是“能不能找到一个点,被尽可能多的区间覆盖”。题目名字 The Treasure of The Segments 翻译过来就是“线段的宝藏”,故事其实简单:有 n 条线段,有时候它们互相错开,你只能删掉其中一部分,让剩下的所有线段至少共享同一个宝藏点,问最少删掉多少条。
我最初看这题时,第一反应是往“区间贪心、排序后扫描”的方向想,总觉得和射箭、合并区间差不多。后来仔细推了一下才发现,这题比想象中更优雅的地方在于:最优候选点根本不需要在整个数轴上去找,只需要枚举所有输入区间的左端点就够了。如果你能把这条思路理清楚,整个代码其实只有十几行。这篇文章就不绕弯子了,从题意、转化、证明、代码到边界坑,一次讲透,争取让你以后再遇到“删除区间让剩余区间有交集”的问题时,能直接照搬套路。
1. 题意拆解与等价转化
1.1 原题到底在问什么
给定 n 个闭区间,每个区间用 [l_i, r_i] 表示,代表数轴上的一条线段。你可以删除任意一些区间,要求剩下的区间共同覆盖至少一个点。换句话说,存在一个位置 x,所有没被删除的区间都满足 l_i ≤ x ≤ r_i。题目要你输出最少删除几个区间才能达到这个条件。
这个条件要注意一个隐藏细节,题目说的是“点”,不是“整段交集非空也可以”。只要有一个共同的点穿过所有剩余区间就够了。比如 [1, 10] 和 [5, 6],这两个区间重叠区域很大,但你也可以说它们都覆盖点 5,当然这是显然的。真正容易出错的是当两个区间只共享一个端点时,比如 [1, 3] 和 [3, 5],点 3 同时在两个区间内,所以它们已经满足条件,不需要删除任何一个。这个闭区间边界细节,后面在做题和写代码时非常关键。
数据范围方面,n 最大可以到 2×10^5,坐标值可以到 10^9,而且会有多组测试数据。唯一的好消息是所有测试数据的 n 总和不超过 2×10^5,因此设计一个 O(n log n) 的做法完全够用,O(n^2) 则一定超时。
1.2 用一个很小的例子找感觉
先拿一组区间感受一下题目在问什么。假设区间是:
[1, 3], [2, 6], [8, 10], [2, 4]
如果你保留所有区间,显然不存在一个点能让 [8, 10] 和前面三个区间同时覆盖,因为它离得太远了。那最少删多少条?
我们手动数一下每个关键点被多少区间覆盖。点 2 被 [1, 3]、[2, 6]、[2, 4] 覆盖,一共 3 个。点 3 也被 [1, 3]、[2, 6]、[2, 4] 覆盖,还是 3 个。点 4 只被 [2, 6] 和 [2, 4] 覆盖,是 2 个。点 8 到 10 这段只被 [8, 10] 自己覆盖,最多 1 个。所以最多能同时保留 3 个区间,最少删掉 1 个区间就能让剩下区间共享点 2 或点 3。
再看一个极端的例子,区间是 [1, 5]、[2, 3]、[3, 4],此时点 3 被三条区间同时覆盖,所以不需要删任何区间,答案就是 0。从这个例子能看出来,答案并不一定是“最大重叠数减一”之类的东西,最大覆盖多少,就最多能保留多少,删除数就是 n 减最大覆盖数。
1.3 “删最少”其实等价于“找一个点被最多区间覆盖”
把上面的观察严格化。设 k(x) 表示数轴上点 x 被多少条给定区间覆盖。如果我能找到一个点 x,使得 k(x) 尽量大,那么把所有不覆盖 x 的区间删掉,留下覆盖 x 的区间,剩下的区间当然共同覆盖 x,因此是一个合法方案,删除数量是 n - k(x)。
反过来,任意一个合法方案如果保留了 m 个区间,那么这 m 个区间一定存在公共点 y。既然所有保留的区间都覆盖 y,所以 y 至少被原来所有区间中的 m 条覆盖,也就是说 m ≤ k(y)。而 k(y) 一定不会超过所有点里最大的覆盖数,记作 maxCover。所以任何合法方案保留的区间数都不会超过 maxCover,那么最少删除数至少是 n - maxCover。
两个方向一拼,最少删除数精确等于 n - maxCover。于是题目从“删区间”变成了“找点”,而且这个点只需要关心它被多少区间覆盖,不再需要关心线段的排列顺序。这是整道题最核心的一步,后面所有解法都建立在这个转化上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最优候选点为什么只可能是某个区间的左端点
2.1 剩余区间的交集是什么样的
既然问题是找一个点被尽量多区间覆盖,那么候选点理论上可以在数轴上任意取。但坐标范围高达 1e9,不可能枚举所有实数点。不过区间有个非常好的性质:若干闭区间的交集一定还是一个区间,而且这个区间是连续的。
假设最终我们保留了一组区间,它们存在公共点,那么它们的交集可以写成 [maxL, minR] 的形式,其中 maxL 是所有保留区间左端点的最大值,minR 是所有保留区间右端点的最小值。只要 maxL ≤ minR,说明交集非空。这个交集里的任意一个点都满足要求,但最值得关注的是它的左端点 maxL。
注意,maxL 是所有保留区间左端点里最大的那个,所以它一定来自某个被保留的区间,也就是来自某个输入区间的左端点。这意味着,哪怕最优方案保留的区间内部长得再复杂,它的公共交集最左边那个点一定落在某个输入区间的左端点上。
2.2 用反证法缩小候选集合
现在可以证明,我们只需要枚举所有输入区间的左端点,就能找到最优的 maxCover。
假设存在一个最优方案,保留了 m 个区间,公共交集为 [maxL, minR]。如果我把候选点取成 maxL,那么因为 maxL 是某个输入区间的左端点,它一定在我们的枚举范围内。而所有被保留的 m 个区间都包含 maxL,因为 maxL 是它们公共交集的左端点,不是某个区间刚好在 maxL 右边开始。所以点 maxL 被至少 m 个区间覆盖,于是枚举左端点时得到的覆盖数不会小于 m。
既然任何合法方案能保留的 m 都不会超过“某个输入左端点处的最大覆盖数”,那么上一节说的 maxCover 直接取输入左端点里的最大值就够了。可能有些直觉上觉得会在两个左端点之间的空隙取到更大的覆盖数,但区间都是连续的,如果一段覆盖数字在某个小区间内恒定,那这段区间的左边界一定是某个输入区间的左端点,或者是整个可行区间的边界。比如 [1,3] 和 [2,4],覆盖数为 2 的点在 [2,3] 内都有,这个小区间的左边界就是区间 [2,4] 的左端点 2,所以枚举左端点 2 一定能看到覆盖数 2。
这个结论极大缩小了枚举范围。n 只有 2×10^5,枚举每个左端点完全能做到 O(n log n)。
3. 排序加二分:一份能直接 AC 的核心解法
3.1 对固定点 x,覆盖数可以拆成两个计数相减
现在问题变成:给定一堆区间,再给一个候选点 x,如何快速统计有多少区间覆盖 x?
一个区间 [l, r] 覆盖点 x,当且仅当 l ≤ x 且 r ≥ x。所以答案可以表示成:满足 l ≤ x 的区间数,减去那些虽然 l ≤ x 但 r < x 的区间数。后一类区间虽然左端点在 x 左边,但右端点已经在 x 之前结束了,所以它们并不覆盖 x,需要在统计时减掉。
这里有一个非常关键的化简:如果 r < x,因为区间一定满足 l ≤ r,所以自然有 l ≤ r < x。也就是说,只要右端点小于 x,那么左端点一定也小于等于 x,根本不需要额外判断左端点的条件。因此“满足 l ≤ x 但 r < x”的区间集合,恰好就等于“满足 r < x”的区间集合。
于是覆盖数就是:
覆盖点 x 的区间数 = (左端点 ≤ x 的区间数) - (右端点 < x 的区间数)
这个公式把所有区间之间的二维比较关系,拆成了两个一维数组的计数。左端点和右端点分别排好序后,每个计数都能用一次二分在 O(log n) 时间内完成。枚举 n 个候选左端点,总复杂度 O(n log n)。
3.2 C++ 参考实现
实现的时候,我习惯先把所有区间存到两个 vector 里:一个专门存原区间用于枚举左端点,另外两个分别存所有左端点和所有右端点,然后排序后者。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n;
cin >> n;
vector<int> l(n), r(n);
vector<int> L(n), R(n);
for (int i = 0; i < n; i++) {
cin >> l[i] >> r[i];
L[i] = l[i];
R[i] = r[i];
}
sort(L.begin(), L.end());
sort(R.begin(), R.end());
int best = 0;
for (int i = 0; i < n; i++) {
int x = l[i];
int leftLe = upper_bound(L.begin(), L.end(), x) - L.begin();
int rightLt = lower_bound(R.begin(), R.end(), x) - R.begin();
int cover = leftLe - rightLt;
best = max(best, cover);
}
cout << n - best << '\n';
}
return 0;
}
这里 L 数组存储的是所有区间的左端点,R 数组存储的是所有区间的右端点。外层循环遍历每个区间的左端点 l[i],把它作为候选点 x,然后用两个二分分别算出“左端点 ≤ x 的区间数量”和“右端点 < x 的区间数量”,相减得到当前点的覆盖数。维护全局最大覆盖数 best,最后输出 n - best 即可。
3.3 每个二分为什么都用对
第一次写这题的人很容易在 upper_bound 和 lower_bound 的选择上翻车。这里值得单独拆开说。
先看左端点统计。我想要的是“左端点 ≤ x”的区间数量。对有序数组 L,upper_bound 返回的是第一个大于 x 的位置,所以从数组开头到这个位置之前的所有元素都满足 ≤ x,位置下标本身就是数量。如果你错用 lower_bound,那么左端点恰好等于 x 的那些区间会被排除在外,而它们明明覆盖点 x,结果就是少算了很多重叠区间。
再看右端点统计。我想要的是“右端点 < x”的区间数量。对有序数组 R,lower_bound 返回的是第一个大于等于 x 的位置,所以位置下标之前的所有元素都满足 < x。如果你错用 upper_bound,那么右端点恰好等于 x 的区间也会被减掉,同样会造成误判。右端点等于 x 的区间其实应该覆盖点 x,是不能减的。
一句话总结:左端点那边要包含等号,所以用 upper_bound;右端点那边要排除等号,所以用 lower_bound。
4. 边界条件与实战避坑
4.1 upper_bound 和 lower_bound 千万别反
我见过很多人把上面两个二分写反,导致的错误非常隐蔽。因为你随机生成数据去对拍,大部分情况下结果可能只差一两个,很难一眼看出来,但提交到 Codeforces 后就会在某些边界数据上 WA。
为了快速检查,可以在实现完后用几个手造数据验证。比如区间:
[1, 2], [2, 3]
正确答案是 0,因为点 2 同时被两个区间覆盖。如果你把左端点统计用成了 lower_bound,那么候选点 x=2 时,左端点 ≤2 的数量会算成 1,右端点 <2 的数量是 0,cover=1,n-cover=1,答案会错算成 1。就这一组数据就足够暴露问题。
下面这张表建议直接背下来,做题时可以少走很多弯路:
| 目标统计 | 使用函数 | 原因 |
|---|---|---|
| 计数:左端点 ≤ x | upper_bound(L.begin(), L.end(), x) | 返回第一个大于 x 的位置,等于号计入 |
| 计数:右端点 < x | lower_bound(R.begin(), R.end(), x) | 返回第一个大于等于 x 的位置,等于号排除 |
4.2 闭区间语义:边界上的区间一个都不能丢
这道题所有区间都是闭区间,也就是说端点本身也属于线段覆盖范围。很多从贪心区间题过来的人,习惯性把区间看成左闭右开,或者下意识觉得“重合一个点不算啥”,在这题里都会出事。
比如区间 [1, 3] 和 [3, 5],它们只在点 3 处重合。按题目要求,点 3 确实同时在这两个区间内,所以答案应为 0,不需要删除。而如果你在统计时不把 r=3 等于候选点的情况计入覆盖,或者把左端点等于候选点的情况排除掉,就会认为这两个区间没有公共点,最后得到错误答案 1。
我自己在写公式的时候,为了防止这种错误,会刻意把闭区间的条件写成 l ≤ x ≤ r,然后推公式时每一步都带等号。最后代码里左端点的等号体现在 upper_bound,右端点的等号体现在 lower_bound。建议你在纸上推导时也养成写清楚等号的习惯。
4.3 对 0 和全重叠的特殊情形把握
当所有区间本身就存在公共点时,答案是 0。比如:
[1, 5], [2, 4], [3, 6]
点 3 被所有区间覆盖,所以 best = n,最后输出 n - best = 0。很多新手会怀疑答案是不是应该为 1,因为“至少保留一个点”听起来像要删一条区间。不是的,题目的条件只是剩余区间有公共点,已经满足情况就不需要删除。
反过来,如果区间之间完全不重叠,比如 [1,2]、[3,4]、[5,6],那么每个候选左端点处最多只被 1 个区间覆盖,best=1,答案就是 n-1。这个结果也符合预期:你随便保留一个区间,它自己当然有公共点,所以至少要删 n-1 个;选两个区间就不可能让它们共享一个点了。
这两种极端情况建议作为自测数据,能快速判断代码有没有犯低级错误。
4.4 多组数据下的复杂度与内存习惯
题目给了多组测试数据,所有测试数据的 n 总和不超过 2×10^5。虽然每组数据都要重新 sort,总复杂度仍然是 O(totalN log totalN),完全没问题。
实现注意事项主要有两条。第一,vector 要定义在 while (T--) 里面,这样每组数据会自动释放重置,避免上一组残留数据影响下一组。第二,不要为了省事每次复制整个数组或者在循环里反复排序,那会把 O(n log n) 变成 O(n^2 log n)。
至于变量类型,l、r 和候选点 x 最大 1e9,用 int 就够了,因为二分返回值是下标,不会超过 2e5。不过如果你觉得不放心,或者以后把代码改成需要做加减运算的版本,用 long long 也完全没有问题。我的习惯是坐标类变量直接开 int,因为题目范围明确,没必要过度设计;但对初学者来说,统一用 long long 有时候能避免一些隐蔽的类型溢出问题。
5. 其他解法和选型对比
5.1 扫描线思路:正确但要注意事件顺序
看到“区间覆盖数最大值”,很多人会想到扫描线。思路也很自然:把每个区间拆成两个事件,左端点处覆盖数加一,右端点处覆盖数减一。把所有事件按坐标从小到大排序,遍历时维护当前覆盖数,取最大值。
这题用扫描线确实可以过,但有一个非常容易翻车的细节:事件顺序。用闭区间时,如果区间 A 在点 x 结束,区间 B 在同一个点 x 开始,那么在 x 这个点,A 和 B 都应该被算作覆盖了 x。所以扫描到同一个坐标时,必须先处理所有开始事件,再处理所有结束事件,否则你会在那一刻错误地先减掉 A 的贡献,导致覆盖数少 1。
不少选手为了避开这个细节,会把结束事件放在 r+1 的位置。如果题目坐标是离散整数点,这招很好用;但在 CF1462F 里,线段是连续的实数区间,虽然输入坐标都是整数,但理论上的公共点可以是任意实数,所以不能想当然地把 r+1 当作离开点。处理办法要么写同坐标先加后减的事件排序,要么就回到本文的主解法,用排序加二分老老实实统计,反而少一些特殊判断。
5.2 离散化线段树:看着通用但细节点过多
另一种直觉是用离散化加线段树,把所有区间做区间加一,然后查全局最大值。这个思路本身没问题,但当坐标轴表示的是连续区间时,离散化后不能简单地把每一个原始端点当成一个点。如果你把线段树叶子设计成“离散化坐标之间的连续段”,那么每个闭区间需要对离散化后的小段做区间加,思维量明显比排序二分大。
而且离散化的时候还要处理端点之间不留空格的问题。比如区间 [1,2] 和 [2,3],离散化后端点有 1、2、3,如果直接把点 2 当成单点加,可能误以为最优覆盖只发生在端点 2 处,实际上连续区间里点 2 这个端点已经足够表达两个闭区间的重合了。如果左右端点之间还有空隙,比如 [1,5] 和 [2,4],空隙内整段都是覆盖数 2,离散化点叶子也能表达,但要把“段”映射成节点,处理比较繁琐。
线段树做法也可以 AC,但代码量通常在 80 行以上,而且调试成本高。对这题来说,排序二分的代码量只有线段树的三分之一,思维也更直接,是我推荐的首选。
5.3 三种方案怎么选
我用一张表总结一下三者的优缺点,方便你根据题目场景做选择:
| 方案 | 时间复杂度 | 代码量 | 典型出错点 |
|---|---|---|---|
| 排序数组 + 二分 | O(n log n) | 短 | lower_bound/upper_bound 选择 |
| 扫描线事件 | O(n log n) | 中 | 同坐标事件处理顺序 |
| 离散化 + 线段树 | O(n log n) | 长 | 连续区间与离散化段映射 |
如果这是一道普通训练题,我推荐直接用排序二分,这也是 Codeforces 官方题解里的主流思路。如果是在实际项目中遇到类似“区间重叠人数最多时刻”这种问题,且坐标本身就是整数天,那么扫描线加差分数组往往更直观,甚至根本不用二分。
6. 从这题提炼出的通用套路
6.1 区间相交计数的万能公式
CF1462F 最值得学习的点,我觉得是它的公式能推广到更一般的“区间相交”问题。
假设有 n 个区间,任意给定一个区间 [l, r],问它与多少个区间相交。一个区间如果与 [l, r] 不相交,只有两种可能:它完全在 [l, r] 左边,或者完全在右边。完全在左边等价于这个区间的右端点 < l;完全在右边等价于这个区间的左端点 > r。
所以相交数量可以写成:
相交数 = n - (右端点 < l 的数量) - (左端点 > r 的数量)
把后面两个数量再转化一下,因为“左端点 > r 的数量”等于 n - “左端点 ≤ r 的数量”,代进去就能得到:
相交数 = (左端点 ≤ r 的数量) - (右端点 < l 的数量)
这个公式和本题的覆盖数公式一模一样。本质上,你可以把本题中的候选点 x 看成一个长度为 0 的退化区间 [x, x]。我们要找的就是一个退化区间能和最多原始区间相交。理解了这一层,以后看到任何“一个点被多少区间覆盖”的题,都可以套这套排序加二分的方法。
6.2 还能迁移到哪些问题
受这题启发,下面几类问题也适合用同一套思路处理。
第一类,给出一堆时间段,问哪个时刻同时在线人数最多。如果时间坐标非常大,比如 10^18,需要把每个区间拆成开始和结束事件,或者按上面的公式枚举某个人群的开始时刻。这个场景和 CF1462F 几乎一样,只是换了一层壳。
第二类,给定课程区间,想删除最少课程让剩余课程存在共同空闲时间段。这就是直接换皮,甚至可以直接把区间复制上去套代码。
第三类,问一个区间和其他多少个区间相交。上面已经推导过公式,只需要对原区间的 l 和 r 分别做两次二分,不需要枚举每个点。这类题在 LeetCode 和 Codeforces 里经常出现,作为扩展题非常合适。
第四类,类似“最多重叠区间数量”问题。如果把每个区间看成一段在线时间,答案本质上就是求数轴上被区间覆盖次数最多的那个点,这和本题求 maxCover 完全等价,只是不需要输出“删除哪些区间”。
6.3 做题习惯层面的建议
这类区间题做多了,我形成了一套固定的检查流程:拿到题先问自己,题目能不能转化成“选一个点”或者“选一条扫描线”;如果能,候选点集合能不能缩小到所有区间的端点;固定点之后,统计覆盖数能不能用预排序数组加二分加速。经过这三步,很多看似复杂的区间题都会暴露出比较简单的结构。
另外,区间题极其依赖边界测试。我每次写出代码后,都会手造这几组数据自测:所有区间都重叠、所有区间完全不重叠、两个区间只在端点上重合、单条区间、区间左右端点全部相同。这五组数据几乎能过滤掉八成以上的细节错误。比如全重叠数据能验证答案是否为 0,端点重合数据能验证两个二分是否用对。
第 6.1 节那个通用公式也可以反向使用。如果你某次写的是容斥 n 减两边,发现代码比预期复杂,试着把它化简成两个一维计数相减,大概率能让代码更短。这个思路我在不少题目里都用过,确实能给代码减负。
最后再分享一个小技巧。CF1462F 这类题,复制数组之前最好先想清楚有没有必要。比如我在上面的实现里单独存了一份 l 和 r,又存了排序后的 L 和 R。其实也可以一边读入一边往 L 和 R 里 push,存下原始区间后,排序时直接排序 L 和 R,这样能少开两个 vector,代码也更紧凑。对我来说,空间不是瓶颈,四份数组反而更容易理解,所以没有刻意优化。你如果有自己习惯的写法,保持一致性比追求最省内存更重要。
