按开始时间排序后,用小根堆维护每个牛棚最后占用的结束时间;堆顶能空出就复用,否则新开棚,得到最少牛棚数与一套合法分配。
OJ: luogu
题目 ID: P2859
难度:普及
标签:贪心排序堆usaco
创建: 2026-09-27 16:30
更新: 2026-09-27 16:41
形式化题目
给定
题目只保证牛棚数最少、分配合法,具体编号不唯一(特殊评测)。
样例里 5 头牛在时间轴上的覆盖如下。每一列是一个时刻,有
| 时刻 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
牛1 [1,10] |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
牛2 [2,4] |
✓ | ✓ | ✓ | |||||||
牛3 [3,6] |
✓ | ✓ | ✓ | ✓ | ||||||
牛4 [5,8] |
✓ | ✓ | ✓ | ✓ | ||||||
牛5 [4,7] |
✓ | ✓ | ✓ | ✓ | ||||||
| 同时在棚的牛数 | 1 | 2 | 3 | 4 | 4 | 4 | 3 | 2 | 1 | 1 |
时刻
暴力解法
思路
把"每头牛进哪个棚"直接看成一串选择,逐头牛决定。按开始时间顺序处理第
- 它可以放进任意一个已经开好、且当前的牛不会和它冲突的棚;
- 也可以另外新开一个棚。
用 choose[id] 记录当前方案里每头牛去的棚,stall_end[k] 记录第 dfs(dep, used) 枚举第 dep == N+1 时所有牛都安排好了,就用 used(开了几个棚)更新最少棚数并保存方案。
因为每一头牛都把"放进哪个棚"的所有可能都试了一遍,所有合法分配都会被枚举到,所以叶子节点里取到的最小 used 就是真正的最少棚数。brute.cpp 里还有一句 if (used >= best_cnt) return;:当前已经开了不少于最优解的棚数,再往下不可能更优,可以提前剪掉。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-09-27 16:30
* update_at: 2026-09-27 16:31
*/
// brute.cpp:小数据暴力解,枚举每头牛放进哪个牛棚,找出最少牛棚数。
// 每层递归依次处理第 dep 头牛,选择它可以放进哪一个已经开了的棚,或者新开一个棚。
// 只适合 n 很小的情况(指数级),用来帮助理解题意并和 main.cpp 对拍。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
const int MAXN = 15;
int n;
struct Cow {
int l;
int r;
int id; // 输入顺序
};
Cow cow[MAXN];
int stall_end[MAXN]; // stall_end[k] 表示第 k 个牛棚当前最后一头牛的结束时间
int choose[MAXN]; // choose[id] 表示第 id 头牛当前方案里去的牛棚编号
int best_ans[MAXN]; // 最优方案里每头牛去的牛棚编号
int best_cnt; // 最优方案用了几个棚
// 按开始时间从小到大排序,先处理开始早的牛
bool cmp_cow(const Cow &a, const Cow &b) {
if (a.l != b.l) return a.l < b.l;
return a.r < b.r;
}
// dep:当前处理到第几头牛;used:当前已经开了几个棚
void dfs(int dep, int used) {
// 已经不可能比当前最优解更好了,剪枝
if (used >= best_cnt) return;
if (dep == n + 1) {
// 走到这里说明所有牛都安排好了,更新最优解
best_cnt = used;
for (int i = 1; i <= n; i++) {
best_ans[i] = choose[i];
}
return;
}
// 尝试放进已经开好的某个棚(端点也算占用,要严格小于才开始)
for (int k = 1; k <= used; k++) {
if (stall_end[k] < cow[dep].l) {
int old = stall_end[k];
stall_end[k] = cow[dep].r;
choose[cow[dep].id] = k;
dfs(dep + 1, used);
stall_end[k] = old; // 恢复现场
}
}
// 或者为这头牛新开一个棚
stall_end[used + 1] = cow[dep].r;
choose[cow[dep].id] = used + 1;
dfs(dep + 1, used + 1);
stall_end[used + 1] = 0; // 恢复现场
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> cow[i].l >> cow[i].r;
cow[i].id = i;
}
sort(cow + 1, cow + 1 + n, cmp_cow);
best_cnt = n + 1; // 初始化为一个一定更差的上界
dfs(1, 0);
cout << best_cnt << "\n";
for (int i = 1; i <= n; i++) {
cout << best_ans[i] << "\n";
}
return 0;
}复杂度
- 时间:每头牛可选"已开的棚或新开一个",分支数随深度增长,上界是
级别,实际只适合 左右。 - 空间:
choose、stall_end等均为。
瓶颈
暴力的低效来自把"放进哪个棚"当成完全自由的选择,于是必须试遍所有棚。但一个棚的状态其实只有一个数有用:它当前最后一头牛的结束时间。而且判断"还有没有空棚"也不需要逐个检查,只要看最早空出的那个棚——它都没空,别的棚更不可能空。抓住这两点,就不必枚举全部放法。
正解
关键观察
一、按开始时间排序,逐头处理。 把牛按
二、一个棚能否复用,只看它当前占用到什么时候。 当前牛是
三、最早空出的棚决定有没有空棚。 把所有已开棚按"当前结束时间"放进一个小根堆,堆顶就是最早空出的棚。若堆顶都满足 结束时间 >= A,说明所有棚都还占着,只能新开;否则直接弹出堆顶复用。棚与棚之间完全等价,复用哪个空棚都不影响结果,所以复用堆顶即可。
有了这三点,每头牛只做一次"看堆顶"的判断,不需要回头枚举。
贪心用的就是最少棚数
设贪心第
这也顺带证明了前面那句话:最少棚数就等于同时挤奶牛数的最大值。
思路
- 读入时记下每头牛的原始编号,按左端点
升序排序( 相同则按右端点)。 - 维护一个小根堆,每个元素是一个牛棚:编号
id和它当前最后一头牛的结束时间cow。 - 依次处理每头牛
: - 堆非空且堆顶的
cow < A:弹出堆顶,把这头牛放进这个棚,把该棚的cow改成再压回堆; - 否则:新开一个棚
cnt++,把(cnt, B)压入堆。
- 堆非空且堆顶的
- 答案就是最后的
cnt;按原始编号输出每头牛的棚号。
样例的贪心过程如下表。每一步都只比较"堆顶的结束时间"和当前牛的开始时间。
| 处理顺序 | 牛(输入编号) | 区间 | 堆顶(最早空出的棚) | 决策 |
|---|---|---|---|---|
| 1 | 牛1 | [1,10] |
堆空 | 新开 棚1 |
| 2 | 牛2 | [2,4] |
棚1 结束 |
新开 棚2 |
| 3 | 牛3 | [3,6] |
棚2 结束 |
新开 棚3 |
| 4 | 牛5 | [4,7] |
棚2 结束 |
新开 棚4 |
| 5 | 牛4 | [5,8] |
棚2 结束 |
复用 棚2 |
第 4 步是本题最容易错的地方:4 >= 4 判定为没空,必须新开棚。最终分配是牛1→棚1、牛2→棚2、牛3→棚3、牛4→棚2、牛5→棚4,与样例输出一致。
代码
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-09-27 16:30
* update_at: 2026-09-27 16:31
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
const int MAXN = 50005;
int n;
int ans[MAXN]; // ans[i] 表示第 i 头牛被分到的牛棚编号
// 一头牛:挤奶区间 [l, r],id 是输入顺序
struct Cow {
int l;
int r;
int id;
};
Cow cow[MAXN];
// 按挤奶开始时间从小到大排序;开始时间相同则结束早的排前面
bool cmp_cow(const Cow &a, const Cow &b) {
if (a.l != b.l) return a.l < b.l;
return a.r < b.r;
}
// 牛棚:id 是编号,cow 是这个棚当前最后一头牛的结束时间
struct Node {
int id;
int cow;
// priority_queue 默认是大根堆,重载 < 让结束时间早的排在堆顶
bool operator<(const Node &b) const {
return cow > b.cow;
}
};
priority_queue<Node> pq; // 小根堆,堆顶是“最快空出来”的牛棚
void read_data() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> cow[i].l >> cow[i].r;
cow[i].id = i;
}
}
void solve() {
sort(cow + 1, cow + 1 + n, cmp_cow);
int cnt = 0; // 当前已经启用的牛棚数,也就是最少牛棚数
for (int i = 1; i <= n; i++) {
int l = cow[i].l;
int r = cow[i].r;
int id = cow[i].id;
// 区间端点也算使用时间,所以必须堆顶的结束时间严格小于 l 才能复用
if (!pq.empty() && pq.top().cow < l) {
Node t = pq.top();
pq.pop();
ans[id] = t.id; // 复用这个最早空出来的棚
t.cow = r; // 它的占用时间更新为当前这头牛
pq.push(t);
} else {
// 所有已开的棚都还没空,只能新开一个
cnt++;
Node t;
t.id = cnt;
t.cow = r;
ans[id] = cnt;
pq.push(t);
}
}
cout << cnt << "\n";
for (int i = 1; i <= n; i++) {
cout << ans[i] << "\n";
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(0);
read_data();
solve();
return 0;
}同目录下的 main.py 是同一套贪心的 Python 实现,用 heapq 存 (结束时间, 棚号)。
复杂度
- 时间:排序
,每头牛最多一次堆弹出和一次堆压入,各 ,总计 。 - 空间:堆、排序数组、答案数组均为
。
总结
- 模型:把牛看成闭区间,同棚区间两两不相交;求区间划分的最少组数。
- 贪心:按开始时间排序,用小根堆维护各棚"最后占用的结束时间",堆顶能空出来(严格小于当前左端点)就复用,否则新开棚。
- 为什么对:每次被迫新开第
个棚时,有 头牛同时挤奶,必须 个棚,这是下界;贪心恰好只用 个棚,因此最优。 - 易错点:复用条件必须是
结束时间 < 开始时间(严格小于),因为区间端点也算占用;输出要按输入顺序,所以读入时要存原始编号。 - 一句话记忆:最少棚数 = 同时挤奶牛数的最大值;用堆只维护"最早空出的棚",就能在线求出这个最大值。