一、引子:贪心为什么是"最难"的算法范式?
如果你学过 Dijkstra 最短路径算法,你就已经用过贪心了------每次从优先队列中取出距离最短的节点,这个"局部最优选择"最终导向了全局最短路径。但面试中不会考你背模板,而是考你设计新贪心策略的能力。
贪心与其他算法范式完全不同。DFS/BFS 有固定的遍历模板,DP 有状态转移方程的模式,二分查找有单调性条件。但贪心------没有统一模板。每道题都是独立的。
这篇文章要讲三件事:
-
贪心策略的数学基础:贪心选择性质、最优子结构、交换论证法
-
三题递进:LeetCode 55(可行性判断)→ 45(最优值求解)→ 435(区间调度)
-
贪心 vs DP 的决策边界:什么时候该用贪心,什么时候必须退回到 DP
二、贪心策略的数学基础
2.1 两个性质:贪心选择性质 + 最优子结构
贪心算法能工作的前提是问题同时满足两个性质:
最优子结构(Optimal Substructure):问题的最优解包含子问题的最优解。这个性质是 DP 和贪心共享的。
贪心选择性质(Greedy Choice Property):通过局部最优选择可以构造出全局最优解。这个性质是贪心独有的------DP 不需要它,因为 DP 会枚举所有可能的选择;贪心需要它,因为贪心只做一个选择。
| 维度 | 动态规划 | 贪心算法 |
|---|---|---|
| 选择方式 | 枚举所有可能的选择,取最优 | 只做一个"看起来最优"的选择 |
| 子问题求解 | 解所有子问题 | 解一个子问题 |
| 证明要求 | 最优子结构即可 | 最优子结构 + 贪心选择性质 |
| 代码复杂度 | 中等 ~ 复杂 | 简单 ~ 极简 |
| 时间复杂度 | 通常 O(N²) ~ O(N³) | 通常 O(N) ~ O(N log N) |
| 典型问题 | 背包、编辑距离 | 跳跃游戏、区间调度 |
关键洞察:贪心是 DP 的"特化"。当问题具有贪心选择性质时,我们不需要枚举所有选择。
2.2 交换论证法(Exchange Argument)
交换论证是证明贪心正确的数学方法,分两步:
-
假设存在一个最优解 OPT,它不一定等于贪心解 G
-
通过相邻交换证明:可以把 OPT 转换成 G,且解的质量不会下降
2.3 贪心 vs DP 的决策树
问题有最优子结构吗?
├── 否 → 不能使用贪心或 DP
└── 是 → 有贪心选择性质吗?
├── 是 → 贪心 ✅(O(N) 或 O(N log N) 可解)
└── 否 → DP ✅(需要枚举所有选择)
三、LeetCode 55:Jump Game(跳跃游戏)
问题描述
给定一个非负整数数组 nums,从下标 0 出发,判断是否能到达最后一个下标。
示例 1:nums = [2, 3, 1, 1, 4] → true
示例 2:nums = [3, 2, 1, 0, 4] → false
贪心策略
维护 maxReach 记录当前能到达的最远位置。遍历数组,如果 i > maxReach 返回 false,否则更新 maxReach = max(maxReach, i + nums[i])。
证明思路(交换论证)
用反证法:如果贪心选择(最远位置)到达不了终点,那么任何更近的位置也到达不了------因为贪心选择已经覆盖了所有位置的可达范围。
代码实现
# Python
class Solution:
def canJump(self, nums: List[int]) -> bool:
maxReach = 0
n = len(nums)
for i in range(n):
if i > maxReach:
return False
maxReach = max(maxReach, i + nums[i])
if maxReach >= n - 1:
return True
return True
// Java
class Solution {
public boolean canJump(int[] nums) {
int maxReach = 0;
int n = nums.length;
for (int i = 0; i < n; i++) {
if (i > maxReach) return false;
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= n - 1) return true;
}
return true;
}
}
// C++
class Solution {
public:
bool canJump(vector<int>& nums) {
int maxReach = 0;
int n = nums.size();
for (int i = 0; i < n; i++) {
if (i > maxReach) return false;
maxReach = max(maxReach, i + nums[i]);
if (maxReach >= n - 1) return true;
}
return true;
}
};
复杂度分析
| 维度 | 值 |
|---|---|
| 时间复杂度 | O(N),一次遍历 |
| 空间复杂度 | O(1) |
如果用 DP 做?
# Python DP 解法 O(N²)
def canJump_dp(nums):
n = len(nums)
dp = [False] * n
dp[n - 1] = True
for i in range(n - 2, -1, -1):
for j in range(1, nums[i] + 1):
if i + j < n and dp[i + j]:
dp[i] = True
break
return dp[0]
当 N = 10^4 时,DP 需要约 10^8 次操作(可能超时),贪心只需要 10^4 次。这就是贪心的价值------不是"能不能解",而是"能不能高效地解"。
四、LeetCode 45:Jump Game II(跳跃游戏 II)
问题描述
返回到达最后一个下标的最小跳跃次数。题目保证可到达。
示例:nums = [2, 3, 1, 1, 4] → 输出 2
贪心策略:BFS 层序遍历
将跳跃过程看作 BFS 的层序遍历。维护两个变量:
-
curEnd:当前层能到达的最远边界 -
nextMax:下一层能到达的最远边界
遍历数组,当 i == curEnd 时,跳跃次数 +1,curEnd = nextMax。
代码实现
# Python
class Solution:
def jump(self, nums: List[int]) -> int:
n = len(nums)
if n == 1:
return 0
jumps = 0
curEnd = 0
nextMax = 0
for i in range(n - 1):
nextMax = max(nextMax, i + nums[i])
if i == curEnd:
jumps += 1
curEnd = nextMax
if curEnd >= n - 1:
break
return jumps
// Java
class Solution {
public int jump(int[] nums) {
int n = nums.length;
if (n == 1) return 0;
int jumps = 0, curEnd = 0, nextMax = 0;
for (int i = 0; i < n - 1; i++) {
nextMax = Math.max(nextMax, i + nums[i]);
if (i == curEnd) {
jumps++;
curEnd = nextMax;
if (curEnd >= n - 1) break;
}
}
return jumps;
}
}
// C++
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
if (n == 1) return 0;
int jumps = 0, curEnd = 0, nextMax = 0;
for (int i = 0; i < n - 1; i++) {
nextMax = max(nextMax, i + nums[i]);
if (i == curEnd) {
jumps++;
curEnd = nextMax;
if (curEnd >= n - 1) break;
}
}
return jumps;
}
};
复杂度分析
| 维度 | 值 |
|---|---|
| 时间复杂度 | O(N) |
| 空间复杂度 | O(1) |
55 和 45 的关系
| 维度 | 55 题 | 45 题 |
|---|---|---|
| 问题类型 | 可行性判断 | 最优值求解 |
| 贪心策略 | 维护最远位置 | BFS 层 + 边界 |
| 时间复杂度 | O(N) | O(N) |
| 空间复杂度 | O(1) | O(1) |
五、LeetCode 435:Non-overlapping Intervals(无重叠区间)
问题描述
求最少移除多少个区间,使剩余区间互不重叠。
示例:intervals = [[1,2], [2,3], [3,4], [1,3]] → 输出 1
问题转化
等价于:最多能保留多少个互不重叠的区间?答案 = 总数 − 最多保留数。
贪心策略
按结束时间升序排序。遍历,如果当前区间开始时间 ≥ 上一个保留区间的结束时间,则保留;否则移除。
为什么按结束时间排序?
按结束时间排序才能给后续区间留出最大空间。
反例:[[1, 10], [2, 3], [4, 5]]
-
按开始时间排序:先选
[1, 10],只能保留 1 个 -
按结束时间排序:先选
[2, 3],再选[4, 5],保留 2 个
证明思路
交换论证:设贪心选最早结束的区间 g1,最优解的第一个区间为 opt1。g1.end ≤ opt1.end,将 opt1 替换为 g1 后,剩余空间更大或相等,不会减少解的个数。
代码实现
# Python
class Solution:
def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
if not intervals:
return 0
intervals.sort(key=lambda x: x[1])
count = 0
prevEnd = intervals[0][1]
for i in range(1, len(intervals)):
if intervals[i][0] < prevEnd:
count += 1
else:
prevEnd = intervals[i][1]
return count
// Java
class Solution {
public int eraseOverlapIntervals(int[][] intervals) {
if (intervals.length == 0) return 0;
Arrays.sort(intervals, (a, b) -> a[1] - b[1]);
int count = 0;
int prevEnd = intervals[0][1];
for (int i = 1; i < intervals.length; i++) {
if (intervals[i][0] < prevEnd) count++;
else prevEnd = intervals[i][1];
}
return count;
}
}
// C++
class Solution {
public:
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 = 0;
int prevEnd = intervals[0][1];
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] < prevEnd) count++;
else prevEnd = intervals[i][1];
}
return count;
}
};
复杂度分析
| 维度 | 值 |
|---|---|
| 时间复杂度 | O(N log N),排序占主导 |
| 空间复杂度 | O(1) |
变体题目
-
452. Minimum Number of Arrows:本质是找不相交区间的数量
-
56. Merge Intervals:合并重叠区间,按开始时间排序
-
763. Partition Labels:划分字母区间,本质是合并重叠区间
六、总结:贪心的三种模式
模式一:可行性判断(55 题)
-
维护一个"最远可达"边界,看能否覆盖目标
-
时间复杂度 O(N),空间 O(1)
模式二:最优值求解(45 题)
-
BFS 层序遍历 + 贪心边界
-
时间复杂度 O(N),空间 O(1)
模式三:区间调度(435 题)
-
按结束时间排序,贪心选最早结束
-
时间复杂度 O(N log N),空间 O(1)
通用框架
-
验证问题是否具有最优子结构
-
尝试构造反例,用交换论证法严格证明
-
如果贪心选择性质成立,设计贪心解
-
如果贪心选择性质不成立,退回到 DP
