贪心算法
贪心算法(Greedy)是一种每步都选择"当前看起来最优"的策略,期望最终得到全局最优解的算法。它比 DP 更简单、更快(通常 O(n log n) 或 O(n)),但不是所有问题都能用贪心——只有满足"贪心选择性质"的问题才行。
核心思想
贪心的本质是:在对问题求解时,总是做出在当前看来最好的选择,不回头、不尝试其他可能。这要求:
- 贪心选择性质:可以通过局部最优选择得到全局最优解。即每一步的选择独立于未来的选择。
- 最优子结构:问题的最优解包含子问题的最优解(与 DP 共享)。
与回溯/DP 的关键区别:回溯枚举所有可能,DP 通过子问题最优组合出全局最优,贪心只走一条路不回头。
何时能用贪心?
判断一个题能否贪心是难点。常用判据:
- 尝试构造反例:能否找到一个局部最优导致全局非最优的例子?找不到就尝试证明。
- 交换论证:假设最优解与贪心解不同,证明可通 过"交换"把最优解变成贪心解而不变差,从而贪心解也是最优。
- 排序后扫描:很多贪心题需要先排序,再按某种策略选。
实战中常见的提示词:"最大化/最小化"、"求最少次数"、"能否到达"、"区间问题"。出现这些时优先想贪心,构造不出反例就上。
与 DP 的区别
| 维度 | 贪心 | DP |
|---|---|---|
| 决策方式 | 当前最优不回头 | 枚举所有选择取最优 |
| 适用条件 | 贪心选择性质 + 最优子结构 | 重叠子问题 + 最优子结构 |
| 复杂度 | 通常 O(n) 或 O(n log n) | 通常 O(n²) ~ O(n * m) |
| 是否保证最优 | 仅当贪心性质成立 | 总是 |
| 典型题 | 跳跃游戏、分发糖果 | 背包、LIS、编辑距离 |
背包的反例:0-1 背包不能用贪心(按价值/重量比选)。比如物品
(w, v)为(2, 3), (3, 4), (4, 5),容量 5。按 v/w 比:(2,3) 比 1.5 最大,先选它,剩余 3 容量选 (3,4),总价值 7。但选 (3,4)+(4,5)... 容量 5 装不下两个,最是 (3,4)+(2,3) = 7。换例子[(10, 60), (20, 100), (30, 120)]容量 50:贪心选 (10,60) v/w=6,剩 40 容量,选 (20,100) v/w=5,剩 20 选不了 (30,120),总 160。但选 (20,100)+(30,120)=220 才最优。这就是 0-1 背包必须用 DP 的原因。完全背包同样不能贪心。
经典题一:跳 跃游戏(LC 55)
判断能否从起点跳到终点,每个位置表示最大跳跃步数。
贪心策略:维护"当前能到达的最远位置" maxReach,遍历每个位置,若 i > maxReach 说明到不了,否则更新 maxReach = max(maxReach, i + nums[i]),到终点返回 true。
function canJump(nums: number[]): boolean {
let maxReach = 0;
for (let i = 0; i < nums.length; i++) {
if (i > maxReach) return false; // 当前位置到不了
maxReach = Math.max(maxReach, i + nums[i]);
if (maxReach >= nums.length - 1) return true;
}
return true;
}
- 时间 O(n),空间 O(1)
- 贪心正确性:能到达 i 就能到达 i 之前所有位置,所以维护最远即可。
经典题二:跳跃游戏 II(LC 45)
求跳到终点的最少步数。每步可以选择当前位置 1~nums[i] 范围内的任意目标。
贪心策略:每一步都尽可能跳到能到达更远的位置。维护当前步 能到达的右边界 end 和下步能到达的最远 maxReach,到 end 时步数 +1 并更新 end。
function jump(nums: number[]): number {
let steps = 0;
let end = 0; // 当前步能到达的右边界
let maxReach = 0; // 下一步能到达的最远
for (let i = 0; i < nums.length - 1; i++) {
maxReach = Math.max(maxReach, i + nums[i]);
if (i === end) {
steps++;
end = maxReach;
if (end >= nums.length - 1) break;
}
}
return steps;
}
- 时间 O(n),空间 O(1)
- 这题 DP 会超时(O(n * max(nums))),贪心是最优解。
经典题三:分发糖果(LC 135)
n 个孩子各有评分,相邻孩子评分高的糖果更多,每人至少 1 颗,求最少糖果数。
贪心策略:两次遍历。从左到右保证比左边评分高的孩子糖果更多;从右到左保证比右边评分高的更多。取两次的 max。
function candy(ratings: number[]): number {
const n = ratings.length;
const candies = new Array(n).fill(1);
// 从左到右
for (let i = 1; i < n; i++) {
if (ratings[i] > ratings[i - 1]) {
candies[i] = candies[i - 1] + 1;
}
}
// 从右到左
for (let i = n - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candies[i] = Math.max(candies[i], candies[i + 1] + 1);
}
}
return candies.reduce((a, b) => a + b, 0);
}
- 时间 O(n),空间 O(n)(可优化到 O(1) 用上坡/下坡计数)
- 贪心正确性:每个方向只考虑一个约束,两次取 max 同时满足两个约束。
经典题四:区间调度(LC 435 无重叠区间)
给一组区间,求最少删除多少个使剩余不重叠。
贪心策略:按右端点排序,每次选右端点最小且不与上一个冲突的区间。直觉:右端点越小,留给后续的空间越大。
function eraseOverlapIntervals(intervals: number[][]): number {
if (intervals.length === 0) return 0;
intervals.sort((a, b) => a[1] - b[1]); // 按右端点排序
let count = 1; // 不重叠区间数
let end = intervals[0][1];
for (let i = 1; i < intervals.length; i++) {
if (intervals[i][0] >= end) {
count++;
end = intervals[i][1];
}
}
return intervals.length - count; // 删除数 = 总数 - 保留数
}
- 时间 O(n log n)(排序),空间 O(1)
- 正确性证明(交换论证):假设最优解不选右端点最小的区间 A 而选 B(B 右端点更大),可把 B 换成 A,A 右端点更小不与后续冲突,仍是最优。反复交换可得贪心解 = 最优解。
经典题五:合并会议室 / 射击气球(LC 452)
用最少的箭射爆所有气球(气球用区间表示,箭射中区间即爆)。
贪心策略:按右端点排序,从左到右扫描,每次箭射在当前最右端点位置,能击穿所有起点 <= 该位置的气球。
function findMinArrowShots(points: number[][]): number {
if (points.length === 0) return 0;
points.sort((a, b) => a[1] - b[1]);
let arrows = 1;
let end = points[0][1];
for (let i = 1; i < points.length; i++) {
if (points[i][0] > end) { // 不在当前箭范围
arrows++;
end = points[i][1];
}
}
return arrows;
}
经典题六:分发饼干(LC 455)
n 个孩子胃口 g[i],m 块饼干大小 s[j],饼干 >= 胃口才能满足,求最多满足多少孩子。
贪心策略:双指针 + 排序。小饼干先满足胃口小的孩子。
function findContentChildren(g: number[], s: number[]): number {
g.sort((a, b) => a - b);
s.sort((a, b) => a - b);
let i = 0, j = 0;
while (i < g.length && j < s.length) {
if (s[j] >= g[i]) i++; // 满足孩子 i
j++; // 饼干用掉
}
return i;
}
经典题七:任务调度器(LC 621)
给任务列表和冷却 n,相同任务需间隔 n 个时间单位,求最少时间。
贪心策略:安排出现次数最多的任务作为"骨架",剩余空隙填其他任务。
function leastInterval(tasks: string[], n: number): number {
const freq = new Map<string, number>();
for (const t of tasks) freq.set(t, (freq.get(t) ?? 0) + 1);
const maxFreq = Math.max(...freq.values());
// 出现 maxFreq 次的任务数
const maxCount = [...freq.values()].filter(v => v === maxFreq).length;
// 框架长度:(maxFreq - 1) * (n + 1) + maxCount
return Math.max(tasks.length, (maxFreq - 1) * (n + 1) + maxCount);
}
- 时间 O(n),空间 O(1)(任务种类有限)
- 正确性:最高频任务决定最少时间,框架已是最优;任务数更多时直接填满空闲。
贪心常用技巧
- 排序后扫描:区间问题、调度问题、双指针贪心。按起点或终点排序是关键。
- 优先队列(堆):需要动态选最大/最小时,如合并 K 个有序链表、IPO 问题。
- 按维度贪心:分发糖果是"两次单维度贪心取 max"。
- 维护极值:跳跃游戏维护 maxReach。
- 从结果倒推:有时从结果(最大/最小)反推更容易,如"删 k 个数字使剩余数最大"用单调栈。
常见反例:贪心不适用的情况
- 0-1 背包:上面已证,按 v/w 比选不保证最优。必须 DP。
- 找零钱(任意面值):硬币
[1, 3, 4]凑 6。贪心选最大 4,再选 1+1=3 枚,但 3+3=2 枚最优。常规贪心不适用,必须 DP(特殊币种 如 1/5/10 才适用)。 - 最长递增子序列:朴素贪心(每次选更大的)会错。正确解是 DP 或二分贪心(维护 tails 数组)。
- 旅行商问题:贪心(最近邻)不保证最优,必须 DP 状态压缩或回溯。
如何证明贪心正确?
面试中常被追问"为什么贪心对"。常用证明方法:
- 交换论证:假设最优解与贪心解不同,构造交换使最优解向贪心解靠拢且不变差。
- 数学归纳:证明前 k 步贪心与最优解一致。
- 反证法:假设贪心不是最优,导出矛盾。
- 构造下界:证明任何解都 >= 某下界,贪心解恰等于该下界。
复杂度对比
| 题 | DP 解 | 贪心解 |
|---|---|---|
| 跳跃游戏 II | O(n * max(nums)) | O(n) |
| 分发糖果 | O(n²) | O(n) |
| 区间调度 | O(n²) | O(n log n) |
| 任务调度 | 模拟 O(time) | O(n) |
| 跳跃游戏 | O(n²) | O(n) |
当 DP 超时、且能构造贪心策略时,优先贪心。