算法训练--贪心算法

201 阅读15分钟

什么是贪心算法

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

    这么说有点抽象,来举一个例子:

    例如,有一堆钞票,你可以拿走十张,如果想达到最大的金额,你要怎么拿?

    指定每次拿最大的,最终结果就是拿走最大数额的钱。

    每次拿最大的就是局部最优,最后拿走最大数额的钱就是推出全局最优。

    再举一个例子如果是 有一堆盒子,你有一个背包体积为n,如何把背包尽可能装满,如果还每次选最大的盒子,就不行了。这时候就需要动态规划。

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

  • 唯一的难点就是如何通过局部最优,推出整体最优
  • 最好用的策略就是举反例,如果想不到反例,那么就试一试贪心吧

贪心的一般解题步骤

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

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

其实这个分的有点细了,真正做题的时候很难分出这么详细的解题步骤,可能就是因为贪心的题目往往还和其他方面的知识混在一起

简单题目

455. 分发饼干

  • 题目描述

    image.png

  • 题解

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

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

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

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

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

    想清楚局部最优,想清楚全局最优,感觉局部最优是可以推出全局最优,并想不出反例,那么就试一试贪心

    455.分发饼干

    class Solution {
        public int findContentChildren(int[] g, int[] s) {
          	//优先考虑胃口,先喂饱大胃口
            Arrays.sort(g);
            Arrays.sort(s);
            int count=0;
            int start=s.length-1;
          	//遍历胃口
            for(int i=g.length-1;i>=0;i--){
                if(start>=0 && g[i]<=s[start]){
                    count++;
                    start--;
                }
            }
            return count;
        }
    }
    

1005. K 次取反后最大化的数组和

  • 题目描述

    image.png

  • 题解

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

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

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

    这里其实是为了给大家展现出来 经常被大家忽略的贪心思路,这么一道简单题,就用了两次贪心!

    那么本题的解题步骤为:

    • 第一步:将数组按照绝对值大小从大到小排序,注意要按照绝对值的大小
    • 第二步:从前向后遍历,遇到负数将其变为正数,同时K--
    • 第三步:如果K还大于0,那么反复转变数值最小的元素,将K用完
    • 第四步:求和
    class Solution {
        public int largestSumAfterKNegations(int[] nums, int k) {
            if (nums.length == 1) return k % 2 == 0 ? nums[0] : -nums[0];
            Arrays.sort(nums);
            int sum = 0;
            int idx = 0;
            for (int i = 0; i < k; i++) {
              	//存在负数
                if (i < nums.length - 1 && nums[idx] < 0) {
                    nums[idx] = -nums[idx];
                    if (nums[idx] >= Math.abs(nums[idx + 1])) idx++;
                    continue;
                }
              	//没有负数且k仍大于0 从左往右翻转为负数
                nums[idx] = -nums[idx];
            }
            for (int i = 0; i < nums.length; i++) {
                sum += nums[i];
            }
            return sum;
        }
    }
    

中等题目

376. 摆动序列

  • 题目描述

    image.png

  • 题解

    用示例二来举例,如图所示:

    376.摆动序列

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

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

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

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

    本题代码实现中,还有一些技巧,例如统计峰值的时候,数组最左面和最右面是最不好统计的。

    例如序列[2,5],它的峰值数量是2,如果靠统计差值来计算峰值个数就需要考虑数组最左面和最右面的特殊情况。

    所以可以针对序列[2,5],可以假设为[2,2,5],这样它就有坡度了即preDiff = 0,如图:

    376.摆动序列1

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

    保持区间波动,只需要把单调区间上的元素移除就可以了

    class Solution {
        public int wiggleMaxLength(int[] nums) {
            if(nums.length<=1){
                return nums.length;
            }
            int curDiff=0;
            int preDiff=0;
            int res=1;
            for(int i=1;i<nums.length;i++){
              	//更新当前差值
                curDiff=nums[i]-nums[i-1];
              	//如果当前差值和上一个差值为一正一负
                //等于0的情况表示初始时的preDiff
                if((curDiff>0 && preDiff<=0) || (curDiff<0 && preDiff>=0)){
                    res++;
                  	//更新上一个差值
                    preDiff=curDiff;
                }
            }
            return res;
        }
    }
    

738. 单调递增的数字

  • 题目描述

    image.png

  • 题解

    例如:98,一旦出现strNum[i - 1] > strNum[i]的情况(非单调递增),首先想让strNum[i - 1]--,然后strNum[i]给为9,这样这个整数就是89,即小于98的最大的单调递增整数。

    这一点如果想清楚了,这道题就好办了。

    局部最优:遇到strNum[i - 1] > strNum[i]的情况,让strNum[i - 1]--,然后strNum[i]给为9,可以保证这两位变成最大单调递增整数

    全局最优:得到小于等于N的最大单调递增的整数

    但这里局部最优推出全局最优,还需要其他条件,即遍历顺序,和标记从哪一位开始统一改成9

    此时是从前向后遍历还是从后向前遍历呢?

    从前向后遍历的话,遇到strNum[i - 1] > strNum[i]的情况,让strNum[i - 1]减一,但此时如果strNum[i - 1]减一了,可能又小于strNum[i - 2]。

    这么说有点抽象,举个例子,数字:332,从前向后遍历的话,那么就把变成了329,此时2又小于了第一位的3了,真正的结果应该是299。

    所以从前后向遍历会改变已经遍历过的结果!

    那么从后向前遍历,就可以重复利用上次比较得出的结果了,从后向前遍历332的数值变化为:332 -> 329 -> 299

    确定了遍历顺序之后,那么此时局部最优就可以推出全局,找不出反例,试试贪心

    class Solution {
        public int monotoneIncreasingDigits(int n) {
            String s=String.valueOf(n);
            char[] ch=s.toCharArray();
            int start=s.length();
          	//前一个大于后一位,前一位减1,记录后几位
            for(int i=s.length()-2;i>=0;i--){
                if(ch[i]>ch[i+1]){
                    ch[i]--;
                    start=i+1;
                }
            }
          	//前一个大于后一位,前一位减1,后面的全部置为9
            for(int i=start;i<s.length();i++){
                ch[i]='9';
            }
            return Integer.parseInt(String.valueOf(ch));
        }
    }
    

122. 买卖股票的最佳时机 II

  • 题目描述

    image.png

  • 题解

    本题首先要清楚两点:

    • 只有一只股票!
    • 当前只有买股票或者卖股票的操作

    想获得利润至少要两天为一个交易单元

    这道题目可能我们只会想,选一个低的买入,在选个高的卖,在选一个低的买入.....循环反复。

    如果想到其实最终利润是可以分解的,那么本题就很容易了!

    如何分解呢?

    假如第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])。

    如图:

    122.买卖股票的最佳时机II

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

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

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

    class Solution {
        public int maxProfit(int[] prices) {
            int result=0;
            for(int i=1;i<prices.length;i++){
                result+=Math.max(prices[i]-prices[i-1],0);
            }
            return result;
        }
    }
    

714. 买卖股票的最佳时机含手续费

  • 题目描述

    image.png

  • 题解

    本题有了手续费,就要关系什么时候买卖了,因为计算所获得利润,需要考虑买卖利润可能不足以手续费的情况。

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

    此时无非就是要找到两个点,买入日期,和卖出日期。

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

    所以我们在做收获利润操作的时候其实有三种情况:

    • 情况一:收获利润的这一天并不是收获利润区间里的最后一天(不是真正的卖出,相当于持有股票),所以后面要继续收获利润。
    • 情况二:前一天是收获利润区间里的最后一天(相当于真正的卖出了),今天要重新记录最小价格了。
    • 情况三:不作操作,保持原有状态(买入,卖出,不买不卖)
    class Solution {
        public int maxProfit(int[] prices, int fee) {
            int res=0;
            int minPrice=prices[0];
            for (int i = 1; i < prices.length; i++) {
                // 情况二:相当于买入
                if (prices[i] < minPrice) minPrice = prices[i];
                // 计算利润,可能有多次计算利润,最后一次计算利润才是真正意义的卖出
                if (prices[i] > minPrice + fee) {
                    res += prices[i] - minPrice - fee;
                    minPrice = prices[i] - fee; // 情况一,这一步很关键
                }
            }
            return res;
        }
    }
    

135. 分发糖果

  • 题目描述

    image.png

  • 题解

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

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

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

    先确定右边评分大于左边的情况(也就是从前向后遍历),此时局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果,如果ratings[i] > ratings[i - 1] 那么[i]的糖 一定要比[i - 1]的糖多一个,所以贪心:candyVec[i] = candyVec[i - 1] + 1

    再确定左孩子大于右孩子的情况(从后向前遍历),如果 ratings[i] > ratings[i + 1],此时candyVec[i](第i个小孩的糖果数量)就有两个选择了,一个是candyVec[i + 1] + 1(从右边这个加1得到的糖果数量),一个是candyVec[i](之前比较右孩子大于左孩子得到的糖果数量)。

    那么又要贪心了,局部最优:取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,保证第i个小孩的糖果数量即大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。

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

    所以就取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,candyVec[i]只有取最大的才能既保持对左边candyVec[i - 1]的糖果多,也比右边candyVec[i + 1]的糖果多

    135.分发糖果1

     /** 
     分两个阶段 
     		1、起点下标1从左往右,只要 右边比左边大,右边的糖果=左边+1
     		2、起点下标ratings.length-2从右往左,只要 左边比右边大,此时左边的糖果应该取本身的糖果数(符合比它左边大)和右边糖果数+1二者的最大值,这样才符合 它比它左边的大,也比它右边大
     */
    class Solution {
        public int candy(int[] ratings) {
            int[] candyVec=new int[ratings.length];
            candyVec[0]=1;
            //从前向后
            for(int i=1;i<ratings.length;i++){
                if(ratings[i]>ratings[i-1]){
                    candyVec[i]=candyVec[i-1]+1;
                }else{
                    candyVec[i]=1;
                }
            }
            //从后向前
            for(int i=ratings.length-2;i>=0;i--){
                if(ratings[i]>ratings[i+1]){
                    candyVec[i]=Math.max(candyVec[i],candyVec[i+1]+1);
                }
            }
          	//统计结果
            int res=0;
            for(int i:candyVec){
                res+=i;
            }
            return res;
        }
    }
    

406. 根据身高重建队列

  • 题目描述

    image.png

    image.png

  • 题解

    本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后在按照另一个维度重新排列。

    如果两个维度一起考虑一定会顾此失彼

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

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

    那么只需要按照k为下标重新插入队列就可以了,为什么呢?

    406.根据身高重建队列

    按照身高排序之后,优先按身高高的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]]

    **void add(int index,Object element):**此方法在列表中的指定索引处插入元素。它将当前位于该位置的元素(如果有)和任何后续元素右移(将在其索引处增加一个)

    class Solution {
        public int[][] reconstructQueue(int[][] people) {
          	//身高从大到小排(身高相同k小的站前面)
            Arrays.sort(people,(a,b)->{
                if(a[0]==b[0]) return a[1]-b[1];
                return b[0]-a[0];
            });
            LinkedList<int[]> qeque=new LinkedList<>();
            for(int[] p:people){
                qeque.add(p[1],p);
            }
            return qeque.toArray(new int[people.length][]);
        }
    }
    

有点难度

53. 最大子数组和

  • 题目描述

    image.png

  • 题解

    贪心贪的是哪里呢?

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

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

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

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

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

    这相当于是暴力解法中的不断调整最大子序和区间的起始位置

    53.最大子序和

    class Solution {
        public int maxSubArray(int[] nums) {
            if(nums.length==1){
                return nums[0];
            }
            int count=0;
            int sum=Integer.MIN_VALUE;
            for(int i=0;i<nums.length;i++){
                count+=nums[i];
                //取区间累计的最大值(相当于不断确定最大子序终止位置)
                sum=Math.max(sum,count);
                if(count<=0){
                    //相当于重置最大子序起始位置,因为遇到负数一定是拉低总和
                    count=0;
                }
            }
            return sum;
        }
    }
    

55. 跳跃游戏

  • 题目描述

    image.png

  • 题解

    其实跳几步无所谓,关键在于可跳的覆盖范围!

    不一定非要明确一次究竟跳几步,每次取最大的跳跃步数,这个就是可以跳跃的覆盖范围。

    这个范围内,别管是怎么跳的,反正一定可以跳过来。

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

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

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

    55.跳跃游戏

    class Solution {
        public boolean canJump(int[] nums) {
            if(nums.length==1) return true;
          	//覆盖范围, 初始覆盖范围应该是0,因为下面的迭代是从下标0开始的
            int coverRange=0;
          	//在覆盖范围内更新最大的覆盖范围
            for(int i=0;i<=coverRange;i++){
                coverRange=Math.max(coverRange,i+nums[i]);
                if(coverRange>=nums.length-1){
                    return true;
                }
            }
            return false;
        }
    }
    

45. 跳跃游戏 II

  • 题目描述

    image.png

  • 题解

    所以真正解题的时候,要从覆盖范围出发,不管怎么跳,覆盖范围内一定是可以跳到的,以最小的步数增加覆盖范围,覆盖范围一旦覆盖了终点,得到的就是最小步数!

    这里需要统计两个覆盖范围,当前这一步的最大覆盖和下一步最大覆盖

    如果移动下标达到了当前这一步的最大覆盖最远距离了,还没有到终点的话,那么就必须再走一步来增加覆盖范围,直到覆盖范围覆盖了终点

    45.跳跃游戏II

    图中覆盖范围的意义在于,只要红色的区域,最多两步一定可以到!(不用管具体怎么跳,反正一定可以跳到)

    理解本题的关键在于:以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,这个范围内最小步数一定可以跳到,不用管具体是怎么跳的,不纠结于一步究竟跳一个单位还是两个单位

    class Solution {
        public int jump(int[] nums) {
            if(nums==null || nums.length==0 || nums.length==1){
                return 0;
            }
            int step=0;
            //当前覆盖最远距离下标
            int curRange=0;
            //下一步覆盖最远距离下标
            int nextRange=0;
            for(int i=0;i<nums.length;i++){
                //更新下一步覆盖最远距离下标
                nextRange=Math.max(nums[i]+i,nextRange);
                if(curRange==i){
                    //如果当前覆盖最远距离下标不是终点
                    if(curRange!=nums.length-1){
                        step++;
                         //更新当前覆盖最远距离下标
                        curRange=nextRange;
                        //下一步的覆盖范围已经可以达到终点,结束循环
                        if(nextRange>=nums.length-1){
                            break;
                        }
                    }else{
                        //当前覆盖最远距离下标是集合终点,不用做ans++操作了,直接结束
                        break;
                    }
                }
            }
            return step;
        }
    }
    

452. 用最少数量的箭引爆气球

  • 题目描述

    image.png

  • 题解

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

    为了让气球尽可能的重叠,需要对数组进行排序

    如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭

    452.用最少数量的箭引爆气球

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

    class Solution {
        public int findMinArrowShots(int[][] points) {
            Arrays.sort(points, (o1, o2) -> Integer.compare(o1[0], o2[0]));
            //points 不为空至少需要一支箭
            int count=1;
            for(int i=1;i<points.length;i++){
                //气球i和气球i-1不挨着,注意这里不是>=
                if(points[i][0]>points[i-1][1]){
                    count++;
                }else{
                    // 气球i和气球i-1挨着,更新重叠气球最小右边界
                    points[i][1]=Math.min(points[i][1],points[i-1][1]);
                }
            }
            return count;
        }
    }
    

435. 无重叠区间

  • 题目描述

    image.png

  • 题解

    按照右边界排序,从左向右记录非交叉区间的个数。最后用区间总数减去非交叉区间的个数就是需要移除的区间个数了

    右边界排序之后,局部最优:优先选右边界小的区间,所以从左向右遍历,留给下一个区间的空间大一些,从而尽量避免交叉。全局最优:选取最多的非交叉区间。

    这里记录非交叉区间的个数还是有技巧的,如图:

    435.无重叠区间

    区间,1,2,3,4,5,6都按照右边界排好序。

    每次取非交叉区间的时候,都是可右边界最小的来做分割点(这样留给下一个区间的空间就越大),所以第一条分割线就是区间1结束的位置。

    接下来就是找大于区间1结束位置的区间,是从区间4开始。那有同学问了为什么不从区间5开始?别忘已经是按照右边界排序的了

    区间4结束之后,在找到区间6,所以一共记录非交叉区间的个数是三个。

    总共区间个数为6,减去非交叉区间的个数3。移除区间的最小数量就是3。

    总结如下难点:

    • 难点一:一看题就有感觉需要排序,但究竟怎么排序,按左边界排还是右边界排。
    • 难点二:排完序之后如何遍历,如果没有分析好遍历顺序,那么排序就没有意义了。
    • 难点三:直接求重复的区间是复杂的,转而求最大非重复区间个数。
    • 难点四:求最大非重复区间个数时,需要一个分割点来做标记。
    class Solution {
        public int eraseOverlapIntervals(int[][] intervals) {
            Arrays.sort(intervals, (a, b) -> {
                if (a[0] == a[0]) return a[1] - b[1];
                return a[0] - b[0];
            });
            int count = 0;
            int pre = intervals[0][1];
            for(int i=1;i<intervals.length;i++){
                if(pre>intervals[i][0]) {
                    count++;
                    pre = Math.min(pre,intervals[i][1]);
                }
                else pre = intervals[i][1];
            }
            return count;
        }
    }
    

763. 划分字母区间

  • 题目描述

    image.png

  • 题解

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

    可以分为如下两步:

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

    763.划分字母区间

    class Solution {
        public List<Integer> partitionLabels(String s) {
            List<Integer> res=new ArrayList<>();
            int[] eden=new int[26];
            char[] sArr=s.toCharArray();
            //记录字符出现的最后位置
            for(int i=0;i<sArr.length;i++){
                eden[sArr[i]-'a']=i;
            }
            int index=0;
            int last=-1;
            for(int i=0;i<sArr.length;i++){
              	//找到字符出现的最远边界
                index=Math.max(index,eden[sArr[i]-'a']);
                if(index==i){
                  	//记录并更新起始下标
                    res.add(index-last);
                    last=i;
                }
            }
            return res;
        }
    }
    

56. 合并区间

  • 题目描述

    image.png

  • 题解

    按照左边界排序,排序之后局部最优:每次合并都取最大的右边界,这样就可以合并更多的区间了,整体最优:合并所有重叠的区间

    按照左边界从小到大排序之后,如果 intervals[i][0] < intervals[i - 1][1] 即intervals[i]左边界 < intervals[i - 1]右边界,则一定有重复,因为intervals[i]的左边界一定是大于等于intervals[i - 1]的左边界。

    即:intervals[i]的左边界在intervals[i - 1]左边界和右边界的范围内,那么一定有重复!

    这么说有点抽象,看图:(注意图中区间都是按照左边界排序之后了

    56.合并区间

    class Solution {
        public int[][] merge(int[][] intervals) {
            List<int[]> res=new LinkedList<>();
          	//按照区间左边界从小到大排序
            Arrays.sort(intervals,(o1,o2)->Integer.compare(o1[0],o2[0]));
            int start=intervals[0][0];
            for(int i=1;i<intervals.length;i++){
                //左边界大于前一个元素的右边界
                if(intervals[i][0]>intervals[i-1][1]){
                    res.add(new int[]{start,intervals[i-1][1]});
                    start=intervals[i][0];
                }else{
                    //合并区间
                    intervals[i][1]=Math.max(intervals[i][1],intervals[i-1][1]);
                }
            }
            //最后一个区间没有合并,将其加入result
            res.add(new int[]{start,intervals[intervals.length-1][1]});
            return res.toArray(new int[res.size()][]);
        }
    }
    

134. 加油站

  • 题目描述

    image.png

    image.png

  • 题解

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

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

    i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,起始位置从i+1算起,再从0计算curSum。

    134.加油站

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

    class Solution {
        public int canCompleteCircuit(int[] gas, int[] cost) {
            int curSum=0;
            int totalSum=0;
            int start=0;
            for(int i=0;i<gas.length;i++){
                totalSum+=gas[i]-cost[i];
                curSum+=gas[i]-cost[i];
              	//当前累加rest[i]和 curSum一旦小于0
                if(curSum<0){
                  	// 当前累加rest[i]和 curSum一旦小于0
                    start=i+1;//起始位置更新为i+1
                    curSum=0; //curSum置为0
                }
            }
            if(totalSum<0) return -1;//说明怎么走都不可能跑一圈了
            return start;
        }
    }
    

968. 监控二叉树

  • 题目描述

    image.png

    image.png

  • 题解

    从题目中示例,其实可以得到启发,我们发现题目示例中的摄像头都没有放在叶子节点上!

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

    此时,大体思路就是从低到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。

    此时这道题目还有两个难点:

    1. 二叉树的遍历
    2. 如何隔两个节点放一个摄像头
    class Solution {
        int  res=0;
        public int minCameraCover(TreeNode root) {
            // 对根节点的状态做检验,防止根节点是无覆盖状态 .
            if(minCame(root)==0){
                res++;
            }
            return res;
        }
        /**
         节点的状态值:
           0 表示无覆盖 
           1 表示 有摄像头
           2 表示有覆盖 
        后序遍历,根据左右节点的情况,来判读 自己的状态
         */
        public int minCame(TreeNode root){
            if(root==null){
                // 空节点默认为 有覆盖状态,避免在叶子节点上放摄像头 
                return 2;
            }
            int left=minCame(root.left);
            int  right=minCame(root.right);
            // 如果左右节点都覆盖了的话, 那么本节点的状态就应该是无覆盖,没有摄像头
            if(left==2&&right==2){
                //(2,2) 
                return 0;
            }else if(left==0||right==0){
                // 左右节点都是无覆盖状态,那 根节点此时应该放一个摄像头
                // (0,0) (0,1) (0,2) (1,0) (2,0) 
                // 状态值为 1 摄像头数 ++;
                res++;
                return 1;
            }else{
                // 左右节点的 状态为 (1,1) (1,2) (2,1) 也就是左右节点至少存在 1个摄像头,
                // 那么本节点就是处于被覆盖状态 
                return 2;
            }
        }
    }