今天要拿下三种全新的贪心场景 —— 区间调度、分数背包、以及第一次接触优先队列
| 0 - 25 min | 区间贪心:为什么必须按「右端点」排序(第 1 节) |
| 25 - 45 min | 分数背包 vs 0/1 背包:同样是背包,为什么一个能贪一个不能(第 2 节) |
| 45 - 70 min | 优先队列(小根堆)入门 —— 今天的全新工具(第 3 节) |
| 70 - 155 min | 做 4 道题,每题限时 30 分钟 |
| 155 - 180 min | 对照本文题解 + 写笔记 + 做自测 |
昨天你学的是「排序关键字 = 贪心策略」。今天会看到这个规律的三种新形态:
· 区间问题按 右端点 排(不是左端点,昨天说了排序是关键,今天要看清排哪个关键字);
· 背包按 性价比 排(不是价值,也不是重量);
· 合并果子不排序,改用优先队列动态地取最小值。
给你一堆时间段:[0,2]、[2,4]、[1,3]……
你要去参加这些活动,但同一时间只能参加一个。问最多能参加几个?
这就是「区间调度」问题,也叫「线段覆盖」。它的特点是:每个区间是一个整体,要么整个参加,要么整个放弃,不能参加一半。
| 排序关键字 | 直觉理由 | 为什么错 |
|---|---|---|
| 按开始时间升序 | 「开始得早的先选,不浪费时间」 | 一个从 0 点开到 24 点的活动会占满全天,后面什么都选不了 |
| 按时长升序 | 「短的占时间少,能塞更多」 | 一个短活动可能恰好卡在两个长活动中间,把它俩都挡住 |
三个区间:[0,100]、[1,2]、[3,4]。
按开始时间升序:先看到 [0,100],它开始最早,选中。结果它把 0~100 全占了,
[1,2] 和 [3,4] 都没法参加了 → 只选到 1 个。
正确答案是 2 个:放弃 [0,100],选 [1,2] 和 [3,4]。
错在哪:开始时间早,不代表结束时间早。「开始得早」看着占便宜,其实可能是个占着茅坑不走的巨无霸。
要选尽可能多的不相交区间,就按「右端点(结束时间)升序」排序,然后能选就选。
核心直觉:结束得越早,剩下的时间就越多。 你不是要「占得早」,而是要「走得早」—— 走得早,后面才有位置留给别人。
照昨天学的三步模板来:
f。
贪心选的第一个区间是 g,也就是所有区间里结束得最早的那个。g 的定义可知 g.end ≤ f.end。f 换成 g。g 会不会和 OPT 里第二个区间(叫 h)冲突?h.start ≥ f.end。而 g.end ≤ f.end,
所以 h.start ≥ f.end ≥ g.end —— h 的开始时间不早于 g 的结束时间,两者不冲突。一定存在一个最优解,它的第一个区间就是「结束最早」的那个。
选完它之后,剩下的问题变成「在 g.end 之后的区间里再选尽可能多」——
和原问题一模一样,只是范围变小了。所以可以一路贪下去,每一步都选「剩下区间里结束最早的」。
这种「做了这一步,剩下的还是同一个问题」的性质,是贪心能成立的关键。
本题 P1803 的样例里,[0,2] 和 [2,4] 可以同时参加 ——
所以判断条件是 s >= lastEnd(大于等于)。
有些题目写的是「结束时间必须小于下一场的开始时间」,那就要用 s > lastEnd。
差一个等号,答案就不一样。
struct Node { int s, e; }; // s 开始,e 结束
bool cmp(const Node &a, const Node &b){
return a.e < b.e; // ★ 按「右端点」升序,不是左端点!
}
sort(a, a + n, cmp);
int ans = 0, lastEnd = -1; // 上一场选中的结束时间
for(int i = 0; i < n; i++){
if(a[i].s >= lastEnd){ // 能接上就选(结束最早,最不碍事)
ans++;
lastEnd = a[i].e;
}
}
山洞里有 N 堆金币,每堆有自己的总重量和总价值。你背了一个承重 T 的包,想装走尽可能多价值的金币。
关键条件:金币可以随意切割。一堆 20 斤的金币,你可以只拿 7 斤,价值按比例算。
性价比 = 单位价值 = 总价值 ÷ 总重量。
性价比最高的先拿,能全拿就全拿;装不下了,就把当前这堆切一部分塞满背包,然后结束。
生活里的比方:超市散装糖果,20 元/斤的和 8 元/斤的,你肯定先把贵的装够,剩下的空间再用便宜的填。
设 A 的性价比比 B 高(v_A / m_A > v_B / m_B)。
假设某个最优解里,拿了 w 斤的 B,却没有拿满 A。我们把「w 斤 B」换成「w 斤 A」:
| 对比项 | 换之前 | 换之后 |
|---|---|---|
| 总重量 | w | w(一样,背包没超重) |
| 总价值 | w × v_B / m_B | w × v_A / m_A(更大) |
因为 A 的性价比更高,换完之后重量没变、价值变大。这说明原方案不是最优的,矛盾。 所以只要还有性价比更高的东西没拿满,就绝不应该去拿性价比低的 —— 按性价比降序拿是安全的。
如果物品不能切开(要么整个拿,要么不拿),按性价比贪心就是错的。这就是 Day 26 要学的 0/1 背包问题,必须用 DP。
反例(可以手算验证):背包承重 T = 10,三件物品:
| 物品 | 重量 | 价值 | 性价比 |
|---|---|---|---|
| A | 6 | 12 | 2.0 ← 最高 |
| B | 5 | 9 | 1.8 |
| C | 5 | 9 | 1.8 |
按性价比贪心:先拿 A(重量 6,价值 12),背包只剩 4 —— B 和 C 都是 5,一个都塞不进去 → 总价值 12。
但正确答案是拿 B + C:重量 5+5 = 10(正好装满),价值 9+9 = 18。
物品能切开 → 贪心(按性价比)
物品不能切开 → 必须 DP(0/1 背包)
原因:物品不能切的时候,「拿一个」会占掉一整块空间,这个选择和后面的选择是会互相影响的 —— 昨天说过,一旦选择互相干扰,贪心就不安全了。
看今天的 P1090 合并果子。策略是:每次从所有果子堆里挑出最小的两堆合并。
如果每轮都用循环扫一遍找最小值,n = 10⁴ 就要扫 n 轮、每轮 O(n),总共 O(n²) = 10⁸ —— 有点悬。
而且合并之后会产生新的一堆,数据在动态变化,没法简单排序一次就完事。
priority_queue 是一个会自动把最值放到队头的容器。
你往里塞东西、从队头取东西,它永远保证队头是最大(或最小)的那个。
插入 O(log n),取最值 O(1),删除最值 O(log n) —— 比每轮扫一遍快得多。
// ① 默认:大根堆,队头是【最大】的元素 priority_queue<int> big; big.push(3); big.push(7); big.push(1); cout << big.top(); // 输出 7 // ② 小根堆:队头是【最小】的元素 —— 需要第三个模板参数 greater<int> priority_queue<int, vector<int>, greater<int> > small; small.push(3); small.push(7); small.push(1); cout << small.top(); // 输出 1 ★ 合并果子要的就是这个
1. 要写 greater<int> 而不是 less<int>。
名字反直觉:想取最小的,反而要写 greater(「更大的排后面」)。
2. 中间的 vector<int> 不能省。
priority_queue 的三个模板参数是「元素类型、底层容器、比较器」,中间那个是容器,必须写。
3. 头文件。需要 #include <queue>(放 priority_queue)
和 #include <vector>、#include <functional>(放 greater)。
4. 老编译器要写 > > 中间留空格。
写成 >> 在 C++98 里会被当成右移运算符报错。C++11 以后不写空格也行,
但留个空格是好习惯,永远不会错。
| 操作 | 作用 | 复杂度 |
|---|---|---|
pq.push(x) | 把 x 塞进去,自动排好位置 | O(log n) |
pq.top() | 看队头(最大 / 最小)的那个值,不删除 | O(1) |
pq.pop() | 删除队头那个值,不返回它 | O(log n) |
pq.size() | 里面有几个元素 | O(1) |
pq.empty() | 是不是空的 | O(1) |
top() 和 pop() 要成对用
pop() 只删除、不返回那个值(这是 C++ 和很多语言不一样的地方)。
想取出来,必须写成:
int x = pq.top(); // 先看一眼 pq.pop(); // 再删掉
维护一个只装 k 个元素的小根堆,扫一遍数据,比堆顶大的就塞进去、弹掉堆顶。 扫完之后堆顶就是第 k 大的数。这个技巧在后面的题目里会反复出现。
下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译过(零警告),
跑完了官方样例,还额外写了暴力程序随机对拍。所有「实测结果」都是真实输出。
题意:有 n 场比赛,每场知道开始时间和结束时间。参加一场就必须从头参加到尾, 而且不能同时参加两场。问最多能参加几场。
1 ≤ n ≤ 10⁶,0 ≤ aᵢ < bᵢ ≤ 10⁶。
n 最大 10⁶,这是今天最大的数据量。所以:
· 必须用 O(n log n) 的排序做法,任何 O(n²) 的暴力都会超时;
· 读入要加 ios::sync_with_stdio(false),否则 10⁶ 行数据光读入就可能超时;
· 数组要开到 10⁶ 以上(本文开 1000005)。
注意 aᵢ < bᵢ:题目保证每场比赛的开始时间严格小于结束时间,所以不会出现「零长度」的区间。
贪心策略:按结束时间升序排序,然后从头到尾扫一遍,能接上就选。
第 1 节已经用交换论证证过:结束最早的那场,一定可以放进某个最优解里。 选完它之后,剩下的问题和原问题一样,所以可以一直贪下去。
实现起来非常简单:记一个 lastEnd(上一场选中的结束时间),
遇到 s >= lastEnd 的比赛就选它,并更新 lastEnd。
因为我们已经按结束时间排好序了,扫到的每一场都是「当前剩下的比赛里结束最早的」。 如果它能接上,那选它永远不会比选任何别的更差(它占用的时间最少)。 所以看到能接上就直接拿,不用犹豫。
3 0 2 2 4 1 3
2
按结束时间排序后是 [0,2]、[1,3]、[2,4]。
选 [0,2] → lastEnd = 2;
[1,3] 的 1 < 2,接不上,跳过;
[2,4] 的 2 ≥ 2,能接上,选它 → 共 2 场。
注意这里 [0,2] 和 [2,4] 端点相接是可以的,所以判断用的是 >=。
#include <iostream>
#include <algorithm>
using namespace std;
struct Match {
int s; // 开始时间
int e; // 结束时间
};
Match a[1000005]; // ★ n 最大 10^6,数组要开够
// 按右端点(结束时间)从小到大排序
bool cmp(const Match &p, const Match &q){
return p.e < q.e; // ★ 是 e 不是 s !按开始时间排就错了
}
int main(){
ios::sync_with_stdio(false); // ★ n 最大 10^6,关掉同步加速读入
cin.tie(0);
int n;
cin >> n;
for(int i = 0; i < n; i++){
cin >> a[i].s >> a[i].e;
}
sort(a, a + n, cmp);
int ans = 0;
int lastEnd = -1; // 上一场选中的比赛的结束时间
for(int i = 0; i < n; i++){
if(a[i].s >= lastEnd){ // ★ 是 >= ,端点相接算不冲突
ans++;
lastEnd = a[i].e;
}
}
cout << ans << "\n";
return 0;
}
官方样例 3 / 0 2 / 2 4 / 1 3 → 输出 2 与样例完全一致
区间全重叠:输入 3 / 0 10 / 1 5 / 2 3 → 输出 1 正确
(三场互相重叠,只能挑一场)
最大数据:n = 10⁶,随机生成 10⁶ 个长度为 1 的区间 →
输出 631528,实测耗时 846 ms
(包含进程启动和从硬盘读入 10⁶ 行的全部时间,O(n log n) 完全跑得动)
暴力对拍:另写一个程序枚举全部 2ⁿ 种子集(n ≤ 9),检查选出来的区间是否两两不重叠, 取最大个数;随机造 400 组数据对比 → 400/400 全部一致。
[0,100] [1,2] [3,4] 会算出 1,正确答案是 2。a[i].s > lastEnd:端点相接的比赛会被漏掉。本题样例就依赖 >=,
如果写成 >,样例输出会变成 1 而不是 2。lastEnd 初值写 0:本题 aᵢ ≥ 0,用 0 也能过;但如果题目允许开始时间是负数就会出错。
用 -1 更稳。cin 可能超时。题意:N 堆金币,第 i 堆总重 mᵢ、总价值 vᵢ。背包承重 T。金币可以随意分割, 切割后单位价值不变。问最多能拿走多少价值的金币,输出两位小数。
N ≤ 100,T ≤ 1000,1 ≤ mᵢ, vᵢ ≤ 100(这是题面里直接给出的约束)。
数据非常小,怎么算都不会超时。关键不在性能,在于能不能想到「按性价比排序」。
注意输出格式:一个实数,保留两位小数。
所以要用 double(别用 int),并且 fixed << setprecision(2)。
第 2 节已经讲清楚了:按性价比(单位价值 v/m)从高到低排序,能全拿就全拿,装不下就切一部分。
实现上有两个小要点:
cmp 里每次都得重算一遍除法(慢,而且容易写错)。break。
因为后面所有堆的性价比都更低,装它们还不如装当前这堆剩下的部分。因为已经按性价比降序排好了。当前这堆切掉一部分之后背包就满了, 如果改用后面某堆去填同样的重量,价值只会更低。所以最优解到这里就结束了。
4 50 10 60 20 100 25 100 15 45
240.00
性价比:第 1 堆 60/10 = 6.0(最高)、第 2 堆 100/20 = 5.0、第 3 堆 100/25 = 4.0、第 4 堆 45/15 = 3.0。
按这个顺序拿:全拿第 1 堆(用掉 10,剩 40,价值 60);
全拿第 2 堆(用掉 20,剩 20,价值 160);
第 3 堆需要 25 但只剩 20 → 切 20/25 = 0.8 份,价值 100 × 0.8 = 80;
背包满了,结束。总价值 60 + 100 + 80 = 240.00。
#include <iostream>
#include <algorithm>
#include <iomanip>
using namespace std;
struct Heap {
double m; // 总重量
double v; // 总价值
double r; // 单位价值(性价比)= v / m
};
Heap a[105];
// 按性价比从高到低排序
bool cmp(const Heap &p, const Heap &q){
return p.r > q.r; // ★ 降序!用 > 不是 <
}
int main(){
int n;
double T; // 背包容量,会参与小数运算
cin >> n >> T;
for(int i = 0; i < n; i++){
cin >> a[i].m >> a[i].v;
a[i].r = a[i].v / a[i].m; // 提前算好性价比存起来
}
sort(a, a + n, cmp);
double ans = 0;
for(int i = 0; i < n; i++){
if(T >= a[i].m){
// 背包还装得下整堆,全拿走
ans += a[i].v;
T -= a[i].m;
} else {
// 装不下了:把这堆切下来 T 重量,后面的一律不用看了
ans += a[i].r * T;
T = 0;
break;
}
}
cout << fixed << setprecision(2) << ans << "\n";
return 0;
}
官方样例 4 50 / 10 60 / 20 100 / 25 100 / 15 45 → 输出 240.00 与样例完全一致
背包比总重量还大:输入 2 1000 / 10 60 / 20 100 → 输出 160.00 正确
(背包能装下全部,就全拿走,不会去切)
背包容量为 0:输入 2 0 / 10 60 / 20 100 → 输出 0.00 正确
(T 可以是小数、也可能装不下任何整堆,第一堆就直接走 else 分支,乘 0 得 0)
暴力对拍:另写一个程序枚举「哪些堆整堆拿走 + 哪一堆切一部分」(这是分数背包线性规划的全部候选解, 不依赖任何贪心规则),随机造 300 组数据(n ≤ 7)对比 → 300/300 全部一致。
cmp 写成升序 p.r < q.r:那会先拿性价比最低的,答案错得离谱。int 存 m 和 v:v / m 会是整数除法,性价比全变成整数(比如 60/10 得 6,但 45/15 也会得 3,
而 100/25 得 4 —— 凑巧还行,但换成 7/2 就得 3 而不是 3.5,全错)。必须用 double。ans += a[i].v:那是整堆的价值,应该按比例算 a[i].r * T。fixed << setprecision(2):输出会变成 240 而不是 240.00,格式错误。题意:有 n 堆果子,每次可以把两堆合并成新的一堆,消耗的体力等于两堆重量之和。 合并 n−1 次之后就只剩一堆。问总共消耗的体力最小是多少。
1 ≤ n ≤ 10⁴(30% 的数据 n ≤ 10³,50% 的数据 n ≤ 5×10³), 1 ≤ aᵢ ≤ 2×10⁴。题面写明「输入数据保证这个值小于 2³¹」。
也就是说,出题人认为答案用 int 装得下。不过计划里提醒了「P1090 需要 long long」,
我实际测了一下:随机生成 n = 10⁴ 的合法数据,答案是 1308616454(小于 2³¹,int 确实够);
但如果把 10⁴ 堆重量全设成上限 20000,答案会跑到 2672320000 —— 已经超过 2³¹−1 了。
所以题面那句「保证小于 2³¹」意味着官方数据里不会有这么极端的情况。
但 long long 不花任何额外代价(不慢也不多占多少内存),用它更保险。
贪心策略:每次合并当前最小的两堆。
为什么?想成一棵树:把合并过程画出来,最后合成的那一大堆放最上面, 越早被合并的果子堆在树里位置越深。
一堆果子的重量,每被合并一次就累加一次体力。所以在最底层、最早参与合并的堆,被累加的次数最多; 最后才被合进来的堆,被累加的次数最少。
既然「被累加的次数」是固定的(由位置决定),那就应该让重量小的堆被累加更多次、重量大的堆被累加更少次—— 这样总和最小。
而「每次取最小的两堆合并」,恰好就是把最小的堆一路推到最深的位置去。这就是哈夫曼(Huffman)编码的思路。
直观的比方:搬东西的时候,重的东西少搬两趟、轻的东西多搬两趟,总力气最省。反过来就亏了。
实现:用小根堆。每次弹出最小的两个,相加累加到答案里,再把新堆塞回堆里。重复到只剩一堆。
3 1 2 9
15
先合并 1 和 2 → 得到 3,消耗 3;再合并 3 和 9 → 得到 12,消耗 12。总共 3 + 12 = 15。
如果先合并 2 和 9(消耗 11),再和 1 合并(消耗 12),总共 23,明显更差 —— 这就是「先拿小的」的价值。
#include <iostream>
#include <queue>
#include <vector>
#include <functional>
using namespace std;
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
// ★ 小根堆:永远能从堆顶取到当前最小的那堆果子
// 三个参数分别是:元素类型、底层容器、比较器
priority_queue<int, vector<int>, greater<int> > pq;
for(int i = 0; i < n; i++){
int x;
cin >> x;
pq.push(x); // 自己会排好,不用管
}
long long ans = 0; // ★ 用 long long 保险
while(pq.size() > 1){ // 合并到只剩一堆为止
int x = pq.top(); pq.pop(); // 第一小
int y = pq.top(); pq.pop(); // 第二小
ans += x + y; // 合并的体力 = 两堆重量之和
pq.push(x + y); // ★ 合并成的新堆放回堆里,参与后面的合并
}
cout << ans << "\n";
return 0;
}
while(pq.size() > 1)
因为每合并一次,堆里的数量就减少 1(弹出两个、放回一个)。 从 n 个减到 1 个,正好需要 n−1 次合并 —— 这就是题目说的「经过 n−1 次合并」。
另外注意 n = 1 的情况:一开始就只有一堆,一次都不用合并,循环直接不执行,答案是 0。
官方样例 3 / 1 2 9 → 输出 15 与样例完全一致
边界 n = 1:输入 1 / 5 → 输出 0 正确
(只有一堆,不需要任何合并)
最大数据的合法情况:n = 10⁴、重量在 1~2×10⁴ 之间随机 →
输出 1308616454,实测耗时 41 ms
(这个数小于 2³¹−1 = 2147483647,和题面「保证小于 2³¹」吻合)
故意超界的压力测试:n = 10⁴、所有重量都取上限 20000 → 输出 2672320000
(超过 int 上限,long long 正确输出,耗时 27 ms)
暴力对拍:另写一个程序递归枚举所有「先合并哪两堆」的顺序(n ≤ 6)取最小消耗, 随机造 200 组数据对比 → 200/200 全部一致。
priority_queue<int, vector<int>, greater<int> >。
写成 priority_queue<int> 会变成大根堆(每次取最大的两堆),答案会大很多。pq.push(x + y) 不能漏。pop() 之后还想用那个值:pop() 不返回值,必须先 top() 存下来再 pop()。while(!pq.empty()):只剩一堆时还会试图弹两个,程序直接崩。int 存答案:极端数据下会超(我实测到 2672320000),用 long long 零成本保平安。题意:需要采购总量 n 的牛奶,有 m 个奶农,第 i 个奶农的单价是 pᵢ、每天最多能卖 aᵢ。 可以从某个奶农那里买不超过他产量的任意整数数量。问买够 n 最少花多少钱。
0 ≤ n, aᵢ ≤ 2×10⁶,0 ≤ m ≤ 5000,0 ≤ pᵢ ≤ 1000。
注意 aᵢ 和 m 都可能为 0(所以是「0 ≤」不是「1 ≤」),代码要能处理空输入。
算一下答案有多大:最贵情况是 n = 2×10⁶、单价 pᵢ = 1000,总花费 2×10⁶ × 1000 = 2×10⁹。
已经很接近 int 上限 2.147×10⁹ 了 —— 必须用 long long,
用 int 就是在悬崖边上写代码。
题目保证所有奶农的总产量不少于需求量,所以一定有解,不用考虑「买不够」的情况。
和 P2240 是同一个套路,只是牛奶本身就是连续的(可以买任意整数升),所以更简单:
能买多少买多少(min(还需要多少, 这家的产量)),买够了就停。
为什么对:这是分数背包的整数版。牛奶可以拆分,所以「先买便宜的」永远不吃亏 —— 只要还有更便宜的没买完,就绝不应该去买贵的(用第 2 节的交换论证,把贵的换成便宜的,花费只减不增)。
实现只要一个循环:
for 每个奶农(已按单价升序):
take = min(还需要的量, 这家产量)
cost += take * 单价
还需要的量 -= take
100 5 5 20 9 40 3 10 8 80 6 30
630
按单价排序:(3, 10)、(5, 20)、(6, 30)、(8, 80)、(9, 40)。
买 10 升 @3 = 30(还差 90)→ 买 20 升 @5 = 100(还差 70)→ 买 30 升 @6 = 180(还差 40)→
买 40 升 @8 = 320(够了,停)。
总计 30 + 100 + 180 + 320 = 630。注意最后一个奶农(单价 9)一元都没买,因为前面就够了。
#include <iostream>
#include <algorithm>
using namespace std;
struct Farmer {
long long p; // 单价
long long a; // 最多能卖的牛奶量
};
Farmer f[5005];
// 按单价从小到大排序
bool cmp(const Farmer &x, const Farmer &y){
return x.p < y.p; // ★ 升序:先买便宜的
}
int main(){
ios::sync_with_stdio(false);
cin.tie(0);
long long n; // 需要的总奶量(最大 2×10^6)
int m; // 奶农个数(最大 5000)
cin >> n >> m;
for(int i = 0; i < m; i++){
cin >> f[i].p >> f[i].a;
}
sort(f, f + m, cmp);
long long cost = 0; // ★ 最贵能到 2×10^9,必须 long long
for(int i = 0; i < m && n > 0; i++){ // ★ n > 0:买够了就提前退出
long long take = min(n, f[i].a); // 这家人最多能给这么多
cost += take * f[i].p;
n -= take;
}
cout << cost << "\n";
return 0;
}
n 的写法好不好?
循环里 n -= take 把「还需要多少」直接存在 n 里了。
这种写法很简洁,而且循环条件 n > 0 会自动在买够时停止,
顺带解决了「m = 0」和「n = 0」两个边界(循环一次都不进,直接输出 0)。
缺点是可读性稍差一点。如果你担心混淆,可以另开一个变量 need = n; 来用 —— 两种写法都对。
官方样例 100 5 / 5 20 / 9 40 / 3 10 / 8 80 / 6 30 → 输出 630 与样例完全一致
最大花费(贴着 int 上限):输入 2000000 1 / 1000 2000000 →
输出 2000000000 正确
(20 亿,int 上限是 2147483647,只剩 1.4 亿的余量 —— 再大一点就爆了)
需求为 0:输入 0 2 / 5 10 / 3 4 → 输出 0 正确
(循环条件 n > 0 直接挡住)
最大数据量:n = 2×10⁶、m = 5000 → 实测耗时 31 ms (O(m log m) 排序 + O(m) 扫描,完全无压力)
暴力对拍:另写一个程序枚举每个奶农买 0~aᵢ 升的所有组合(m ≤ 4,aᵢ ≤ 7) 求最小花费,随机造 300 组数据对比 → 300/300 全部一致 (前 57 组不一致是我造的数据违反了「总产量 ≥ 需求」这个题面保证,剔除掉不可行的用例后全对)
int 存总花费:能到 2×10⁹,紧贴 int 上限,非常危险。take 用 int:min(n, f[i].a) 如果 n 是 long long 而 f[i].a 是 int,
会有一个隐式转换;两边都声明成 long long 最省心。n > 0 提前退出:虽然结果一样(min(0, a) = 0),但会白跑循环;
而且如果 n 变成负数,跑下去就可能算错。100 5,别读成 5 100。| # | 错误写法 | 后果 | 正确写法 |
|---|---|---|---|
| 1 | P1803 按开始时间排序 | 经典的区间贪心错误。反例 [0,100] [1,2] [3,4]:错答 1,正解 2 |
按结束时间(右端点)升序 |
| 2 | P1803 判断写成 s > lastEnd |
端点相接的比赛被漏掉。本题官方样例就会从 2 变成 1 | s >= lastEnd(先看清题目定义) |
| 3 | P2240 按总价值或总重量排序 | 都错。要按单位价值(v/m)降序 | a[i].r = a[i].v / a[i].m; return p.r > q.r; |
| 4 | 把 0/1 背包也按性价比贪心 | 物品不能切时会错。反例:T=10,(6,12)(5,9)(5,9) → 贪心给 12,正解是 18 | 0/1 背包必须用 DP(Day 26) |
| 5 | priority_queue<int> pq; 当小根堆用 |
这是大根堆,每次取出最大的一堆,合并果子答案会大很多 | priority_queue<int, vector<int>, greater<int> > pq; |
| 6 | P1090 忘了把新堆塞回堆里 | 合并出来的新堆直接丢掉,后续合并全是错的 | pq.push(x + y); |
| 7 | P1090 循环写 while(!pq.empty()) |
只剩一堆时还要弹两个,程序崩溃 | while(pq.size() > 1) |
| 8 | P1208 读入顺序反了 | 把「需要量」和「奶农个数」读反,后面全乱 | 第一行 n m,每行 p a |
| 9 | P1208 / P2240 用 int 存花费 |
P1208 最贵能到 2×10⁹,紧贴 int 上限 2147483647,再大一点就爆 |
long long cost |
| 10 | P1803 数组只开 100000 | n 能到 10⁶,越界 → RE 或玄学 WA | 开到 1000005 |
| 11 | 大 n 不用快读 | 10⁶ 行输入用默认 cin 可能超时 |
ios::sync_with_stdio(false); cin.tie(0); |
| 12 | 贪心策略不验证就提交 | 今天四道题里有三道都有「换个关键字就错」的陷阱 | 写暴力对拍,随机几百组数据互相对答案 |
答:因为「开始得早」不代表「结束得早」。一个从 0 开到 100 的活动如果开始最早,先选中它就会挡住后面所有活动。
反例:[0,100]、[1,2]、[3,4]。
按开始时间排 → 先选 [0,100] → 后面全冲突 → 1 个;
按结束时间排 → 选 [1,2]、[3,4] → 2 个。
一句话记:你要的不是「占得早」,而是「走得早」。
答:性价比 = 总价值 ÷ 总重量(单位重量的价值)。
举例:A: 重量 10, 价值 60(性价比 6);B: 重量 25, 价值 100(性价比 4)。
如果按总价值排,会先拿 B;但 A 才是更划算的那个 —— 同样的背包空间,装 A 能拿到更多价值。
答:因为合并会产生新的一堆,它的大小不一定,会插到已排序序列的中间去。
比如 3 4 5 100:先合并 3 和 4 得到 7,这个 7 应该排在 5 和 100 之间 ——
双指针只会从两端往中间走,没法把新堆插到中间。
所以要用优先队列(堆),它能随时接纳新元素并自动维护最小值。
答:背包 T = 10,物品 A(6, 12)、B(5, 9)、C(5, 9)。
按性价比贪心:先拿 A(性价比 2.0 最高),背包只剩 4 —— B 和 C 都是 5,塞不进去 → 总价值 12。
正确答案:拿 B + C,重量正好 10,价值 18。
原因:物品不能切开时,「拿 A」会一次占掉很大一块空间,这个决定和后面的选择互相干扰了。 一旦选择互相干扰,贪心就不安全,要靠 DP。
priority_queue<int, vector<int>, greater<int> > pq; // ↑ ↑ ↑ // 元素类型 底层容器 比较器 // greater = 小的排前面 = 小根堆
答:元素类型 int、底层容器 vector<int>、比较器 greater<int>。
常考点:名字是反的 —— 想要最小的在队头,写的是 greater(「更大的排后面」)。
想取最大的反而写 less<int>(或者干脆什么都不写,默认就是大根堆)。
还要注意:老编译器要求两个 > 之间留空格写成 > >,否则会被当成右移运算符。
答:都是「资源可以按比例拿走,按单价 / 性价比排序后从优到劣拿」的贪心, 本质上 P1208 就是分数背包的整数版(牛奶可以按升买,等于可以切分)。
区别只在于:P2240 可能只拿到一堆的一部分(要按比例算价值,用 double);
P1208 买的是整数升,直接 take * price 就行。
【区间贪心(线段覆盖)】
struct Node { int s, e; };
bool cmp(const Node&a, const Node&b){ return a.e < b.e; } // ★ 右端点升序
sort(a, a + n, cmp);
int ans = 0, lastEnd = -1;
for(i = 0; i < n; i++)
if(a[i].s >= lastEnd){ ans++; lastEnd = a[i].e; }
// 端点相接算不算重叠,看题目!P1803 是 >=
【分数背包(物品可切)】
// r[i] = v[i] / m[i] 按 r 降序
for 每堆:
if(T >= m) { ans += v; T -= m; }
else { ans += r * T; break; } // ★ 切完就 break
【0/1 背包(物品不可切)】
❌ 不能按性价比贪心!
反例:T=10, A(6,12) B(5,9) C(5,9) → 贪心 12,正解 18
→ 必须用 DP(Day 26)
【优先队列小根堆】
#include <queue> <vector> <functional>
priority_queue<int, vector<int>, greater<int> > pq;
pq.push(x); // 塞进去,O(log n)
int x = pq.top(); // 看最小值,O(1)
pq.pop(); // 删掉(不返回值!)
pq.size() // 有几个
// 默认 priority_queue<int> 是【大根堆】!
【合并果子(Huffman)】
while(pq.size() > 1){
int x = pq.top(); pq.pop();
int y = pq.top(); pq.pop();
ans += x + y; // 用 long long
pq.push(x + y); // ★ 新堆要放回去
}
【按单价买(P1208 型)】
sort by price 升序
for(i = 0; i < m && n > 0; i++){
long long take = min(n, f[i].a);
cost += take * f[i].p; // cost 用 long long
n -= take;
}
【今天的时间复杂度目标】
P1803 n=10^6 → 必须 O(n log n),要快读
P1090 n=10^4 → 堆,O(n log n)
P1208 m=5000 → 排序,O(m log m)
P2240 N ≤ 100 → 随便写
前缀和与差分:把 O(n) 查询变成 O(1)
连续两天都在学贪心,明天换换口味 —— 学两个「降复杂度神器」:
明天的 P2367 语文成绩要处理 n ≤ 5×10⁶ 的数据,P3397 地毯是二维差分的经典题 —— 这两个技巧在后面的 DP 里也会反复用到,务必练熟。