1、贪心理论基础
贪心算法其实就是没有什么规律可言,所以大家了解贪心算法 就了解它没有规律的本质就够了
基本贪心的题目 有两个极端,要不就是特简单,要不就是死活想不出来
学完贪心之后再去看动态规划,就会了解贪心和动规的区别
题目分类大纲如下:
什么是贪心
贪心的本质是选择每一阶段的局部最优,从而达到全局最优
这么说有点抽象,来举一个例子:
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱
每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优
再举一个例子如果是 有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。动态规划的问题在下一个系列会详细讲解
贪心的套路(什么时候用贪心)
贪心算法并没有固定的套路,所以唯一的难点就是如何通过局部最优,推出整体最优
那么如何能看出局部最优是否能推出整体最优呢?有没有什么固定策略或者套路呢?不好意思,也没有! 靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划
有同学问了如何验证可不可以用贪心算法呢?
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧
想要严格的数学证明,一般数学证明有如下两种方法:
1、数学归纳法
2、反证法
刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心
那么刷题的时候什么时候真的需要数学推导呢?
例如这道题目:leetcode 142.环形链表II,这道题不用数学推导一下,就找不出环的起始位置,想试一下就不知道怎么试,这种题目确实需要数学简单推导一下
做题的时候,只要想清楚 局部最优 是什么,如果推导出全局最优,其实就够了
2、外内层循环饼干还是孩子 与 循环顺序
2.1 leetcode 455:分发饼干
第一遍代码是两个数列都从小到大排序(贪心,每次都找刚刚好的),然后以饼干为外循环,如果孩子可以吃就指向下一个孩子,如果孩子不可以吃就进入下一个外层循环,到下一个饼干
因为指向了下一个孩子,所以当数字等于孩子的数量时直接return
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int j = 0;//孩子
for(int i = 0; i < s.size(); i++) {//饼干,如果饼干做外层循环,因为是从小到大排序,外层循环如果从小到大就只能是饼干
if(s[i] >= g[j]) {
j++;
if(j >= g.size()) {
return g.size();
}
}
else {
continue;
}
}
return j;
}
};
思路
为了满足更多的小孩,就不要造成饼干尺寸的浪费
大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的
这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩
可以尝试使用贪心策略,先将饼干数组和小孩数组排序
然后从后向前 遍历 小孩数组,用大饼干优先满足胃口大的(也可以像第一遍代码一样从前往后,用小饼干优先满足胃口小的,但是像代码随想录这样的,除去小孩的每次循环外,作为内层循环,饼干最多动一次,往前动了就不会退回来了),并统计满足小孩数量
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;
}
};
从代码中可以看出我用了一个 index 来控制饼干数组的遍历,遍历饼干并没有再起一个 for 循环,而是采用自减的方式(第一次代码使用了j 来控制孩子数组的遍历(一样的道理)),这也是常用的技巧
注意事项
注意版本一的代码中,可以看出来,是先遍历的胃口,再遍历的饼干,那么可不可以 先遍历 饼干,再遍历胃口呢?
在从后往前的遍历中,其实是不可以的
外面的 for 是里的下标 i 是固定移动的,而 if 里面的下标 index 是符合条件才移动的
如果 for 控制的是饼干, if 控制胃口,就是出现如下情况 :
if 里的 index 指向 胃口 10, for 里的 i 指向饼干 9,因为 饼干 9 满足不了 胃口 10,所以 i 持续向前移动,而 index 走不到s[index] >= g[i]
的逻辑,所以 index 不会移动,那么当 i 持续向前移动,最后所有的饼干都匹配不上
所以 当从后往前遍历(即寻找更大胃口吃更大饼干,充分利用饼干尺寸喂饱一个)一定要 for 控制 胃口,里面的 if 控制饼干
也可以换一个思路,小饼干先喂饱小胃口
就跟第一次代码一样,第一遍代码,是 先遍历的饼干,再遍历的孩子(if 控制,每次走一步),那么可不可以 先遍历 孩子,再遍历 饼干 呢?与从后往前不同的是,是可以的,逻辑上也没问题
关键在于内层循环不会回退,所以如果前一个遍历把后面可能满足条件的遍历过了,后面就没办法再遍历到那个满足条件的了
先遍历 孩子,再遍历 饼干代码
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int j = 0;//饼干
for(int i = 0; i < s.size(); i++) {//孩子
if(s[i] >= g[j]) {
j++;
if(j >= g.size()) {
return g.size();
}
}
else {
continue;
}
}
return j;
}
};
2.2 leetcode 455:总结
想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心
3、山峰山谷处理摆动序列,处理平坡
3.1 leetcode 376:摆动序列
第一遍代码不是用贪心整的,整了个递归
理解错题意了,子列不需要连续,可以跳着取,第一遍代码写成寻找连续的摆动序列了
虽然理解就错了,但是在按照错误理解写的过程中,还是发现了不少问题的
注意num在刚开始只有一个差值(即两个不等元素的时候)就已经有两个了
但是要处理有两个及以上相等元素的情况,定义一个指针一直往后直到指向与前值不相等的元素为止
不满足条件了要去搜索下一个可能子列了,然后比较子列长度
注意这里的在nums里面的起始位置要加上 startIndex, i + startIndex 这个序列要放在数组里看
为什么不是 i + startIndex - 1?因为之前的i是tmp数组的,tmp数组天生就自带-1,因为间隔比分割的数字数量小一
Testcase:[1,17,5,10,13,15,10,5,16,8]
i+startIndex为6,4,3就是真实的子列起点
后面找到不符合条件的情况不需要再循环下去了,直接可以返回结果了
class Solution {
public:
int MaxLength(vector<int>& nums, int startIndex) {
if(nums.size() - startIndex == 1) {
return 1;
}
if(nums.size() - startIndex == 2) {
if(nums[startIndex] == nums[startIndex + 1]) {
return 1;
}
else {
return 2;
}
}
vector<int> tmp;
int num = 2;
//注意num在刚开始只有一个差值(即两个不等元素的时候)就已经有两个了
//但是要处理有两个及以上相等元素的情况,定义一个指针一直往后直到指向与前值不相等的元素为止
int point_to_differ = startIndex + 1;
while(nums[point_to_differ] == nums[point_to_differ - 1]) {
if(point_to_differ == nums.size() - 1) {
return 1;
}
point_to_differ++;
}
for(int i = point_to_differ; i < nums.size(); i++) {
tmp.push_back(nums[i] - nums[i - 1]);
}
for(int i = 1; i < tmp.size(); i++) {
if(tmp[i] * tmp[i - 1] < 0) {
num++;
}
else {
//不满足条件了要去搜索下一个可能子列了,然后比较子列长度
int num2 = MaxLength(nums, i+startIndex);
/*
注意这里的在nums里面的起始位置要加上startIndex,i+startIndex这个序列要放在数组里看
为什么不是i+startIndex-1?因为之前的i是tmp数组的,tmp数组天生就自带-1,因为间隔比数字小一
Testcase:[1,17,5,10,13,15,10,5,16,8]
i+startIndex为6,4,3就是真实的子列起点
*/
cout << i+startIndex << endl;
if(num < num2) {
num = num2;
}
break;//不需要再循环下去了
}
}
return num;
}
int wiggleMaxLength(vector<int>& nums) {
return MaxLength(nums, 0);
}
};
如果不是连续的话,还是比较难的
思路
1、思路 1(贪心解法)
本题要求通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序
相信这么一说吓退不少同学,这要求最大摆动序列又可以修改数组,这得如何修改呢?
来分析一下,要求删除元素使其达到最大摆动序列,应该删除什么元素呢?
用示例二来举例,如图所示:
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值(一个峰值,一个谷值)
整体最优:整个序列有最多的局部峰值(谷值),从而达到最长摆动序列
局部最优推出全局最优,并举不出反例,那么试试贪心!
(为方便表述,以下说的峰值(谷值)都是指局部峰值(谷值))
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值(谷值)数量(峰值就是向上尖上的 那一个数,谷值就是向下尖上的 那一个数)就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值(谷值)尽可能的保持峰值(谷值),然后删除单一坡度上的节点
在计算是否有峰值(谷值) 的时候,大家知道遍历的下标i,计算 prediff(nums[i] - nums[i-1])
和 curdiff(nums[i+1] - nums[i])
,如果prediff < 0 && curdiff > 0
(峰值)或者 prediff > 0 && curdiff < 0
(谷值)此时就有波动就需要统计
这是我们思考本题的一个大题思路,但本题要考虑三种情况:
情况一:上下坡中有平坡
情况二:数组首尾两端
情况三:单调坡中有平坡
情况一:上下坡中有平坡
例如 [1,2,2,2,1]这样的数组,如图:
它的摇摆序列长度是多少呢? 其实是长度是 3,也就是我们在删除的时候 要不删除左面的三个 2,要不就删除右边的三个 2
如图,可以与单调坡统一规则,删除左边的三个 2:
当 i 指向最后一个 2 的时候 prediff = 0 && curdiff < 0
,当是谷值(谷底)也是同理,即prediff = 0 && curdiff > 0
所以我们记录峰值的条件应该是: (preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)
,为什么这里允许 prediff == 0
,就是为了 上面我说的这种情况
情况二:数组首尾两端
所以本题**统计峰值(谷值)**的时候,数组最左面和最右面如何统计呢?
题目中说了,如果只有两个不同的元素,那摆动序列也是 2
例如序列[2,5],如果靠统计差值来计算峰值(谷值)个数就需要考虑数组最左面和最右面的特殊情况
因为我们在计算 prediff(nums[i] - nums[i-1])
和 curdiff(nums[i+1] - nums[i])
的时候,至少需要三个数字才能计算,而数组只有两个数字
这里我们可以写死,就是 如果只有两个元素,且元素不同,那么结果为 2
不写死的话,如何和我们的判断规则结合在一起呢?
对于最左端:
之前我们在 讨论 情况一:相同数字连续 的时候, prediff = 0 ,curdiff < 0 或者 >0 也记为波谷
那么为了规则统一,针对序列[2,5] / [2,1],设定初始值preDiff = 0,这样对于开头,它就有坡度了即 preDiff = 0 && curDiff > / < 0,满足上面判断峰值(谷值)的统一条件
对于最右端:
默认就有一个峰值,result的初值设为1,因为
1、如果最后一个数跟前面不等自然有一个
2、就算与前面都相等,因为之前对于平坡的处理是保留最后一个,而最后一个已经没有后续元素了,所以还是要给一个
之所以要对最左端/最右端特殊处理,是因为中间只计算了峰值或者谷值本身,所以忽略了一头一尾,主要考虑一头一尾的节点要加进去
经过以上分析后,我们可以写出如下代码:
// 版本一
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 ((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)) {
result++;
}
preDiff = curDiff;
}
return result;
}
};
情况三:单调坡度有平坡
在版本一中,我们忽略了一种情况,即 如果在一个单调坡度上有平坡,例如[1,2,2,2,3,4],如图:
版本一的代码在三个地方记录峰值,但其实结果是 2,因为 单调中的平坡 不能算峰值(即摆动)
为了解决这个问题,延后更新prediff
需要在 这个坡度 摆动变化的时候,更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判
对第一次代码做修改(就重写了)
注释注意一下
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int preWave = 0;//前一对差值
//为了让第一个节点只要 数组内元素不是全是一样 的就计入总和,直接初值就是平坡
//如[2,5]直接相当于[2,2,5],与后面共用逻辑
int curWave;//当前一对差值
int result = 1;//最右边默认有一个,原因见前面分析
for(int i = 0; i < nums.size() - 1; i++) {
curWave = nums[i] - nums[i + 1];
if((preWave >= 0 && curWave < 0) || (preWave <= 0 && curWave > 0)) {
result++;
preWave = curWave;//解决平坡的一种情况,见前面分析
}
}
return result;
}
};
代码随想录整体代码
// 版本二
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 ((preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)) {
result++;
preDiff = curDiff; // 注意这里,只在摆动变化的时候更新prediff,平着走的时候preDiff是不更新的
}
}
return result;
}
};
本题异常情况的本质(包括对最右最左端的处理),就是要考虑平坡, 平坡分两种,一个是 上下中间有平坡,一个是 单调有平坡,如图:
2、思路 2(动态规划)
考虑用动态规划的思想来解决这个问题
很容易可以发现,对于我们当前考虑的这个数,要么是作为山峰(即 nums[i] > nums[i-1]
),要么是作为山谷(即 nums[i] < nums[i - 1]
)接在前面的山谷 / 山峰后面
设 dp 状态dp[i][0],表示考虑前 i 个数,第 i 个数作为山峰的摆动子序列的最长长度
设 dp 状态dp[i][1],表示考虑前 i 个数,第 i 个数作为山谷的摆动子序列的最长长度
则转移方程为:
dp[i][0] = max(dp[i][0], dp[j][1] + 1)
,其中0 < j < i
且nums[j] < nums[i]
,表示将 nums[i]接到前面某个山谷后面,作为山峰(所以是之前 最后一个元素是山谷的摆动序列 的数量dp[j][1] 加上1)
dp[i][1] = max(dp[i][1], dp[j][0] + 1)
,其中0 < j < i
且nums[j] > nums[i]
,表示将 nums[i]接到前面某个山峰后面,作为山谷(所以是之前 最后一个元素是山峰的摆动序列 的数量dp[j][1] 加上1)
初始状态:
由于一个数可以接到前面的某个数后面,也可以以自身为子序列的起点(一个节点也算一个摆动序列),所以初始状态为:dp[0][0] = dp[0][1] = 1
C++代码如下:
class Solution {
public:
int dp[1005][2];
int wiggleMaxLength(vector<int>& nums) {
memset(dp, 0, sizeof dp);
dp[0][0] = dp[0][1] = 1;
for (int i = 1; i < nums.size(); ++i) {
dp[i][0] = dp[i][1] = 1; // 别忘了,最小就是1
for (int j = 0; j < i; ++j) {
if (nums[j] > nums[i]) dp[i][1] = max(dp[i][1], dp[j][0] + 1);
}
for (int j = 0; j < i; ++j) {
if (nums[j] < nums[i]) dp[i][0] = max(dp[i][0], dp[j][1] + 1);
}
}
return max(dp[nums.size() - 1][0], dp[nums.size() - 1][1]);
}
};
或者这么写也行,因为 本来初始值就为1,只是因为 memset初始化整数1会出问题
class Solution {
public:
int wiggleMaxLength(vector<int>& nums) {
int dp[1001][2];
for (int i = 0; i < nums.size(); i++) {
dp[i][0] = dp[i][1] = 1;
}
// memset(dp, 0, sizeof(dp));
// dp[0][1] = dp[0][0] = 1;
for (int i = 0; i < nums.size(); i++) {
// dp[i][1] = dp[i][0] = 1;
for (int j = 0; j < i; j++) {
if (nums[i] < nums[j])
dp[i][1] = max(dp[i][1], dp[j][0] + 1);
if (nums[i] > nums[j])
dp[i][0] = max(dp[i][0], dp[j][1] + 1);
}
}
return dp[nums.size() - 1][0] > dp[nums.size() - 1][1] ? dp[nums.size() - 1][0] : dp[nums.size() - 1][1];
}
};
memset 函数的作用是将一段内存中的每个字节都设置为特定的值。它接受的第二个参数是一个整数,表示要设置的值。当这个参数是1时,它会将每个字节的所有8个位都设置为1。
对于字符型数组,这通常是没有问题的,因为每个字符只需要一个字节的存储空间,所以每个字符都会被设置为ASCII码值为1对应的字符,即控制字符SOH(Start of Header)
但是对于整型数组,每个整数通常占据多个字节的存储空间,所以如果使用 memset 将整型数组的每个元素都设置为1,实际上会将每个整数的每个字节都设置为1。这可能不是我们期望的行为,因为在内存中的表示方式可能会导致整数的值并非全部为1
3.2 leetcode 376:进阶
可以用两棵线段树来维护区间的最大值
每次更新dp[i][0],则在tree1的nums[i]位置值更新为dp[i][0]
每次更新dp[i][1],则在tree2的nums[i]位置值更新为dp[i][1]
则 dp 转移方程中就没有必要 j 从 0 遍历到 i-1,可以直接在线段树中查询指定区间的值即可
时间复杂度:O(nlog n)
空间复杂度:O(n)
4、leetcode 53:记录最大的“连续和”解决最大子序和
只想到暴力法求解,贪心法没想出来
贪心解法
贪心贪的是哪里呢?
如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为 负数只会拉低总和(思想核心),这就是贪心贪的地方
局部最优:当前 “连续和”为负数 的时候 立刻放弃,从 下一个元素重新计算“连续和”,因为 负数加上下一个元素 “连续和”只会越来越小
全局最优:选取整个数组中算过的最大“连续和”
局部最优的情况下,并记录最大的“连续和”,可以推出全局最优
这相当于是暴力解法中的不断调整最大子序和区间的起始位置,那么整个连续和区间中间可能出现更好的起点使连续和更大吗?
不可能,因为 假设新起点存在,从 现在起点到那个新起点的前一个结点之和 一定是大于0的(小于0直接就换起点了),那么 包含原来起点到新起点前面一个数结束 的 数组 一定是更优的选择(加上大于0的一段)
区间终止位置不用调整么? 如何才能得到 最大“连续和” 呢?
区间的终止位置,其实就是如果 count 取到最大值了,及时记录下来了。例如如下代码:
if (count > result) result = count;
这样相当于是用 result 记录最大子序和区间和(变相的算是调整了终止位置)
按照思路写代码,以为特别简单,但是对res的初值的选取直接选输入下界-10000就出了问题
直接res设为-10000的话只有一个元素为负数的会出问题,如果数组中全是负数呢?就需要找出最大的那个
第一遍代码如下:
class Solution {
public:
int maxSubArray(vector<int>& nums) {
int start = nums[0];
int startIndex = 0;
for (int i = 0; i < nums.size(); i++) {
if (nums[i] < 0) {
if (start < nums[i]) {
start = nums[i];
startIndex = i;
}
}
else {
start = nums[i];
startIndex = i;
break;
}
}
int tmp = start;
for (int i = startIndex + 1; i < nums.size(); i++) {
// 注意考虑 输入为-1,-2的情况
if (tmp + nums[i] < 0)
tmp = 0;
else {
tmp = tmp + nums[i];
if (tmp > start)
start = tmp;
}
}
return start;
}
};
代码随想录代码:
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];
if (count > result) { // 取区间累计的最大值(相当于不断确定最大子序终止位置,注意此时count小于0也可以进入这个if,跟下面那个if不冲突,解决了第一遍代码里面的问题,result 要初始化为最小负数)
result = count;
}
if (count <= 0) count = 0; // 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
}
return result;
}
};
4,遇到 -1 的时候,我们依然累加了,为什么呢?
因为和为 3,只要连续和还是正数就会 对后面的元素 起到增大总和的作用。 所以只要连续和为正数我们就保留
这里也会有疑惑,那 4 + -1 之后 不就变小了吗? 会不会错过 4 成为最大连续和的可能性?
其实并不会,因为还有一个变量 result 一直在更新 最大的连续和,只要有更大的连续和出现,result 就更新了,那么 result 已经把 4 更新了,后面 连续和变成 3,也不会对最后结果有影响