1 问题:用最少的区间铺满一段路
给出一些区间,选出尽可能少的区间,覆盖整个目标范围;如果做不到,就报告无解。
可以把它想成铺路:每块木板只能放在指定的位置,允许木板重叠,但不能留下缺口。我们想用最少的木板,从起点一直铺到终点。
这和 雷达设备 里的「区间选点」不同:
- 区间选点:给定区间,选尽量少的点,让每个区间里都有点。
- 区间覆盖:给定区间,选尽量少的区间,让目标范围里的每个位置都被盖住。
对象都是区间,选的东西却不一样,不能只看见「区间」就照搬按右端点排序的做法。
2 例题:搞清洁
题目:luogu P2684: 搞清洁。
有
选择尽可能少的奶牛,使每个时间段都有至少一头奶牛工作。如果无法覆盖,输出 -1。
数据范围:
输入格式:第一行是
输出格式:最少的奶牛数,或无解时的 -1。
输入:
3 10
1 7
3 6
8 10
输出:
2
选择
如果覆盖的是实数线段
3 从第一个缺口出发
假设已经连续覆盖了 cur = 0,表示还没有覆盖任何时间段。
接下来最要紧的不是「哪里能盖得多」,而是:
第一个缺口
,谁来覆盖?
如果跳过它,即使把右边很长的一段盖住了,整条路也仍然没有铺通。
能覆盖这个缺口的区间满足
它们都只花费一次选择,所以应该比较什么?比较右端点:能把连续覆盖边界推得越远,留给后面的任务就越少。
在所有能覆盖第一个缺口
若没有这样的区间,则无解;否则把 cur 更新为这个右端点,答案加一,继续处理新的缺口。
实现时可以只筛选 best 初始等于 cur。那些右端点不超过 cur 的区间不会更新 best,自然就被排除了。
3.1 为什么不是选最长的区间
目标是覆盖整数点
即使已经限定了「能接上」,也不能只比较区间长度。例如当前覆盖到
已经覆盖的左半边不必重复计分;我们需要的是向右延伸,而不是区间本身有多长。
4 正确性:把最优解的一步换成我们的选择
直觉说「推得远不会吃亏」,证明必须回答:换成它以后,会不会丢掉原来覆盖的位置?
固定已经选好的前缀,它连续覆盖了
- 这个方案里必有区间
覆盖 ,否则它不是一个补全方案。 - 设贪心选出的区间是
。由于它在所有候选中右端最远,有 。 - 若方案中还没有
,用 替换 。原来 在前缀以右覆盖的点都属于 ; 覆盖 ,且右端不小于 ,因此这些点仍然被覆盖。更靠左的位置已经由固定前缀负责,不会产生新缺口。若方案原本已有 ,则直接把它作为接下来的一步即可。 - 所以存在一个最优补全方案包含本轮贪心选择。固定这一选择,对新的前缀重复相同论证,就得到一个整体最优方案。
这里并没有声称「
4.1 无法推进为什么就是无解
若所有满足 cur,那么它们都盖不到缺口。
剩下的区间左端点大于
5 先写正确:不排序,每轮重扫
最直接的实现,每次都从头检查全部区间。
long long cur = 0;
int ans = 0;
while (cur < t) {
long long best = cur;
for (int i = 0; i < n; i++) {
if (seg[i].l <= cur + 1) {
best = max(best, seg[i].r);
}
}
if (best == cur) {
cout << -1 << '\n';
return 0;
}
cur = best;
ans++;
}
cout << ans << '\n';
这已经是正确的贪心,不要求输入有序。每次选完一个区间,它就不可能再次延伸边界,因此成功选择最多
但每轮都扫描
接下来的排序不是为了改变贪心,而是为了更快地找到同一个贪心选择。
6 再做得快:左端点排序,指针只走一遍
6.1 旧候选为什么可以丢掉
一轮结束后,新的 cur 是本轮所有候选右端点的最大值。所以本轮看过的每一个区间都满足
它们以后都不可能把覆盖边界推得更远,因此没有必要重新检查,也不需要把它们放进堆里保留。
6.2 未来候选为什么不会漏掉
把区间按左端点升序排序,用 j 指向第一个尚未扫描的区间。
- 只要
seg[j].l <= cur + 1,就检查它的右端点,然后让j右移。 - 遇到左端点太大的区间就停止。排序保证后面的左端点更大,本轮也都不满足条件。
- 本轮扫描结束,才把
cur更新为best,并把答案加一。
内层循环使用固定的旧 cur 来判断候选,best 只负责记录最大右端点。
不要一边扫描一边修改 cur,也不要用 l <= best + 1 继续扩大本轮候选范围。那会把需要多个区间才能接起来的路段,错误地算成一次选择。例如
6.3 样例走一遍
排序后是
| 轮次 | 旧 cur |
第一个缺口 | 本轮新扫描的区间 | best |
累计选择数 |
|---|---|---|---|---|---|
| 1 | 0 | 1 | 7 | 1 | |
| 2 | 7 | 8 | 10 | 2 |
第二轮扫到
6.4 完整 C++17 实现
#include <iostream>
#include <algorithm>
using namespace std;
typedef long long ll;
const int MAXN = 25000 + 5;
// 区间结构体:每头奶牛工作的起止时间段
struct Segment {
ll l;
ll r;
};
// 按左端点升序排序;若左端点相同则按右端点升序排序
bool cmp(const Segment &a, const Segment &b) {
if (a.l != b.l) return a.l < b.l;
return a.r < b.r;
}
ll n, t;
Segment seg[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
if (!(cin >> n >> t)) return 0;
for (ll i = 0; i < n; i++) {
cin >> seg[i].l >> seg[i].r;
}
sort(seg, seg + n, cmp);
ll cur = 0; // 当前已经连续覆盖到时间点 cur
ll ans = 0; // 选用的最少奶牛数量
ll j = 0; // 扫描区间的双指针
while (cur < t) {
ll best = cur; // 记录能覆盖 cur + 1 的所有候选区间中,最远的右端点
while (j < n && seg[j].l <= cur + 1) {
if (seg[j].r > best) {
best = seg[j].r;
}
j++;
}
// 如果没有区间能接上当前缺口,说明无法完全覆盖
if (best == cur) {
cout << -1 << "\n";
return 0;
}
cur = best;
ans++;
}
cout << ans << "\n";
return 0;
}
6.5 复杂度
排序花费 while,指针 j 在所有轮次中一共只增加
总时间复杂度为
例如输入顺序是
若不排序,第一个区间现在不能用;遇到它就停止,会漏掉后面的
因此要么不排序、每轮重扫;要么先排序、用指针一次扫描。边界单调增加,不等于无序数组里的候选会按顺序出现。
7 连续线段版本:为什么不能加一
若目标是非退化的实数线段
| 项目 | 整数点 |
实数线段 |
|---|---|---|
| 初始化 | cur = 0 |
cur = L |
| 本轮候选 | l <= cur + 1 |
l <= cur |
| 必须真正推进 | best > cur |
best > cur |
| 结束条件 | cur >= T |
cur >= R |
连续版本初始 cur = L 表示从左端点出发准备延伸,不是说已经选过一个区间。第一轮仍要找左端点不超过
实数轴上不能跳过任何空隙,所以
不要把这个改动理解成「整数题加一,浮点题减一个误差」。先弄清题目要求覆盖的是哪些位置,再写衔接条件;若确实使用浮点数,还需要按题目精度要求处理比较误差。
8 边界自查
| 区间 | 目标整数点 | 答案 | 检查的细节 |
|---|---|---|---|
| 1 | 目标只有一个点 | ||
| -1 | 起点就无法覆盖 | ||
| -1 | 中间缺少整数点 4 | ||
| 2 | 整数相邻可以接上 | ||
| 1 | 同左端点要比较所有右端点 | ||
| 1 | 超过目标也算完成 |
9 总结:选得对,与找得快
选得对:从第一个缺口出发,在能接上的区间里选右端最远的一个。交换论证说明它不会增加后续需要的区间数。
找得快:按左端点排序,让候选顺序出现;每轮扫过的旧候选不再有延伸能力,可以永久丢弃,于是指针只需走一遍。
识别信号是:选最少的区间 + 连续覆盖目标 + 允许重叠但不允许缺口。它不同于区间选点,也不同于选最多的不相交区间。
练习时先完成 luogu P2684: 搞清洁,再尝试不看代码,独立解释三个问题:为什么看第一个缺口?为什么选最远?为什么扫描过的区间不必再看?
10 相关题解
| 题目 | OJ | 难度 |
|---|---|---|
| 搞清洁 | luogu | 普及 |