代码随想录第三十一天 | 贪心:贪心理论基础;外内层循环饼干还是孩子与循环顺序(455);山峰山谷处理摆动序列,处理平坡(376);记录最大的“连续和”解决最大子序和(53)

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 控制胃口,就是出现如下情况 :
for 控制的是饼干 if 控制胃口
if 里index 指向 胃口 10for 里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
删除左边的3个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 < inums[j] < nums[i],表示将 nums[i]接到前面某个山谷后面,作为山峰(所以是之前 最后一个元素是山谷的摆动序列 的数量dp[j][1] 加上1
dp[i][1] = max(dp[i][1], dp[j][0] + 1),其中0 < j < inums[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,也不会对最后结果有影响

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值