贪心算法是一种在每一步决策中都采取当前 "当前最优" 选择的算法思想,它通过局部局部最优解来期望获得全局最优解。尽管并非所有问题都适用,但在具有贪心选择性质和最优子结构的问题中,贪心算法能以极高的效率(通常是线性或线性对数级)给出最优解。本文将从贪心算法的核心思想出发,结合 C++ 实现,解析其适用场景、设计策略及经典应用。
一、贪心算法的核心思想
贪心算法的本质是逐步构建解决方案,每一步都选择对当前而言最优的选项,不回溯。其核心要素包括:
1.1 贪心选择性质
问题的全局最优解可以通过一系列局部最优选择(贪心选择)来获得。即,每次选择只依赖于已做出的选择,不依赖未做出的选择。
1.2 最优子结构
问题的最优解包含其子问题的最优解。这是所有可以用动态规划或贪心算法解决的问题的共同特征。
1.3 与动态规划的区别
- 贪心算法:自顶向下,每次做局部最优选择,不回溯,不保存子问题解。
- 动态规划:通常自底向上(或备忘录法),保存子问题解,根据子问题解做出选择。
贪心算法的优势在于时间和空间效率更高,但适用范围更窄;动态规划适用范围更广,但通常更复杂。
二、贪心算法的设计步骤
设计贪心算法需遵循以下步骤:
- 问题分析:判断问题是否具有贪心选择性质和最优子结构。
- 确定贪心策略:明确 "当前最优" 的标准(如最大、最小、最早、最晚等)。
- 排序预处理:多数贪心问题需要先对数据排序(按特定维度)。
- 迭代选择:按贪心策略逐步选择,构建解决方案。
- 验证正确性:证明贪心选择能得到全局最优解(关键且困难的一步)。
三、经典贪心问题与 C++ 实现
3.1 活动选择问题(Activity Selection)
问题:有 n 个活动,每个活动有开始时间start[i]和结束时间end[i],选择最多的互不重叠的活动。
贪心策略:每次选择最早结束的活动,为剩余活动留出更多时间。
实现:
cpp
运行
#include
#include
#include
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& 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 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; // 输出4return 0;
}
正确性证明:假设存在最优解包含的活动数多于贪心选择的结果,通过替换可推出矛盾,故贪心策略有效。
3.2 零钱兑换问题(Coin Change)
问题:用面额为coins的硬币兑换金额amount,求最少硬币数(假设每种硬币数量无限,且存在 1 元硬币)。
贪心策略:每次选择最大面额的硬币,尽可能用大面额硬币覆盖金额。
实现:
cpp
运行
#include
#include
#include
using namespace std;
int coinChange(vector& 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 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
#include
#include
using namespace std;
struct Interval {int start;int end;
};
int minIntervalsToCover(vector& 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 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
#include
#include
#include
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& 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 huffmanCoding(unordered_map& freq) {// 最小堆存储节点priority_queue, 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 codes;generateCodes(minHeap.top(), "", codes);return codes;
}
int main() {unordered_map 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
#include
using namespace std;
int maxProfit(vector& 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 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> reconstructQueue(vector>& people) {// 排序:身高降序,k升序sort(people.begin(), people.end(), [](const vector& a, const vector& b) {return a[0] > b[0] || (a[0] == b[0] && a[1] < b[1]);});vector> 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& nums) {vector 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)结合,通过合理的排序策略和数据结构选择,可高效解决各类问题。
学习贪心算法的关键在于:
- 识别问题是否具有贪心选择性质(这是最困难的一步)。
- 设计正确的贪心策略(如选择最早结束、最大价值、最高频率等)。
- 通过数学证明或反例验证策略的正确性。
尽管贪心算法不能解决所有优化问题,但在调度、编码、覆盖等领域,它是不可或缺的工具。掌握贪心算法不仅能提高解题效率,更能培养 "抓主要矛盾" 的问题分析能力,这对于复杂系统设计同样具有重要意义。
