第 4.2 章:USACO 中的贪心
能用贪心解决的 USACO 题目是最令人满足的——一旦看到那个洞察,代码几乎自己就写出来了。本章通过几道以贪心为关键的 USACO 风格题目来实战演练。
4.2.1 模式识别:这是贪心题吗?
识别贪心问题是最难的部分——它看起来像 DP,闻起来像 DP,但有特殊结构让你能做出局部决策。以下是实用框架。
三问检验法
编码前问自己:
-
能用某种聪明的方式对输入排序吗? 大多数贪心算法从排序开始。若能找到一个自然顺序(按截止时间、结束时间、比率、自定义比较器),你很可能在贪心的正确轨道上。
-
每一步有「自然的」贪心选择吗? 总能找到一个「当下显然最好」的元素/决策,并能论证选它不会关闭更好的未来选项吗?
-
能构造交换论证吗? 若任意两个相邻选择「不符合贪心顺序」,交换它们不会使解变差吗?如果是,通过冒泡排序推理,贪心顺序是最优的。
三个都是 → 尝试贪心。若找到反例 → 改用 DP。
USACO 贪心模式分类
理解一道题属于哪个模式通常是关键洞察:
| 模式 | 触发词/结构 | 排序依据 | 例子 |
|---|---|---|---|
| 活动选择 | 「最多不重叠区间」 | 右端点升序 | USACO Bronze 调度 |
| EDF 调度 | 「最小化最大延迟/截止时间」 | 截止时间升序 | Convention II(变体) |
| SJF / 完成时间 | 「最小化总等待/完成时间」 | 处理时间升序 | 奶牛排序(相邻交换) |
| 贪心 + 二分 | 「最小化最大值」或「最大化最小值」 | 二分答案 | USACO Convention、Haybales |
| 双指针匹配 | 两个有序数组,最大化匹配对数 | 两数组都排序 | Paired Up、分发饼干 |
| 扫描线/模拟 | 带时间戳的事件、容量约束 | 事件时间 | 奶牛信号、会议室 |
| 后悔贪心 | 「选 K 个元素且决策可取消」 | 最大堆 + 后悔节点 | USACO Gold 进阶题 |
| 自定义比较器 | 「按最优顺序排列 N 个元素」 | a+b vs b+a,w/t 比率 | 最大数,SJF |
红色警报:贪心失败的信号
警惕这些表明贪心不奏效的迹象:
- 物品/选择有权重: 若选一个物品会排除多个有不同合并价值的其他物品,贪心往往失败。用 DP。(0/1 背包,加权区间调度)
- 决策非局部交互: 若现在选元素 i 会以非平凡的方式影响两步之后哪些元素可用。(最长递增子序列——贪心给出错误答案)
- 你能构造 3 元素反例: 始终在小输入上测试:N=2 和 N=3。若 N=3 破坏了你的贪心,它就是错的。
⚠️ USACO 竞赛提示: Silver/Gold 级别的贪心题几乎总是需要正确性证明草稿——无论是交换论证还是二分搜索的单调性论证。若你无法用 2 句话草拟贪心的有效性,要更加谨慎。
4.2.2 USACO Bronze:奶牛排序
题目: N 头奶牛站成一排,奶牛 i 的「暴躁值」为 g[i]。想排序这一排使暴躁值严格递增。唯一允许的操作是交换两头相邻的奶牛。交换第 i 和 j 位置的奶牛(相邻)时,代价为 g[i] + g[j]。求排序所需的最小总代价。
样例输入:
3
3 1 2
样例输出:
9
逆序对方案(O(N²)):
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
vector<long long> g(n);
for (long long &x : g) cin >> x;
// 总代价 = 每对逆序对 (i,j)(i<j,g[i]>g[j])的 g[i]+g[j] 之和
long long totalCost = 0;
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (g[i] > g[j]) {
totalCost += g[i] + g[j]; // 这个逆序对的代价
}
}
}
cout << totalCost << "\n";
return 0;
}
// 时间:O(N²) — N ≤ 10^5 时用归并排序逆序对计数(O(N log N))
验证: 对 [3,1,2] 的冒泡排序:
- 交换(3,1) = 代价 4 → [1,3,2]
- 交换(3,2) = 代价 5 → [1,2,3]
- 总计 = 9 ✓
4.2.3 USACO Bronze:奶牛信号(贪心模拟)
许多 USACO Bronze 题是带贪心技巧的纯模拟:按时间顺序处理事件,在每步贪心维护最优状态。关键是确定模拟什么以及按什么顺序。
题目: N 头奶牛各想从牛棚出发到达牧场。奶牛 i 准备在时间 t[i] 离开。牛棚和牧场之间的路同时最多容纳 C 头奶牛,过路恰好需要 1 个时间单位。路满时奶牛在牛棚等待。假设尽早送奶牛出发,最后一头奶牛何时到达?
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, c;
cin >> n >> c;
vector<int> t(n);
for (int &x : t) cin >> x;
sort(t.begin(), t.end()); // 按出发时间顺序处理
int ans = 0;
// 每 c 头奶牛一组处理
for (int i = 0; i < n; i += c) {
// 这批奶牛中最早的是 t[i]
ans = max(ans, t[i]); // 这批出发时间至少要等最早的奶牛准备好
ans++; // 过路需要 1 个时间单位
}
cout << ans << "\n";
return 0;
}
4.2.4 USACO Silver:配对
题目: 你有 A 组 N 头奶牛和 B 组 N 头奶牛。必须将 A 中每头奶牛与 B 中恰好一头配对(一对一)。奶牛 a 与奶牛 b 配对的利润是 min(a, b)。最大化 N 对的总利润。
样例输入:
3
1 3 5
2 4 6
样例输出:
9
追踪(两数组升序排序后按下标配对):
A 排序后:[1, 3, 5]
B 排序后:[2, 4, 6]
配对 (1,2):min=1
配对 (3,4):min=3
配对 (5,6):min=5
总计 = 1+3+5 = 9 ✓
为什么同向排序? 交换论证:若某个配对中 a₁ < a₂ 与 b₁ > b₂ 配对(A 升序但 B 不是),交换到同向配对总是不差的(见第 4.1.7 节排列不等式)。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> A(n), B(n);
for (int &x : A) cin >> x;
for (int &x : B) cin >> x;
sort(A.begin(), A.end());
sort(B.begin(), B.end());
long long total = 0;
for (int i = 0; i < n; i++) {
total += min(A[i], B[i]); // 第 i 小的与第 i 小的配对
}
cout << total << "\n";
return 0;
}
4.2.5 USACO Silver:Convention(二分 + 贪心)
题目(USACO 2018 February Silver): N 头奶牛在时间 t[1..N] 到达公共汽车站。有 M 辆公共汽车,每辆最多容纳 C 头奶牛。将奶牛分配给公共汽车,最小化任意奶牛的最大等待时间。
做法:二分答案 + 贪心检查。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int n, m, c;
vector<long long> cows; // 按到达时间排序
// 最大等待时间 <= maxWait 时能否调度所有奶牛?
bool canDo(long long maxWait) {
int busesUsed = 0;
int i = 0; // 当前奶牛下标
while (i < n) {
busesUsed++;
if (busesUsed > m) return false; // 公共汽车不够用了
// 该车从奶牛 i 开始服务
// 车必须在 cows[i] + maxWait 之前出发
long long depart = cows[i] + maxWait;
// 尽量多地装奶牛(容量 c,到达时间 ≤ 出发时间)
int count = 0;
while (i < n && count < c && cows[i] <= depart) {
i++;
count++;
}
}
return true;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cin >> n >> m >> c;
cows.resize(n);
for (long long &x : cows) cin >> x;
sort(cows.begin(), cows.end());
// 对最大等待时间二分
long long lo = 0, hi = 1e14;
while (lo < hi) {
long long mid = lo + (hi - lo) / 2;
if (canDo(mid)) hi = mid;
else lo = mid + 1;
}
cout << lo << "\n";
return 0;
}
4.2.6 USACO Bronze:放牧(贪心观察)
题目: 三头奶牛站在数轴上不同的整数位置 a、b、c。每次移动可以选任意一头奶牛,将它传送到任意空的整数位置。找让三头奶牛处于三个连续整数位置(如 {k, k+1, k+2})所需的最少移动次数。
样例输入: 4 7 9 → 输出: 1(将 9 移到 5 或 6,或将 4 移到 8:{7,8,9})
样例输入 2: 1 10 100 → 输出: 2
贪心洞察: 排序后 a ≤ b ≤ c:
- 0 次移动当且仅当
c - a == 2(已是连续) - 2 次移动始终够用(上界)
- 1 次移动可行当以下任一成立:
c - b == 1或c - b == 2(b 和 c 相邻或差 1)b - a == 1或b - a == 2(a 和 b 相邻或差 1)c - a == 3(移动 b 到 a+1 或 c-1 使三者连续)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
long long a, b, c;
cin >> a >> b >> c;
long long pos[3] = {a, b, c};
sort(pos, pos + 3);
a = pos[0]; b = pos[1]; c = pos[2];
// 0 次移动:已是连续
if (c - a == 2) { cout << 0; return 0; }
// 1 次移动:检查各情况
bool one_move = false;
// (b,c) 对保留:移动 a
if (c - b == 1 || c - b == 2) one_move = true;
// (a,b) 对保留:移动 c
if (b - a == 1 || b - a == 2) one_move = true;
// (a,c) 对保留:移动 b(需 c-a==3)
if (c - a == 3) one_move = true;
if (one_move) { cout << 1; return 0; }
cout << 2;
return 0;
}
4.2.7 USACO 中常见的贪心模式
| 模式 | 描述 | 排序依据 |
|---|---|---|
| 活动选择 | 最多不重叠区间 | 结束时间 |
| 调度 | 最小化完成时间/延迟 | 截止时间或比率 |
| 贪心 + 二分 | 检查可行性,用二分找最优 | 各种 |
| 配对 | 最优匹配两个有序列表 | 两个数组 |
| 模拟 | 按时间顺序处理事件 | 事件时间 |
| 扫描线 | 扫描时维护活跃集合 | 开始/结束事件 |
本章总结
📌 核心要点
USACO 中的贪心算法通常涉及:
- 排序输入(以某种聪明的方式)
- 用简单的更新规则扫描一次(或两次)
- 偶尔与二分答案结合使用
❓ 常见问题
Q1:「二分答案 + 贪心检查」的模板是什么?
A:外层:对答案 X 二分(lo=最小可能,hi=最大可能)。内层:编写
check(X)函数,用贪心策略验证 X 是否可行,根据结果调整 lo/hi。关键要求是check必须是单调的(若 X 可行,X+1 也可行,或反过来)。
Q2:USACO 贪心题和 LeetCode 贪心题有什么不同?
A:USACO 贪心题通常需要正确性证明(交换论证),且常与二分搜索和排序结合。LeetCode 倾向于更简单的「总是选最大/最小」贪心。USACO Silver 贪心题明显比 LeetCode Medium 更难。
Q3:什么时候用 priority_queue 辅助贪心?
A:当需要反复提取「当前最优」元素时(如 Huffman 编码、最小会议室数、反复取最大/最小值)。
priority_queue将「找最优」从 O(N) 降到 O(log N)。
🔗 与其他章节的联系
- 第 4.1 章涵盖了贪心的理论和交换论证;本章将它们应用到真实的 USACO 题目
- 第 3.3 章(二分搜索)介绍了直接在 Convention 题中用到的「二分答案」模式
- 第 7.1 章(理解 USACO)和第 7.2 章(解题策略)将进一步讨论如何在竞赛中识别贪心 vs DP
- 第 3.1 章(STL)介绍了
priority_queue,在本章的贪心模拟中频繁出现
练习题
题目 4.2.1 — USACO 2016 December Bronze:统计干草堆
题目: N 捆干草放在数轴上(位置可重复)。Q 次查询:范围 [L, R] 内有多少捆干草?
示例:
N=7, Q=4
位置:6 3 2 7 5 1 4
查询:2 5 / 1 1 / 4 8 / 10 15
输出:
4
1
4
0
💡 提示
排序位置,然后用 lower_bound / upper_bound 二分搜索统计 [L, R] 内的元素。这道题练习「排序 + 二分搜索」思维——大多数贪心算法的第一步。
✅ 完整题解
排序后,[L, R] 内的数量 = upper_bound(R) - lower_bound(L)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, q;
cin >> n >> q;
vector<int> pos(n);
for (int &x : pos) cin >> x;
sort(pos.begin(), pos.end()); // 关键:先排序以支持二分搜索
while (q--) {
int l, r;
cin >> l >> r;
auto lo = lower_bound(pos.begin(), pos.end(), l);
auto hi = upper_bound(pos.begin(), pos.end(), r);
cout << (hi - lo) << "\n";
}
return 0;
}
// 时间复杂度:O(N log N + Q log N)
题目 4.2.2 — USACO 2019 February Bronze:睡觉奶牛排序
题目: N 头奶牛编号 1~N 以随机顺序站成一排。每次操作:取出排尾的奶牛,将其插入任意位置。最少几次操作能让队伍变为 1, 2, ..., N 的顺序?
示例:
N=5
顺序:1 4 2 5 3
输出:4
💡 提示
核心思路: 找队尾已经有序的最长后缀(连续的 k, k+1, ..., N)。这些奶牛不需要移动。答案 = N − 后缀长度。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
vector<int> cows(n);
for (int &x : cows) cin >> x;
// 找最长已排序后缀:cows[i], cows[i+1], ..., cows[n-1]
// 条件:cows[i] + 1 == cows[i+1](连续递增)
int keep = 1; // 至少最后一头奶牛留下
for (int i = n - 2; i >= 0; i--) {
if (cows[i] + 1 == cows[i + 1]) {
keep++;
} else {
break; // 后缀必须连续——遇到第一个断点就停
}
}
cout << n - keep << "\n";
return 0;
}
// 时间复杂度:O(N)
题目 4.2.3 — 任务调度器 🟡 中等
题目: N 个标有 A~Z 的任务,每个需要 1 个时间单位。执行标有 X 的任务后,CPU 必须等待至少 k 个时间单位才能再次执行 X(期间可运行其他任务或空闲)。找完成所有任务的最短总时间。
示例:
tasks = [A, A, A, B, B, B], k = 2
输出:8(A→B→空闲→A→B→空闲→A→B)
💡 提示
关键公式:ans = max(任务总数, (maxCount-1)*(k+1) + countMax)
其中 maxCount = 出现次数最多的任务的频率。
贪心策略:每个「帧」(每 k+1 个时间单位)先填最频繁的剩余任务。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, k;
cin >> n >> k;
vector<int> freq(26, 0);
for (int i = 0; i < n; i++) {
char c; cin >> c;
freq[c - 'A']++;
}
int maxCount = *max_element(freq.begin(), freq.end());
int countMax = count(freq.begin(), freq.end(), maxCount);
int ans = max(n, (maxCount - 1) * (k + 1) + countMax);
cout << ans << "\n";
return 0;
}
// 时间复杂度:O(N)
题目 4.2.4 — USACO 2018 February Silver:Convention II 🔴 困难
题目: N 头奶牛按资历排序(下标越小资历越高)。奶牛 i 在时间 a[i] 到达饮水处,喝水需要 t[i] 个时间单位(设备每次服务一头奶牛)。设备空闲时,等待的奶牛中资历最高(下标最小)的先喝水。求所有奶牛中最大等待时间。
💡 提示
用优先队列(按资历下标最小堆)模拟。维护已到达但未喝水的奶牛的「等待队列」。每次设备空闲时,从等待队列中取资历最高(下标最小)的奶牛。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
// {到达时间, 原始下标(资历), 喝水时长}
vector<tuple<int,int,int>> cows(n);
for (int i = 0; i < n; i++) {
int a, t;
cin >> a >> t;
cows[i] = {a, i, t}; // 原始下标 i = 资历(0 = 最高资历)
}
sort(cows.begin(), cows.end()); // 按到达时间排序
// 最小堆:{资历下标, 到达时间, 喝水时长}——下标最小优先级最高
priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, greater<>> waiting;
int curTime = 0; // 设备下次空闲的时间
int maxWait = 0;
int idx = 0;
while (idx < n || !waiting.empty()) {
// 将所有在 curTime 之前到达的奶牛加入等待队列
while (idx < n && get<0>(cows[idx]) <= curTime) {
auto [a, seniority, t] = cows[idx];
waiting.push({seniority, a, t});
idx++;
}
if (waiting.empty()) {
curTime = get<0>(cows[idx]);
continue;
}
// 服务资历最高(下标最小)的等待奶牛
auto [seniority, arrTime, drinkTime] = waiting.top();
waiting.pop();
int waitTime = curTime - arrTime;
maxWait = max(maxWait, waitTime);
curTime += drinkTime;
}
cout << maxWait << "\n";
return 0;
}
// 时间复杂度:O(N log N)
题目 4.2.5 — 加权工作调度 🔴 困难(贪心失败→用 DP)
题目: N 个工作,各有开始时间 s[i]、结束时间 e[i] 和利润 p[i]。选择一组不重叠的工作以最大化总利润。
示例:
N=4
工作:(s=1,e=3,p=50), (s=2,e=5,p=10), (s=4,e=6,p=40), (s=6,e=7,p=70)
输出:160(工作 1 + 3 + 4)
✅ 完整题解(包含贪心失败分析)
为什么贪心失败?
- 按利润排序取最大值?不行。反例:(s=1,e=10,p=100) vs (s=1,e=3,p=50)+(s=4,e=7,p=60)——后者总计 110。
- 按最早结束时间?不行。贪心可能选利润为 1 的短工作,错过利润 100 的长工作。
- 贪心无法同时优化「早结束」和「最大利润」。这是加权区间调度——DP 问题。
DP 做法:
#include <bits/stdc++.h>
using namespace std;
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
vector<tuple<int,int,int>> jobs(n); // {结束时间, 开始时间, 利润}
for (int i = 0; i < n; i++) {
int s, e, p;
cin >> s >> e >> p;
jobs[i] = {e, s, p};
}
sort(jobs.begin(), jobs.end()); // 按结束时间排序
vector<int> ends;
for (auto [e, s, p] : jobs) ends.push_back(e);
vector<long long> dp(n + 1, 0); // dp[i]:前 i 个工作的最大利润(1-indexed)
for (int i = 1; i <= n; i++) {
auto [e, s, p] = jobs[i - 1];
// 二分搜索:找最后一个结束时间 <= s[i] 的工作(不重叠)
int lo = 0, hi = i - 1;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (ends[mid - 1] <= s) lo = mid;
else hi = mid - 1;
}
dp[i] = max(dp[i - 1], dp[lo] + p); // 跳过 vs 选取
}
cout << dp[n] << "\n";
return 0;
}
// 时间复杂度:O(N log N)
教训: 选择不重叠区间时,若区间有权重(利润),贪心不起作用——用 DP。只有当所有权重相等(最大化数量)时,才能简化为贪心。