贪心算法是比较难的一个算法,他的难点在于每题的贪心策略都是不一样的,在学习其他算法(如二分查找,动态规划,递归回溯时)我们只要有了某一个类型的例子,就可以举一反三写出很多题目,但是贪心算法不是这样的,虽然你写了20道题,但是可能会发现第21道题用前面20道题的方法都做不出来,在学习贪心算法时,需要多积累,遇到一道题写不出来是很正常的,可以多学习别人的经验,比如说田忌赛马其实就是贪心的策略,但是并不是人人都能想到的,在遇到一个不会的策略,我们就学习这个策略,往后再遇到同样类型的题,就可以利用前面的经验
1.什么是贪心算法
贪心算法也可以称为贪心策略,他是一种解决问题的策略,这个策略是:每次都去找局部最优解,最终得到全局最优解。我们可以把这句话拆分成三步:
- 1.把解决问题的过程分为若干步。
- 2.解决每一步时,都选择当前看起来“最优的”解法(贪心)
- 3.希望得到全局最优解
这里的希望指的是:通过贪心策略(每次选择当前最优解)得到的结果并不一定是正确的,即局部最优不能代表全局最优,有可能是你的贪心策略不对,也有可能是这题无法使用贪心算法。
2.贪心算法的特点
1.贪心策略的提出
- (1).贪心策略的提出是没有标准以及模板的(这也是贪心最难的地方)
- (2).可能每一道题的贪心策略都是不一样的。
2.贪心策略的正确性
因为你的贪心策略可能是一个错误的策略,所以我们应该要知道一个贪心策略为什么是正确的,我们需要去证明(通常是数学中的那些证明方法)。
3. 例题
在讲解例题过程中,我会直接告诉你贪心策略是什么,此时不要去纠结为什么贪心策略是这个,前面也说过了,贪心学的主要是经验。
3.1 柠檬水找零
题目链接:860. 柠檬水找零 - 力扣(LeetCode)
贪心策略:
每一杯柠檬水的价格为5美元,如果用户给5美元,直接收下;如果用户给10美元,那么就要找5美元;如果用户给20美元,那么我们可以给一张10美元和一张5美元,还可以一次给三张5美元。
给20美元的情况有两种,那么这里就要进行贪心了(10+5策略比较好,还是3*5比较好)。在上面分析中可以发现5美元的作用是最大的。
那么在遇到需要找零的情况下,我们应该尽可能多的保留5美元,以便后续进行找零。
题解:
class Solution
{
public:
bool lemonadeChange(vector<int>& bills)
{
int five = 0, ten = 0;
//20元不需要找出去,所以不用统计
for (auto money : bills)
{
if (money == 5)
++five;
else if (money == 10)
{
if (five == 0)
return false;
else
--five, ++ ten;
}
else
{
if (ten >= 1 && five >= 1) //贪心
--ten, --five;
else if (five >= 3)
five -= 3;
else
return false;
}
}
return true;
}
};
3.2 将数组和减半的最少操作次数
题目链接:2208. 将数组和减半的最少操作次数 - 力扣(LeetCode)
贪心策略:
要使数组和减半最快,最好的方法就是每次选择数组中值最大的那个数进行减半,直到数组和减少到一半。
要每次都找到最大的那个数,可以使用一个优先级队列(大根堆)。
题解:
class Solution
{
public:
int halveArray(vector<int>& nums)
{
priority_queue<double> heap;
//计算nums的数组和
double sum = 0;
for (auto e : nums)
{
heap.push(e);
sum += e;
}
sum /= 2;
int step = 0; //操作次数
while (sum > 0)
{
double top = heap.top();
heap.pop();
heap.push(top / 2);
sum -= top / 2;
++step;
}
return step;
}
};
3.3 最大数
贪心策略:
这题有点像排序题,排完序后要求所有数组合的是一个最大数。排序的本质都是确定元素的先后顺序,如排升序那就是小的在前大的在后,排降序就是大的在前小的在后。
这里的排序也是一个道理,我们需要找到一种策略,使排完序后整个数组组成的数最大。
假如有两个数[10, 2],那么这两个数只有两种拼接方法:102或者210,而210>102,我们就知道了应该把2放在10的前面;同样如果有两个数a和b,也只有两种拼接方法:ab或者ba,如果ab>ba,那么a就应该放在前面,如果ba>ab,那就把b放在前面。
所以这里的贪心策略就是:有两个数a和b,选择ab或者ba中较大的进行排序。
题解:
- 1.因为库中已经实现了排序的函数,我们只需要修改排序中的比较方法。
- 2.将a和b进行拼接时,可以转化成字符串再拼接,用字符串的字典序比较即可,所以我们可以一开始就把整个数组都转化成字符串类型。
- 3.排完序的结果,可能会存在很多前导0,此时只需要返回一个0,所以我们可以判断第一个数是否为0,如果第一个数为0,那么后面全都是0,此时就只返回一个0
class Solution
{
public:
string largestNumber(vector<int>& nums)
{
vector<string> strs;
for (auto e : nums)
strs.push_back(to_string(e));
sort(strs.begin(), strs.end(), [](const string& str1, const string& str2){
return str1 + str2 > str2 + str1;
});
string ret;
for (auto e : strs)
ret += e;
if (ret[0] == '0')
return "0";
else
return ret;
}
};
3.4 摆动序列
贪心策略:
为了方便观察,我们可以把数组中所有的点放到一个折线图中,如下图所示。
从宏观角度上(不看中间点,只看整体趋势)来看,上图就是一个摆动序列,那么现在我们要找到最长的摆动序列,所以我们要尽可能多的从上图中挑选一些点出来。
因为要尽可能多,所以第一个点我们直接选(绿色代表选中这个点)
在选择完第一个点之后,第二个点的选择有以下三种情况:1.在第一个点和极值点之间的点 2.极值点 3.极值点后面的点。
我们先来考虑一下,选上面哪种情况是最优的。
- 1.选择情况1中的点没有选情况2的点好,假设按上图来看,现在是上升的趋势,那么下一次就要下降了,并且情况1中的点的值是小于情况2的点的,也就是说情况1中可选择下降的点一定是少于情况2的。
- 2.选择情况3中的点,一定会在中间漏掉某些拐点,所以这种情况是不如情况2的。
综上,当我们确定第一个点时,第二个点最优的情况就是极值点,那么在确定第二个点去找第三个点时也是同理,所以,我们最终的贪心策略就是:每次都选择极值点(包括极大值和极小值)
题解:
在写题目之前,我们需要思考下面的问题。
如何得出一个点是极值点?
如果当前数为nums[i],那么我们可以计算nums[i] - nums[i-1] 和 nums[i+1] - nums[i],如果前者为正后者为负,则为极大值点,如果前者为负后者为正,则为极小值。
但是可能会存在水平的情况,一共有下面四种情况。
左边两种是不存在极大值和极小值的,因为整体趋势就是一直上升或者下降。右边两种情况存在极大值和极小值。
右边两种情况是需要考虑水平点,会存在与左右两边的差值为0的情况,需要特殊判断,所以我们可以将中间的点全部删除,只考虑左右两边的点,如下图所示。
如何编写代码
使用一个全局变量left,left用于指明当前点左边的变化趋势,当left > 0,左边单调递增;left < 0,左边单调递减;left = 0,表示左边既可以递增也可以递减(数组中第一个值左边既可以递增也可以递减)
此时我们只需要计算当前点和右边点的差值即可 right = nums[i+1] - nums[i]。
如果left * right <= 0,说明左右变化趋势不一样,即当前点的极值,如果遇到right = 0的情况,也就是右边点的值和当前点的值相同,那么我们直接跳过即可。
class Solution
{
public:
int wiggleMaxLength(vector<int>& nums)
{
//贪心策略:每次选择极值点作为摆动序列
int left = 0; //左边点的变化趋势
int ret = 0; //最终结果
for (int i = 0; i < nums.size() - 1; i++)
{
//计算与右边点的差值
int right = nums[i+1] - nums[i];
if (right == 0)
continue;
if (left * right <= 0)
++ret;
left = right; //更新left的值
}
++ret; //最后一个结点可直接加入摆动序列中
return ret;
}
};
3.5 最长递增子序列
题目链接:300. 最长递增子序列 - 力扣(LeetCode)
在学习这道题之前,建议先去学习一下动态规划解决这道题的思路,因为贪心的策略是基于动态规划的,笔者在前面也写过相关的文章,建议大家先去学习一下动态规划的解法:一文带你掌握动态规划(二)-CSDN博客。
除了动态规划,这道题贪心策略也有二分的思想,所以不熟悉二分的同学,也可以先去学习一下二分。一文带你彻底掌握二分查找-CSDN博客
算法原理:
先来回顾一下动态规划的状态表示,dp[i]表示:以 i 位置的元素为结尾的所有子序列中,最长递增子序列的长度。
而状态表示为dp[i] = max(dp[j] + 1) (j < i && nums[j] < nums[i])
也就是说动态规划的求解思路就是用nums[i]往前找,找到一个 j 位置,使得nums[i]能够拼接到nums[j]后面即可。也就是说我们在考虑最长递增子序列的长度时,并不关心这个序列长什么样子,只关心最后一个元素是谁。
贪心优化:
假设有以下序列,现在要求这个序列的最长递增子序列
在遍历第一个数的时候,我们让7单独形成一个长度为1的序列,上面说过我们只需要关心最后一个元素是什么即可,所以我们只保存7。
在遍历到3的时候,我们发现3是接不到7的后面的,所以我们就让3单独形成一个长度为1的序列,所以现在长度为1的序列中就有7和3,贪心的策略就在这里体现了:长度较大的那个值就不用存了,因为能拼接在7的后面也一定能拼接到3的后面,所以在长度为1的序列中我们只需要保存3就可以了。
接下来我们就继续向后扫描,此时扫描到8,那么就有两种策略:1.单独形成一个长度为1的序列 2.拼接到3的后面,因为我们要找的是最长递增子序列,所以第2种情况一定是优于第一种的。
接下来就要扫描4了,4是可以拼接在3后面,但是不能拼接到8后面,所以我们可以把8去掉,长度为2的只保存4。
接下来的是7,7可以拼接到3后面,也可以拼接掉4后面,所以就形成了长度为3的序列。
当扫描到2的时,2无法拼接到3的后面,但因为需要保存最小的元素,所以我们把3干掉,保存2。
14可以放在2后面,也可以放到4后面,也可以放到7后面,所以就能形成一个长度为4的序列
最后一个16也是同理,所以整个数组扫描完毕之后,就能发现一个长度为5的序列,所以最长递增子序列的长度就是5,最长的就是[3,4,7,14,16]
总结:
- 1.存什么:保存所有长度为x的递增子序列中,最后一个元素的最小值。
- 2.存哪里:所有大于等于nums[i]的最小值的位置。
但是要找到大于等于nums[i]的最小值时,需要遍历整个数组,那么就要O(N)的时间,此时可以使用二分查找进行优化,在上图分析中,我们可以发现整个数组是单调递增的,所以是满足二分条件的。
我们要查找大于等于nums[i]的最小值的位置,也就是说需要查找的是左端点的位置。
题解:
class Solution
{
public:
int lengthOfLIS(vector<int>& nums)
{
//ret.size()表示最长递增子序列的长度
//ret[i]表示长度为i-1的最长递增子序序列最后一个元素中的最小值
vector<int> ret;
ret.push_back(nums[0]);
for (int i = 1; i < nums.size(); i++)
{
//如果比最后一个元素还大,则直接尾插
if (nums[i] > ret.back())
{
ret.push_back(nums[i]);
continue;
}
//一定能在ret数组中所有大于等于nums[i]的最小值的位置。
//二分查找,找到要修改的位置
int left = 0;
int right = ret.size() - 1;
while (left < right)
{