文章目录
- 贪心算法理论基础
-
- [1. 贪心算法的基本概念](#1. 贪心算法的基本概念)
-
- [1.1 基本术语](#1.1 基本术语)
- [1.2 贪心算法的特点](#1.2 贪心算法的特点)
- [1.3 贪心算法的本质](#1.3 贪心算法的本质)
- [2. 贪心算法的适用条件](#2. 贪心算法的适用条件)
-
- [2.1 贪心选择性质](#2.1 贪心选择性质)
- [2.2 最优子结构](#2.2 最优子结构)
- [2.3 无后效性](#2.3 无后效性)
- [3. 贪心算法与动态规划的区别](#3. 贪心算法与动态规划的区别)
-
- [3.1 核心区别](#3.1 核心区别)
- [3.2 如何选择](#3.2 如何选择)
- [4. 贪心算法的解题步骤](#4. 贪心算法的解题步骤)
-
- [4.1 第一步:理解问题](#4.1 第一步:理解问题)
- [4.2 第二步:分析局部最优策略](#4.2 第二步:分析局部最优策略)
- [4.3 第三步:验证贪心选择性质](#4.3 第三步:验证贪心选择性质)
- [4.4 第四步:实现算法](#4.4 第四步:实现算法)
- [5. 贪心算法常见题型](#5. 贪心算法常见题型)
-
- [5.1 分配问题](#5.1 分配问题)
- [5.2 区间问题](#5.2 区间问题)
- [5.3 跳跃问题](#5.3 跳跃问题)
- [5.4 股票问题](#5.4 股票问题)
- [5.5 其他问题](#5.5 其他问题)
- [6. 贪心算法模板](#6. 贪心算法模板)
-
- [6.1 分配问题模板](#6.1 分配问题模板)
- [6.2 区间问题模板](#6.2 区间问题模板)
- [6.3 跳跃问题模板](#6.3 跳跃问题模板)
- [6.4 股票问题模板](#6.4 股票问题模板)
- [6.5 划分字母区间模板](#6.5 划分字母区间模板)
- [6.6 其他问题模板](#6.6 其他问题模板)
- [7. 贪心算法经典题目详解](#7. 贪心算法经典题目详解)
-
- [7.1 分发饼干(455)](#7.1 分发饼干(455))
- [7.2 用最少数量的箭引爆气球(452)](#7.2 用最少数量的箭引爆气球(452))
- [7.3 跳跃游戏(55)](#7.3 跳跃游戏(55))
- [7.4 跳跃游戏II(45)](#7.4 跳跃游戏II(45))
- [7.5 买卖股票的最佳时机(121)](#7.5 买卖股票的最佳时机(121))
- [7.6 买卖股票的最佳时机II(122)](#7.6 买卖股票的最佳时机II(122))
- [7.7 划分字母区间(763)](#7.7 划分字母区间(763))
- [8. 贪心算法的时间复杂度](#8. 贪心算法的时间复杂度)
-
- [8.1 时间复杂度分析](#8.1 时间复杂度分析)
- [8.2 空间复杂度分析](#8.2 空间复杂度分析)
- [9. 何时使用贪心算法](#9. 何时使用贪心算法)
-
- [9.1 使用贪心算法的场景](#9.1 使用贪心算法的场景)
- [9.2 判断标准](#9.2 判断标准)
- [9.3 贪心算法的优缺点](#9.3 贪心算法的优缺点)
- [10. 贪心算法的证明方法](#10. 贪心算法的证明方法)
-
- [10.1 反证法](#10.1 反证法)
- [10.2 数学归纳法](#10.2 数学归纳法)
- [10.3 交换论证](#10.3 交换论证)
- [10.4 举反例](#10.4 举反例)
- [11. 常见题型总结](#11. 常见题型总结)
-
- [11.1 分配问题类](#11.1 分配问题类)
- [11.2 区间问题类](#11.2 区间问题类)
- [11.3 跳跃问题类](#11.3 跳跃问题类)
- [11.4 股票问题类](#11.4 股票问题类)
- [11.5 其他问题类](#11.5 其他问题类)
- [12. 总结](#12. 总结)
贪心算法理论基础
1. 贪心算法的基本概念
**贪心算法(Greedy Algorithm)**是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法策略。
1.1 基本术语
- 局部最优:在当前步骤中做出的最优选择
- 全局最优:整个问题的最优解
- 贪心选择性质:可以通过局部最优选择来构造全局最优解
- 最优子结构:问题的最优解包含子问题的最优解
- 无后效性:当前的选择不会影响后续选择的状态
1.2 贪心算法的特点
- 局部最优:每一步都选择当前最优解
- 不回溯:一旦做出选择,就不再改变
- 高效性:时间复杂度通常较低
- 适用性有限:不是所有问题都适合贪心算法
核心思想:
贪心算法的核心思想是:局部最优 -> 全局最优
想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,
并想不出反例,那么就试一试贪心。
示例:
贪心算法就像在迷宫中每次都选择当前看起来最好的路径:
- 每一步都选择距离目标最近的方向
- 不回溯,不改变已做的选择
- 希望最终能找到最优路径
1.3 贪心算法的本质
贪心算法的本质是局部最优策略的全局应用:
- 通过每一步的局部最优选择,期望得到全局最优解
- 不需要考虑所有可能的解,只需要考虑当前最优
- 通常需要证明贪心策略的正确性
关键点:
- 贪心选择性质:可以通过局部最优选择来构造全局最优解
- 最优子结构:问题的最优解包含子问题的最优解
- 无后效性:当前的选择不会影响后续选择的状态
2. 贪心算法的适用条件
2.1 贪心选择性质
定义:可以通过局部最优选择来构造全局最优解。
判断方法:
- 每一步都选择当前最优解
- 不需要考虑未来的影响
- 局部最优可以推导出全局最优
示例:
- 分发饼干:优先满足胃口大的孩子(局部最优),可以最大化满足的孩子数量(全局最优)
- 跳跃游戏:每次跳到能跳最远的位置(局部最优),可以到达最远的位置(全局最优)
2.2 最优子结构
定义:问题的最优解包含子问题的最优解。
判断方法:
- 问题可以分解为子问题
- 子问题的最优解可以组合成原问题的最优解
- 子问题之间相互独立
示例:
- 合并区间:合并后的区间是最优的,包含的子区间也是最优的
- 无重叠区间:删除最少的区间后,剩余区间是最优的
2.3 无后效性
定义:当前的选择不会影响后续选择的状态。
判断方法:
- 当前选择只影响当前状态
- 不会因为之前的选择而改变后续选择
- 状态转移是单向的
示例:
- 买卖股票:今天的买卖决策不影响明天的决策
- 柠檬水找零:每次找零都是独立的,不影响后续找零
3. 贪心算法与动态规划的区别
3.1 核心区别
| 特性 | 贪心算法 | 动态规划 |
|---|---|---|
| 选择方式 | 每一步都选择当前最优 | 考虑所有可能的选择 |
| 状态转移 | 只考虑当前状态 | 考虑所有历史状态 |
| 回溯 | 不回溯,不改变选择 | 可能回溯,改变选择 |
| 时间复杂度 | 通常较低 O(n) 或 O(n log n) | 通常较高 O(n²) 或更高 |
| 空间复杂度 | 通常较低 O(1) 或 O(n) | 通常较高 O(n) 或 O(n²) |
| 适用问题 | 具有贪心选择性质的问题 | 具有最优子结构的问题 |
3.2 如何选择
使用贪心算法:
- 问题具有贪心选择性质
- 可以通过局部最优推导全局最优
- 时间复杂度要求较低
使用动态规划:
- 问题具有最优子结构
- 需要保存历史状态
- 贪心策略无法得到最优解
示例对比:
- 跳跃游戏:可以用贪心(每次跳最远),也可以用DP(记录每个位置是否可达)
- 背包问题:0-1背包用DP,分数背包用贪心
4. 贪心算法的解题步骤
4.1 第一步:理解问题
关键点:
- 明确问题的目标
- 理解约束条件
- 确定优化目标(最大化或最小化)
示例:
- 分发饼干:目标是最多满足几个孩子,约束是每个孩子只能分一个饼干
- 跳跃游戏:目标是能否到达最后一个位置,约束是每次最多跳nums[i]步
4.2 第二步:分析局部最优策略
关键点:
- 思考每一步应该选择什么
- 确定局部最优的选择标准
- 考虑如何量化"最优"
示例:
- 分发饼干:局部最优 = 优先满足胃口大的孩子(用最大的饼干满足最大的胃口)
- 跳跃游戏:局部最优 = 每次跳到能跳最远的位置
4.3 第三步:验证贪心选择性质
关键点:
- 证明局部最优可以推导全局最优
- 思考反例,看是否存在反例
- 如果找不到反例,可以尝试贪心
验证方法:
- 数学证明(如果可能)
- 举反例验证
- 通过测试用例验证
4.4 第四步:实现算法
关键点:
- 按照贪心策略实现
- 注意边界条件
- 优化时间复杂度
实现步骤:
- 排序(如果需要)
- 遍历,应用贪心策略
- 更新结果
5. 贪心算法常见题型
5.1 分配问题
特点:需要将资源分配给多个对象,使得某种目标最优。
常见题目:
- 455.分发饼干:用饼干满足孩子
- 135.分发糖果:给每个孩子分糖果
- 860.柠檬水找零:找零问题
核心思路:
- 排序:通常需要先排序
- 匹配:按照贪心策略进行匹配
- 优化:最大化或最小化目标值
5.2 区间问题
特点:处理区间重叠、合并、选择等问题。
常见题目:
- 452.用最少数量的箭引爆气球:区间重叠问题
- 435.无重叠区间:删除最少的重叠区间
- 56.合并区间:合并所有重叠区间
- 763.划分字母区间:划分不重叠的区间
核心思路:
- 排序:按起点或终点排序
- 贪心策略:选择结束最早的区间(或开始最晚的区间)
- 判断重叠:判断区间是否重叠
5.3 跳跃问题
特点:在数组上跳跃,求能否到达或最少步数。
常见题目:
- 55.跳跃游戏:判断能否到达最后一个位置
- 45.跳跃游戏II:求到达最后一个位置的最少步数
核心思路:
- 贪心策略:每次跳到能跳最远的位置
- 维护最远距离:记录当前能到达的最远位置
- 更新步数:当需要跳跃时更新步数
5.4 股票问题
特点:买卖股票,使得利润最大。
常见题目:
- 122.买卖股票的最佳时机II:可以多次买卖
核心思路:
- 贪心策略:只要今天价格比昨天高就买卖
- 累加利润:将所有正利润累加
5.5 其他问题
常见题目:
- 376.摆动序列:求最长摆动子序列
- 53.最大子序和:求最大子数组和(也可以用DP)
- 1005.K次取反后最大化的数组和:K次取反后最大和
- 738.单调递增的数字:找到小于等于n的最大单调递增数字
- 406.根据身高重建队列:按身高和前面人数重建队列
6. 贪心算法模板
6.1 分配问题模板
适用场景:资源分配问题,如分发饼干、分发糖果等。
核心思路:
- 排序:对资源和要求进行排序
- 贪心匹配:按照贪心策略进行匹配
- 更新结果:匹配成功后更新结果
模板代码:
cpp
// 分发饼干模板
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
// 1. 排序
sort(g.begin(), g.end());
sort(s.begin(), s.end());
// 2. 贪心匹配
int index = s.size() - 1; // 从最大的开始
int result = 0;
for (int i = g.size() - 1; i >= 0; i--) {
if (index >= 0 && s[index] >= g[i]) {
result++;
index--;
}
}
return result;
}
};
关键点:
- 排序:通常需要先排序
- 贪心策略:优先满足大的(或小的)
- 匹配:双指针或单指针匹配
6.2 区间问题模板
适用场景:区间重叠、合并、选择等问题。
核心思路:
- 排序:按起点或终点排序
- 贪心选择:选择结束最早的区间
- 判断重叠:判断当前区间是否与已选区间重叠
模板代码:
cpp
// 用最少数量的箭引爆气球模板
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.empty()) return 0;
// 1. 按结束位置排序
sort(points.begin(), points.end(), [](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
// 2. 贪心选择
int arrows = 1;
int end = points[0][1];
for (int i = 1; i < points.size(); i++) {
// 如果当前区间起点大于前一个区间的结束点,需要新箭
if (points[i][0] > end) {
arrows++;
end = points[i][1];
}
}
return arrows;
}
};
关键点:
- 排序:按结束位置排序(或按开始位置)
- 贪心策略:选择结束最早的区间
- 重叠判断:判断区间是否重叠
6.3 跳跃问题模板
适用场景:在数组上跳跃,判断能否到达或求最少步数。
核心思路:
- 维护最远距离:记录当前能到达的最远位置
- 贪心策略:每次跳到能跳最远的位置
- 更新步数:当需要跳跃时更新步数
模板代码(判断能否到达):
cpp
// 跳跃游戏模板
class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0; // 当前能覆盖的最远距离
for (int i = 0; i <= cover; i++) {
cover = max(cover, i + nums[i]);
if (cover >= nums.size() - 1) return true;
}
return false;
}
};
模板代码(最少步数 - 正向):
cpp
// 跳跃游戏II模板(正向)
class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() == 1) return 0;
int curDistance = 0; // 当前覆盖的最远距离
int ans = 0; // 记录走的最大步数
int nextDistance = 0; // 下一步覆盖的最远距离
for (int i = 0; i < nums.size(); i++) {
nextDistance = max(nextDistance, i + nums[i]);
if (i == curDistance) {
ans++;
curDistance = nextDistance;
if (curDistance >= nums.size() - 1) {
break;
}
}
}
return ans;
}
};
模板代码(最少步数 - 反向):
cpp
// 跳跃游戏II模板(反向)
class Solution {
public:
int jump(vector<int>& nums) {
int position = nums.size() - 1;
int steps = 0;
while (position > 0) {
for (int i = 0; i < position; i++) {
if (i + nums[i] >= position) {
position = i;
steps++;
break;
}
}
}
return steps;
}
};
关键点:
- 维护最远距离:记录当前能到达的最远位置
- 贪心策略:每次跳到能跳最远的位置
- 步数更新:当需要跳跃时更新步数
6.4 股票问题模板
适用场景:买卖股票,使得利润最大。
类型一:只能买卖一次(121)
核心思路:
- 贪心策略:维护最低买入价,计算每天卖出的利润
- 更新策略:不断更新最小买入价和最大利润
模板代码:
cpp
// 买卖股票的最佳时机模板(只能买卖一次)
class Solution {
public:
int maxProfit(vector<int>& prices) {
if (prices.empty()) return 0;
int minPrice = prices[0]; // 当前最小买入价
int maxProfit = 0; // 最大利润初始化为0
for (int i = 1; i < prices.size(); i++) {
// 1. 当前卖出价格 - 最小买入价 = 当前利润
int profit = prices[i] - minPrice;
// 2. 更新最大利润
if (profit > maxProfit) {
maxProfit = profit;
}
// 3. 更新最小买入价
if (prices[i] < minPrice) {
minPrice = prices[i];
}
}
return maxProfit;
}
};
类型二:可以多次买卖(122)
核心思路:
- 贪心策略:只要今天价格比昨天高就买卖
- 累加利润:将所有正利润累加
模板代码:
cpp
// 买卖股票的最佳时机II模板(可以多次买卖)
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 1; i < prices.size(); i++) {
// 只要今天价格比昨天高,就买卖
if (prices[i] > prices[i - 1]) {
result += prices[i] - prices[i - 1];
}
}
return result;
}
};
关键点:
- 121题:只能买卖一次,维护最低买入价
- 122题:可以多次买卖,累加所有正利润
- 不需要考虑具体买卖时机,只考虑利润
6.5 划分字母区间模板
适用场景:划分字符串,使得同一字母最多出现在一个片段中。
核心思路:
- 记录每个字符最后出现的位置
- 贪心策略:维护当前段的最远右边界
- 切分条件:当遍历到边界时,可以切分
模板代码:
cpp
// 划分字母区间模板
class Solution {
public:
vector<int> partitionLabels(string s) {
// 1. 记录每个字符最后出现的位置
vector<int> last(26, 0);
for (int i = 0; i < s.size(); i++) {
last[s[i] - 'a'] = i;
}
vector<int> result;
int start = 0; // 当前段起点
int end = 0; // 当前段终点
// 2. 贪心遍历
for (int i = 0; i < s.size(); i++) {
// 更新当前段的最远右边界
end = max(end, last[s[i] - 'a']);
// 3. 到达边界,可以切分
if (i == end) {
result.push_back(end - start + 1);
start = i + 1; // 开启新一段
}
}
return result;
}
};
关键点:
- 记录每个字符最后出现的位置
- 贪心策略:维护当前段的最远右边界
- 切分条件:当遍历到边界时,可以切分
6.6 其他问题模板
摆动序列模板:
cpp
// 摆动序列模板
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
if (nums.size() <= 1) return nums.size();
int curDiff = 0; // 当前一对差值
int preDiff = 0; // 前一对差值
int result = 1; // 记录峰值个数,默认序列最右边有一个峰值
for (int i = 0; i < nums.size() - 1; i++) {
curDiff = nums[i + 1] - nums[i];
// 出现峰值
if ((curDiff > 0 && preDiff <= 0) || (curDiff < 0 && preDiff >= 0)) {
result++;
preDiff = curDiff;
}
}
return result;
}
};
最大子序和模板(贪心):
cpp
// 最大子序和模板(贪心)
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) {
count += nums[i];
// 取区间累计的最大值(相当于不断确定最大子序终止位置)
if (count > result) {
result = count;
}
// 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
if (count <= 0) {
count = 0;
}
}
return result;
}
};
7. 贪心算法经典题目详解
7.1 分发饼干(455)
题目:假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
贪心策略:
- 局部最优:优先满足胃口大的孩子(用最大的饼干满足最大的胃口)
- 全局最优:最多满足的孩子数量
代码:
cpp
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int index = s.size() - 1; // 饼干数组的下标
int result = 0;
for (int i = g.size() - 1; i >= 0; i--) { // 遍历胃口
if (index >= 0 && s[index] >= g[i]) { // 遍历饼干
result++;
index--;
}
}
return result;
}
};
关键点:
- 排序:对胃口和饼干都排序
- 贪心:从大到小匹配
- 时间复杂度:O(n log n)
7.2 用最少数量的箭引爆气球(452)
题目:有一些球形气球贴在一堵用 XY 平面表示的墙上。墙可以用一条无限长的线表示。气球用一个二维数组 points 表示,其中 points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend 之间的气球。
贪心策略:
- 局部最优:每次选择结束最早的区间,用一支箭射穿
- 全局最优:用最少的箭射穿所有气球
代码:
cpp
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.empty()) return 0;
// 按区间的结束位置排序
sort(points.begin(), points.end(), [](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});
int arrows = 1; // 至少需要一支箭
int end = points[0][1]; // 第一个区间的结束点
for (int i = 1; i < points.size(); i++) {
// 如果当前区间的起点大于前一个区间的结束点,说明需要一支新的箭
if (points[i][0] > end) {
arrows++;
end = points[i][1]; // 更新箭的作用区间
}
}
return arrows;
}
};
关键点:
- 排序:按结束位置排序
- 贪心:选择结束最早的区间
- 重叠判断:判断区间是否重叠
7.3 跳跃游戏(55)
题目:给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
贪心策略:
- 局部最优:每次跳到能跳最远的位置
- 全局最优:能否到达最后一个位置
代码:
cpp
class Solution {
public:
bool canJump(vector<int>& nums) {
int cover = 0; // 当前能覆盖的最远距离
for (int i = 0; i <= cover; i++) {
cover = max(cover, i + nums[i]);
if (cover >= nums.size() - 1) return true;
}
return false;
}
};
关键点:
- 维护最远距离:记录当前能到达的最远位置
- 贪心策略:每次跳到能跳最远的位置
- 判断条件:如果能覆盖最后一个位置,返回true
7.4 跳跃游戏II(45)
题目:给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。你的目标是使用最少的跳跃次数到达数组的最后一个位置。
贪心策略:
- 局部最优:每次跳到能跳最远的位置
- 全局最优:用最少的步数到达最后一个位置
方法一:正向贪心(推荐)
代码:
cpp
class Solution {
public:
int jump(vector<int>& nums) {
if (nums.size() == 1) return 0;
int curDistance = 0; // 当前覆盖的最远距离
int ans = 0; // 记录走的最大步数
int nextDistance = 0; // 下一步覆盖的最远距离
for (int i = 0; i < nums.size(); i++) {
nextDistance = max(nextDistance, i + nums[i]);
if (i == curDistance) {
ans++;
curDistance = nextDistance;
if (curDistance >= nums.size() - 1) {
break;
}
}
}
return ans;
}
};
方法二:反向贪心
代码:
cpp
class Solution {
public:
int jump(vector<int>& nums) {
int position = nums.size() - 1;
int steps = 0;
while (position > 0) {
for (int i = 0; i < position; i++) {
if (i + nums[i] >= position) {
position = i;
steps++;
break;
}
}
}
return steps;
}
};
关键点:
- 正向方法:维护两个距离(当前覆盖距离和下一步覆盖距离),从前往后遍历
- 反向方法:从终点往前找,找到能到达当前位置的最远起点
- 贪心策略:每次跳到能跳最远的位置
- 步数更新:当需要跳跃时更新步数
7.5 买卖股票的最佳时机(121)
题目:给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
贪心策略:
- 局部最优:在最低点买入,在最高点卖出
- 全局最优:获取最大利润
代码:
cpp
class Solution {
public:
int maxProfit(vector<int>& prices) {
if (prices.empty()) return 0; // 空数组特判
int minPrice = prices[0]; // 当前最小买入价
int maxProfit = 0; // 最大利润初始化为0
for (int i = 1; i < prices.size(); i++) {
// 1. 当前卖出价格 - 最小买入价 = 当前利润
int profit = prices[i] - minPrice;
// 2. 更新最大利润
if (profit > maxProfit) {
maxProfit = profit;
}
// 3. 更新最小买入价
if (prices[i] < minPrice) {
minPrice = prices[i];
}
}
return maxProfit;
}
};
关键点:
- 贪心策略:维护最低买入价,计算每天卖出的利润
- 更新策略:不断更新最小买入价和最大利润
- 时间复杂度:O(n)
7.6 买卖股票的最佳时机II(122)
题目:给你一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以先购买,然后在 同一天 出售。返回 你能获得的 最大 利润 。
贪心策略:
- 局部最优:只要今天价格比昨天高就买卖
- 全局最优:累加所有正利润
代码:
cpp
class Solution {
public:
int maxProfit(vector<int>& prices) {
int result = 0;
for (int i = 1; i < prices.size(); i++) {
// 只要今天价格比昨天高,就买卖
if (prices[i] > prices[i - 1]) {
result += prices[i] - prices[i - 1];
}
}
return result;
}
};
关键点:
- 贪心策略:只要今天价格比昨天高就买卖
- 累加利润:将所有正利润累加
- 不需要考虑具体买卖时机
7.7 划分字母区间(763)
题目:给你一个字符串 s 。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。注意,划分结果需要满足:将所有划分结果按顺序连接,得到的仍然是 s 。返回一个表示每个字符串片段的长度的列表。
贪心策略:
- 局部最优:尽可能划分出更多的片段
- 全局最优:划分出最多的片段
代码:
cpp
class Solution {
public:
vector<int> partitionLabels(string s) {
// 1. 记录每个字符最后出现的位置
vector<int> last(26, 0);
for (int i = 0; i < s.size(); i++) {
last[s[i] - 'a'] = i;
}
vector<int> result;
int start = 0; // 当前段起点
int end = 0; // 当前段终点
// 2. 贪心遍历
for (int i = 0; i < s.size(); i++) {
// 更新当前段的最远右边界
end = max(end, last[s[i] - 'a']);
// 3. 到达边界,可以切分
if (i == end) {
result.push_back(end - start + 1);
start = i + 1; // 开启新一段
}
}
return result;
}
};
关键点:
- 记录每个字符最后出现的位置
- 贪心策略:维护当前段的最远右边界
- 切分条件:当遍历到边界时,可以切分
- 时间复杂度:O(n)
8. 贪心算法的时间复杂度
8.1 时间复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 排序 | O(n log n) | 大多数贪心问题需要先排序 |
| 遍历 | O(n) | 遍历数组或集合 |
| 总体 | O(n log n) | 通常是排序 + 遍历 |
注意:
- 大多数贪心问题需要先排序,所以时间复杂度通常是O(n log n)
- 如果不需要排序,时间复杂度可以是O(n)
- 空间复杂度通常是O(1)或O(n)
8.2 空间复杂度分析
| 数据结构 | 空间复杂度 | 说明 |
|---|---|---|
| 基本变量 | O(1) | 只使用几个变量 |
| 排序 | O(1) | 原地排序 |
| 辅助数组 | O(n) | 需要额外的数组空间 |
注意:
- 大多数贪心问题的空间复杂度是O(1)
- 如果需要排序,通常是原地排序,空间复杂度O(1)
- 如果需要额外的数组,空间复杂度O(n)
9. 何时使用贪心算法
9.1 使用贪心算法的场景
-
分配问题
- 分发饼干
- 分发糖果
- 柠檬水找零
-
区间问题
- 用最少数量的箭引爆气球
- 无重叠区间
- 合并区间
- 划分字母区间
-
跳跃问题
- 跳跃游戏
- 跳跃游戏II
-
股票问题
- 买卖股票的最佳时机(只能买卖一次)
- 买卖股票的最佳时机II(可以多次买卖)
-
其他问题
- 摆动序列
- 最大子序和
- K次取反后最大化的数组和
- 单调递增的数字
- 根据身高重建队列
9.2 判断标准
当遇到以下情况时,考虑使用贪心算法:
- 问题具有贪心选择性质
- 可以通过局部最优推导全局最优
- 时间复杂度要求较低
- 不需要保存历史状态
当遇到以下情况时,考虑使用动态规划:
- 问题具有最优子结构
- 需要保存历史状态
- 贪心策略无法得到最优解
- 需要回溯或改变选择
9.3 贪心算法的优缺点
优点:
- 时间复杂度低:通常是O(n log n)或O(n)
- 空间复杂度低:通常是O(1)或O(n)
- 实现简单:代码通常比较简洁
- 效率高:不需要考虑所有可能的解
缺点:
- 适用性有限:不是所有问题都适合贪心算法
- 需要证明:需要证明贪心策略的正确性
- 可能不是最优解:如果贪心策略不正确,可能得到次优解
- 难以设计:贪心策略的设计可能比较困难
10. 贪心算法的证明方法
10.1 反证法
方法:假设贪心策略不是最优的,然后推导出矛盾。
示例:
- 分发饼干:假设不优先满足胃口大的孩子,那么可能无法满足更多孩子,与最优解矛盾
10.2 数学归纳法
方法:证明贪心策略在每一步都是最优的。
示例:
- 跳跃游戏:证明每次跳到能跳最远的位置,可以到达最远的位置
10.3 交换论证
方法:证明任何最优解都可以通过交换操作转换为贪心解。
示例:
- 区间问题:证明任何最优解都可以通过交换操作转换为按结束位置排序的解
10.4 举反例
方法:如果找不到反例,可以尝试贪心策略。
注意:
- 举反例是验证贪心策略的重要方法
- 如果找不到反例,可以尝试贪心策略
- 但找到反例并不意味着贪心策略一定错误,可能需要调整策略
11. 常见题型总结
11.1 分配问题类
-
分发饼干
- 455.分发饼干:用饼干满足孩子,优先满足胃口大的
-
分发糖果
- 135.分发糖果:给每个孩子分糖果,满足左右规则
-
找零问题
- 860.柠檬水找零:找零问题,优先使用大面额
11.2 区间问题类
-
区间重叠
- 452.用最少数量的箭引爆气球:用最少的箭射穿所有气球
- 435.无重叠区间:删除最少的重叠区间
-
区间合并
- 56.合并区间:合并所有重叠区间
-
区间划分
- 763.划分字母区间:划分不重叠的区间
11.3 跳跃问题类
-
判断能否到达
- 55.跳跃游戏:判断能否到达最后一个位置
-
最少步数
- 45.跳跃游戏II:求到达最后一个位置的最少步数
11.4 股票问题类
- 多次买卖
- 122.买卖股票的最佳时机II:可以多次买卖,求最大利润
11.5 其他问题类
-
序列问题
- 376.摆动序列:求最长摆动子序列
- 53.最大子序和:求最大子数组和
-
数字问题
- 1005.K次取反后最大化的数组和:K次取反后最大和
- 738.单调递增的数字:找到小于等于n的最大单调递增数字
-
重建问题
- 406.根据身高重建队列:按身高和前面人数重建队列
-
划分问题
- 763.划分字母区间:划分字符串,使得同一字母最多出现在一个片段中
12. 总结
贪心算法是一种重要的算法策略,通过局部最优选择来构造全局最优解。
核心要点:
- 贪心选择性质:可以通过局部最优选择来构造全局最优解
- 最优子结构:问题的最优解包含子问题的最优解
- 无后效性:当前的选择不会影响后续选择的状态
- 局部最优 -> 全局最优:想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心
使用建议:
- 根据问题特性判断是否适合贪心算法
- 设计贪心策略时,想清楚局部最优和全局最优
- 通过举反例验证贪心策略的正确性
- 注意边界条件处理
- 掌握常见题型的模板
常见题型总结:
- 分配问题:分发饼干、分发糖果、柠檬水找零
- 区间问题:用最少数量的箭引爆气球、无重叠区间、合并区间、划分字母区间
- 跳跃问题:跳跃游戏、跳跃游戏II(正向和反向两种方法)
- 股票问题:买卖股票的最佳时机(只能买卖一次)、买卖股票的最佳时机II(可以多次买卖)
- 其他问题:摆动序列、最大子序和、K次取反后最大化的数组和、单调递增的数字、根据身高重建队列
学习路径:
- 理解贪心算法的基本思想
- 从简单的分配问题开始
- 学习区间问题(重叠、合并等)
- 学习跳跃问题和股票问题
- 注意贪心策略的证明
- 掌握常见题型的模板