一、理论基础

1.什么是贪心?

        贪心的本质就是选择每一阶段的局部最优,从而达到全局最优
【举例】有一堆各种面额的钞票,一共拿十张,如何保证最终拿的钱最多?
        显然每次都拿最大的,最终结果才能拿得钱最多。 " 每次拿最大的 "就是局部最优,最后拿走最大数额的钱就是推出全局最优。

2.贪心一般解题步骤

        贪心算法一般分为如下四步: 
  • 将问题分解为若干个子问题
  • 找出适合的贪心策略
  • 求解每一个子问题的最优解
  • 将局部最优解堆叠成全局最优解
【tips】其实这四步过于理论化了,平时在做贪心类的题目,很难去按照这四步去思考。做题的时候,只要想清楚局部最优是什么,是否能推导出全局最优。

二、相关题目推荐

1.455.分发饼干

  • 思路:要尽可能满足更多的孩子(全局最优),可以考虑先用小饼干满足小胃口的孩子(局部最优)。因为如果先分发大饼干给小胃口的孩子,那么剩下的小饼干就不能满足大胃口。其实本质上还是尽可能满足更多的孩子。
               这里涉及到区分小饼干大饼干、小胃口大胃口,所以需要先对饼干尺寸和孩子胃口进行排序
    class Solution {
        public int findContentChildren(int[] g, int[] s) {
            //分别对孩子的胃口和饼干尺寸进行升序排序
            Arrays.sort(g);
            Arrays.sort(s);
            int max=0;
            int i=0,j=0;
            while(i<g.length&&j<s.length){
                if(s[j] >= g[i]){
                    max++;
                    i++;
                    j++;
                }else{
                    j++;
                }
            }
            return max;
        }
    }

2.★376. 摆动序列

  • 思路:这道题有两种情况:
            情况①:当前元素前后差值一正一负,即为摆动,此时(pre>0 && cur<0) || (pre<0 && cur>0);
            ★情况②:如果有若干相邻重复元素,如[1,2,2,2,1],此时只看一个第一个或最后一个2即可。假如只看最后一个2,它的前差和后差:pre==0&&cur<0,也是摆动。
    ★综合两种情况后的摆动条件为:(pre >= 0 && cur<0) || (pre <= 0 && cur>0)。
    ★除此之外本题还要注意对首尾两元素的处理,因为首元素无前差,尾元素无后差,所以我们可以假设首元素前面有一个相同元素,这样前差就为0了;而一个元素也算摆动,所以直接默认尾元素就是摆动,所以rslt初始为1
    class Solution {
        public int wiggleMaxLength(int[] nums) {
            //仅有一个元素序列也视作摆动序列。
            if(nums.length==1){
                return 1;
            }
            //一个元素也算摆动,所以就不考虑最后一个元素了,遍历完倒数第二个元素就结束。因此rslt初始值设为1(即算上了最后一个元素)
            int rslt=1;
            //一个元素的前差和后差
            int pre=0;//★假设第一个元素前有一个相同元素,所以pre初始是0!
            int cur=0;
            //遍历完倒数第二个元素就结束
            for(int i=0;i<nums.length-1;i++){
                //前差通过后差获得
                //计算后差(遍历完倒数第二个元素就结束,所以i+1不会报错)
                cur=nums[i+1]-nums[i];
                //情况①当前元素前后差值一正一负,即(pre>0&&cur<0)||(pre<0&&cur>0),当前元素是摆动
                //★情况②相邻元素相同,这里算最左边的那个相同元素,即pre==0&&cur!=0,这几个相邻相同元素算一次摆动
                if((pre>=0&&cur<0)||(pre<=0&&cur>0)){
                    rslt++;
                    //★更新前差:下一个元素的前差是现在的后差
                    pre=cur;
                }
            }
            return rslt;
        }
    }

3.53. 最大子序和

  • 思路:如果当前连续和是负数了,就应该从下一个元素重新开始计算连续和,因为负数加上下一个元素只会使最终的连续和越来越小。
    public int maxSubArray(int[] nums) {
        int rslt=Integer.MIN_VALUE;
        int sum=0;
        for(int i=0;i<nums.length;i++){
            sum+=nums[i];
            //★因为nums可能全是负数,所以无论sum大于0还是小于0,都需要更新rslt。
            rslt=sum>rslt?sum:rslt;
            //如果当前连续和小于0了
            if(sum<0){
                //重置sum,相当于从下个位置重新求和
                sum=0;
            }
        }
        return rslt;
    }

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

  • 思路:贪心怎么贪,今天买了明天就卖,只要挣钱的利润(正利润)。想明白这个这道题就很简单了,只需要求出今天和明天的差值,将所有正差加起来即为最大利润
    class Solution {
        public int maxProfit(int[] prices) {
            int sum=0;
            for(int i=0;i<prices.length-1;i++){
                int cha=prices[i+1]-prices[i];
                if(cha>0){
                    sum+=cha;
                }
            }
            return sum;
        }
    }

5.55. 跳跃游戏

  • 思路:这道题怎么跳不重要,重要的是从当前位置最远能跳到哪,那么从当前位置 i 到最远位置 end 的每个位置都能到达,再遍历这个范围[ i ,end ]的最远位置,不能到最后一个下标位置就返回false,能到就返回true。
               比如说[2,3,1,1,4],从第0个位置最远能跳到第2个下标位置,那么遍历当前位置0到第2个下标位置,看看能不能跳到最后一个下标位置,可以发现在第1个下标位置最远可以跳3步,能跳到最后一个下标位置,返回true。
               再比如说[3,2,1,0,4],从第0个位置最远能跳到第3个下标位置,遍历下标[0,3],最远也跳不到最后一个下标位置,所以返回false。
    class Solution {
        public boolean canJump(int[] nums) {
            //只有一个元素,一定能跳到最后
            if(nums.length==1){
                return true;
            }
            //最远能跳到哪
            int end=0;
            //在当前位置和最远位置内遍历,再看能不能跳到最后一个位置
            for(int i=0;i<=end;i++){
                int tempEnd=0;
                tempEnd=i+nums[i];
                end=tempEnd>end?tempEnd:end;
                //能跳到最后一个位置
                if(end>=nums.length-1){
                    return true;
                }
            }
            return false;
        }
    }

6.★45. 跳跃游戏 II

  • 思路:这道题的前提是肯定能到达最后一个位置,那么只需要考虑每一次跳跃所能跳到的最远位置(不是每个位置能跳到的最远位置)就行了。
    class Solution {
        public int jump(int[] nums) {
            if(nums.length==1) return 0;
            //当前这一次能跳到的最远位置
            int curReach=0;
            //下一步能跳到的最远位置
            int nextReach=0;
            int count=0;
            for(int i=0;i<nums.length;i++){
                //★更新下一步能跳到的最远位置,来保证每一次跳跃都是最远的
                nextReach=Math.max(i+nums[i],nextReach);
                //如果下一步能跳到最后一个位置,count+1,结束
                if(nextReach>=nums.length-1){
                    return count+1;
                }
                //下一步不能跳到,现在所处的位置是否是当前能跳到的最远位置,如果是,就需要再跳一次
                if(i==curReach){
                    //再跳一次
                    count++;
                    //更新这一次的最远位置
                    curReach=nextReach;
                }
            }
            return count;
        }
    }

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

  • 思路:对nums进行排序,遍历nums并求和,如果k还有次数,就将负数取反。如果遍历完了k为0,直接返回sum;如果遍历完了k还有次数,并且剩奇数次取反,就对sum进行处理。
    class Solution {
        public int largestSumAfterKNegations(int[] nums, int k) {
            int sum=0;
            Arrays.sort(nums);
            for(int i=0;i<nums.length;i++){
                //如果k还有次数,就将负数取反
                if(k>0&&nums[i]<0){
                    nums[i]=-nums[i];
                    k--;
                }
                sum+=nums[i];
            }
            //★将所有正数再排序,方便确定最小的正数
            Arrays.sort(nums);
            //如果k还有次数,说明nums中所有负数都取反了,现在nums中全是非负数
            if(k>0){
                //如果剩的k是偶数,直接返回sum,因为正数取反两次还是正数
                if(k%2==0){
                    return sum;
                }else{//k剩的是奇数
                    //只需要将最小的数取反,对sum进行处理
                    sum=sum-nums[0]*2;
                }
            }
            //k没有次数了
            return sum;
        }
    }

8.134. 加油站

  • 思路:能开一圈需要满足两个条件:
            - 车从i站能开到i+1
            - 所有站里的油加起来(totalSum)要>=车子的总耗油量。
    那么,假设从第 0 站开始,一直开到 k 站,此时剩的油(restSum)小于0了,就开不到 k+1 站了。这时,应该将起点设置为 k+1 站。
    class Solution {
        public int canCompleteCircuit(int[] gas, int[] cost) {
            int start=0;
            //经过若干加油站后剩的所有油
            int restSum=0;
            //★经过所有加油站剩的油
            int totalSum=0;
            for(int i=0;i<gas.length;i++){
                //从出发到当前加油站共剩的油
                restSum=restSum+(gas[i]-cost[i]);
                //经过全部加油站剩的油
                totalSum=totalSum+(gas[i]-cost[i]);
                //如果经过当前加油站后,油箱剩的油为负数了,说明到不了下一个加油站,更新起始位置,重置油箱
                if(restSum<0){
                    start=i+1;
                    restSum=0;
                }
            }
            //★如果经过所有加油站后剩的油为负,那么无论如何都转不了一圈(这就是为什么需要totalSum)
            if(totalSum<0){
                return -1;
            }else{
                return start;
            }
        }
    }

9.135. 分发糖果

  • 思路:题目要求左右两边都要比较,我们可以分两次,分别只比较右边只比较左边。要注意:
    为什么正序要与左边的孩子比较?
    ——因为第一个孩子的左边没有孩子,正序与左孩子比,可以跳过第一个孩子,直接给第一个孩子一颗糖(最少就是一颗)。
    ★第二次比较为什么要取最大值? 即:num[ i ] = Math.max( num[ i + 1 ] + 1,num[ i ]);
    ——因为与左右两边相邻的孩子都要比较。num[i+1]+1是与右边孩子比较得出应该分的糖数,num[i]是与左边孩子比较得出应该分的糖数。取二者大的,才能保证满足与左右两边都比较。
    class Solution {
        public int candy(int[] ratings) {
            //每个孩子需要分几颗
            int[] num=new int[ratings.length];
            //★正序遍历与左边孩子比较
            //第一个孩子的左边没有其他孩子,先给1颗
            num[0]=1;
            for(int i=1;i<ratings.length;i++){
                //如果比左孩子分高,比左孩子多给一颗
                //如果比左孩子分低或同分,只给1颗
                num[i]=ratings[i]>ratings[i-1]?num[i-1]+1:1;
            }
            //倒序与右边孩子比较(最后一个孩子右边没有孩子)
            for(int i=ratings.length-2;i>=0;i--){
                //如果比右边孩子分高
                if(ratings[i]>ratings[i+1]){
                    //★取满足左右两边评分比较的最大糖数
                                    //num[i+1]+1是与右边孩子比较得出应该分的糖数,num[i]是与左边孩子比较得出应该分的糖数。取二者大的,才能保证满足与左右两边都比较。
                    num[i]=Math.max(num[i+1]+1,num[i]);
                }
            }
            //求一共几颗糖
            int count=0;
            for(int i=0;i<num.length;i++){
                count+=num[i];
            }
            return count;
        }
    }

10.860. 柠檬水找零

  • 思路:这道题只需要注意对20元的找零策略即可。20元有两种找零策略:10+5和5+5+5,那么我们应该优先用哪种策略来找零呢?
               ——优先用10+5。因为10只能用来找20元,而5能用来找10和20,这意味着可以接更多的订单。所以优先用10而让手里的5多一些(贪心)
    class Solution {
        public boolean lemonadeChange(int[] bills) {
            //手里面额为5和10的钱的张数,一开始手里没钱
            int five=0;
            int ten=0;
            for(int i=0;i<bills.length;i++){
                if(bills[i]==5){//不找
                    five++;
                }else if(bills[i]==10){//找5块
                    five--;
                    ten++;
                }else{//找15
                    //★有10优先用10+5进行找零
                    if(ten>0){
                        ten--;
                        five--;
                    }else{//没有10的用三个5找
                        five-=3;
                    }
                }
                //找完零后手里还欠钱
                if(five<0||ten<0){
                    return false;
                }
            }
            return true;
        }
    }

11.★406. 根据身高重建队列

  • 思路:死记硬背的题。。。做法是:优先按身高高的人的k来插入,因为只有先让身高高的先进入队伍,后面身高矮的才能根据前面高的来找自己的位置。所以需要先对people按身高降序、身高相同则k升序进行排序。本题还要注意:
               ①因为要频繁插入,所以使用链表实现的list——LinkedList;
               ②list→数组时,要返回指定类型的数组
    class Solution {
        public int[][] reconstructQueue(int[][] people) {
            //局部最优:优先按身高高的人的k来插入
            Arrays.sort(people,(a,b)->{
                //★按身高降序排序,身高相同则k小的在前(升序)
                if (a[0] == b[0]) return a[1] - b[1];
                return b[0] - a[0];
            });
            //★频繁插入,用链表实现的list
            LinkedList<int[]> rslt=new LinkedList<>();
            for(int i=0;i<people.length;i++){
                //people[i]在队伍中该站的位置
                int position=people[i][1];
                rslt.add(position,people[i]);
            }
            //★返回指定类型int[][]
            return rslt.toArray(new int[people.length][2]);
        }
    }

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

  • 思路:只射重叠最多的气球,用的弓箭一定最少。为了让气球尽可能重叠,需要对所有气球进行排序,按左边界和右边界都行。
    如何判断两个气球是否有重叠部分?
    ——当前气球的左边界小于等于上一个气球的右边界,即为有重叠部分。此时先不需要射箭,因为不知道后面还有没有气球跟这部分重叠了。
    什么时候需要射箭?
    ——当前气球与前面的重叠部分不重叠时,即当前气球的左边界 大于 重叠部分的最小右边界,就需要射一箭。
    class Solution {
        public int findMinArrowShots(int[][] points) {
            //对气球按左边界升序排序
                    //★使用Integer内置比较方法,不会溢出
            Arrays.sort(points,(a,b)->Integer.compare(a[0],b[0]));
            //points不为空,所以至少需要1支箭
            int count=1;
            //因为第一个气球(下标0)跟前一个气球(根本没有)一定不会重合,所以从下标为1的第二个气球开始遍历
            for(int i=1;i<points.length;i++){
                //当前气球与上一个气球有重叠,暂时不需要射箭
                if(points[i][0]<=points[i-1][1]){
                    //★更新重叠部分的最小右边界(因为当前气球可能完全在前一个气球里面),用来判断后面的气球是否与重叠部分再重叠
                    points[i][1]=Math.min(points[i-1][1],points[i][1]);
                }else{
                    //当前气球与上一个气球不重叠,需要射1支箭
                    count++;
                }
            }
            return count;
        }
    }

13.435. 无重叠区间

  • 思路:这道题跟上一题很类似。因为要求移除区间的最小数量,也就是说一旦重叠,就要移除范围大的那个区间。
    class Solution {
        public int eraseOverlapIntervals(int[][] intervals) {
            int count=0;
            //对所有区间按左边界升序排序
            Arrays.sort(intervals,(a,b)->Integer.compare(a[0],b[0]));
            //下标为0的第一个区间没有前一个区间,所以从下标1开始遍历
            for(int i=1;i<intervals.length;i++){
                //重叠
                if(intervals[i][0]<intervals[i-1][1]){
                    //更新重叠区域的最小右边界(为了移除范围大的那个区间)
                    intervals[i][1]=Math.min(intervals[i-1][1],intervals[i][1]);
                    //有重叠,就要移除
                    count++;
                }
            }
            return count;
        }
    }

14.763. 划分字母区间

  • 思路:题目要求同一字母最多只能出现在一个片段中,所以要按片段内所有字母的最远边界(即分割点)来划分片段。所以这道题可以分为两步:
    (一)统计所给字符串中每个字母出现的最远边界(即最后出现的位置);
    (二)遍历所给字符串,并更新当前片段的终止位置,如果当前下标和片段终止位置相等了,说明到达了片段的终止位置,则找到了分割点。

    class Solution {
        public List<Integer> partitionLabels(String s) {
            List<Integer> rslt=new ArrayList<>();
            int len=0;
            //用来记录每个字母出现的最远位置
            int[] position=new int[26];
            char[] arr=s.toCharArray();
            //记录每个字母出现的最远位置
            for(int i=0;i<arr.length;i++){
                position[arr[i]-'a']=i;
            }
            //片段的起始和终止位置[]
            int start=0;
            int end=0;
            for(int i=0;i<arr.length;i++){
                //更新当前片段的终止位置
                end=Math.max(end,position[arr[i]-'a']);
                //★如果到达当前片段的终止位置,切割片段
                if(i==end){
                    //计算片段长度
                    len=end-start+1;
                    rslt.add(len);
                    //★更新下一个片段的起始位置
                    start=end+1;
                }
            }
            return rslt;
        }
    }

15.56. 合并区间

  • 思路:合并区间就是要找到重叠的两个区间,将二者合并,合并后的新区间的左边界是二者左边界最小值,右区间是二者右边界最大值。先按左边界对所有区间升序排序后,合并后的左边界就是上一个区间的左边界了。
    class Solution {
        public int[][] merge(int[][] intervals) {
            List<int[]> rslt=new LinkedList<>();
            //按左边界升序排序
            Arrays.sort(intervals,(a,b)->Integer.compare(a[0],b[0]));
            //将第一个区间加进去(如果只有一个区间,这也是结果集了)
            rslt.add(intervals[0]);
            for(int i=1;i<intervals.length;i++){
                //如果当前区间的左边界小于等于前一个区间的右边界,则重叠
                if(intervals[i][0]<=intervals[i-1][1]){
                    //合并当前区间与上一个区间
                    intervals[i][0]=intervals[i-1][0];//左区间取最左边界(按左边界升序排序了)
                    intervals[i][1]=Math.max(intervals[i][1],intervals[i-1][1]);//右边界取最右边界
                    //移除结果集最后一个区间(因为这个区间合并了当前区间,二者成了新的合并区间)
                    rslt.remove(rslt.size()-1);
                    //将合并区间加入结果集
                    rslt.add(new int[]{intervals[i][0],intervals[i][1]});
                }else{
                    //不重叠,直接将该区间加进去
                    rslt.add(intervals[i]);
                }
            }
            return rslt.toArray(new int[rslt.size()][]);
        }
    }

16.738.单调递增的数字

  • 思路:【死记硬背】从低位向高位遍历时,如果高位比低位大,就让高位减1,高位后的所有低位都为9。
    class Solution {
        public int monotoneIncreasingDigits(int n) {
            //1234 -> "1234"
            String nStr=String.valueOf(n);
            char[] arr=nStr.toCharArray();
            //★用来记录从哪开始往后都是9
            //★start初始值为arr.length是为了当n为个位数时,跳过赋'9'的for循环!
            int start=arr.length;
            //从低位向高位遍历(千 百 十 个)
            for(int i=arr.length-1;i>=1;i--){
                //★如果高位比低位大,就让高位-1,【所有】低位都变9,这样就能得到最大单调递增数
                if(arr[i-1]>arr[i]){
                    arr[i-1]--;
                    //更新start
                    start=i;
                }
            }
            //从start开始都为9
            for(int i=start;i<arr.length;i++){
                arr[i]='9';
            }
            //char[]->string->int
            //★parseInt("0023")会自动转为整数23.
            return Integer.parseInt(String.valueOf(arr));
        }
    }

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

  • 思路:这道题比前面买卖股票问题多了手续费。整体思路是将手续费直接算在买入时,遇到高价就假装卖出(局部最优),因为我们不知道下一天会不会涨。用buy表示算上手续费买入股票的最低价格,在第一天我们以prices[0]+fee买入股票(即buy的初始值),然后遍历:分三种情况考虑:
        情况1:当前价格price[ i ]加上手续费比我们手头的买入价格还要低,就用现在的price[ i ]+fee买入,更新buy;
        情况2:光当前价格price[ i ]都比买入价格高了,我们就【假装卖出】(因为明天的价格可能还会上涨),计算此时的利润rslt=rslt+prices[i]-buy。最关键的一步:我们要更新buy=price[ i ]相当于以prices[ i ]买入了一只股票,这样如果明天价格上涨,就又能加上这两天的利润了;
        情况3:当前价格没有低到我们放弃手上的股票去选择它,也没有高到我们可以通过卖出获得收益,直接跳过。
    这样就能获得最大利润。
    public int maxProfit(int[] prices, int fee) {
        //利润
        int rslt=0;
        //表示【算上手续费】,买入股票的最低价格
        int buy=prices[0]+fee;
        for(int i=1;i<prices.length;i++){
            //情况1:如果当前价格加上手续费比我们手头的买入价格低,就用现在的price+fee买入
            if(prices[i]+fee<buy){
                //更新buy
                buy=prices[i]+fee;
            }else if(prices[i]>buy){//情况2:如果当前价格比买入价格高,我们就【假装卖出】,因为可能明天的价格还会上涨
                //假装卖出时的利润
                rslt=rslt+prices[i]-buy;
                //★只是假装卖掉,下一天可能还会涨价,所以现在我们相当于以prices[i]买入了一只股票(更新buy),这样如果明天价格上涨,就又能加上这两天的利润了
                buy=prices[i];
            }
            //情况3:当前价格没有低到我们放弃手上的股票去选择它,也没有高到我们可以通过卖出获得收益,直接跳过
        }
        return rslt;
    }

18.★★968. 监控二叉树

  • 思路:有题目可知,摄像头能覆盖上中下三层(父节点、自己、左右孩子),那么要让摄像头数量最少,就应该从下往上(后序遍历)装摄像头,尽量让叶子节点的父节点装摄像头,然后每隔两个节点放一个摄像头,直到头结点(还要单独处理)。问题的难点:
    (一)如何做到每隔两个节点放一个摄像头?
    ——通过判断左右孩子的覆盖状态,来确定当前节点的状态。一个节点只能有三种状态没被覆盖(0)、装了摄像头(1)、被覆盖了(2)
    (二)怎么通过左右孩子判断当前节点的状态?
    ——主要有以下四种情况:
    • 情况1:左右孩子都被覆盖了,那么当前节点就是无覆盖
              所以要让一开始叶子节点就是无覆盖,那么空节点应该都被覆盖
    • 情况2:左右孩子至少有一个没被覆盖,那么当前节点就要装摄像头
              这个不难理解,毕竟有一个孩子没有覆盖,父节点就应该放摄像头。
    • 情况3:左右孩子至少有一个装了摄像头,那么当前节点就是被覆盖
    • 情况4头结点没被覆盖,递归结束单独处理。
              递归结束之后,可能头结点还没被覆盖:
              
    class Solution {
        int rslt=0;
        public int minCameraCover(TreeNode root) {
            //★情况4:递归完了头结点还没被覆盖
            if(countCamera(root)==0){
                rslt++;
            }
            return rslt;
        }
        public int countCamera(TreeNode root){
            //终止条件——空节点被覆盖
            if(root==null)  return 2;
            //递归逻辑——后序遍历
            //左、右节点的状态
            int left=countCamera(root.left);
            int right=countCamera(root.right);
            //中——对当前节点进行处理(三种情况)
            //情况1:如果左右都被覆盖,当前节点未覆盖
            if(left==2&&right==2) return 0;
            //情况2:如果左右至少一个没被覆盖,当前节点装摄像头
            if(left==0||right==0){
                rslt++;
                return 1;
            }
            //情况3:如果左右至少一个有摄像头,当前节点被覆盖
            if(left==1||right==1) return 2;
            //★不加会报无返回值的错误,只是为了有个返回值不报错,其实根本到不了这里,没有实际意义
            return -1;
        }
    }