前言
贪心算法是最"直觉"的算法。很多人问:什么时候用贪心,什么时候用DP? 答案是:能用贪心就用贪心(更简单),不能用贪心才用DP(更复杂)。
我并没有能力让你看完就能证明所有贪心的正确性,我只是想让你理解贪心的本质、知道贪心和DP的区别、掌握经典贪心问题的套路。
摘要
从"跳跃游戏能否到达终点"问题出发,剖析贪心算法的核心思想与证明方法。通过贪心与DP的对比、局部最优到全局最优的推导、以及区间调度问题的详细分析,揭秘贪心算法的适用场景与解题套路。配合LeetCode高频题目,给出贪心算法的完整攻略。
一、从跳跃游戏说起
周一早上,哈吉米遇到一道题:
LeetCode 55 - 跳跃游戏
给定一个非负整数数组 nums ,你最初位于数组的第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
示例:
输入:nums = [2,3,1,1,4]
输出:true
解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。
输入:nums = [3,2,1,0,4]
输出:false
解释:无论怎样,总会到达下标为 3 的位置。但该下标的最大跳跃长度是 0 , 所以永远不可能到达最后一个下标。
哈吉米:"这不是DP吗?"
南北绿豆:"可以用DP,但贪心更简单。"
二、贪心 vs DP
南北绿豆:"先看两种思路的区别。"
2.1 DP思路
dp[i]:能否到达位置i
dp[0] = true(起点)
dp[i] = 是否存在j < i,使得dp[j]==true 且 j+nums[j]>=i
遍历所有位置,判断能否到达
时间复杂度:O(n²)
2.2 贪心思路
维护一个变量maxReach:当前能到达的最远位置
遍历数组:
如果i > maxReach,说明到不了i,返回false
否则,更新maxReach = max(maxReach, i + nums[i])
如果maxReach >= n-1,返回true
时间复杂度:O(n)
2.3 对比图示
flowchart TB
A["跳跃游戏"]
B["DP思路<br/>判断每个位置<br/>能否到达"]
C["贪心思路<br/>维护最远<br/>可达位置"]
D["O(n²)"]
E["O(n)"]
A --> B
A --> C
B --> D
C --> E
style C fill:#e1ffe1
style E fill:#e1ffe1
哈吉米:"贪心更简单,为什么不都用贪心?"
阿西噶阿西:"因为不是所有问题贪心都正确。"
三、什么是贪心算法
南北绿豆:"贪心算法的本质:每一步都做当前看起来最优的选择,希望最终得到全局最优解。"
3.1 生活化场景
场景1:找零钱(贪心成功)
你要找给顾客99元
现有硬币:[50, 20, 10, 5, 1]
贪心策略:每次选面额最大的
50 + 20 + 20 + 5 + 1 + 1 + 1 + 1 = 99(7个硬币)✓
这是最优解吗?是的!
场景2:找零钱(贪心失败的反例)
硬币:[25, 10, 1]
要找30元
贪心策略:
25 + 1 + 1 + 1 + 1 + 1 = 30(6个硬币)
最优解:
10 + 10 + 10 = 30(3个硬币)✓
贪心失败!
哈吉米:"所以贪心不一定对?"
南北绿豆:"对,贪心需要证明正确性。如果局部最优推导不出全局最优,就不能用贪心,要用DP。"
四、贪心的正确性证明
阿西噶阿西:"证明贪心正确性有两种方法。"
4.1 方法1:反证法
证明:假设贪心解不是最优解,推导出矛盾。
示例:区间调度问题(后面会讲)
4.2 方法2:归纳法
证明:
- 第一步贪心选择是正确的
- 假设前k步贪心选择正确,证明第k+1步也正确
哈吉米:"听起来很数学。"
南北绿豆:"实际做题时,多数情况靠直觉+测试。如果贪心AC了,说明正确;如果WA了,就换DP。"
五、例题1:跳跃游戏
5.1 思路分析
南北绿豆:"回到最开始的跳跃游戏。"
贪心策略:维护最远可达位置maxReach
nums = [2,3,1,1,4]
i=0:maxReach = max(0, 0+2) = 2
i=1:1 <= 2,可达,maxReach = max(2, 1+3) = 4
i=2:2 <= 4,可达,maxReach = max(4, 2+1) = 4
...
maxReach=4 >= 4(最后一个位置),返回true
图示:
nums = [2, 3, 1, 1, 4]
index: 0 1 2 3 4
从0出发,最远到2:
[====]
0 1 2
从1出发,最远到4:
[==========]
1 2 3 4
能到达最后一个位置 ✓
5.2 代码实现
Java版本:
public boolean canJump(int[] nums) {
int maxReach = 0;
for (int i = 0; i < nums.length; i++) {
// 如果当前位置到不了,返回false
if (i > maxReach) {
return false;
}
// 更新最远可达位置
maxReach = Math.max(maxReach, i + nums[i]);
// 如果已经能到达最后一个位置,提前返回
if (maxReach >= nums.length - 1) {
return true;
}
}
return true;
}
C++版本:
bool canJump(vector<int>& nums) {
int maxReach = 0;
for (int i = 0; i < nums.size(); i++) {
if (i > maxReach) {
return false;
}
maxReach = max(maxReach, i + nums[i]);
if (maxReach >= nums.size() - 1) {
return true;
}
}
return true;
}
Python版本:
def canJump(nums):
maxReach = 0
for i in range(len(nums)):
if i > maxReach:
return False
maxReach = max(maxReach, i + nums[i])
if maxReach >= len(nums) - 1:
return True
return True
六、例题2:跳跃游戏II
6.1 题目
LeetCode 45 - 跳跃游戏 II
给定一个长度为 n 的 0 索引整数数组 nums。初始位置为 nums[0]。
每个元素 nums[i] 表示从索引 i 向前跳转的最大长度。
返回到达 nums[n - 1] 的最小跳跃次数。
示例:
输入:nums = [2,3,1,1,4]
输出:2
解释:跳到最后一个位置的最小跳跃数是 2。
从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。
输入:nums = [2,3,0,1,4]
输出:2
6.2 思路分析
南北绿豆:"这题要求最少跳跃次数,稍微复杂点。"
贪心策略:在当前能到达的范围内,选择能跳得最远的位置。
阿西噶阿西:"维护两个变量:
- curEnd:当前跳跃能到达的最远位置
- maxReach:下一跳能到达的最远位置"
示例:nums = [2,3,1,1,4]
第0跳:从0出发
curEnd = 0
maxReach = 0 + nums[0] = 2
遍历[0,curEnd]范围,更新maxReach:
i=0:maxReach = max(2, 0+2) = 2
到达curEnd,跳跃次数+1
第1跳:从[1,2]范围出发
curEnd = 2
遍历[1,2]范围:
i=1:maxReach = max(2, 1+3) = 4
i=2:maxReach = max(4, 2+1) = 4
到达curEnd,跳跃次数+1
maxReach=4 >= 4,到达终点
答案:2次跳跃
执行过程表格:
| i | nums[i] | curEnd | maxReach | 操作 | 跳跃次数 |
|---|---|---|---|---|---|
| 0 | 2 | 0 | 2 | i==curEnd,跳跃+1 | 1 |
| 1 | 3 | 2 | 4 | - | 1 |
| 2 | 1 | 2 | 4 | i==curEnd,跳跃+1 | 2 |
| 3 | 1 | 4 | 4 | maxReach>=终点,结束 | 2 |
6.3 代码实现
Java版本:
public int jump(int[] nums) {
int jumps = 0;
int curEnd = 0; // 当前跳跃能到达的最远位置
int maxReach = 0; // 下一跳能到达的最远位置
for (int i = 0; i < nums.length - 1; i++) {
// 更新下一跳能到达的最远位置
maxReach = Math.max(maxReach, i + nums[i]);
// 到达当前跳跃的边界,需要跳跃
if (i == curEnd) {
jumps++;
curEnd = maxReach;
}
}
return jumps;
}
C++版本:
int jump(vector<int>& nums) {
int jumps = 0;
int curEnd = 0;
int maxReach = 0;
for (int i = 0; i < nums.size() - 1; i++) {
maxReach = max(maxReach, i + nums[i]);
if (i == curEnd) {
jumps++;
curEnd = maxReach;
}
}
return jumps;
}
Python版本:
def jump(nums):
jumps = 0
curEnd = 0
maxReach = 0
for i in range(len(nums) - 1):
maxReach = max(maxReach, i + nums[i])
if i == curEnd:
jumps += 1
curEnd = maxReach
return jumps
哈吉米:"每次在可达范围内选能跳最远的,这就是贪心。"
七、例题3:无重叠区间
7.1 题目
LeetCode 435 - 无重叠区间
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。
返回需要移除区间的最小数量,使剩余区间互不重叠 。
示例:
输入:intervals = [[1,2],[2,3],[3,4],[1,3]]
输出:1
解释:移除 [1,3] 后,剩下的区间没有重叠。
输入:intervals = [[1,2],[1,2],[1,2]]
输出:2
解释:你需要移除两个 [1,2] 来使剩下的区间没有重叠。
7.2 思路分析
南北绿豆:"这是经典的区间调度问题。"
问题转换:
- 移除最少的区间 = 保留最多的不重叠区间
- 转换成:从所有区间中选择最多的不重叠区间
贪心策略:按结束时间排序,每次选结束最早的区间。
为什么选结束最早的?
阿西噶阿西:"因为结束越早,后面留给其他区间的空间越多。"
示例:
区间:[[1,4], [2,3], [3,5]]
策略1:选[1,4]
→ 后面只能选[5,...],空间很少 ✗
策略2:选[2,3](结束最早)
→ 后面可以选[3,5]或[4,...],空间多 ✓
7.3 执行过程演示
示例:intervals = [[1,2],[2,3],[3,4],[1,3]]
第1步:按结束时间排序
原始:[[1,2],[2,3],[3,4],[1,3]]
排序:[[1,2],[2,3],[1,3],[3,4]]
end=2 end=2 end=3 end=4
第2步:贪心选择
| 区间 | 结束时间 | 操作 | 已选 | 说明 |
|---|---|---|---|---|
| [1,2] | 2 | 选择 | 1个 | 第一个,直接选 |
| [2,3] | 3 | 选择 | 2个 | 2>=2,不重叠 |
| [1,3] | 3 | 跳过 | 2个 | 1<3,重叠 |
| [3,4] | 4 | 选择 | 3个 | 3>=3,不重叠 |
结果:保留3个区间,移除1个
7.4 代码实现
Java版本:
public int eraseOverlapIntervals(int[][] intervals) {
if (intervals.length == 0) return 0;
// 按结束时间排序
Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
int count = 1; // 至少保留第一个区间
int end = intervals[0][1];
for (int i = 1; i < intervals.length; i++) {
// 如果不重叠,保留
if (intervals[i][0] >= end) {
count++;
end = intervals[i][1];
}
}
// 移除的区间数 = 总数 - 保留的
return intervals.length - count;
}
C++版本:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) return 0;
sort(intervals.begin(), intervals.end(),
[](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
int count = 1;
int end = intervals[0][1];
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] >= end) {
count++;
end = intervals[i][1];
}
}
return intervals.size() - count;
}
Python版本:
def eraseOverlapIntervals(intervals):
if not intervals:
return 0
# 按结束时间排序
intervals.sort(key=lambda x: x[1])
count = 1
end = intervals[0][1]
for i in range(1, len(intervals)):
if intervals[i][0] >= end:
count += 1
end = intervals[i][1]
return len(intervals) - count
八、例题4:用最少数量的箭引爆气球
8.1 题目
LeetCode 452 - 用最少数量的箭引爆气球
有一些球形气球贴在一堵用 XY 平面表示的墙面上。
墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示气球的直径在 xstart 和 xend之间。
你可以沿着 x 轴由左向右发射箭来戳破气球。
如果气球的 xstart ≤ x ≤ xend,该气球会被引爆 。
求所需箭的最小数量。
示例:
输入:points = [[10,16],[2,8],[1,6],[7,12]]
输出:2
解释:气球可以用2支箭来爆破:
-在x = 6处射出箭,击破气球[2,8]和[1,6]。
-在x = 11处射出箭,击破气球[10,16]和[7,12]。
8.2 思路分析
南北绿豆:"这题和无重叠区间类似,但要求的是最少的箭。"
贪心策略:按结束时间排序,尽量让一支箭射穿更多气球。
阿西噶阿西:"关键:相交的区间用一支箭,不相交的需要新箭。"
8.3 代码实现
Java版本:
public int findMinArrowShots(int[][] points) {
if (points.length == 0) return 0;
// 按结束位置排序
Arrays.sort(points, (a, b) -> Integer.compare(a[1], b[1]));
int arrows = 1;
int end = points[0][1];
for (int i = 1; i < points.length; i++) {
// 如果不相交,需要新箭
if (points[i][0] > end) {
arrows++;
end = points[i][1];
}
}
return arrows;
}
C++版本:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.empty()) return 0;
sort(points.begin(), points.end(),
[](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
int arrows = 1;
int end = points[0][1];
for (int i = 1; i < points.size(); i++) {
if (points[i][0] > end) {
arrows++;
end = points[i][1];
}
}
return arrows;
}
Python版本:
def findMinArrowShots(points):
if not points:
return 0
points.sort(key=lambda x: x[1])
arrows = 1
end = points[0][1]
for i in range(1, len(points)):
if points[i][0] > end:
arrows += 1
end = points[i][1]
return arrows
九、贪心算法总结
9.1 贪心 vs DP
| 对比项 | 贪心算法 | 动态规划 |
|---|---|---|
| 策略 | 每步选局部最优 | 考虑所有可能 |
| 是否回溯 | 不回溯(一条路走到黑) | 会考虑之前的状态 |
| 正确性 | 需要证明 | 一定正确(穷举) |
| 复杂度 | 通常O(n)或O(nlogn) | 通常O(n²)或更高 |
| 适用场景 | 局部最优=全局最优 | 有重叠子问题 |
9.2 何时用贪心
南北绿豆:
- 问题有贪心选择性质:局部最优能推导出全局最优
- 问题有最优子结构:子问题的最优解能组成全局最优解
- 能证明正确性:或者AC了就说明正确
9.3 常见贪心策略
阿西噶阿西:
| 问题类型 | 贪心策略 | 典型题目 |
|---|---|---|
| 区间调度 | 按结束时间排序,选结束早的 | LeetCode 435、452 |
| 跳跃问题 | 维护最远可达位置 | LeetCode 55、45 |
| 分配问题 | 排序后贪心分配 | 分发饼干、分发糖果 |
| 最值问题 | 每次选最大/最小 | 数组拆分、最大子数组和 |
9.4 识别技巧
南北绿豆:
- 看到最少、最多、最大、最小,先考虑贪心
- 看到区间、调度、选择,想贪心
- 如果贪心WA了,再考虑DP
哈吉米:"先试贪心,不行再DP。"
参考资料:
- 《算法导论》- Thomas H. Cormen
- 《算法竞赛进阶指南》- 李煜东
- LeetCode题解 - 贪心算法专题