这篇文章来讲贪心算法(Greedy Algorithm),这是一种特殊的动态规划算法
1、本质
我们在之前的文章中讲过,动态规划可以解决一类具有最优子结构和重叠子问题特征的问题
贪心算法本质上是一种特殊的动态规划算法,因此在看这篇文章前,建议先看之前动态规划的文章
回到正题,先来简单回顾下动态规划,动态规划的核心就是状态转移方程
无论是自上而下利用递归的解题思路,还是自下而上利用递推的解题思路,都可以用状态转移方程串联起来
状态转移方程的关键就是怎么根据较小规模的问题推导出较大规模的问题
在每一步推导的过程中,其实就是在做一件事情,即在当前状态下,尝试所有选择,到达新的状态
然后不断重复这个过程,从已知状态开始直至待求状态结束
跳跃游戏 II | leetcode45
给定一个非负整数数组,初始位于数组的第一个下标,数组中的元素代表所在位置可以跳跃的最大长度
假设一定可以到达数组最后一个位置,问最少需要跳多少次才能到达
为了更好地理解这一过程,这里以跳跃游戏为例,假设给定数组 [2, 3, 1, 1, 4]
,画出递归树如下:
注意到这个问题是满足最优子结构和重叠子问题的,因此必定可以用动态规划来解
这里以自上而下的解题思路为例给出代码
class Solution {
public:
vector<int> memo;
// 动态规划
int dp(vector<int>& nums, int pos) {
int n = nums.size();
// 边界条件
if (pos >= n - 1) {
return 0;
}
// 查备忘录
if (memo[pos] != n) {
return memo[pos];
}
// 尝试所有选择,优化点
// 写备忘录
for (int i = 1; i <= nums[pos]; i++) {
memo[pos] = min( memo[pos], dp(nums, pos + i) + 1 );
}
// 返回
return memo[pos];
}
int jump(vector<int>& nums) {
int n = nums.size();
memo = vector<int>(n, n); // 初始化备忘录
return dp(nums, 0);
}
};
动态规划在每一步的推导过程中,都要尝试所有选择,这是因为在某些问题中无法判断哪个选择是最优选择
但是贪心算法无需尝试所有选择,而是可以通过推导得出当前最优解,并且局部最优解可以到达全局最优解
假设现在站在索引 0 的位置上,其可跳长度为 2,那么可能的选择有:跳到索引 1 的位置上、跳到索引 2 的位置上。动态规划的思路是,我不知道这两种做法哪种好,所以我就两种都试一下。而贪心算法建立在更强的约束上,在面临多个选择时,能判断出当前最优解是什么。回到上述例子,索引 1 位置的可跳长度是 3,如果当前选择跳到索引 1,那么下一步最远可以跳到索引 4;同理,索引 2 位置的可跳长度是 1,如果当前选择跳到索引 2,那么下一步最远可以跳到索引 3。明显当前跳到索引 1 是优于跳到索引 2 的,因为下一步可跳位置的选择更多,因此贪心算法就会直接选择跳到索引 1,而不会再去尝试跳到索引 2
class Solution {
public:
// 无需递归
int jump(vector<int>& nums) {
int n = nums.size();
int pos = 0; // 当前位置
int ans = 0; // 当前步数
// 进主流程
while (pos < n - 1) {
int nxtPos = 0; // 下一位置
int maxPos = 0; // 最远位置
// 直接判断最优位置
// 无需尝试所有选择
for (int i = 0; i <= nums[pos]; i++) {
if (pos + i >= n - 1) {
nxtPos = pos + i;
break;
}
if (maxPos < pos + i + nums[pos + i]) {
maxPos = pos + i + nums[pos + i];
nxtPos = pos + i;
}
}
pos = nxtPos;
ans = ans + 1;
}
// 返回
return ans;
}
};
2、核心
贪心算法是一种特殊的动态规划算法,它的约束更强,适用场景更少,但一般来说具有更高的效率
这是因为动态规划需要尝试所有选择,而贪心算法则可以通过推导直接选择当前最优解
贪心算法所解决的问题除了要满足最优子结构外,更重要的是要满足贪心选择性质
所谓的贪心选择性质就是说每次选择仅依赖于之前的选择,而不依赖于后续的选择
理解这个性质很关键,这是贪心算法的核心
在每一步推导过程中,贪心算法在当前状态下做局部最优解以到达下一状态,直至到达待求的问题
对于一个具体的问题,使用贪心算法前必须要证明每一步推导所做的局部最优解能到达全局最优解
凑零钱问题 | leetcode322
给定一个整数数组
coins
表示不同面额的硬币,以及一个整数amount
表示总金额假设每种硬币的数量是无限的,问凑成总金额所需的最少的硬币个数,若无法凑成,则返回
-1
注意,如果盲目使用贪心算法,可能取不到全局最优解,这里以凑零钱问题为例说明这种情况
对于上述问题,使用贪心算法的一个直观思路是:每次选择面额最大的硬币,直至凑满总金额
这对于普通的面额分布确实是可行的,例如 coins = [1, 5, 10], amount = 13
但对于特殊的面额分布就会出现问题,例如 coins = [1, 4, 5 ], amount = 13
如果使用贪心算法,那么选择的硬币依次是 5、5、1、1、1
,但最优选择方式却是 5、4、4
3、框架
贪心算法的框架很简单,总结起来就是一句话:在推导过程中,每次都选择当前最优解
只不过在使用贪心算法时需要特别注意,局部最优解必须能到达全局最优解
4、例题
(1)无重叠区间 | leetcode435
给定一个区间集合,返回移除区间的最小数量,使得剩余的区间互不重叠
基本思路
题目要求移除最少数量的区间使得剩余的区间互不重叠
可转换为选择最多数量的区间使得选择的区间互不重叠
根据贪心算法的思路,每次选择右端点最小且不与先前区间重叠的区间即可
这是因为同样是增加一个区间,如果选择右端点较小的区间,那么以后选择的可能性更多
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
// 特判
int n = intervals.size();
if (n <= 0) {
return 0;
}
// 预处理,为了提高效率
// 按右端点从小到大排序
sort(intervals.begin(), intervals.end(), [](const auto& u, const auto& v) {
return u[1] < v[1];
});
// 每次选择右端点最小且不与先前区间重叠的区间
int ans = 1;
int end = intervals[0][1];
for (int i = 1; i < n; i++) {
// 因为数组已提前排序,所以按顺序遍历就能选择右端点最小的区间
// 然后只需保证当前区间与先前区间无重叠,也即当前区间的左端点大于等于先前区间的右端点
if (intervals[i][0] >= end) {
ans = ans + 1;
end = intervals[i][1];
}
}
// 返回结果
return n - ans;
}
};
(2)单调递增的数字 | leetcode738
给定一个整数,返回小于等于这个数字的最大数字,使得返回的数字是单调递增的
基本思路
从折线图的角度来看,从左往后找第一个谷顶,将这个数字减去一,将后面数字改为九
如:13542 -> 13499
若第一个谷顶是平的,则找谷顶最左边的位置,将这个数字减去一,将后面数字改为九
如:15542 -> 14999
class Solution {
public:
int monotoneIncreasingDigits(int n) {
if (n < 10) {
return n;
}
string s = std::to_string(n);
int m = s.size();
int i = 0;
while (i < m - 1 && s[i] <= s[i + 1]) {
i++;
}
if (i == m - 1) {
return n;
}
while (i > 0 && s[i] == s[i - 1]) {
i--;
}
s[i] = s[i] - 1;
i++;
while (i < m) {
s[i] = '9';
i++;
}
return stoi(s);
}
};