贪心算法是一种在每一步决策中都采取当前 "当前最优" 选择的算法思想,它通过局部局部最优解来期望获得全局最优解。尽管并非所有问题都适用,但在具有贪心选择性质 和最优子结构的问题中,贪心算法能以极高的效率(通常是线性或线性对数级)给出最优解。本文将从贪心算法的核心思想出发,结合 C++ 实现,解析其适用场景、设计策略及经典应用。
一、贪心算法的核心思想
贪心算法的本质是逐步构建解决方案,每一步都选择对当前而言最优的选项,不回溯。其核心要素包括:
1.1 贪心选择性质
问题的全局最优解可以通过一系列局部最优选择(贪心选择)来获得。即,每次选择只依赖于已做出的选择,不依赖未做出的选择。
1.2 最优子结构
问题的最优解包含其子问题的最优解。这是所有可以用动态规划或贪心算法解决的问题的共同特征。
1.3 与动态规划的区别
- 贪心算法:自顶向下,每次做局部最优选择,不回溯,不保存子问题解。
- 动态规划:通常自底向上(或备忘录法),保存子问题解,根据子问题解做出选择。
贪心算法的优势在于时间和空间效率更高,但适用范围更窄;动态规划适用范围更广,但通常更复杂。
二、贪心算法的设计步骤
设计贪心算法需遵循以下步骤:
- 问题分析:判断问题是否具有贪心选择性质和最优子结构。
- 确定贪心策略:明确 "当前最优" 的标准(如最大、最小、最早、最晚等)。
- 排序预处理:多数贪心问题需要先对数据排序(按特定维度)。
- 迭代选择:按贪心策略逐步选择,构建解决方案。
- 验证正确性:证明贪心选择能得到全局最优解(关键且困难的一步)。
三、经典贪心问题与 C++ 实现
3.1 活动选择问题(Activity Selection)
问题 :有 n 个活动,每个活动有开始时间start[i]和结束时间end[i],选择最多的互不重叠的活动。
贪心策略 :每次选择最早结束的活动,为剩余活动留出更多时间。
实现:
cpp
运行
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 活动结构体
struct Activity {
int start;
int end;
};
// 按结束时间排序
bool compare(const Activity& a, const Activity& b) {
return a.end < b.end;
}
int maxActivities(vector<Activity>& activities) {
if (activities.empty()) return 0;
// 排序
sort(activities.begin(), activities.end(), compare);
int count = 1; // 至少选择第一个活动
int last_end = activities[0].end;
// 迭代选择
for (size_t i = 1; i < activities.size(); ++i) {
// 如果当前活动开始时间 >= 上一个活动结束时间,选择它
if (activities[i].start >= last_end) {
count++;
last_end = activities[i].end;
}
}
return count;
}
int main() {
vector<Activity> activities = {
{1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 9}, {5, 9}, {6, 10}, {8, 11}, {8, 12}, {2, 14}, {12, 16}
};
cout << "最多可选择的活动数: " << maxActivities(activities) << endl; // 输出4
return 0;
}
正确性证明:假设存在最优解包含的活动数多于贪心选择的结果,通过替换可推出矛盾,故贪心策略有效。
3.2 零钱兑换问题(Coin Change)
问题 :用面额为coins的硬币兑换金额amount,求最少硬币数(假设每种硬币数量无限,且存在 1 元硬币)。
贪心策略 :每次选择最大面额的硬币,尽可能用大面额硬币覆盖金额。
实现:
cpp
运行
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int coinChange(vector<int>& coins, int amount) {
if (amount == 0) return 0;
// 排序(从大到小)
sort(coins.rbegin(), coins.rend());
int count = 0;
int remaining = amount;
for (int coin : coins) {
if (remaining >= coin) {
// 尽可能用当前最大面额
int num = remaining / coin;
count += num;
remaining -= num * coin;
}
if (remaining == 0) break;
}
// 如果无法兑换(remaining未减到0)
return remaining == 0 ? count : -1;
}
int main() {
vector<int> coins = {25, 10, 5, 1}; // 美元硬币面额
int amount = 37;
cout << "最少硬币数: " << coinChange(coins, amount) << endl; // 3(25+10+1+1 → 实际是25+10+1+1?不,37=25+10+1+1是4枚?哦,正确应为25+10+1+1是4枚?实际正确计算:25+10+1+1=37,确实4枚)
return 0;
}
注意 :贪心策略仅适用于 "canonical coin systems"(如大多数国家的货币体系)。对于非标准面额(如{1, 3, 4}兑换 6),贪心可能失效,需用动态规划。
3.3 区间覆盖问题(Interval Covering)
问题 :用最少的区间覆盖目标区间[target_start, target_end],给定一系列候选区间intervals。
贪心策略:
- 先选择所有与目标区间起点重叠且终点最远的区间。
- 更新目标起点为选中区间的终点,重复步骤 1,直到覆盖整个目标区间。
实现:
cpp
运行
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Interval {
int start;
int end;
};
int minIntervalsToCover(vector<Interval>& intervals, int target_start, int target_end) {
// 按起点排序
sort(intervals.begin(), intervals.end(), [](const Interval& a, const Interval& b) {
return a.start < b.start;
});
int count = 0;
int current_end = target_start;
int next_end = target_start;
size_t i = 0;
int n = intervals.size();
while (current_end < target_end) {
// 找到所有与当前起点重叠的区间,记录最远终点
while (i < n && intervals[i].start <= current_end) {
next_end = max(next_end, intervals[i].end);
i++;
}
// 没有可覆盖的区间,无法完成
if (next_end == current_end) return -1;
count++;
current_end = next_end;
}
return count;
}
int main() {
vector<Interval> intervals = {
{1, 4}, {2, 3}, {5, 7}, {6, 8}, {9, 10}
};
int target_start = 1, target_end = 10;
cout << "最少需要的区间数: " << minIntervalsToCover(intervals, target_start, target_end) << endl; // 3([1,4], [5,8], [9,10])
return 0;
}
3.4 哈夫曼编码(Huffman Coding)
问题:为字符构建前缀编码,使总编码长度最短(字符出现频率已知)。
贪心策略 :每次选择频率最低的两个节点合并,生成新节点,重复直到只剩一个节点(根节点)。
实现:
cpp
运行
#include <iostream>
#include <queue>
#include <vector>
#include <unordered_map>
using namespace std;
// 哈夫曼树节点
struct HuffmanNode {
char data;
int freq;
HuffmanNode *left, *right;
HuffmanNode(char d, int f) : data(d), freq(f), left(nullptr), right(nullptr) {}
};
// 优先队列比较器(最小堆)
struct Compare {
bool operator()(HuffmanNode* a, HuffmanNode* b) {
return a->freq > b->freq; // 频率低的优先
}
};
// 生成哈夫曼编码(递归)
void generateCodes(HuffmanNode* root, string code, unordered_map<char, string>& codes) {
if (!root) return;
// 叶子节点
if (!root->left && !root->right) {
codes[root->data] = code.empty() ? "0" : code; // 处理只有一个字符的情况
}
generateCodes(root->left, code + "0", codes);
generateCodes(root->right, code + "1", codes);
}
// 构建哈夫曼树并返回编码
unordered_map<char, string> huffmanCoding(unordered_map<char, int>& freq) {
// 最小堆存储节点
priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> minHeap;
// 初始化叶子节点
for (auto& pair : freq) {
minHeap.push(new HuffmanNode(pair.first, pair.second));
}
// 合并节点
while (minHeap.size() > 1) {
// 取出两个频率最低的节点
HuffmanNode* left = minHeap.top();
minHeap.pop();
HuffmanNode* right = minHeap.top();
minHeap.pop();
// 合并为新节点(数据用特殊字符表示非叶子节点)
HuffmanNode* merged = new HuffmanNode('$', left->freq + right->freq);
merged->left = left;
merged->right = right;
minHeap.push(merged);
}
// 生成编码
unordered_map<char, string> codes;
generateCodes(minHeap.top(), "", codes);
return codes;
}
int main() {
unordered_map<char, int> freq = {
{'a', 5}, {'b', 9}, {'c', 12}, {'d', 13}, {'e', 16}, {'f', 45}
};
auto codes = huffmanCoding(freq);
cout << "哈夫曼编码:" << endl;
for (auto& pair : codes) {
cout << pair.first << ": " << pair.second << endl;
}
return 0;
}
输出说明 :频率高的字符(如f:45)编码短,频率低的字符(如a:5)编码长,符合贪心策略。
3.5 买卖股票的最佳时机 II
问题 :给定股票价格数组prices,可多次买卖(买入后必须卖出才能再买),求最大利润。
贪心策略:只要当天价格高于前一天,就前一天买入、当天卖出,累积所有正收益。
实现:
cpp
运行
#include <iostream>
#include <vector>
using namespace std;
int maxProfit(vector<int>& prices) {
if (prices.size() < 2) return 0;
int max_profit = 0;
for (size_t i = 1; i < prices.size(); ++i) {
// 只要有收益就交易
if (prices[i] > prices[i-1]) {
max_profit += prices[i] - prices[i-1];
}
}
return max_profit;
}
int main() {
vector<int> prices = {7, 1, 5, 3, 6, 4};
cout << "最大利润: " << maxProfit(prices) << endl; // 7((5-1)+(6-3)=4+3=7)
return 0;
}
原理:所有上升区间的收益之和即为最大利润,贪心策略通过捕捉每一个微小的上升区间实现全局最优。
四、贪心算法的局限性与适用场景
4.1 局限性
-
并非所有问题都适用:仅当问题具有贪心选择性质时有效,否则可能得到次优解。
- 反例:零钱兑换问题中,若面额为
{1, 3, 4},兑换 6 元,贪心策略(4+1+1,3 枚)不如最优解(3+3,2 枚)。
- 反例:零钱兑换问题中,若面额为
-
局部最优≠全局最优:短视的选择可能导致后续可选方案受限。
-
难以证明正确性:贪心策略的正确性证明通常需要复杂的数学推导(如交换论证法)。
4.2 适用场景
贪心算法适用于以下类型的问题:
- 调度问题:如活动选择、任务调度、CPU 调度等(通常选择最早结束 / 开始的)。
- 覆盖问题:如区间覆盖、集合覆盖(选择覆盖范围最大的)。
- 编码问题:如哈夫曼编码、前缀编码(优先处理频率高 / 低的)。
- 资源分配问题:如水资源分配、带宽分配(按某种优先级分配)。
- 排序相关问题:如根据两个维度排序(如根据结束时间、重量、价值等)。
五、贪心算法的优化与技巧
5.1 排序是关键
多数贪心问题需要先排序,排序的维度直接决定贪心策略的有效性:
- 活动选择:按结束时间排序
- 区间覆盖:按起点排序
- fractional knapsack(部分背包):按单位价值排序
5.2 贪心 + 数据结构
复杂的贪心问题常需结合高效数据结构:
- 优先队列(堆):如哈夫曼编码、实时任务调度
- 并查集:如最小生成树的 Kruskal 算法
- 平衡树:如维护动态区间的贪心选择
5.3 多维度贪心
当问题涉及多个维度时,需找到正确的贪心标准:
示例:根据身高和人数排列队列
cpp
运行
// 问题:people[i] = [h, k],k表示该人前面应有k个身高≥他的人
// 贪心策略:先按身高降序排序,身高相同则按k升序排序,然后按k插入对应位置
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
// 排序:身高降序,k升序
sort(people.begin(), people.end(), [](const vector<int>& a, const vector<int>& b) {
return a[0] > b[0] || (a[0] == b[0] && a[1] < b[1]);
});
vector<vector<int>> result;
for (auto& p : people) {
// 按k值插入到对应位置
result.insert(result.begin() + p[1], p);
}
return result;
}
六、贪心算法与其他算法的结合
6.1 贪心 + 动态规划
某些问题可先用贪心简化,再用动态规划求解:
- 旅行商问题(TSP):贪心算法(如最近邻)可得到近似解,动态规划可得到精确解但复杂度高。
- 最长上升子序列(LIS):贪心 + 二分查找可将 O (n²) 优化为 O (n log n)。
cpp
运行
// 贪心+二分查找求LIS长度
int lengthOfLIS(vector<int>& nums) {
vector<int> tails;
for (int num : nums) {
// 找到第一个≥num的位置
auto it = lower_bound(tails.begin(), tails.end(), num);
if (it == tails.end()) {
tails.push_back(num); // 扩展序列
} else {
*it = num; // 替换为更小的数,为后续元素留出空间
}
}
return tails.size();
}
6.2 贪心 + 图论
图论中的经典算法常基于贪心思想:
- 最小生成树:Kruskal 算法(按边权排序,选不形成环的边)和 Prim 算法(每次加最近的点)
- 最短路径:Dijkstra 算法(每次选当前最短距离的点)
七、总结
贪心算法是一种高效且直观的算法思想,其核心在于每一步都做出局部最优选择,适用于具有贪心选择性质和最优子结构的问题。与动态规划相比,贪心算法通常更简单、效率更高,但适用范围较窄。
在 C++ 实现中,贪心算法常与排序(<algorithm>中的sort)和优先队列(priority_queue)结合,通过合理的排序策略和数据结构选择,可高效解决各类问题。
学习贪心算法的关键在于:
- 识别问题是否具有贪心选择性质(这是最困难的一步)。
- 设计正确的贪心策略(如选择最早结束、最大价值、最高频率等)。
- 通过数学证明或反例验证策略的正确性。
尽管贪心算法不能解决所有优化问题,但在调度、编码、覆盖等领域,它是不可或缺的工具。掌握贪心算法不仅能提高解题效率,更能培养 "抓主要矛盾" 的问题分析能力,这对于复杂系统设计同样具有重要意义。
