羊羊刷题笔记Day31/60 | 第八章 贪心算法P1 | 贪心算法理论基础、455. 分发饼干、376. 摆动序列、53. 最大子序和

126 阅读9分钟

贪心算法理论基础

本章的题目如下:
20210917104315.png

(图片来源:代码随想录)

什么是贪心

贪心的本质是选择每一阶段的局部最优,从而达到全局最优
举个例子:
例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?
指定每次拿最大的,最终结果就是拿走最大数额的钱。
每次拿最大的就是局部最优,最后拿走最大数额的钱就是全局最优
再举一个例子如果是 有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。(动态规划的问题在下一个系列会详细讲解

贪心的套路(什么时候用贪心)

Q:做过二叉树的递归,回溯,看到什么能想到要去用贪心算法?

说实话贪心算法并没有固定的套路。不像二叉树一样,看到二叉树自然想到递归。有了递归就有回溯......
所以唯一的难点就是如何通过局部最优,推出整体最优。

Q:那么如何能看出局部最优是否能推出整体最优呢?有没有什么固定策略或者套路呢?

不好意思,也没有! 不像递归,回溯有固定三部曲,不像层序遍历一样有较固定的模板。
方法只有:靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。

Q:那如何验证可不可以用贪心算法呢?

刷题或者面试的时候,手动模拟一下感觉可以局部最优推出整体最优,而且想不到反例,那么就试一试贪心。
最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧


关于数学证明

如果想更严谨,想去证明普遍性,可以使用数学证明
一般数学证明有如下两种方法:

  • 数学归纳法
  • 反证法

当然这属于专业数学的范畴了,很多教课书上讲解贪心可以是一堆公式,所以对证明法感兴趣可以去查资料 另外,面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了*。*

当然,部分题目还是需要简单的数学推导。例如这道题目:142 环形链表Ⅱ,这道题不用数学推导一下,就找不出环的起始位置,想试一下就不知道怎么试,这种题目确实需要数学简单推导一下的。


贪心一般解题步骤

贪心算法一般分为如下四步:

  • 将问题分解为若干个子问题
  • 找出适合的贪心策略
  • 求解每一个子问题的最优解
  • 将局部最优解堆叠成全局最优解

正如前面所说,贪心算法没有固定套路,这些都是“死”的东西。做题的时候,只要想清楚** 局部最优 是什么,如果推导出全局最优**,其实就够了。

总结

如之前的递归,回溯,层序遍历一样,本部分谈论给出了什么是贪心以及大家关心的贪心算法固定套路。
但是心没有套路,就是需要常识性推导加上举反例

455 分发饼干

本题较简单。
思路:先将孩子和饼干从小到大排序,分别从孩子和饼干的开头开始遍历

  • 如果饼干能满足(大于等于)孩子的胃口,则孩子饼干继续遍历
  • 如果饼干不能满足(小于)孩子的胃口,则继续向后遍历,在饼干中寻找

代码:

public int findContentChildren(int[] g, int[] s) {
    int count = 0;
    Arrays.sort(g);
    Arrays.sort(s);

    // i为孩子下标 j为饼干的下标
    for (int i = 0, j = 0; i <= g.length - 1 && j <= s.length - 1; ) {
        if (s[j] >= g[i]) {
            // 能满足胃口
            i++;
            j++;
            count++;
        } 
            // 不能满足胃口
        else j++;
    }
    return count;
}

376 摆动序列

思路

本题要求通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。
来分析一下,应该删除什么元素呢?
用示例二来举例,根据摆动的特性画出如下图:
image.png
局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值
整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列
局部最优推出全局最优,并举不出反例,那么试试贪心!
(为方便表述,以下说的峰值都是指局部峰值)
实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)
这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点
在计算是否有峰值的时候,大家知道遍历的下标 i ,计算前一个坡度茶: prediff(nums[i] - nums[i-1]) 和 后一个坡度差:curdiff(nums[i+1] - nums[i]),如果prediff < 0 && curdiff > 0 或者 prediff > 0 && curdiff < 0 此时就有波动就需要统计。
这是我们思考本题的一个大题思路,目前要考虑两种情况:

  1. 情况一:上下坡中有平坡
  2. 情况二:数组首尾两端

情况一:上下坡中有平坡

例如 [1,2,2,2,1]这样的数组,如图:
image.png
它的摇摆序列长度是多少呢? 其实是长度是 3,也就是我们在删除的时候 要不删除左面的三个 2,要不就删除右边的三个 2。
如图,可以统一规则,删除左边的三个 2:
image.png
在图中,当 i 指向第一个 2 的时候,prediff > 0 && curdiff = 0 ,当 i 指向最后一个 2 的时候 prediff = 0 && curdiff < 0。
如果我们采用,删左面三个 2 的规则,那么 当 prediff = 0 && curdiff < 0 也要记录一个峰值,因为他是把之前相同的元素都删掉留下的峰值。
所以我们记录峰值的条件应该是:** (preDiff <= 0 && curDiff > 0) || (preDiff >= 0 && curDiff < 0)**,要有一侧可以==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,2,5],这样它就有坡度了即 preDiff = 0,如图:
image.png
针对以上情形,result 初始为 1(默认最右面有一个峰值),此时 curDiff > 0 && preDiff <= 0, 那么 result++(计算了左面的峰值),最后得到的 result 就是 2(峰值个数为 2 即摆动序列长度为 2)
经过以上分析后,我们可以写出如下代码:

// 默认前面有一个相同的元素,构成一个摆动条件
for (int i = 1; i < nums.length;i++) {
    // 计算当前插值
    curDiff = nums[i] - nums[i - 1];
    // 判断是否为摆动
    if ((preDiff <=0 && curDiff > 0) || (preDiff >=0 && curDiff < 0)) {
        count++;
    }

    preDiff = curDiff;
}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

此时会发现 以上代码提交不能通过本题
这就是我们要注意的情况三!

🔴情况三:单调坡度有平坡

在版本一中,我们忽略了一种情况,即 如果在一个单调坡度上有平坡,例如[1,2,2,2,3,4],如图:
image.png
图中,我们可以看出,版本一的代码在三个地方记录峰值,但其实结果因为是 2,因为 单调中的平坡 不能算峰值(即摆动)
之所以版本一会出问题,是因为我们实时更新了 prediff。
那么应该什么时候更新 prediff 呢?
只需要在 这个坡度 摆动变化的时候,更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判。(也就是把pre的改变放在if语句里
所以本题的最终代码为:

// 默认前面有一个相同的元素,构成一个摆动条件
for (int i = 1; i < nums.length;i++) {
    // 计算当前插值
    curDiff = nums[i] - nums[i - 1];
    // 判断是否为摆动
    if ((preDiff <=0 && curDiff > 0) || (preDiff >=0 && curDiff < 0)) {
        count++;

        // 有了摆动pre才前进,防止单调坡
        preDiff = curDiff;
    }
}

其实本题看起来好像简单,但需要考虑的情况还是很复杂的,而且很难一次性想到位。
本题异常情况的本质,就是要考虑平坡, 平坡分两种,一个是 上下中间有平坡,一个是单调有平坡,如图:
image.png

53 最大子序和

思路

贪心贪的是哪里呢?
如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为负数只会拉低总和,这就是贪心贪的地方
局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。
全局最优:选取最大“连续和”
局部最优的情况下,并记录最大的“连续和”,可以推出全局最优
从代码角度上来讲:遍历 nums,从头开始用 count 累积,如果 count 一旦加上 nums[i]变为负数,那么就应该从 nums[i+1]开始从 0 累积 count 了,(因为已经变为负数的 count,只会拖累总和)
这相当于是暴力解法中的不断调整最大子序和区间的起始位置

区间终止位置不用调整么? 如何才能得到最大“连续和”呢?
区间的终止位置,其实就是如果 count 取到最大值了,及时记录下来了。例如如下代码:

if (cur > max) max = cur;

这样相当于是用 result 记录最大子序和区间和(变相的算是调整了终止位置)
如动画所示:(注意:是和为负数时才归零,累加遇到负数不一定归零哦~

红色的起始位置就是贪心每次取 count 为正数的时候,开始一个区间的统计。
整体代码如下:
值得注意的是:需要先记录了目前最大值再判断目前总和是否为负数归零,否则如果数组全是负数,最后结果会是0

public int maxSubArray(int[] nums) {
    // 一个元素时就是它本身,减少内存消耗,不加也可以
    if (nums.length == 1) return nums[0];

    int cur = 0;
    int max = Integer.MIN_VALUE;

    for (int i = 0; i < nums.length; i++) {
        // 累加
        cur += nums[i];
        // 先根据cur更新最大值
        if (cur > max) max = cur;
        // 再改变cur值 - 如果总和为负数,加上会拖累,弃之
        if (cur < 0) cur = 0;
    }

    return max;

}
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

学习资料:

理论基础

455 分发饼干

376. 摆动序列

53 最大子序和