专题八:贪心算法

声明:

参考:代码随想录

个人总结归纳,仅用于复习回顾,不作他用!如发现文章内容有误,恳请批评指出!

目录

贪心算法理论基础

1、什么是贪心? 

2、什么时候用贪心? 

3、贪心一般解题步骤

简单题目 

例1: 455. 分发饼干 - 力扣(LeetCode)

例2:1005. K 次取反后最大化的数组和 - 力扣(LeetCode)

例3:860. 柠檬水找零 - 力扣(LeetCode)

中等题目 

题型一:序列问题

例1:376. 摆动序列 - 力扣(LeetCode)

例2: 738. 单调递增的数字 - 力扣(LeetCode)

题型二:贪心解决股票问题

例1: 122. 买卖股票的最佳时机 II - 力扣(LeetCode)

例2:714. 买卖股票的最佳时机含手续费 - 力扣(LeetCode)

题型三:两个维度权衡问题

例1: 135. 分发糖果 - 力扣(LeetCode)

例2:406. 根据身高重建队列 - 力扣(LeetCode)

有点难度 

 题型一:区间问题

例1: 55. 跳跃游戏 - 力扣(LeetCode)

例2:45. 跳跃游戏 II - 力扣(LeetCode)

例3:452. 用最少数量的箭引爆气球 - 力扣(LeetCode)

例4:435. 无重叠区间 - 力扣(LeetCode) 

例5:763. 划分字母区间 - 力扣(LeetCode)

​编辑 例6:56. 合并区间 - 力扣(LeetCode)

例:53. 最大子数组和 - 力扣(LeetCode)

例:134. 加油站 - 力扣(LeetCode) 

例: 968. 监控二叉树 - 力扣(LeetCode)

贪心算法理论基础

1、什么是贪心? 

贪心的本质是选择每一阶段的局部最优,从而达到全局最优

2、什么时候用贪心? 

贪心算法并没有固定的套路

所以唯一的难点就是如何通过局部最优,推出整体最优。

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

也没有! 靠自己手动模拟,如果模拟可行,就可以试一试贪心策略,如果不可行,可能需要动态规划。

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

最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧

面试中基本不会让面试者现场证明贪心的合理性,代码写出来跑过测试用例即可,或者自己能自圆其说理由就行了

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

贪心有时候就是常识性的推导,所以会认为本应该就这么做!

3、贪心一般解题步骤

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

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

贪心没有套路,说白了就是常识性推导加上举反例。 

简单题目 

例1: 455. 分发饼干 - 力扣(LeetCode)

 

为了满足更多的小孩,就不要造成饼干尺寸的浪费。

大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。

这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩

可以尝试使用贪心策略,先将饼干数组和小孩数组排序。

然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量。

  解法一:大尺寸的饼干优先满足胃口大的孩子

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;
    }
};
//贪心算法
//时间复杂度O(nlogn)
//空间复杂度O(1)

 解法二:小饼干优先满足小胃口

class Solution {
public:
    int findContentChildren(vector<int>& g, vector<int>& s) {
        sort(g.begin(), g.end());
        sort(s.begin(), s.end());
        int index = 0;
        for (int i = 0; i < s.size(); i++) {//饼干
            if (index < g.size() && g[index] <= s[i]) {//胃口
                index++;
            }
        }
        return index;
    }
};
//贪心算法
//时间复杂度O(nlogn)
//空间复杂度O(1)

解法二中两个循环的顺序改变了,先遍历的饼干,再遍历的胃口,这是因为遍历顺序变了,我们是从小到大遍历。

注意遍历顺序(先遍历饼干还是先遍历胃口)与遍历方向(从大到小还是从小到大)的匹配 !! 

注意事项:

注意解法一的代码中,可以看出来,是先遍历的胃口,再遍历的饼干,从大到小遍历;那么可不可以 先遍历 饼干,在遍历胃口呢?

其实是不可以的。

外面的 for 中的下标 i 是固定移动的,而 if 里面的下标 index 是符合条件才移动的。

如果 for 控制的是饼干, if 控制胃口,就是出现如下情况 :

if 里的 index 指向 胃口 10, for 里的 i 指向饼干 9,因为 饼干 9 满足不了 胃口 10,所以 i 持续向前移动,而 index 走不到s[index] >= g[i] 的逻辑,所以 index 不会移动,那么当 i 持续向前移动,最后所有的饼干都匹配不上。

所以 一定要 for 控制 胃口,里面的 if 控制饼干。(从大到小遍历时)

例2:1005. K 次取反后最大化的数组和 - 力扣(LeetCode)

class Solution {
private:
    static bool cmp(int a,int b) {
        return abs(a) > abs(b);
    }
public:
    int largestSumAfterKNegations(vector<int>& nums, int k) {
        sort(nums.begin(), nums.end(), cmp);//将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
        for (int i = 0; i < nums.size(); i++) {//从前向后遍历,遇到负数将其变为正数,同时K--
            if (nums[i] < 0 && k > 0) {
                nums[i] *= -1;
                k--;
            }
        }
        if (k % 2 == 1) nums[nums.size() - 1] *= -1;//如果K还大于0,那么反复转变数值最小的元素,将K用完
        int result = 0;
        for (int num : nums) result += num;//求和
        return result;
    }
};
//贪心算法
//时间复杂度O(nlogn)
//空间复杂度O(1)

思路:

如何可以让数组和最大呢?

贪心的思路,局部最优:让绝对值大的负数变为正数,当前数值达到最大,整体最优:整个数组和达到最大。

局部最优可以推出全局最优。

那么如果将负数都转变为正数了,K依然大于0,此时的问题是一个有序正整数序列,如何转变K次正负,让数组和达到最大?

那么又是一个贪心:局部最优:只找数值最小的正整数进行反转,当前数值和可以达到最大(例如正整数数组{5, 3, 1},反转1 得到-1 比 反转5得到的-5 大多了),全局最优:整个 数组和 达到最大。

这么一道简单题,就用了两次贪心!

那么本题的解题步骤为:

  • 第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
  • 第二步:从前向后遍历,遇到负数将其变为正数,同时K--
  • 第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
  • 第四步:求和

例3:860. 柠檬水找零 - 力扣(LeetCode)

 

class Solution {
public:
    bool lemonadeChange(vector<int>& bills) {
        int five = 0, ten = 0, twenty = 0;
        for (int bill : bills) {
            // 情况一
            if (bill == 5) {
                five++;
            }
            // 情况二
            if (bill == 10) {
                if (five <= 0) return false;
                ten++;
                five--;
            }
            // 情况三
            if (bill == 20) {
                // 优先消耗10美元,因为5美元的找零用处更大,能多留着就多留着
                if (ten > 0 && five > 0) {
                    twenty++;// 其实这行代码可以删了,因为记录20已经没有意义了,不会用20来找零
                    ten--;
                    five--; 
                } else if (five >= 3) {
                    twenty++;// 同理,这行代码也可以删了
                    five -= 3;
                } else return false;
            }
        }
        return true;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

只需要维护三种金额的数量,5,10和20。

有如下三种情况:

  • 情况一:账单是5,直接收下。
  • 情况二:账单是10,消耗一个5,增加一个10
  • 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5

此时大家就发现 情况一,情况二,都是固定策略,都不用我们来做分析了,而唯一不确定的其实在情况三。

而情况三逻辑也不复杂甚至感觉纯模拟就可以了,其实情况三这里是有贪心的。

账单是20的情况,为什么要优先消耗一个10和一个5呢?

因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能!

所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。

局部最优可以推出全局最优,并找不出反例,那么就试试贪心算法!

中等题目 

题型一:序列问题

例1:376. 摆动序列 - 力扣(LeetCode)

 

 解法一:贪心算法

class Solution {
public:
    int wiggleMaxLength(vector<int>& nums) {
        if (nums.size() <= 1) return nums.size();
        int prediff = 0;// 前一对差值
        int curdiff = 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
            }
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

 分析:

局部最优:删除单调坡度上的节点(不包括单调坡度两端的节点),那么这个坡度就可以有两个局部峰值

整体最优:整个序列有最多的局部峰值,从而达到最长摆动序列

实际操作上,其实连删除的操作都不用做,因为题目要求的是最长摆动子序列的长度,所以只需要统计数组的峰值数量就可以了(相当于是删除单一坡度上的节点,然后统计长度)

这就是贪心所贪的地方,让峰值尽可能的保持峰值,然后删除单一坡度上的节点

在计算是否有峰值的时候,大家知道遍历的下标 i ,计算 prediff(nums[i] - nums[i-1]) 和 curdiff(nums[i+1] - nums[i]),如果prediff < 0 && curdiff > 0 或者 prediff > 0 && curdiff < 0 此时就有波动就需要统计。

这是我们思考本题的一个大题思路,但本题要考虑三种情况:

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

情况一:上下坡中有平坡

它的摇摆序列长度是 3,也就是我们在删除的时候 要不删除左面的三个 2,要不就删除右边的三个 2。如图,可以统一规则,删除左边的三个 2:

在图中,当 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),为什么这里允许 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,如图:

 针对以上情形,result 初始为 1(默认最右面有一个峰值),此时 curDiff > 0 && preDiff <= 0,那么 result++(计算了左面的峰值),最后得到的 result 就是 2(峰值个数为 2 即摆动序列长度为 2)

情况三:单调坡度有平坡

 如果在一个单调坡度上有平坡,例如[1,2,2,2,3,4],如图:

单调中的平坡 不能算峰值(即摆动)。

我们需要在 这个坡度 摆动变化的时候,更新 prediff 就行,这样 prediff 在 单调区间有平坡的时候 就不会发生变化,造成我们的误判。

例2: 738. 单调递增的数字 - 力扣(LeetCode)

class Solution {
public:
    int monotoneIncreasingDigits(int n) {
        string strNum = to_string(n);
        // flag用来标记赋值9从哪里开始
        // 设置为这个默认值,为了防止第二个for循环在flag没有被赋值的情况下执行
        int flag = strNum.size();
        for (int i = strNum.size() - 1; i > 0; i--) {
            if (strNum[i - 1] > strNum[i]) {
                flag = i;
                strNum[i - 1]--;
            }
        }
        for (int i = flag; i < strNum.size(); i++) {
            strNum[i] = '9';
        }
        return stoi(strNum);
    }
};
//贪心算法
//时间复杂度O(n) n为数字长度
//空间复杂度O(n) 需要一个字符串,转化为字符串操作更方便

注意:

1、当strNum[i - 1] > strNum[i](非单调递增)时,首先让strNum[i - 1]减一,strNum[i]赋值9;例如输入98,输出为89。

2、只有从后向前遍历才能重复利用上次比较的结果。

3、用一个flag来标记从哪里开始赋值9。

to_string 函数:将数字常量转换为字符串,返回值为转换完毕的字符串

头文件:#include<string>

stoi 函数:将 n 进制的字符串转化为十进制,使用时包含头文件string.

题型二:贪心解决股票问题

例1: 122. 买卖股票的最佳时机 II - 力扣(LeetCode)

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int result = 0;
        for (int i = 1; i < prices.size(); i++) {
            result += max(prices[i] - prices[i - 1], 0);
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

注意:

利润分解为以每天为单位的维度,而不是整体去考虑。

 第一天没有利润,至少要第二天才会有利润,所以利润的序列比股票序列少一天! 

从图中可以发现,其实我们需要收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间

那么只收集正利润就是贪心所贪的地方!

局部最优:收集每天的正利润,全局最优:求得最大利润

例2:714. 买卖股票的最佳时机含手续费 - 力扣(LeetCode)

class Solution {
public:
    int maxProfit(vector<int>& prices, int fee) {
        int result = 0;
        int minPrice = prices[0];// 记录最低价格
        for (int i = 1; i < prices.size(); i++) {
            // 情况二:相当于买入
            if (prices[i] < minPrice) minPrice = prices[i];
            // 情况三:保持原有状态(因为此时买则不便宜,卖则亏本)
            if (prices[i] >= minPrice && prices[i] <= minPrice + fee) continue;
            // 计算利润,可能有多次计算利润,最后一次计算利润才是真正意义的卖出
            if (prices[i] > minPrice + fee) {
                result += prices[i] - minPrice - fee;
                minPrice = prices[i] - fee;// 情况一,这一步很关键,避免重复扣手续费
            }
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

 注意:

1、例1中用贪心策略不用关心具体什么时候买卖,只要收集每天的正利润,最后稳稳的就是最大利润了。而本题有了手续费,就要关系什么时候买卖了,因为计算所获得利润,需要考虑买卖利润可能不足以支付手续费的情况。

使用贪心策略,就是最低值买,最高值(如果算上手续费还盈利)就卖。 

  • 买入日期:其实很好想,遇到更低点就记录一下。
  • 卖出日期:没有必要算出准确的卖出日期,只要当前价格大于(最低价格+手续费),就可以收获利润,至于准确的卖出日期,就是连续收获利润区间里的最后一天(并不需要计算是具体哪一天)。

 收获利润操作有如下三种情况:

  • 情况一:收获利润的这一天并不是收获利润区间里的最后一天(不是真正的卖出,相当于持有股票),所以后面要继续收获利润。
  • 情况二:前一天是收获利润区间里的最后一天(相当于真正的卖出了),今天要重新记录最小价格了。
  • 情况三:不作操作,保持原有状态(买入,卖出,不买不卖)

2、 从代码中可以看出对情况一的操作,因为如果还在收获利润的区间里,表示并不是真正的卖出,而计算利润每次都要减去手续费,所以要让minPrice = prices[i] - fee;,这样在明天收获利润的时候,才不会多减一次手续费!

题型三:两个维度权衡问题

例1: 135. 分发糖果 - 力扣(LeetCode)

class Solution {
public:
    int candy(vector<int>& ratings) {
        vector<int> candyVec(ratings.size(), 1);
        //从前向后
        for (int i = 1; i < ratings.size(); i++) {
            if (ratings[i] > ratings[i - 1]) {
                candyVec[i] = candyVec[i - 1] + 1;
            }
        }
        //从后向前
        for (int i = ratings.size() - 2; i >= 0; i--) {
            if (ratings[i] > ratings[i + 1]) {
                candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1);
            }
        }
        //统计结果
        int result = 0;
        for (int i = 0; i < candyVec.size(); i++) {
            result += candyVec[i];
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(n)

分析:

本题采用了两次贪心的策略:

  • 一次是从左到右遍历,只比较右边孩子评分比左边大的情况。
  • 一次是从右到左遍历,只比较左边孩子评分比右边大的情况。

这样从局部最优推出了全局最优,即:相邻的孩子中,评分高的孩子获得更多的糖果。

例2:406. 根据身高重建队列 - 力扣(LeetCode)

 

#include <iostream>
#include <vector>
#include <list>
#include <algorithm>
using namespace std;

class Solution {
private:
    // 身高从大到小排(身高相同k小的站前面)
    static bool cmp(const vector<int> a, const vector<int> b) {
        if (a[0] == b[0]) return a[1] < b[1];
        return a[0] > b[0];
    }
public:
    vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
        sort(people.begin(), people.end(), cmp);
        list<vector<int>> que;// list底层是链表实现,插入效率比vector高的多
        for (int i = 0; i < people.size(); i++) {
            int position = people[i][1];// 插入到下标为position的位置
            std::list<vector<int>>::iterator it = que.begin();
            while (position--) {// 寻找在插入位置
                it++;
            }
            que.insert(it, people[i]);
        }
        return vector<vector<int>>(que.begin(), que.end());
    }
};

int main()
{
    vector<vector<int>> people = { {7,0},{4,4},{7,1},{5,0},{6,1},{5,2}};
    Solution A;
    vector<vector<int>> output = A.reconstructQueue(people);
    for (vector<int>& row : output)
    {
        for (int num : row)
        {
            cout << num << '\t';
        }
    }
}
//贪心算法
//时间复杂度O(nlogn + n^2)
//空间复杂度O(n)

 分析:

本题有两个维度,思路是确定一个维度,再按照另一个维度重新排列。

如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来。

那么按照身高h来排序呢,身高一定是从大到小排(身高相同的话则k小的站前面),让高个子在前面。

此时我们可以确定一个维度了,就是身高,前面的节点一定都比本节点高!

按照身高排序之后,优先按身高高的people的k来插入,后序插入节点也不会影响前面已经插入的节点,最终按照k的规则完成了队列。

所以在按照身高从大到小排序后:

局部最优:优先按身高高的people的k来插入。插入操作过后的people满足队列属性

全局最优:最后都做完插入操作,整个队列满足题目队列属性

排序完的people: [[7,0], [7,1], [6,1], [5,0], [5,2],[4,4]]

插入的过程:

  • 插入[7,0]:[[7,0]]
  • 插入[7,1]:[[7,0],[7,1]]
  • 插入[6,1]:[[7,0],[6,1],[7,1]]
  • 插入[5,0]:[[5,0],[7,0],[6,1],[7,1]]
  • 插入[5,2]:[[5,0],[7,0],[5,2],[6,1],[7,1]]
  • 插入[4,4]:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]

此时就按照题目的要求完成了重新排列。

注意:

本题用容器用 list(底层链表实现) 比 vector (底层普通数组实现)效率高很多!

 C++中vector(可以理解是一个动态数组,底层是普通数组实现的)如果插入元素大于预先普通数组大小,vector底部会有一个扩容的操作,即申请两倍于原先普通数组的大小,然后把数据拷贝到另一个更大的数组上。

所以使用vector(动态数组)来insert,是费时的,插入再拷贝的话,单纯一个插入的操作就是O(n^2)了,甚至可能拷贝好几次,就不止O(n^2)了。

vector是怎么扩容的呢?

vector的大小有两个维度一个是size一个是capicity,size就是我们平时用来遍历vector时候用的,例如:

for (int i = 0; i < vec.size(); i++) {

}

而capicity是vector底层数组(就是普通数组)的大小,capicity可不一定就是size。

当insert数据的时候,如果已经大于capicity,capicity会成倍扩容,但对外暴漏的size其实仅仅是+1。

那么既然vector底层实现是普通数组,怎么扩容的?

就是重新申请一个二倍于原数组大小的数组,然后把数据都拷贝过去,并释放原数组内存。

原vector中的size和capicity相同都是3,初始化为1 2 3,此时要push_back一个元素4。

那么底层其实就要申请一个大小为6的普通数组,并且把原元素拷贝过去,释放原数组内存,注意图中底层数组的内存起始地址已经变了

而本题中,使用vector来做insert的操作,虽然表面上复杂度是O(n^2),但是其底层都不知道额外做了多少次全量拷贝了,所以算上vector的底层拷贝,整体时间复杂度可以认为是O(n^2 + t × n)级别的,t是底层拷贝的次数。 

对某一种语言容器的使用,特性的选择都会不同程度上影响效率​​​​​​

list<vector<int>>::iterator it = que.begin();

que.begin()使it指向第一个元素,而que.end()会使it指向最后一个元素的下一个位置。

有点难度 

 题型一:区间问题

例1: 55. 跳跃游戏 - 力扣(LeetCode)

class Solution {
public:
    bool canJump(vector<int>& nums) {
        int cover = 0;
        if (nums.size() == 1) return true;// 只有一个元素,就是能达到
        for (int i = 0; i <= cover; i++) {// 注意这里是小于等于cover
            cover = max(i + nums[i], cover);
            if (cover >= nums.size() - 1) return true;// 说明可以覆盖到终点了
        }
        return false;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

分析:

这道题目关键点在于:不用拘泥于每次究竟跳几步,而是看覆盖范围,覆盖范围内一定是可以跳过来的,不用管是怎么跳的。

那么这个问题就转化为跳跃覆盖范围究竟可不可以覆盖到终点!

每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。

贪心算法局部最优解:每次取最大跳跃步数(取最大覆盖范围),整体最优解:最后得到整体最大覆盖范围,看是否能到终点

i 每次移动只能在 cover 的范围内移动,每移动一个元素,cover 得到该元素数值(新的覆盖范围)的补充,让 i 继续移动下去。

而 cover 每次只取 max(该元素数值补充后的范围, cover 本身范围)。

如果 cover 大于等于了终点下标,直接 return true 就可以了。

例2:45. 跳跃游戏 II - 力扣(LeetCode)

#include <iostream>
#include <vector>
using namespace std;

class Solution {
public:
    int jump(vector<int>& nums) {
        int curDistance = 0;// 当前覆盖的最远距离下标
        int ans = 0; // 记录走的最大步数
        int nextDistance = 0;// 下一步覆盖的最远距离下标
        for (int i = 0; i < nums.size() - 1; i++) {// 注意这里是小于nums.size() - 1,这是关键所在
            nextDistance = max(nums[i] + i, nextDistance);// 更新下一步覆盖的最远距离下标
            if (i == curDistance) {// 遇到当前覆盖的最远距离下标
                curDistance = nextDistance;// 更新当前覆盖的最远距离下标
                ans++;
            }
        }
        return ans;
    }
};

int main()
{
    vector<int> nums = {2, 3, 1, 1, 4};
    Solution A;
    int output = A.jump(nums);
    cout << output << endl;
}
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

分析:

移动下标只要遇到当前覆盖最远距离的下标,直接步数加一,不考虑是不是终点的情况。

想要达到这样的效果,只要让移动下标,最大只能移动到 nums.size() - 2 的地方就可以了。

因为当移动下标指向 nums.size - 2 时:

  • 如果移动下标等于当前覆盖最大距离下标, 需要再走一步(即 ans++),因为最后一步一定是可以到的终点。(题目假设总是可以到达数组的最后一个位置),如图:

  • 如果移动下标不等于当前覆盖最大距离下标,说明当前覆盖最远距离就可以直接达到终点了,不需要再走一步。如图:

例3:452. 用最少数量的箭引爆气球 - 力扣(LeetCode)

class Solution {
private:
    static bool cmp(const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0];//按起始位置(左边界)从小到大排序
    }
public:
    int findMinArrowShots(vector<vector<int>>& points) {
        if (points.size() == 0) return 0;
        sort(points.begin(), points.end(), cmp);
        int result = 1;// points 不为空至少需要一支箭
        for (int i = 1; i < points.size(); i++) {
            if (points[i][0] > points[i-1][1]) {// 气球i和气球i-1不挨着,注意这里不是>=
                result++;// 需要一支箭
            } else {// 气球i和气球i-1挨着
                points[i][1] = min(points[i - 1][1], points[i][1]);// 更新重叠气球最小右边界
            }
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(nlogn),因为有一个快排
//空间复杂度O(n),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

 分析:

局部最优:当气球出现重叠,一起射,所用弓箭最少。全局最优:把所有气球射爆所用弓箭最少。

如果把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过就行了,没有必要让气球数组remove气球,只要记录一下箭的数量就可以了,以此来模拟气球被射爆的过程。

为了让气球尽可能的重叠,需要按照气球起始位置对数组进行排序如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭

以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)

 可以看出首先第一组重叠气球,一定是需要一个箭,气球3,的左边界大于了 第一组重叠气球的最小右边界,所以再需要一支箭来射气球3了。

注意题目中说的是:满足 xstart ≤ x ≤ xend,则该气球会被引爆。那么说明两个气球挨在一起不重叠也可以一起射爆,

所以代码中 if (points[i][0] > points[i - 1][1]) 不能是>=

例4:435. 无重叠区间 - 力扣(LeetCode) 

class Solution {
private:
    static bool cmp(const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0];// 左边界排序
    }
public:
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);
        int count = 0;// 注意这里从0开始,因为是记录重叠区间
        for (int i = 1; i < intervals.size(); i++) {
            if (intervals[i][0] < intervals[i - 1][1]) {//重叠情况
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1]);
                count++;
            }
        }
        return count;
    }
};
//贪心算法
//时间复杂度O(nlogn),有一个快排
//空间复杂度O(n),有一个快排,最差情况(倒序)时,需要n次递归调用。因此确实需要O(n)的栈空间

分析:
        本题和例3:用最少数量的箭引爆气球思路很相似,将区间按左边界从小到大排序,定义变量count来记录重叠区间,不同的是,本题中count从0开始。比较前一个区间的左边界和下一个区间的右边界,若左边界小于右边界,则表示两区间重叠,count 加1;不断更新右边界,依次判断。

例5:763. 划分字母区间 - 力扣(LeetCode)

class Solution {
public:
    vector<int> partitionLabels(string s) {
        int hash[27] = {0};// i为字符,hash[i]为字符出现的最后位置
        for (int i = 0; i < s.size(); i++) { // 统计每一个字符最后出现的位置
            hash[s[i] - 'a'] = i;
        }
        vector<int> result;
        int left = 0;
        int right = 0;
        for (int i = 0; i < s.size(); i++) {
            right = max(right, hash[s[i] - 'a']); // 找到字符出现的最远边界
            if (i == right) {
                result.push_back(right - left + 1);
                left = i + 1;
            }
        }
        return result;
    }
};
//时间复杂度O(n)
//空间复杂度O(1),使用的hash数组是固定大小

 分析:

在遍历的过程中相当于是要找每一个字母的边界,如果找到之前遍历过的所有字母的最远边界,说明这个边界就是分割点了。此时前面出现过所有字母,最远也就到这个边界了。

可以分为如下两步:

  • 统计每一个字符最后出现的位置
  • 从头遍历字符,并更新字符的最远出现下标,如果找到字符最远出现位置下标和当前下标相等了,则找到了分割点

 例6:56. 合并区间 - 力扣(LeetCode)

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        vector<vector<int>> result;
        if (intervals.size() == 0) return result;// 区间集合为空直接返回
         // 排序的参数使用了lambda表达式
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});
        // 第一个区间就可以放进结果集里,后面如果重叠,在result上直接合并
        result.push_back(intervals[0]);
        for (int i = 1; i < intervals.size(); i++) {
            if (result.back()[1] >= intervals[i][0]) {// 发现重叠区间
                // 合并区间,只更新右边界就好,因为result.back()的左边界一定是最小值,因为我们按照左边界排序的
                result.back()[1] = max(result.back()[1], intervals[i][1]);
            } else {
                result.push_back(intervals[i]);// 区间不重叠
            }
        }
        return result;
    }
};
//时间复杂度O(nlogn)
//空间复杂度O(logn),排序需要的空间开销

分析: 

本题的本质其实还是判断重叠区间问题,和例3:用最少数量的箭引爆气球和例4:无重叠区间是一个套路。区别就是判断区间重叠后的逻辑,本题是判断区间重叠后要进行区间合并。

所以一样的套路,先排序,让所有的相邻区间尽可能的重叠在一起,按左边界,或者右边界排序都可以,处理逻辑稍有不同。

按照左边界从小到大排序之后,如果 intervals[i][0] <= intervals[i - 1][1] 即intervals[i]的左边界 <= intervals[i - 1]的右边界,则一定有重叠。(本题相邻区间也算重叠,所以是<=)

注意图中区间都是按照左边界排序之后了) 

模拟合并区间:

其实就是用合并区间后左边界和右边界,作为一个新的区间,加入到result数组里就可以了。如果没有合并就把原区间加入到result数组。

例:53. 最大子数组和 - 力扣(LeetCode)

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) result = count;// 取区间累计的最大值(相当于不断确定最大子序终止位置)
            if (count <= 0) count = 0;// 相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
        }
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

分析:

如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为负数只会拉低总和,这就是贪心贪的地方!

局部最优:当前“连续和”为负数的时候立刻放弃,从下一个元素重新计算“连续和”,因为负数加上下一个元素 “连续和”只会越来越小。

全局最优:选取最大“连续和”

局部最优的情况下,并记录最大的“连续和”,可以推出全局最优

从代码角度上来讲:遍历 nums,从头开始用 count 累积,如果 count 一旦加上 nums[i]变为负数,那么就应该从 nums[i+1]开始从 0 累积 count 了,因为已经变为负数的 count,只会拖累总和。 

例:134. 加油站 - 力扣(LeetCode) 

 

 

class Solution {
public:
    int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {
        int curSum = 0;
        int totalSum = 0;
        int start = 0;
        for (int i = 0; i < gas.size(); i++) {
            curSum += gas[i] - cost[i];
            totalSum += gas[i] - cost[i];
            if (curSum < 0) { // 当前累加rest[i]和 curSum一旦小于0
                start = i + 1;// 起始位置更新为i+1
                curSum = 0;// curSum从0开始
            }
        }
        if (totalSum < 0) return -1;// 说明怎么走都不可能跑一圈了
        return start;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(1)

 分析:

首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。

每个加油站的剩余量rest[i]为gas[i] - cost[i]。

i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。

局部最优:当前累加rest[i]的和curSum一旦小于0,起始位置至少要是i+1,因为从i之前开始一定不行。全局最优:找到可以跑一圈的起始位置

例: 968. 监控二叉树 - 力扣(LeetCode)

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
 * };
 */
class Solution {
private:
    int result;
    int traversal(TreeNode* cur) {
        // 空节点,该节点有覆盖
        if (cur == nullptr) return 2;
        int left = traversal(cur->left); // 左
        int right = traversal(cur->right);// 右
        // 情况1
        // 左右节点都有覆盖
        if (left == 2 && right == 2) return 0;
        // 情况2
        // left == 0 && right == 0 左右节点无覆盖
        // left == 1 && right == 0 左节点有摄像头,右节点无覆盖
        // left == 0 && right == 1 左节点有无覆盖,右节点摄像头
        // left == 0 && right == 2 左节点无覆盖,右节点覆盖
        // left == 2 && right == 0 左节点覆盖,右节点无覆盖
        if (left == 0 || right == 0) {
            result++;
            return 1;
        }
        // 情况3
        // left == 1 && right == 2 左节点有摄像头,右节点有覆盖
        // left == 2 && right == 1 左节点有覆盖,右节点有摄像头
        // left == 1 && right == 1 左右节点都有摄像头
        // 其他情况前段代码均已覆盖
        if (left == 1 || right == 1) return 2;
        // 以上代码我没有使用else,主要是为了把各个分支条件展现出来,这样代码有助于读者理解
        // 这个 return -1 逻辑不会走到这里。
        return -1;
    }
public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        // 情况4
        if (traversal(root) == 0) result++;// root 无覆盖
        return result;
    }
};
//贪心算法
//时间复杂度O(n)
//空间复杂度O(n)

 分析:

从题目中示例,可以发现题目示例中的摄像头都没有放在叶子节点上!

摄像头可以覆盖上中下三层,如果把摄像头放在叶子节点上,就浪费的一层的覆盖。

所以把摄像头放在叶子节点的父节点位置,才能充分利用摄像头的覆盖面积。

为什么不从头结点开始看起呢,为啥要从叶子节点看呢?

因为头结点放不放摄像头也就省下一个摄像头, 叶子节点放不放摄像头省下了的摄像头数量是指数阶别的。

从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少!

1、确定遍历顺序 

使用后序遍历(左右中)从底向上推导,代码中取了左孩子的返回值,右孩子的返回值,即left 和 right, 以后推导中间节点的状态。

2、如何隔两个节点放一个摄像头

分析状态:

  • 该节点无覆盖,用0表示
  • 本节点有摄像头,用1表示
  • 本节点有覆盖,用2表示

注意:空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了。

3、递推终止条件

遇到空节点,返回2

4、单层逻辑处理

  • 情况1:左右节点都有覆盖,那么中间节点应为无覆盖状态
 if (left == 2 && right == 2) return 0;
  • 情况2:左右节点至少有一个无覆盖的情况

如果是以下情况,则中间节点(父节点)应该放摄像头:

  • left == 0 && right == 0 左右节点无覆盖
  • left == 1 && right == 0 左节点有摄像头,右节点无覆盖
  • left == 0 && right == 1 左节点有无覆盖,右节点摄像头
  • left == 0 && right == 2 左节点无覆盖,右节点覆盖
  • left == 2 && right == 0 左节点覆盖,右节点无覆盖

这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。

此时摄像头的数量要加一,并且return 1,代表中间节点放摄像头。

if (left == 0 || right == 0) {
    result++;
    return 1;
}
  • 情况3:左右节点至少有一个有摄像头

如果是以下情况,其实就是 左右孩子节点有一个有摄像头了,那么其父节点就应该是2(覆盖的状态)

  • left == 1 && right == 2 左节点有摄像头,右节点有覆盖
  • left == 2 && right == 1 左节点有覆盖,右节点有摄像头
  • left == 1 && right == 1 左右节点都有摄像头
if (left == 1 || right == 1) return 2;
  •  情况4:头结点没有覆盖,result++
int minCameraCover(TreeNode* root) {
    result = 0;
    if (traversal(root) == 0) { // root 无覆盖
        result++;
    }
    return result;
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值