贪心算法在信息学奥赛中的地位
贪心算法是信息学奥赛中解决优化问题的重要方法,它以其简洁高效的特点,在各类竞赛中占据着不可替代的地位。
与动态规划等算法相比,贪心算法往往具有更低的时间复杂度,实现也更为简单。掌握贪心算法的核心思想和适用场景,能够帮助你在竞赛中快速解决大量优化类问题,为复杂问题争取宝贵的时间。
贪心算法基本概念
贪心算法(Greedy Algorithm)是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法。
贪心算法的特点
-
•
局部最优选择:在每一步,选择当前状态下最好的选项
-
•
无后效性:过去的选择不会影响未来的选择,只与当前状态有关
-
•
简洁高效:通常时间复杂度较低,实现简单
-
•
不保证全局最优:并非所有问题都适用贪心算法
贪心算法的基本步骤
- 建立数学模型来描述问题
- 把求解的问题分成若干个子问题
- 对每个子问题求解,得到子问题的局部最优解
- 把子问题的局部最优解合成原来问题的一个解
贪心与其他算法的对比
适合用贪心算法解决的问题
活动选择问题
在时间不冲突的情况下选择最多的活动
资源分配问题
如找零钱、背包问题的特定情况
图论优化问题
最小生成树、最短路径等问题
区间问题
区间覆盖、区间调度等问题
编码问题
如哈夫曼编码等数据压缩问题
调度问题
如任务调度以最小化等待时间
贪心算法的核心原理
贪心算法能够成功的关键在于问题本身具有"贪心选择性质"和"最优子结构性质"。只有同时满足这两个性质的问题,才能用贪心算法求得最优解。
贪心选择性质
贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择来达到。即,在每一步做出的选择都是当前状态下的最佳选择,而不考虑将来的影响。
这是贪心算法与动态规划的主要区别。动态规划需要考虑所有可能的选择并存储中间结果,而贪心算法则做出局部最优选择,并且不再回头改变这个选择。
最优子结构性质
最优子结构性质是指问题的最优解包含其子问题的最优解。如果一个问题的最优解可以由其子问题的最优解构造出来,那么这个问题就具有最优子结构性质。
这是贪心算法和动态规划都需要满足的性质。例如,最短路径问题就具有最优子结构:从A到C的最短路径如果经过B,那么A到B和B到C的路径也分别是它们之间的最短路径。
如何证明贪心算法的正确性
在竞赛中,我们需要能够判断一个问题是否适合用贪心算法解决,并证明其正确性。常用的证明方法有:
1. 交换论证法(Exchange Argument)
假设存在一个最优解没有采用贪心选择,我们证明可以通过交换其中的某些选择,使其采用贪心选择,并且仍然是最优解。通过这种方式,可以证明贪心选择能够得到最优解。
2. 归纳法(Induction)
基础步骤:证明贪心算法对于规模为1的问题能得到最优解。
归纳步骤:假设贪心算法对于规模为k的问题能得到最优解,证明对于规模为k+1的问题也能得到最优解。
3. 反证法(Contradiction)
假设贪心算法不能得到最优解,那么存在一个最优解与贪心解的第一个不同选择点。通过分析这个选择点,得出矛盾的结论,从而证明贪心算法的正确性。
贪心策略的选择与实现
贪心算法的关键在于如何选择贪心策略,即如何定义"当前状态下的最优选择"。对于同一个问题,不同的贪心策略可能会导致不同的结果。
常见的贪心策略
-
•
按价值排序:如单位重量价值最高的物品优先
-
•
按时间排序:如结束时间最早的活动优先
-
•
按长度/大小排序:如最短路径优先
-
•
按比例排序:如性价比最高的选择优先
-
•
按需分配:如优先满足最紧急的需求
选择贪心策略的技巧
1. 从简单案例入手,尝试找到规律
2. 考虑多种可能的排序方式
3. 用反例验证策略的正确性
4. 结合问题约束条件设计策略
贪心算法的一般实现步骤
// 贪心算法的一般框架
// 1. 数据准备与预处理
void greedyAlgorithm(Data input) {
// 2. 根据贪心策略对数据进行排序
sort(data.begin(), data.end(), [](const Element& a, const Element& b) {
// 定义比较规则,即贪心策略
return compare(a, b);
});
// 3. 按照排序后的顺序,依次选择并处理
Result result;
for (const auto& element : data) {
// 4. 检查当前元素是否可以加入解集中
if (canAdd(element, result)) {
addToResult(element, result);
}
}
// 5. 返回结果
return result;
}
示例:选择不重叠区间
// 贪心算法示例:选择最多的不重叠区间
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 定义区间
struct Interval {
int start;
int end;
Interval(int s, int e) : start(s), end(e) {}
};
// 贪心策略:按结束时间排序
bool compareIntervals(const Interval& a, const Interval& b) {
return a.end < b.end;
}
// 选择最多的不重叠区间
int selectMaxIntervals(vector<Interval>& intervals) {
if (intervals.empty()) return 0;
// 按结束时间排序
sort(intervals.begin(), intervals.end(), compareIntervals);
int count = 1; // 至少选择一个区间
int lastEnd = intervals[0].end; // 记录最后选择的区间的结束时间
// 遍历所有区间
for (int i = 1; i < intervals.size(); i++) {
// 如果当前区间与上一个选择的区间不重叠
if (intervals[i].start >= lastEnd) {
count++;
lastEnd = intervals[i].end;
}
}
return count;
}
int main() {
vector<Interval> intervals = {
Interval(1, 4),
Interval(3, 5),
Interval(0, 6),
Interval(5, 7),
Interval(3, 9),
Interval(5, 9),
Interval(6, 10),
Interval(8, 11),
Interval(8, 12),
Interval(2, 14),
Interval(12, 16)
};
cout << "最多可选择的不重叠区间数: " << selectMaxIntervals(intervals) << endl;
// 输出:4 (例如选择 [1,4], [5,7], [8,11], [12,16])
return 0;
}
贪心算法经典例题解析
活动选择问题
问题描述:有n个活动,每个活动有一个开始时间和结束时间。一个人只能参加一个活动,且活动时间不能重叠。请找出最多能参加的活动数量。
解题思路:
这是贪心算法的经典应用。最优策略是选择结束时间最早的活动,这样可以留下更多时间参加其他活动。
- 将所有活动按结束时间从小到大排序
- 选择第一个活动
- 依次选择下一个活动,要求其开始时间不早于上一个选择的活动的结束时间
- 重复步骤3,直到没有活动可选择
代码实现
// 活动选择问题
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 活动结构体
struct Activity {
int start; // 开始时间
int end; // 结束时间
int index; // 活动编号
Activity(int s, int e, int i) : start(s), end(e), index(i) {}
};
// 比较函数:按结束时间排序
bool compare(const Activity& a, const Activity& b) {
return a.end < b.end;
}
// 选择最多的活动
vector<int> selectActivities(vector<Activity>& activities) {
// 按结束时间排序
sort(activities.begin(), activities.end(), compare);
vector<int> selected;
int lastEnd = -1; // 上一个选中活动的结束时间
// 遍历所有活动
for (const auto& act : activities) {
// 如果当前活动的开始时间晚于或等于上一个活动的结束时间
if (act.start >= lastEnd) {
selected.push_back(act.index);
lastEnd = act.end;
}
}
return selected;
}
int main() {
// 示例输入
vector<Activity> activities = {
Activity(1, 4, 1),
Activity(3, 5, 2),
Activity(0, 6, 3),
Activity(5, 7, 4),
Activity(3, 9, 5),
Activity(5, 9, 6),
Activity(6, 10, 7),
Activity(8, 11, 8),
Activity(8, 12, 9),
Activity(2, 14, 10),
Activity(12, 16, 11)
};
vector<int> result = selectActivities(activities);
cout << "最多可参加 " << result.size() << " 个活动,分别是:" << endl;
for (int idx : result) {
cout << "活动 " << idx << " ";
}
cout << endl;
return 0;
}
哈夫曼编码
问题描述:哈夫曼编码是一种用于数据压缩的前缀编码算法。给定一组字符及其出现频率,构造哈夫曼树并生成每个字符的哈夫曼编码,使总编码长度最小。
解题思路:
哈夫曼编码采用贪心策略,每次选择频率最低的两个节点合并成一个新节点,直到只剩下一个节点。
- 将所有字符看作频率为其出现次数的节点
- 创建一个优先队列(最小堆)存储这些节点
- 从队列中取出频率最小的两个节点,合并成一个新节点,新节点的频率为两个节点的频率之和
- 将新节点加入队列
- 重复步骤3-4,直到队列中只剩下一个节点(根节点)
- 从根节点出发,为每个字符生成编码(左子树为0,右子树为1)
代码实现
// 哈夫曼编码
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
#include <string>
using namespace std;
// 哈夫曼树节点
struct HuffmanNode {
char data; // 字符
int frequency; // 频率
HuffmanNode *left, *right; // 左右子树
HuffmanNode(char d, int f) : data(d), frequency(f), left(nullptr), right(nullptr) {}
};
// 比较函数,用于优先队列(最小堆)
struct CompareNodes {
bool operator()(HuffmanNode* a, HuffmanNode* b) {
return a->frequency > b->frequency; // 频率小的节点优先级高
}
};
// 生成哈夫曼编码
void generateCodes(HuffmanNode* root, string code,
unordered_map<char, string>& huffmanCodes) {
if (root == nullptr) return;
// 如果是叶子节点,记录编码
if (root->left == nullptr && root->right == nullptr) {
huffmanCodes[root->data] = code;
return;
}
// 左子树加"0",右子树加"1"
generateCodes(root->left, code + "0", huffmanCodes);
generateCodes(root->right, code + "1", huffmanCodes);
}
// 构建哈夫曼树并返回编码
unordered_map<char, string> buildHuffmanTree(unordered_map<char, int>& frequencies) {
// 创建最小堆
priority_queue<HuffmanNode*, vector<HuffmanNode*>, CompareNodes> minHeap;
// 将所有字符加入堆
for (auto& pair : frequencies) {
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* newNode = new HuffmanNode('$', left->frequency + right->frequency);
newNode->left = left;
newNode->right = right;
// 将新节点加入堆
minHeap.push(newNode);
}
// 生成哈夫曼编码
unordered_map<char, string> huffmanCodes;
generateCodes(minHeap.top(), "", huffmanCodes);
return huffmanCodes;
}
int main() {
// 示例:字符及其频率
unordered_map<char, int> frequencies = {
{'a', 5}, {'b', 9}, {'c', 12}, {'d', 13}, {'e', 16}, {'f', 45}
};
// 构建哈夫曼树并获取编码
unordered_map<char, string> huffmanCodes = buildHuffmanTree(frequencies);
// 输出结果
cout << "哈夫曼编码:" << endl;
for (auto& pair : huffmanCodes) {
cout << pair.first << " : " << pair.second << endl;
}
// 计算总编码长度
int totalLength = 0;
for (auto& pair : frequencies) {
totalLength += pair.second * huffmanCodes[pair.first].length();
}
cout << "总编码长度: " << totalLength << endl;
return 0;
}
最小硬币找零问题
问题描述:给定不同面额的硬币和一个总金额,编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回-1。假设每种硬币的数量是无限的。
解题思路:
当硬币面额满足"贪心选择性质"时(如人民币的面额),可以使用贪心算法:每次选择最大面额的硬币,直到总金额为0。
注意:
贪心算法并不适用于所有硬币系统。例如,对于面额为[1, 3, 4]且总金额为6的情况,贪心算法会选择4+1+1(3枚),而最优解是3+3(2枚)。
代码实现
// 最小硬币找零问题(贪心算法)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 贪心算法解决硬币找零问题
// 前提:硬币系统满足贪心选择性质(如人民币系统)
vector<int> minCoinsGreedy(vector<int>& coins, int amount) {
vector<int> result;
// 按面额从大到小排序
sort(coins.rbegin(), coins.rend());
for (int coin : coins) {
// 尽可能使用当前面额的硬币
while (amount >= coin) {
amount -= coin;
result.push_back(coin);
}
// 如果金额已凑够,退出循环
if (amount == 0) break;
}
// 如果无法凑够金额,返回空向量
if (amount != 0) {
return {};
}
return result;
}
int main() {
// 人民币硬币系统(满足贪心选择性质)
vector<int> coins = {1, 5, 10, 20, 50, 100};
int amount = 38;
vector<int> solution = minCoinsGreedy(coins, amount);
if (solution.empty()) {
cout << "无法凑成金额 " << amount << endl;
} else {
cout << "凑成金额 " << amount << " 需要的最少硬币数为 " << solution.size() << endl;
cout << "硬币组合:";
for (int coin : solution) {
cout << coin << " ";
}
cout << endl;
}
// 测试不满足贪心性质的情况
vector<int> badCoins = {1, 3, 4};
int badAmount = 6;
vector<int> badSolution = minCoinsGreedy(badCoins, badAmount);
cout << endl << "对于硬币系统 [1,3,4] 和金额 6:" << endl;
if (badSolution.empty()) {
cout << "无法凑成金额 6" << endl;
} else {
cout << "贪心算法得到的硬币数为 " << badSolution.size() << endl;
cout << "贪心算法的硬币组合:";
for (int coin : badSolution) {
cout << coin << " ";
}
cout << endl;
cout << "但最优解应为 2 枚(3+3)" << endl;
}
return 0;
}
区间覆盖问题
问题描述:给定一个长度为L的线段,以及n个区间,每个区间有一个起点和终点。选择最少数量的区间,使它们能够完全覆盖整个线段[0, L]。
解题思路:
这是区间问题的另一个经典应用,贪心策略是每次选择覆盖当前起点且延伸最远的区间。
- 将所有区间按起点从小到大排序
- 初始化当前覆盖的终点为0
- 在所有起点小于等于当前终点的区间中,选择终点最大的区间
- 更新当前覆盖的终点为选中区间的终点
- 如果当前覆盖的终点已达到或超过L,则完成;否则重复步骤3-4
- 如果无法找到合适的区间覆盖剩余部分,则问题无解
代码实现
// 区间覆盖问题
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 区间结构体
struct Interval {
int start; // 起点
int end; // 终点
Interval(int s, int e) : start(s), end(e) {}
};
// 按起点排序
bool compare(const Interval& a, const Interval& b) {
return a.start < b.start;
}
// 求解区间覆盖问题
vector<Interval> coverLine(vector<Interval>& intervals, int L) {
vector<Interval> result;
// 按起点排序
sort(intervals.begin(), intervals.end(), compare);
int currentEnd = 0; // 当前覆盖到的位置
int nextEnd = 0; // 下一步能覆盖到的最远位置
int index = 0; // 当前处理的区间索引
int n = intervals.size();
while (currentEnd < L) {
bool found = false;
// 在所有起点 <= currentEnd 的区间中,找终点最大的
while (index < n && intervals[index].start <= currentEnd) {
if (intervals[index].end > nextEnd) {
nextEnd = intervals[index].end;
found = true;
}
index++;
}
// 如果没有找到合适的区间,说明无法覆盖
if (!found) {
return {};
}
// 选择能覆盖最远的区间
// 找到这个区间(最后一个更新nextEnd的区间)
for (int i = 0; i < index; i++) {
if (intervals[i].end == nextEnd) {
result.push_back(intervals[i]);
break;
}
}
currentEnd = nextEnd;
// 如果已经覆盖整个线段,退出
if (currentEnd >= L) {
break;
}
}
return result;
}
int main() {
// 线段长度
int L = 10;
// 可用区间
vector<Interval> intervals = {
Interval(0, 3),
Interval(2, 6),
Interval(3, 4),
Interval(6, 10),
Interval(5, 7),
Interval(8, 9)
};
vector<Interval> solution = coverLine(intervals, L);
if (solution.empty()) {
cout << "无法覆盖整个线段[0, " << L << "]" << endl;
} else {
cout << "最少需要 " << solution.size() << " 个区间覆盖线段[0, " << L << "]" << endl;
cout << "选择的区间:" << endl;
for (const auto& interval : solution) {
cout << "[" << interval.start << ", " << interval.end << "]" << endl;
}
}
return 0;
}
贪心算法解题策略与技巧
在信息学奥赛中,成功应用贪心算法解题需要掌握一定的策略和技巧。以下是一些实用的经验总结:
如何判断问题是否适合贪心算法
尝试小规模案例
对小规模输入尝试手动求解,观察是否存在贪心选择模式
寻找反例
尝试构造反例来验证贪心策略是否有效,若存在反例则不适合
检查问题性质
判断问题是否具有贪心选择性质和最优子结构性质
参考类似问题
很多贪心问题都属于经典模型的变体,可参考经典问题的解法
设计贪心策略的技巧
多角度排序
尝试按不同的关键字排序(如开始时间、结束时间、长度、价值等)
比例选择
当涉及两个维度时(如价值和重量),考虑按比例排序(如单位重量价值)
局部优化
每次选择对当前局面最有利的选项,不考虑长远影响
分步贪心
将复杂问题分解为多个步骤,每一步应用贪心策略
贪心算法的常见错误与避免方法
错误类型:错误的贪心策略
选择了不恰当的贪心标准,导致无法得到最优解
避免方法:
- 多尝试几种可能的策略
- 用反例验证策略的正确性
- 参考类似问题的标准解法
错误类型:忽略问题约束
在设计贪心策略时,没有考虑到问题的某些约束条件
避免方法:
- 仔细分析问题的所有约束
- 在选择过程中加入约束检查
- 用多个测试案例验证算法
错误类型:不适用于贪心的问题
对不具备贪心选择性质的问题强行使用贪心算法
避免方法:
- 学会识别贪心算法的适用场景
- 掌握其他算法(如动态规划)作为补充
- 当贪心策略无法通过所有测试案例时,及时换用其他方法
贪心与其他算法的结合
在实际问题中,贪心算法常常与其他算法结合使用:
贪心 + 排序
大多数贪心算法都需要先对数据进行排序,以便按贪心策略选择最优项
贪心 + 优先队列
使用优先队列(堆)高效地获取当前最优选择,如哈夫曼编码、任务调度等
贪心 + 动态规划
某些问题可以先用贪心策略缩小问题规模,再用动态规划求解
贪心 + 图论算法
如最小生成树(Kruskal和Prim算法)、最短路径(Dijkstra算法)等
贪心算法常见模型
1. 区间问题模型
区间问题是贪心算法应用最广泛的领域之一,常见的有:
区间选点问题
在数轴上选择最少的点,使每个区间至少包含一个点。策略:按区间右端点排序,每次选择最早结束的区间的右端点。
区间覆盖问题
用最少的区间覆盖整条线段。策略:按区间起点排序,每次选择覆盖当前位置且延伸最远的区间。
区间调度问题
选择最多的不重叠区间。策略:按区间结束时间排序,每次选择最早结束的区间。
核心技巧
区间问题的关键是找到合适的排序方式,通常有三种选择:按起点排序、按终点排序、按长度排序。大多数情况下,按终点排序是有效的策略。
2. 资源分配问题模型
资源分配问题涉及如何最优地分配有限资源,常见的有:
背包问题(部分背包)
可以取物品的一部分,最大化背包价值。策略:按单位重量价值排序,优先选择单位价值高的物品。
任务调度问题
安排任务顺序,最小化总等待时间。策略:按任务执行时间排序,先执行短任务。
机器调度问题
将任务分配给机器,最小化完成时间。策略:将新任务分配给当前负载最小的机器。
核心技巧
资源分配问题的关键是确定资源分配的优先级,通常需要按某种比例或效率指标排序,优先满足效率最高的需求。
3. 图论中的贪心模型
图论中有许多经典算法采用贪心策略:
最小生成树
Kruskal算法:按边权排序,每次选择不形成环的最小边
Prim算法:从顶点出发,每次选择连接树与非树顶点的最小边
单源最短路径
Dijkstra算法:每次选择距离源点最近的未确定顶点,更新其邻接点距离
最优路径问题
在某些特定图中,可使用贪心策略寻找最优路径
核心技巧
图论中的贪心算法通常依赖于某种排序(如边权排序)和集合操作(如并查集、优先队列),关键是证明算法的每一步选择都能导致全局最优解。
4. 组合优化问题模型
组合优化问题涉及从有限个可能的解中寻找最优解:
哈夫曼编码
数据压缩算法,每次合并频率最低的两个节点,构建最优前缀码。
集合覆盖问题
用最少的子集覆盖全集,贪心策略是每次选择覆盖最多未覆盖元素的子集。
旅行商问题(近似解)
寻找访问所有城市的最短回路,贪心近似算法有最近邻点法等。
核心技巧
组合优化问题往往是NP难问题,贪心算法通常只能得到近似解。关键是设计能得到较好近似比的贪心策略,或者证明在特定情况下能得到最优解。
贪心算法练习题
分发饼干
问题描述:假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子i,都有一个胃口值g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干j,都有一个尺寸s[j]。如果s[j] >= g[i],我们可以将这个饼干j分配给孩子i,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
提示:
- 排序两个数组
- 用最小的饼干满足最小胃口的孩子
- 双指针技巧
无重叠区间
问题描述:给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。注意:可以认为区间的终点总是大于它的起点。区间 [1,2] 和 [2,3] 的边界相互"接触",但没有相互重叠。
提示:
- 与活动选择问题类似,但要求计算需要移除的数量
- 按结束时间排序
- 贪心选择不重叠的区间,总区间数减去该数量即为答案
用最少数量的箭引爆气球
问题描述:在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以y坐标并不重要,因此只要知道开始和结束的x坐标就足够了。开始坐标总是小于结束坐标。一支弓箭可以沿着x轴从不同点完全垂直地射出。在坐标x处射出一支箭,若有一个气球的直径的开始和结束坐标为x_start,x_end, 且满足 x_start ≤ x ≤ x_end,则该气球会被引爆。可以射出的弓箭的数量没有限制。弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
提示:
- 类似区间选点问题
- 按气球的结束坐标排序
- 每次在当前气球的结束坐标射箭
买卖股票的最佳时机 II
问题描述:给定一个数组,它的第i个元素是一支给定股票第i天的价格。设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
提示:
- 贪心策略:只要有利可图就进行交易
- 累加所有正的价格差
- 时间复杂度O(n)
重构字符串
问题描述:给定一个字符串S,检查是否能重新排布其中的字母,使得两相邻的字符不同。若可行,输出任意可行的结果。若不可行,返回空字符串。
提示:
- 首先检查是否可能:出现次数最多的字符不能超过(n+1)/2
- 使用优先队列(最大堆)按字符频率排序
- 每次选择频率最高且与前一个字符不同的字符