Skip to content

第 48 章 贪心算法——局部最优的智慧


🏗️ 前情回顾:第 45 和 46 章你学会了二分——在有序空间中高效定位。二分依赖"单调性"。但现实生活中,很多问题没有全局的单调结构,却有另一种朴素智慧:每走一步,都选当下看起来最好的那个选项。这就是贪心算法,它简单、快速,但也容易被"眼前利益"欺骗。


🎯 本章目标

学完这一章,你能:

  • 理解贪心算法的核心思想:每一步选局部最优
  • 区分贪心和 DP 的适用场景
  • 掌握经典贪心:找零钱、最优装载、活动选择、排队接水
  • 学会贪心的证明思路(反证法、归纳法)
  • 识别贪心的"陷阱"——局部最优 ≠ 全局最优
  • 知道哪些经典问题贪心有效、哪些反例说明贪心失效

📖 故事引入

你去吃自助餐,肚子容量有限。桌上摆着各种食物:龙虾、牛排、蔬菜沙拉、小蛋糕……你该怎么吃才能"最划算"?

如果每样食物的"满足感"和"占用肚子空间"不同,这就成了著名的背包问题。直觉告诉你:先吃单价贵的(比如龙虾)——这就是贪心。

但等一下:如果龙虾虽然贵,但非常占肚子,吃了它就没空间吃别的了……或许吃 3 份牛排比 1 份龙虾更划算?

这就是贪心的魅力与困境:每步选最好的,不一定最终最好

再看另一个场景:你面前有 10 个任务,每个有开始和结束时间。你只有一个 CPU,同一时间只能做一个任务。怎么安排能让完成的任务数最多?

直觉:每次都选最早结束的任务,这样就能腾出时间做更多任务。这个贪心恰好是正确的!

结论:贪心不是万能钥匙,但在它适用的场景里,没有比它更简单高效的武器。


🧱 知识讲解

48.1 贪心思想:每步选最好的

贪心算法的框架极其简单:

while (还有选择) {
    在所有可选方案中,选一个"当前最优"的
    做出这个选择
    更新可选集合
}

没有回溯,没有记忆化,一条路走到黑。贪心做出的选择从来不会撤销。

48.2 贪心 vs DP

贪心动态规划(DP)
决策每步选局部最优,不回头考虑所有可能,用之前结果推现在
速度很快(通常 O(n log n))较慢(O(n²) 或更高)
正确性必须证明,不一定对只要状态和转移对,就一定对
空间O(1) 或 O(n)通常 O(n) 或 O(n²)
问题特征贪心选择性质 + 最优子结构最优子结构 + 重叠子问题

🧠 贪心是 DP 的特例——当 DP 的每一步最优选择恰好依赖于贪心策略时,DP 退化为贪心。

48.3 经典贪心一:找零钱

问题:用最少的硬币凑出金额 M。硬币面值为 {1, 5, 10, 20, 50, 100}。

贪心策略:每次选面值最大且不超过剩余金额的硬币。

cpp
int coins[] = {100, 50, 20, 10, 5, 1};
int m, cnt = 0;
cin >> m;
for (int c : coins) {
    cnt += m / c;      // 尽量用大面值
    m %= c;            // 剩下的金额
}
cout << cnt;

这个贪心正确吗? 对于 {1,5,10,20,50,100} 这套面值,是正确的(人民币面值就是这么设计的)。但如果面值变成 {1, 3, 4},凑 6:贪心会选 4+1+1(3 枚),但最优是 3+3(2 枚)。贪心失效了!

⚠️ 找零钱问题的贪心是否有效,取决于硬币面值的性质。一般情况需要用 DP。

48.4 经典贪心二:最优装载

问题:有 n 个物品,重量 w₁...wₙ。一艘船载重为 C。物品不可拆分。问最多能装几件?

贪心策略:按重量从小到大排序,先装轻的,装到装不下为止。

cpp
sort(w, w + n);     // 从小到大
int cnt = 0, sum = 0;
for (int i = 0; i < n; i++) {
    if (sum + w[i] <= C) {
        sum += w[i];
        cnt++;
    } else break;
}
cout << cnt;

这个贪心正确吗? 是的。证明很简单:如果有最优解没选某个最轻的物品而选了一个更重的,把它换成更轻的,重量不增加,件数不减。

48.5 经典贪心三:活动选择(区间调度)

问题:n 个活动,第 i 个开始于 sᵢ,结束于 fᵢ。同一时间只能做一个活动。问最多选几个?

贪心策略:按结束时间从早到晚排序,每次选最早结束且和已选不冲突的。

cpp
struct Act { int s, f; };
Act a[1005];

bool cmp(Act x, Act y) { return x.f < y.f; }

sort(a, a + n, cmp);
int cnt = 1;
int lastEnd = a[0].f;
for (int i = 1; i < n; i++) {
    if (a[i].s >= lastEnd) {   // 不冲突
        cnt++;
        lastEnd = a[i].f;
    }
}
cout << cnt;

🔍 为什么按结束时间而不是开始时间?因为早结束的活动给后面留出更多空间。选"最不贪"的那个(最早结束),反而是最聪明的。

正确性证明(反证法):假设最优解的第一个活动和贪心选的(最早结束的活动)不同——那把最优解的第一个换成最早结束的,不会冲突,且活动数不变或更多。

48.6 经典贪心四:排队接水

问题:n 个人排队接水,第 i 个人需要 tᵢ 时间。所有人的等待时间总和 = 每个人开始接水之前等待的时间之和。求最小的总等待时间。

贪心策略:接水快的人排前面(按 tᵢ 升序排列)。

cpp
sort(t, t + n);        // 接水时间从小到大
long long wait = 0, total = 0;
for (int i = 0; i < n; i++) {
    total += wait;     // 当前这个人等的时间
    wait += t[i];      // 下一个人要多等 t[i]
}
cout << total;

这为什么对?交换法:如果存在 i<j 但 tᵢ > tⱼ,交换他们能减少总等待时间。

48.7 贪心的陷阱:局部最优 ≠ 全局最优

经典的反例帮你建立对贪心的"警惕感":

反例 1 — 硬币找零(面值 {1, 3, 4},凑 6): 贪心:4+1+1=3 枚,最优:3+3=2 枚。

反例 2 — 0-1 背包(贪心价值密度): 背包容量 10。物品 A:重量 6,价值 7(密度 1.17);物品 B:重量 5,价值 5(密度 1.0);物品 C:重量 5,价值 5(密度 1.0)。 贪心先拿 A(密度最高),剩 4 容量拿不了 B 和 C,总价值 7。 最优:拿 B 和 C,总价值 10。

反例 3 — 最大子段和: 不能贪心——从正数开始捡不一定对,因为后面可能有大负数。需要 DP 或分治。

🧠 使用贪心前,问自己三句话:

  1. 我的贪心策略是什么?
  2. 有没有反例能推翻它?
  3. 能不能用反证法/交换法证明它正确?

✋ 动手试试

试试 1:实现活动选择问题。输入 n=5,活动为 (1,4), (3,5), (0,6), (5,7), (3,8), (5,9), (6,10), (8,11), (8,12), (2,13), (12,14)。用贪心选出的最多活动数是多少?手工画出时间轴验证。

试试 2:在找零钱问题中,改变硬币面值为 {1, 3, 4},测试金额 6。对比贪心结果和手动找到的最优解。修改面值为 {1, 5, 11},测试金额 15。贪心对吗?

试试 3:实现排队接水,用 n=5, t={3, 1, 4, 2, 5}。手工计算如果按原序和排序后的总等待时间,对比结果。


⚠️ 容易犯的错

错 1:不看问题性质直接贪心

❌ 遇到最优化问题就贪心 → 可能答案是错的

✅ 先找反例,能推翻就说明贪心不适用

错 2:排序的 key 选错了

❌ 活动选择按开始时间排序 → 可能选出一个很长的活动堵住后面的

✅ 活动选择按结束时间排序

错 3:贪心策略正确但实现有 bug

if (a[i].s > lastEnd) 写成了 >=,漏掉了刚好接上的活动

✅ 看清题意:开始时间等于上一个结束时间算不算冲突

错 4:没考虑数据范围

❌ 排队接水用 int 存总等待时间,n=100000 时溢出

✅ 大规模数据用 long long


📝 练习

基础题

1. 选择题

(1)贪心算法的核心特征是?
A. 每步选局部最优   B. 回溯所有可能   C. 用数组记忆   D. 二分搜索

(2)活动选择问题中,贪心按什么排序?
A. 开始时间   B. 时长   C. 结束时间   D. 重要性

(3)以下哪个问题贪心一定能得到最优解?
A. 0-1 背包   B. 硬币找零(任意面值)  C. 最优装载   D. 旅行商问题

2. 填空题

(1)贪心算法____回头、____之前的选择。

(2)贪心需要两个性质:____选择性质和____子结构。

(3)排队接水按____排序,总等待时间最小。

提高题

3. 编程题 — 合并果子(贪心思想)

有 n 堆果子,每堆有 aᵢ 个。每次合并任意两堆,代价为两堆果子数之和。问合并成一堆的最小总代价。

提示:每次选最小的两堆合并(需要优先队列/堆,但这里只要求你理解贪心策略)。

4. 编程题 — 区间覆盖

数轴上有 n 个闭区间 [lᵢ, rᵢ]。选择最少的区间,使得它们完全覆盖 [0, L]。

贪心策略:在覆盖了 [0, pos] 的前提下,每次选左端点 ≤ pos 且右端点最大的区间。

挑战题

5. 编程题 — 国王游戏(贪心+高精度)

n 个大臣,每人左手和右手各有一个数 aᵢ 和 bᵢ。国王也在最前面。每个大臣获得的奖赏 = 前面所有人左手数之积 / 自己右手的数。求奖赏最多的大臣最少能获得多少。

提示:按 aᵢ × bᵢ 从小到大排序(需要证明!)。数据可能很大,需要高精度。


🧠 本章小结

贪心 = 每步选局部最优

框架:
  while (还有选择) { 选当前最优; 更新候选集; }

经典问题:
  找零钱    → 先选大面值(面值有要求!)
  最优装载  → 先装轻的
  活动选择  → 按结束时间排序
  排队接水  → 快的排前面

证明方法:
  反证法:假设贪心不是最优,推导矛盾
  交换法:最优解中交换两个元素不会变差
  归纳法:前 k 步贪心正确 → 第 k+1 步也正确

⚠ 陷阱:局部最优 ≠ 全局最优
  · 0-1 背包用价值密度贪心是错的
  · 找零钱在非标准面值下贪心可能错
  · 用贪心前先找反例!

📝 配套练习

共8题。贪心五种模式:按时间/按结束/性价比/相邻约束/分组配对——每种一题。。★核心(课堂必做) ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1223https://hydro.ac/p/luogu-P1223排队接水、按时间贪心
◆ 拓展luogu-P1803https://hydro.ac/p/luogu-P1803活动选择、按结束时间贪心
◆ 拓展luogu-P1090https://hydro.ac/p/luogu-P1090合并果子、priority_queue贪心
◆ 拓展luogu-P3817https://hydro.ac/p/luogu-P3817贪心、相邻约束
◆ 拓展luogu-P2240https://hydro.ac/p/luogu-P2240部分背包、性价比贪心
◆ 拓展luogu-P1478https://hydro.ac/p/luogu-P1478贪心、陶陶摘苹果升级
◆ 拓展luogu-P1208https://hydro.ac/p/luogu-P1208贪心、牛奶采购
◆ 拓展luogu-P1094https://hydro.ac/p/luogu-P1094贪心、纪念品分组

💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 7 道◆拓展题,覆盖不同变式和细节。


配套练习

共8题。贪心五种模式:按时间/按结束/性价比/相邻约束/分组配对——每种一题。★核心(课堂必做) · ◆拓展(课后练习)

级别题号链接覆盖知识点
★ 核心luogu-P1223https://hydro.ac/p/luogu-P1223排队接水、按时间贪心
◆ 拓展luogu-P1803https://hydro.ac/p/luogu-P1803活动选择、按结束时间贪心
◆ 拓展luogu-P1090https://hydro.ac/p/luogu-P1090合并果子、priority_queue贪心
◆ 拓展luogu-P3817https://hydro.ac/p/luogu-P3817贪心、相邻约束
◆ 拓展luogu-P2240https://hydro.ac/p/luogu-P2240部分背包、性价比贪心
◆ 拓展luogu-P1478https://hydro.ac/p/luogu-P1478贪心、陶陶摘苹果升级
◆ 拓展luogu-P1208https://hydro.ac/p/luogu-P1208贪心、牛奶采购
◆ 拓展luogu-P1094https://hydro.ac/p/luogu-P1094贪心、纪念品分组

练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 7 道◆拓展题,覆盖不同变式和细节。

自查清单

  • [ ] 我理解贪心"每步选最优、永不回头"的特点
  • [ ] 我能区分贪心和 DP 的适用场景
  • [ ] 我能实现活动选择、排队接水、最优装载
  • [ ] 我知道硬币找零的贪心在什么条件下有效
  • [ ] 我能用反证法/交换法简单证明贪心正确性
  • [ ] 我能举出贪心失效的反例

🚀 下章预告:贪心是"一条路走到黑",而分治恰恰相反——它把大问题切成好几个小问题,各自解决后再拼起来。归并排序你已经学过了,但分治的视野远不止排序——最大子段和、最近点对……第 49 章,我们来一场"化整为零"的战术演练!🧩