贪心算法(Greedy Algorithm)是一种在每一步选择中都采取当前状态下最优(即最有利)的选择,从而希望导致全局最优解的算法设计范式。与动态规划不同,贪心算法并不从整体上考虑所有可能的选择,而是做出局部最优的决策,并期望通过一系列局部最优选择达到全局最优。
贪心算法的本质可以概括为一句话:"今朝有酒今朝醉" -- 在每个决策点,选择当前收益最大或代价最小的方案,不考虑未来的影响。
"贪心算法是一种短视的算法策略,它只关注眼前的利益。但正是这种看似简单的策略,却能高效解决许多经典问题,如最小生成树、最短路径、哈夫曼编码等。"
贪心策略在日常生活中随处可见:
设计一个贪心算法通常遵循以下四个步骤:
分析问题特征,确定每一步的选择标准。常见的贪心策略包括:
将原问题分解为一系列子问题,每个子问题都应用贪心策略进行选择。在做出贪心选择后,剩余的子问题仍然具有与原问题相同的结构。
这是贪心算法设计中最关键也最困难的一步。常用的证明方法包括交换论证法、数学归纳法和反证法(详见第三节)。
根据贪心策略实现具体的算法代码。通常包含排序和线性扫描两个阶段。
// 贪心算法通用框架
Greedy(Data):
1. 对数据按贪心策略进行排序
2. 初始化结果集 result = {}
3. For each item in Data (按排序顺序):
a. 判断当前item是否可以加入结果集
b. 如果可以,将item加入result
c. 更新当前状态
4. Return result
贪心算法正确性证明是算法设计的核心难点。以下介绍三种最常用的证明方法:
交换论证法是最强大的贪心证明工具之一。其核心思想是:假设存在一个最优解,通过一系列不改变最优性的交换操作,逐步将其转化为贪心解。
OPT 是问题的一个全局最优解OPT 与贪心解 GREEDY 的第一个不同选择位置OPT 中的该项替换为贪心选择OPT' 不会比 OPT 差OPT 可以转化为 GREEDY,且不损失最优性问题: 给定 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i],要求选择尽可能多的互不重叠的活动。
贪心策略: 每次选择结束时间最早且不与已选活动重叠的活动。
交换论证:
OPT 为最优解,GREEDY 为贪心解OPT 和 GREEDY 中第一个不同的活动OPT 中选择了活动 a,而贪心解选择了活动 b(b 的结束时间 ≤ a 的结束时间)OPT 中的 a 替换为 b。由于 b 的结束时间不晚于 a,b 不会与已选活动重叠OPT 仍然是可行解通过对算法执行步骤或问题规模进行归纳,证明贪心算法的每一步选择都是正确的。
问题: 给定面额为 25、10、5、1 的硬币(美元币值),用最少数量的硬币凑出金额 n。
贪心策略: 每次都选择面值不超过剩余金额的最大硬币。
归纳证明: 对金额 n 进行归纳。假设对于所有小于 n 的金额,贪心算法给出最优解。对于金额 n,贪心选择面值最大的硬币 c ≤ n,剩余金额为 n - c。由归纳假设,剩余部分的最优解可以由贪心算法得到,因此整体解最优。
假设贪心算法得到的解不是最优解,然后推导出矛盾。
GREEDY 不是最优解OPT 是一个与 GREEDY 前 k 个选择相同、但在第 k+1 个选择处不同的最优解OPT 中对应选择的优劣| 策略模式 | 描述 | 典型问题 |
|---|---|---|
| 最早截止时间优先 | 选择结束/截止时间最早的 | 活动选择、区间调度 |
| 最短处理时间优先 | 选择处理时间最短的 | 最小化总完成时间、排队问题 |
| 最高密度优先 | 选择性价比/单位价值最高的 | 分数背包、哈夫曼编码 |
| 最大收益优先 | 选择当前收益最大的 | Prim算法、Dijkstra算法 |
难度:★☆☆☆☆ 频率:高
题目描述: 给定 n 个闭区间 [ai, bi],在数轴上选择尽量少的点,使得每个区间内至少包含一个选出的点(右端点也算区间内)。
贪心策略: 将所有区间按右端点从小到大排序,选择第一个未覆盖的区间的右端点。
#include <vector>
#include <algorithm>
using namespace std;
struct Interval {
int left, right;
};
// 贪心选点:按右端点排序
int selectPoints(vector<Interval>& intervals) {
if (intervals.empty()) return 0;
// 按右端点升序排序
sort(intervals.begin(), intervals.end(),
[](const Interval& a, const Interval& b) {
return a.right < b.right;
});
int count = 1; // 至少需要一个点
int currentPoint = intervals[0].right; // 选第一个区间的右端点
for (int i = 1; i < intervals.size(); i++) {
// 如果当前区间不包含已选的点
if (intervals[i].left > currentPoint) {
count++;
currentPoint = intervals[i].right; // 选新区间的右端点
}
}
return count;
}
复杂度分析: O(n log n) -- 排序 O(n log n),扫描 O(n)。
正确性证明: 设贪心选择的第一个点为第一个区间的右端点。对于任意一个不包含该点的区间,该区间的左端点必然大于第一个区间的右端点,因此必须另选新的点。按此方式递归处理,每个点都尽可能多地覆盖区间,因此总的选点数量最小。
难度:★★☆☆☆ 频率:高
题目描述: 给定一个目标区间 [S, T] 和 n 个小区间 [ai, bi],选择尽量少的小区间来覆盖目标区间。
贪心策略: 将所有区间按左端点从小到大排序,每次选择能覆盖当前起点且右端点最远的区间。
#include <vector>
#include <algorithm>
#include <climits>
using namespace std;
struct Interval {
int left, right;
};
int minCoverage(vector<Interval> intervals, int S, int T) {
// 按左端点升序排序
sort(intervals.begin(), intervals.end(),
[](const Interval& a, const Interval& b) {
return a.left < b.left;
});
int count = 0;
int curEnd = S; // 当前已覆盖的右端点
int i = 0;
int n = intervals.size();
while (curEnd < T) {
int farthest = curEnd;
// 在所有左端点 ≤ curEnd 的区间中,找右端点最大的
while (i < n && intervals[i].left <= curEnd) {
farthest = max(farthest, intervals[i].right);
i++;
}
// 无法继续覆盖
if (farthest == curEnd) return -1;
count++;
curEnd = farthest; // 更新当前覆盖位置
}
return count;
}
复杂度分析: O(n log n) -- 排序 O(n log n),每个区间最多被访问一次 O(n)。
难度:★★☆☆☆ 频率:高
题目描述: 给定 n 个区间,选择尽量多的区间,使得这些区间两两不相交(端点重合也算相交)。
贪心策略: 按右端点从小到大排序,优先选择右端点最小的区间。
#include <vector>
#include <algorithm>
using namespace std;
struct Interval {
int left, right;
};
int maxNonOverlapping(vector<Interval> intervals) {
if (intervals.empty()) return 0;
// 按右端点升序排序
sort(intervals.begin(), intervals.end(),
[](const Interval& a, const Interval& b) {
return a.right < b.right;
});
int count = 1;
int lastRight = intervals[0].right;
for (int i = 1; i < intervals.size(); i++) {
// 如果当前区间左端点 ≥ 上一个选中区间的右端点(不相交)
if (intervals[i].left >= lastRight) {
count++;
lastRight = intervals[i].right;
}
}
return count;
}
注意: 本题与"区间选点"问题具有对偶性。实际上,最大不相交区间数量等于最少选点数。
难度:★★★☆☆ 频率:中
题目描述: 一个环形路线上有 n 个加油站,第 i 个加油站有汽油 gas[i],从 i 到 i+1 需要消耗汽油 cost[i]。求是否存在一个起点,使得汽车能绕行一周。
贪心策略: 如果一个位置开始无法到达下一个加油站,则这个位置和中间经过的所有位置都不能作为起点。
#include <vector>
using namespace std;
int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
int n = gas.size();
int totalTank = 0; // 总油量盈余
int curTank = 0; // 当前油量
int start = 0; // 起点
for (int i = 0; i < n; i++) {
int diff = gas[i] - cost[i];
totalTank += diff;
curTank += diff;
// 当前起点无法到达 i+1
if (curTank < 0) {
// 从 i+1 重新开始尝试
start = i + 1;
curTank = 0;
}
}
// 总油量 ≥ 总消耗,则一定能找到起点
return totalTank >= 0 ? start : -1;
}
正确性分析: 关键性质 -- 如果从起点 start 出发到 i 时油量变为负,则起点 start 到 i 之间的任何节点都不能作为起点。这是因为从 start 出发到中间节点 k 时油量非负,所以从 start 到 k 积累的油量是正的,而从 k 出发等于在正油量基础上出发都到不了 i,直接从 k 出发更不可能。
难度:★☆☆☆☆ 频率:中
题目描述: 每杯柠檬水售价 5 美元,顾客排队购买,每位顾客只买一杯,支付 5、10 或 20 美元。初始没有零钱,判断能否给每位顾客正确找零。
贪心策略: 收到 20 美元时,优先用 10+5 找零(因为 5 美元更通用),其次用 5+5+5。
#include <vector>
using namespace std;
bool lemonadeChange(vector<int>& bills) {
int five = 0, ten = 0; // 分别记录 5 元和 10 元的数量
for (int bill : bills) {
if (bill == 5) {
five++;
} else if (bill == 10) {
if (five == 0) return false;
five--;
ten++;
} else { // bill == 20
// 贪心:优先用 10+5 组合找零
if (ten > 0 && five > 0) {
ten--;
five--;
} else if (five >= 3) {
five -= 3;
} else {
return false;
}
}
}
return true;
}
/*
* 为什么收到20时要优先用10元?
* 因为5元在找零中扮演更关键的角色:
* - 5元可以给10元和20元找零
* - 10元只能给20元找零
* 保留5元能应对更多找零场景,所以20元尽量先用10元找零。
*/
复杂度分析: O(n) -- 一次线性扫描即可。
难度:★☆☆☆☆ 频率:中
题目描述: 每个孩子有一个胃口因子 g[i],每块饼干有大小 s[j]。当饼干大小 ≥ 孩子胃口时,孩子会满足。每个孩子最多给一块饼干,求最多能满足多少个孩子。
贪心策略: 将孩子胃口和饼干大小排序,用最小的饼干去满足胃口最小的孩子(每次给最接近胃口大小的饼干)。
#include <vector>
#include <algorithm>
using namespace std;
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end()); // 孩子胃口升序
sort(s.begin(), s.end()); // 饼干大小升序
int child = 0; // 已满足的孩子数量
int cookie = 0; // 当前考虑的饼干索引
while (child < g.size() && cookie < s.size()) {
if (s[cookie] >= g[child]) {
child++; // 该孩子得到满足
}
cookie++; // 无论是否满足,当前饼干都被消耗
}
return child;
}
/*
* 为什么贪心策略有效?
* 对于胃口最小的孩子,用最小的可用饼干去满足,保留了更大的饼干。
* 这样做不会影响更多孩子被满足的总数。证明:
* 假设最优解中胃口最小的孩子用了更大的饼干,将其替换为最小的可用饼干后,
* 更大的饼干可以留给胃口更大的孩子,总满足数不会减少。
*/
复杂度分析: O(n log n + m log m) -- 两次排序,一次线性扫描。
贪心算法和动态规划是解决最优化问题的两种重要方法。理解它们的区别至关重要。
| 维度 | 贪心算法 | 动态规划 |
|---|---|---|
| 决策方式 | 只考虑当前最优,不做回溯 | 考虑所有可能选择,通过比较找出最优 |
| 子问题依赖 | 每个阶段只有一个子问题 | 子问题可能重叠,需要状态转移 |
| 时间复杂度 | 通常 O(n log n) 或 O(n) | 通常 O(n²) 或更高 |
| 正确性要求 | 需要严格证明贪心选择性质 | 需要无后效性和最优子结构 |
| 适用范围 | 贪心选择性质成立的问题 | 适用范围更广,但效率可能较低 |
| 空间复杂度 | 通常 O(1) 额外空间 | 通常 O(n) 或 O(n²) |
活动选择问题(区间调度)是展示贪心和DP差异的经典例子。
问题: 给定 n 个活动,每个活动有开始时间 s[i] 和结束时间 f[i],选择最大数量的互不重叠活动。
1. 动态规划解法:
先将活动按结束时间排序。定义 dp[i] 表示前 i 个活动(已按结束时间排序)中能选出的最大活动数。
// DP解法:时间复杂度 O(n²)
int dpSchedule(vector<Interval> intervals) {
int n = intervals.size();
// 按结束时间排序
sort(intervals.begin(), intervals.end(),
[](auto& a, auto& b) { return a.right < b.right; });
vector<int> dp(n + 1, 0);
dp[0] = 0;
for (int i = 1; i <= n; i++) {
// 不选第 i 个活动
dp[i] = dp[i - 1];
// 选第 i 个活动:找到最后一个与 i 不冲突的活动
int j = i - 1;
while (j >= 1 && intervals[j - 1].right > intervals[i - 1].left) {
j--;
}
dp[i] = max(dp[i], dp[j] + 1);
}
return dp[n];
}
// 时间复杂度:O(n²)
2. 贪心解法:
按结束时间排序,每次选择第一个与已选活动不冲突的活动。
// 贪心解法:时间复杂度 O(n log n)
int greedySchedule(vector<Interval> intervals) {
sort(intervals.begin(), intervals.end(),
[](auto& a, auto& b) { return a.right < b.right; });
int count = 1;
int lastEnd = intervals[0].right;
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i].left >= lastEnd) {
count++;
lastEnd = intervals[i].right;
}
}
return count;
}
// 时间复杂度:O(n log n) 排序 + O(n) 扫描
| 领域 | 经典问题 | 贪心策略 |
|---|---|---|
| 图论 | 最小生成树(Kruskal / Prim) | 优先选择权值最小的边/顶点 |
| 图论 | 单源最短路径(Dijkstra) | 优先选择距离源点最近的未处理顶点 |
| 数据结构 | 哈夫曼编码 | 合并频率最小的两个节点 |
| 调度问题 | 活动选择 / 区间调度 | 按结束时间最早排序 |
| 背包问题 | 分数背包 | 按单位价值降序装入 |
| 货币系统 | 找零钱(特定币值) | 优先使用大额硬币 |
| 任务调度 | 最小化延迟 | 按截止时间排序(EDD规则) |
| 字符串 | 拼接最小字符串 | 按自定义比较器排序拼接 |
| 其他 | 柠檬水找零 | 优先使用更通用的组合找零 |
"所有能用贪心算法解决的问题,也都能用动态规划解决。但反之不成立。贪心算法的优势在于其简单性和高效性,但这种优势是有代价的 -- 它并不总是正确的。"
当问题不能保证贪心最优解时,贪心策略可以作为近似算法使用:
key 参数或 cmp_to_key。贪心算法的魅力在于其思想简单但应用广泛。从哈夫曼的压缩编码到 Dijkstra 的导航路线,从 Kruskal 的网络布线到任务调度的截止时间排序,贪心策略无处不在。
"贪心算法教会我们的是:有时候,不瞻前顾后、不权衡再三,只看眼前做出最好的选择,反而能得到最好的结果。这既是算法的智慧,也是一种人生哲学。"