贪心算法
基本定义
顾名思义,贪心算法或贪心思想采用贪心策略,保证每次操作都是局部最优的,从而使最后得到的结果是全局最优的
这么说有点抽象,来举一个例子:
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。
再举一个例子如果是有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。动态规划的问题在下一个系列会详细讲解。
贪心的套路(什么时候用贪心)
说实话贪心算法并没有固定的套路,所以唯一的难点就是如何通过局部最优,推出整体最优。
那么如何能看出局部最优是否能推出整体最优呢?有没有什么固定策略或者套路呢?
不好意思,也没有! 靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。有同学问了如何验证可不可以用贪心算法呢?
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧。
可有有同学认为手动模拟,举例子得出的结论不靠谱,想要严格的数学证明。
一般数学证明有如下两种方法:
数学归纳法
反证法
看教课书上讲解贪心可以是一堆公式,估计大家连看都不想看,所以数学证明就不在要讲解的范围内了,大家感兴趣可以自行查找资料。面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了。
举一个不太恰当的例子:我要用一下1+1 = 2,但我要先证明1+1 为什么等于2。严谨是严谨了,但没必要。
虽然这个例子很极端,但可以表达这么个意思:刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。
例如刚刚举的拿钞票的例子,就是模拟一下每次拿做大的,最后就能拿到最多的钱,这还要数学证明的话,其实就不在算法面试的范围内了,可以看看专业的数学书籍!所以这也是为什么很多同学通过(accept)了贪心的题目,但都不知道自己用了贪心算法,因为贪心有时候就是常识性的推导,所以会认为本应该就这么做!
贪心一般解题步骤
贪心算法一般分为如下四步:
① 将问题分解为若干个子问题
② 找出适合的贪心策略
③ 求解每一个子问题的最优解
④ 将局部最优解堆叠成全局最优解
其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起。
总结
本篇给出了什么是贪心以及大家关心的贪心算法固定套路。
不好意思了,贪心没有套路,说白了就是常识性推导加上举反例。
以下为贪心对应的力扣题目。一般都是和别的知识混合在一起
455.分发饼干
有一群孩子和一堆饼干,每个孩子有一个饥饿度g[i],每个饼干都有一个大小s[j]。每个孩子只能吃最多一个饼干,且只有饼干的大小大于孩子的饥饿度时( s[j] >= g[i]),这个孩子才能吃饱。求解最多有多少孩子可以吃饱。
示例 1:
输入: g = [1,2,3], s = [1,1]
输出: 1
解释: 你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。 所以你应该输出1。
示例 2:
输入: g = [1,2], s = [1,2,3]
输出: 2 解释: 你有两个孩子和三块小饼干,2个孩子的胃口值分别是1,2。你拥有的饼干数量和尺寸都足以让所有孩子满足。 所以你应该输出2.
思路
为了满足更多的小孩,就不要造成饼干尺寸的浪费。
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。
可以尝试使用贪心策略,先将饼干数组和小孩数组排序。然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(),g.end());
sort(s.begin(),s.end());
int child = 0; //能吃饱的孩子数量
int cookie = 0;
while (child < g.size() && cookie < s.size()){
if (g[child] <= s[cookie])
child++;
cookie++;
}
return child;
}
};
2.区间问题
给定多个区间,计算让这些区间互不重叠所需要移除区间的最少个数。起止相连不算重叠
输入一个数组,数组由多个长度固定为2的数组组成,表示区间的开始和结尾。输出一个整数,表示需要移除的区间数量
Input:[ [1, 2], [2, 4], [1, 3] ]
Output: 1
思路
在选择要保留区间时,区间的结尾十分重要:选择的区间结尾越小,余留给其它区间的空间就越大,就越能保留更多的区间。
因此,我们采取的贪心策略为,优先保留结尾小且不相交的区间。具体实现方法为,先把区间按照结尾的大小进行增序排序,每次选择结尾最小且和前一个选择的区间不重叠的区间。
在样例中,排序后的数组为[[1,2],[1,3],[2,4]]。按照我们的贪心策略,首先初始化为区间[1,2];由于[1,3]与[1,2]相交,我们跳过该区间;由于[2,4]与[1,2]不相交,我们将其保留。因此最终保留的区间为[[1,2],[2,4]]。
class Solution {
public:
// 按照区间右边界排升序
static bool cmp(const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
}
int earseOverlapIntervals(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end(), cmp);// 先对每一个数组中的第二个元素按升序进行排序
int count = 1;// 满足条件的区间个数,默认第一组已满足
int pre = intervals[0][1];
int size = intervals.size();
for (int i = 1; i < intervals.size(); i++) {
if (intervals[i][0] >= pre) {
count++;//找到最近不相交区间
pre = intervals[i][1];
}
}
return size - count; //拿原大小减去不相交的区间就是要剔除的区间个数
}
};
力扣.56合并区间
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
示例 1:
输入:intervals = [[1,3],[2,6],[8,10],[15,18]]
输出:[[1,6],[8,10],[15,18]]
解释:区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].
示例 2:
输入:intervals = [[1,4],[4,5]]
输出:[[1,5]] 解释:区间 [1,4] 和 [4,5] 可被视为重叠区间。
class Solution {
public:
//写一个按左区间排序的比较方式
static bool cmp (const vector<int>& a, const vector<int>&b){
return a[0] < b[0];
}
vector<vector<int>> merge(vector<vector<int>>& intervals) {
vector<vector<int>> res;
if (intervals.size() == 0) return res;
//先按照左区间进行数组之间的排序
sort(intervals.begin(), intervals.end(), cmp);
// 定义当前数组的右区间值
int pre = intervals[0][1];
res.push_back({intervals[0][0], intervals[0][1]});
for (int i = 1; i < intervals.size(); i++){
// 遇到左区间<pre的数组,说明一定不是重叠区间,可以直接放入res的后面
if (intervals[i][0] > pre){
res.push_back({intervals[i][0],intervals[i][1]});
}
// 遇到左区间>= pre的数组,需要判断新数组的右区间和pre的大小,来进行合并后右区间的更新
else{
res.back()[1] = max(res.back()[1], intervals[i][1]);
}
// 更新合并或新入容器的数组的右区间
pre = res.back()[1];
}
return res;
}
53.最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例: 输入: [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
暴力解法
挨个遍历数组中的每个元素,然后依次往后找与其累加的结果,每次和上次累加的结果比较,留下大的结果
但是力扣有些例子会超时
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT32_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) { // 设置起始位置
count = 0;
for (int j = i; j < nums.size(); j++) { // 每次从起始位置i开始遍历寻找最大值
count += nums[j];
result = count > result ? count : result;
}
}
return result;
}
};
贪心解法
贪心贪的是哪里呢?
如果 -2 1 在一起,计算起点的时候,一定是从1开始计算,因为负数只会拉低总和,这就是贪心贪的地方!
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。
全局最优:选取最大“连续和”
局部最优的情况下,并记录最大的“连续和”,可以推出全局最优。
从代码角度上来讲:遍历nums,从头开始用count累积,如果count一旦加上nums[i]变为负数,那么就应该从nums[i+1]开始从0累积count了,因为已经变为负数的count,只会拖累总和。
这相当于是暴力解法中的不断调整最大子序和区间的起始位置。
那有同学问了,区间终止位置不用调整么? 如何才能得到最大“连续和”呢?
区间的终止位置,其实就是如果count取到最大值了,及时记录下来了。例如如下代码:
if (count > result) result = count;
这样相当于是用result记录最大子序和区间和(变相的算是调整了终止位置)。
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int result = INT32_MIN;
int count = 0;
for (int i = 0; i < nums.size(); i++) { // 设置起始位置
count += nums[i] ;
// 用来限制区间终止位置,每次都进行比较,count取到最大值就记录下来
// 该行代码必须在下面if判断的前面,为了避免数组中只有一个负数的情况,没来得及将该值赋给result就被置为0了
result = count > result ? count : result;
// 相当于重置最大子序起始位置,从nums[i+1]处重新累加,因为遇到负数一定是拉低总和
if (count <= 0){
count = 0;
}
}
return result;
}
};
122.买卖股票的最佳时机II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 =5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 => 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3
示例 2:
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 注意你不能在第 1 天和第 2天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
示例 3:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
#思路
本题首先要清楚两点:
只有一只股票!
当前只有买股票或者卖股票的操作
想获得利润至少要两天为一个交易单元。
贪心算法
这道题目可能我们只会想,选一个低的买入,在选个高的卖,在选一个低的买入…循环反复。
如果想到其实最终利润是可以分解的,那么本题就很容易了!
如何分解呢?
假如第0天买入,第3天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
此时就是把利润分解为每天为单位的维度,而不是从0天到第3天整体去考虑!
那么根据prices可以得到每天的利润序列:(prices[i] - prices[i - 1])…(prices[1] - prices[0])。
class Solution {
public:
int maxProfit(vector<int>& prices) {
// 把收入具体划分到每一天,只要是正收入,统统都要
int sum = 0;
for (int i = 0; i < prices.size()-1; i++){
if (prices[i+1] - prices[i] > 0 )
sum += (prices[i+1] - prices[i]);
}
return sum;
}
};
714. 买卖股票的最佳时机含手续费
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例 1: 输入: prices = [1, 3, 2, 8, 4, 9], fee = 2
输出: 8 解释: 能够达到的最大利润:
在此处买入 prices[0] = 1 在此处卖出 prices[3] = 8 在此处买入 prices[4] = 4 在此处卖出
prices[5] = 9 总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.
贪心算法
在贪心算法:122.买卖股票的最佳时机II)中使用贪心策略不用关心具体什么时候买卖,只要收集每天的正利润,最后稳稳的就是最大利润了。而本题有了手续费,就要关系什么时候买卖了,因为计算所获得利润,需要考虑买卖利润可能不足以手续费的情况。如果使用贪心策略,就是最低值买,最高值(如果算上手续费还盈利)就卖。
此时无非就是要找到两个点,买入日期,和卖出日期。
买入日期:其实很好想,遇到更低点就记录一下。
卖出日期:这个就不好算了,但也没有必要算出准确的卖出日期,只要当前价格大于(最低价格+手续费),就可以收获利润,至于准确的卖出日期,就是连续收获利润区间里的最后一天(并不需要计算是具体哪一天)。
所以我们在做收获利润操作的时候其实有三种情况:
情况一:收获利润的这一天并不是收获利润区间里的最后一天(不是真正的卖出,相当于持有股票),所以后面要继续收获利润。
情况二:前一天是收获利润区间里的最后一天(相当于真正的卖出了),今天要重新记录最小价格了。
情况三:不作操作,保持原有状态(买入,卖出,不买不卖)
在这里插入代码片