3.1.贪心算法导论——为什么"局部最优"能推出"全局最优"?
系列:贪心算法 | 第 1 篇,共 8 篇
难度:⭐⭐☆☆☆ 入门-中等
标签:贪心正确性证明交换论证反证法算法思想
上一篇:…
下一篇:区间贪心——活动选择、区间调度与区间合并三类经典问题
前言
有一类题,如果你想到了正确的思路,代码极短,运行极快;但如果没想到,怎么想都觉得要 DP。
这类题叫贪心题。
贪心的核心思想是:
每一步都做当前看起来最优的选择,不回头,不后悔。
听上去很冒险——为什么"眼前最优"能保证"最终最优"?
这篇要讲清楚三件事:
- 贪心算法是什么,它解决什么类型的问题
- 贪心正确性怎么证明(两种主流方法)
- 贪心为什么会失效,怎么识别"伪贪心"
一、算法思想:每步做局部最优选择
贪心算法的核心动作就一句话:
在每个决策点,选当前最优的那个选项,直到问题结束。
最典型的日常例子:
- 找零钱时,先用面值最大的硬币,凑不够再用小的
这就是贪心。它的特点是:
- 不枚举所有可能
- 不依赖"之前做了什么"
- 每步只看眼前,快速决策
贪心能用的两个前提
贪心不是万能的。它能给出正确答案,需要问题满足:
1. 贪心选择性质
全局最优解可以通过每步的局部最优决策来构造。
也就是说,当前的"最优选择"不会让你在未来付出代价。
2. 最优子结构
问题的最优解包含子问题的最优解。
这和动态规划的要求一样。区别在于:
- DP 需要枚举子问题
- 贪心直接从子问题的最优选择出发
贪心 vs 动态规划
两者都要求"最优子结构",但选择策略不同:
| 对比项 | 贪心 | 动态规划 |
|---|---|---|
| 决策方式 | 每步直接选当前最优 | 枚举所有子问题,取全局最优 |
| 是否回退 | 不回退 | 依靠状态记忆所有可能 |
| 速度 | 更快 | 通常更慢 |
| 适用范围 | 需证明贪心正确 | 更通用 |
如果一个问题贪心能解,就不用 DP;但贪心的正确性需要证明。
二、完整图解:一个能用贪心的例子 vs 一个不能用的
能用贪心:硬币找零(特定面值)
面值为 [1, 5, 10, 25],目标凑出 41 分。
选 25(剩 16)→ 选 10(剩 6)→ 选 5(剩 1)→ 选 1(结束)
共 4 枚
每步都选当前能用的最大面值,最终用了最少的硬币。✅
不能用贪心:硬币找零(特殊面值)
面值为 [1, 3, 4],目标凑出 6。
贪心做法:
选 4(剩 2)→ 选 1(剩 1)→ 选 1(结束)
共 3 枚
最优做法:
选 3(剩 3)→ 选 3(结束)
共 2 枚 ✅
贪心给出了错误答案。原因:选了 4 之后,后续的选择空间被限制了,整体反而更差。
📌 核心结论:贪心失效的根本原因是——局部最优不等于全局最优。
三、正确性证明:两种主流方法
贪心最难的不是代码,而是证明它是对的。
竞赛中最常用的两种方法:
方法一:交换论证(Exchange Argument)
思路:
假设存在一个最优解,它的某一步选择和贪心不同。证明把那一步改成贪心的选择,结果不会变差(或更优)。
例子:活动选择问题
有若干活动,每个活动有开始时间和结束时间,只能同时参加一个。目标:最多参加多少个活动?
贪心策略:每次选结束时间最早的活动。
交换论证:
- 假设最优解不选"结束最早的活动 A",而选了另一个活动 B(结束更晚)
- 把 B 替换成 A:A 结束更早,不会影响后续活动的选择空间
- 替换后活动数量不减少
- 因此贪心选择不劣于任何最优解 ✅
方法二:反证法(Contradiction)
思路:
假设贪心给出的不是最优解,然后推出矛盾。
例子:任务截止时间问题
有 n 个任务,每个任务有截止时间 d 和罚款 w,每次只能做一个任务(耗时 1)。目标:最小化超期罚款总和。
贪心策略:按截止时间排序,尽量在截止前完成,否则调换顺序推迟。
反证:若存在更优解,必然有某两个任务的顺序和贪心相反——但可以证明调整后结果不变或更优,矛盾。
四、代码结构:贪心的通用写法
贪心算法没有固定模板,但结构上基本都是:
1. 对问题按某个"贪心标准"排序
2. 从头到尾扫描,按贪心策略做决策
3. 维护当前状态(不需要回溯)
五、复杂度分析
大多数贪心算法的时间复杂度来自两部分:
| 步骤 | 时间复杂度 | 说明 |
|---|---|---|
| 排序(如果需要) | O(n log n) | 最常见的前置步骤 |
| 扫描决策 | O(n) * k | 线性扫描一遍,做每一个选择的时间复杂度是k,k有可能是常数,也有可能是O(m)、O(logm)等,取决于选择的具体情况 |
| 整体 | O(n log n) + O(n) * k | 选择简单则瓶颈在排序,选择复杂则瓶颈在选择 |
贪心算法几乎不会有空间上的额外开销,通常是 O(1) 或 O(n)。
六、贪心常见题型分类
| 题型 | 贪心策略 | 典型题目 |
|---|---|---|
| 区间调度 | 按结束时间排序 | 活动选择、会议室安排 |
| 任务调度 | 按截止/权重排序 | 带截止任务、带权完成时间 |
| 分配问题 | 排序后双端配对 | 糖果分配、救生艇 |
| 跳跃问题 | 维护最远可达位置 | 跳跃游戏 |
| 字典序构造 | 单调栈贪心 | 移除k个数字、最小字符串 |
| 数学贪心 | 利用数学性质 | 最大乘积、分组 |
七、OJ 例题讲解
例题 1:LeetCode 455 — 分发饼干(贪心入门)
题目来源:LeetCode,题号 455 难度:⭐☆☆☆☆ 简单
题目链接:https://leetcode.cn/problems/assign-cookies/
题目描述:
每个孩子有一个胃口值
g[i],每块饼干有一个尺寸s[j]。当饼干尺寸s[j] >= g[i]时,孩子i满足。每个孩子最多分一块饼干,求最多满足多少个孩子。
输入样例:
g = [1,2,3], s = [1,1]
输出样例:
1
解题思路:
贪心策略:优先用最小能满足需求的饼干去满足胃口最小的孩子。
- 将
g和s分别升序排序 - 双指针扫描:饼干能满足当前孩子就分配,否则换下一块更大的饼干
- 统计满足孩子数
正确性:用大饼干满足小胃口的孩子是浪费,最小够用的饼干留给最小需求的孩子,后续资源更充裕。
Python 解法:
from typing import List
class Solution:
def findContentChildren(self, g: List[int], s: List[int]) -> int:
g.sort()
s.sort()
i = j = 0
while i < len(g) and j < len(s):
if s[j] >= g[i]:
i += 1 # 孩子被满足,看下一个孩子
j += 1 # 不管满没满足,饼干都用掉了
return i
C++ 解法:
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int i = 0, j = 0;
while (i < (int)g.size() && j < (int)s.size()) {
if (s[j] >= g[i]) i++;
j++;
}
return i;
}
};
代码讲解:
j始终向前推进,表示当前考察的饼干i只在饼干满足孩子时前进,表示已满足的孩子数- 最终
i就是答案
例题 2:LeetCode 860 — 柠檬水找零(状态模拟型贪心)
题目来源:LeetCode,题号 860 难度:⭐⭐☆☆☆ 简单
题目链接:https://leetcode.cn/problems/lemonade-change/
题目描述:
柠檬水每杯 5 美元。顾客按序付款,每次付 5、10 或 20 美元。需要找零时,判断是否始终能正确找零。
输入样例:
bills = [5,5,10,10,20]
输出样例:
false
解题思路:
模拟找零过程,关键在于:
- 收到 10 元:必须找 1 张 5 元
- 收到 20 元:优先找 1 张 10 元 + 1 张 5 元;若没有 10 元,则找 3 张 5 元
贪心:收到 20 元时优先消耗 10 元(因为 5 元比 10 元更万能),这是核心决策点。
Python 解法:
from typing import List
class Solution:
def lemonadeChange(self, bills: List[int]) -> bool:
five = ten = 0
for bill in bills:
if bill == 5:
five += 1
elif bill == 10:
if five == 0: return False
five -= 1
ten += 1
else: # bill == 20
if ten > 0 and five > 0:
ten -= 1; five -= 1
elif five >= 3:
five -= 3
else:
return False
return True
C++ 解法:
class Solution {
public:
bool lemonadeChange(vector<int>& bills) {
int five = 0, ten = 0;
for (int bill : bills) {
if (bill == 5) {
five++;
} else if (bill == 10) {
if (!five) return false;
five--; ten++;
} else {
if (ten && five) { ten--; five--; }
else if (five >= 3) { five -= 3; }
else return false;
}
}
return true;
}
};
代码讲解:
- 维护
five和ten两个计数器即可 - 收到 20 时先用
10+5找零(优先消耗 10 元,保留 5 元给更多场景) - 任意时刻找不开直接返回
false
例题 3:HDU 2037 — 今年暑假不 AC(活动选择贪心入门)
题目来源:HDU OJ,题号 2037 难度:⭐⭐☆☆☆ 简单
题目链接:http://acm.hdu.edu.cn/showproblem.php?pid=2037
题目描述:
暑假期间有
n个节目,每个节目有开始时间和结束时间。每次只能看一个节目,求最多能看多少个完整节目。
输入样例:
12
1 3
3 4
0 7
3 8
15 19
15 20
10 15
8 18
6 12
5 10
4 14
2 9
0
输出样例:
5
解题思路:
经典活动选择问题:
- 按结束时间对所有节目升序排序
- 维护
lastEnd(当前已选节目的最晚结束时间) - 扫描:若节目开始时间
>= lastEnd,选它,更新lastEnd
按结束时间贪心的正确性:每次选最早结束的节目,为后续留出最大空间。
Python 解法:
import sys
input = sys.stdin.readline
while True:
n = int(input())
if n == 0:
break
programs = []
for _ in range(n):
s, e = map(int, input().split())
programs.append((e, s)) # 先按结束时间排
programs.sort()
count = 0
last_end = 0
for end, start in programs:
if start >= last_end:
count += 1
last_end = end
print(count)
C++ 解法:
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
while (cin >> n && n) {
vector<pair<int,int>> progs(n); // {end, start}
for (int i = 0; i < n; i++) {
int s, e;
cin >> s >> e;
progs[i] = {e, s};
}
sort(progs.begin(), progs.end()); // 按结束时间升序
int count = 0, lastEnd = 0;
for (auto& [e, s] : progs) {
if (s >= lastEnd) {
count++;
lastEnd = e;
}
}
cout << count << '\n';
}
return 0;
}
代码讲解:
- 以
{end, start}形式存储,直接按 pair 默认升序排就是按结束时间排 lastEnd记录上一个选中节目的结束时间- 每次遇到不冲突的节目就贪心选择,最终
count即为答案
八、适用场景
| 场景 | 是否适合贪心 | 原因 |
|---|---|---|
| 活动选择 / 区间调度 | ✅ | 经典贪心,按结束时间排序 |
| 找零 / 分配 | ✅(特定条件下) | 需满足贪心选择性质 |
| 背包问题(0-1 背包) | ❌ | 不满足贪心选择性质,用 DP |
| 最短路(无负权) | ✅(Dijkstra) | 满足贪心性质 |
| 有限制的任务调度 | ✅ | 按截止/权重排序 |
九、常见错误总结
| 错误 | 原因 | 正确做法 |
|---|---|---|
| 没有证明正确性就乱用贪心 | 感觉对但其实错 | 先用交换论证或反证法验证 |
| 贪心标准选错 | 比如区间调度按开始时间而非结束时间 | 想清楚哪个维度是关键 |
| 忽视排序步骤 | 直接扫描未排序数组 | 大多数贪心需要先排序 |
| 用贪心解 0-1 背包 | 典型错误 | 0-1 背包是 DP,分数背包才能贪心 |
| 证明时只给举例 | 举例不能证明正确性 | 必须用形式化论证 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 每步做局部最优,不回退 |
| 成立条件 | 贪心选择性质 + 最优子结构 |
| 正确性证明 | 交换论证 / 反证法 |
| 典型失效场景 | 0-1 背包、特殊面值找零 |
| 常见前置步骤 | 排序 |
| 时间复杂度 | 通常 O(n log n)(瓶颈在排序),选择局部最优复杂时瓶颈在遍历选择O(n * k) |
一句话记住贪心:
贪心不是"随便选",而是能证明"当前最优不会影响后续最优"的有根据的选择。
上一篇:…
下一篇:区间贪心——活动选择、区间调度与区间合并三类经典问题
💬 看完有收获的话,点个赞再走~ 有问题欢迎评论区讨论 🙏


4590

被折叠的 条评论
为什么被折叠?



