算法学习之贪心算法

146 阅读25分钟

贪心算法的入门学习

现在我们来学习贪心算法了,我们离结束只剩下两章了,加油加油,冲冲冲

贪心算法的理论基础

首先我们要知道贪心的本质是选择每一阶段的局部最优,从而达到全局最优。这里要注意的是,贪心算法本质上是一个推测的算法,他不能百分百保证正确性,但是对于很多题目来说,我们通过局部最优的形式是可以推导出我们想要的全局最优的结果的

贪心算法并没有固定的套路、策略或者方法。那么我们什么时候使用贪心算法好呢?答案是,靠感觉。当自己没什么思路的时候,可以尝试尝试贪心算法

然后我们来看看贪心算法的步骤

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

我们解题的一般目标就是找到我们的局部最优,然后从局部最优出发,构建从局部最优推导出全局最优的逻辑代码。

分发饼干

我们先来做一题小试牛刀,来看看题目

本题可以使用暴力解法,但是暴力法不是我们所追求的,我们必须要使用贪心算法,我们容易想到的局部最优是,让每个孩子都尽可能地吃到和他的胃口最接近的饼干,那么根据这个思路,我们可以写入我们的代码如下

class Solution {
    public int findContentChildren(int[] g, int[] s) {
        Arrays.sort(g);
        Arrays.sort(s);
        int ans = 0,index = 0;
        for (int i = 0; i < g.length; i++) {
            while (index<s.length && s[index]<g[i]){
                index++;
            }
            if(ans==s.length){
                break;
            }
            if(index<s.length){
                ans++;
            }
            if(index==s.length){
                break;
            }
            index++;
        }
        return ans;
    }
}

我们这个代码的思路是,我们的i指针指向孩子,index指针指向饼干,当我们的饼干小于孩子的胃口时,我们就让饼干指针往前进,直到满足,满足我们就让指针和饼干都前进一位,如果我们的饼干指针或者是孩子指针有任何一个到达了数组终止位置,此时我们都可以结束遍历,直接返回结果,因为此时我们的继续遍历也不会对结果有任何改变

我们上面的代码运行起来是没有问题的,但是这个代码写的太丑了,实际上,我们可以将我们的代码改造如下

class Solution {
    public int findContentChildren(int[] g, int[] s) {
        Arrays.sort(g);
        Arrays.sort(s);
        int ans = 0;
        for (int i = 0,index = 0; i < g.length && index < s.length;index++) {
            if(g[i]<=s[index]){
                ans++;
                i++;
            }
        }
        return ans;
    }
}

我们这里固定递增的指针指向饼干,而特定情况递增的指针则指向孩子,只有当我们的孩子胃口正好小于等于饼干时,我们就让两个指针前进,如果大于,我们则让饼干不断指针前进,寻找更大的饼干,同时,这两个指针无论哪一个到达了尽头都直接结束循环返回答案

摆动序列

接着我们来做一道难一点的题目,要不然大伙们都会觉得贪心这玩意随便做了都

本题我们要利用贪心,问题就在于,我们这里要怎么贪?这一下子似乎没什么思路,其实我们可以将这个数组化作一个函数图像来看待,这样我们要就的摆动序列其实就可以理解为是一个上下不断波动的图像,那么我们要做的事就是去掉中间的点,这样让我们最终形成的函数图像总是一个波动形的,而且由于我们这里只是返回数量即可,所以我们甚至连去掉这个动作都不用做,直接统计对应的点即可

所以本题的局部最优和整体最优分别如下

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

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

那么我们的整体逻辑是让峰值尽可能的保持峰值,然后删除单一坡度上的节点

那么根据我们上面所说的逻辑,我们容易构造出我们的代码如下

class Solution {
    public int wiggleMaxLength(int[] nums) {
        boolean test = true;
        for (int i = 0; i < nums.length-1; i++) {
            if(nums[i]!=nums[i+1]){
                test=false;
                break;
            }
        }
        if(test){
            return 1;
        }
        int ans = 0,val = 0;
        for (int i = 0; i < nums.length-1; i++) {
            if(nums[i+1]==nums[i]){
                if(i==0){
                    ans++;
                }
                continue;
            }
            int num = nums[i+1]-nums[i];
            if(i==0){
                val = num;
                ans++;
                continue;
            }
            if(num>0 && val<0 || num<0 && val>0){
                ans++;
            }
            val = num;
        }
        return ans+1;
    }
}

我们这个代码的基本逻辑是首先判断我们的代码是不是完全一样的,如果是,我们就返回1,确定不是完全一样的之后,我们就进入我们的整体判断,我们这里首先判断当前的数和下一个数是否相等,如果相等我们则跳过,同时由于我们的第一个数也是计入坡度的,因此我们判断当前跳过的数是否是第一个数,如果是,那么我们最后计数时就要加上这个数量,同时由于我们的最后一个数也是要计入数量的,因此我们返回结果时需要将结果+1,代表我们还返回了最后那一位

这里值得一提的是我们会发现我们这里对第一个数要不要增加的判断里还限定的i==0,这是因为只有我们的第一个数是一样的时候,后面的第一位的代码统计代码将无法被调用,此时我们需要手动加上,但是如果其不是第一位的数,那么第一位的数量是被统计上的,此时我们就不需要额外加上该数,因此我们这里进行了一个i==0的特殊判断

然后我们定义两个数,一个数量保存当前数量的差,这个可以理解为我们的函数的坡度,另外一个变量则保存上一位坡度,然后我们每次判断坡度是否是相反的,是的话我们就让结果数量+1

上面的逻辑是没有问题的,但是问题在于,实际上上面的代码实际也是可行的,效率也还不错。但是上面的代码的问题在于太几把麻烦了,我们需要简化这份代码,那么我们应该怎么做呢?我们注意到,我们上面的代码麻烦的地方主要在于我们要处理重复的数字以及开头和结尾的数字的特殊处理,但实际上,我们可以这么做

我们假设出两个坡度,一个坡度记录当前和下一个坡度,另外一个记录上一个坡度,初始时上一个坡度为的值为0,此时代表第一个值有上一个值,而且其上一个值与当前值完全一样,此时我们仍然令其通过我们的判断,令结果+1,通过这种方法,我们相当于是构造了一个值出来,这样我们就便于统计我们的坡度,相当于是将特殊的情况通过构造一个新的条件来令其普通化

那么我们可以构造我们的代码如下

class Solution {
    public int wiggleMaxLength(int[] nums) {
        int prev = 0,ans = 1;
        for (int i = 0; i < nums.length-1; i++) {
            int now = nums[i]-nums[i+1];
            if(now>0 && prev<=0 || now<0 && prev>=0){
                ans++;
                prev=now;
            }
        }
        return ans;
    }
}

我们这里的逻辑是每次我们的统计当前的坡度,和上一个坡度进行比较,由于第一位的上一个坡度总是为0,因此第一位总是能进去到我们的判断条件中,这就相当于是加上了我们第一位的数的数量,同时由于我们的统计的坡度中结果中,如果当前坡度结果为0必然进不去,因此我们这上一位的坡度在第一位的记录之后必然不为0,因此我们当我们遇上两个相等的数时,其必然进不去,因此我们可以跳过相同的数。同时,即使第一个相同后面的不相同也没关系,因为后面只要相同就无法进入判断,因此不会产生重复计数的问题,自然也不需要多加一个判断

这份代码说是非常棒了,最后值得一提的是,这个题目还可以用动态规划的算法来做,但是我们目前还没有学习动态规划,所以就先放着吧,等以后学到了我们再来用动态规划做这一道题

最大子数组和

本题的暴力解法非常简单,但是会超时,直接寄,我们当然要使用贪心算法来做,但问题在于,这一题我们要怎么贪呢?我们容易观察到上面的例子,当-2和1在一起,我们要计算起点的时候,我们肯定是从1开始计算的,因为负数只会拉低我们的总和,因此我们会忽略-2,而这就是本题贪心所贪的地方,我们每次不断累加都将前面的数的和当做一个整体,当这个整体小于0时,我们就立即放弃该整体,从下一个位置为起点继续计算

这种逻辑的具体运算流程可以看下图

那么最终我们容易写出我们的代码如下,注意这里我们的初始答案是最低的值,因为我们的数组是必然有值的,不为空的,而其下的子序列的和是可能为负数的,如果我们将其值定为0的话,那么对于全部数为负数的数组而言,其最终会得到一个0的结果,这肯定是不合理的,所以我们这里需要将我们最初的数量设置为int的最小值,这样就可以保证其总是可以得到数组内子序列的值

class Solution {
    public int maxSubArray(int[] nums) {
        int ans = Integer.MIN_VALUE,now = 0;
        for (int i = 0; i < nums.length; i++) {
            now+=nums[i];
            ans = Math.max(ans,now);
            if(now<0){
                now = 0;
            }
        }
        return ans;
    }
}

最后我们提一下这题的贪心本质,其本质在于不能让“连续和”为负数的时候加上下一个元素,而不是 不让“连续和”加上一个负数

最后提一嘴,这题还能用动态规划和分治法来做,但是我都不会,咱们以后学到那个程度再来用别的方法做这题吧

那么接着我们来进行对我们的贪心算法的进一步学习,有的同学可能会觉得好像学了这么久也不见得有什么套路能一直沿用下去,确实是这样的,因为贪心本身没啥套路,主打的就是一个直觉,直觉到了就会了,没到就寄了

买卖股票的最佳时机 II

这题的暴力思路当然是循环计算求最大值,不过这样的话就太low了,这题还得用贪心来做。那么问题在于,我们应该怎么贪心呢?其实我们在这里的贪心思路可以很简单,我们买股票肯定希望低价格买,高价格卖出,那我们只要看哪天的价格比当天高就把股票卖出去就完了。换言之,局部最优:收集每天的正利润,全局最优:求得最大利润。

那么最终我们可以写出我们的代码如下

class Solution {
    public int maxProfit(int[] prices) {
        if(prices.length==1){
            return 0;
        }
        int ans = 0,buy = -1;
        for (int i = 0; i < prices.length-1; i++) {
            if(prices[i]>=prices[i+1] && buy!=-1){
                ans+=prices[i]-buy;
                buy=-1;
            }else if(prices[i]<prices[i+1] && buy==-1){
                buy = prices[i];
            }
        }
        if(buy!=-1){
            ans+=prices[prices.length-1]-buy;
        }
        return ans;
    }
}

跳跃游戏

做完了上面简单的题目,来做一道重量级一点的题目

这题我们最开始的思路是我们的局部最优的贪心就是每次寻找最大的跳跃范围并跳跃,然而,这个贪心思路一开始就是错误的,每次寻找最大的跳跃范围并跳跃至下一个最大范围处的贪心思路并不能让我们精确得到最终答案,尽管我们后面对我们的代码缝缝补补甚至特判掉一个样例最终我们终于过了本题,但那也是毫无意义的,不但效率低下,而且本质上这种做法不会被认可,因为实际上你就是没做出来这题

之所以这样的贪心思路是错误的,是因为实际上很多例子就是最大的跳跃范围位置并不会是最好的跳跃位置,我们的思路一开始就是错的。

那我们这题应该要怎么贪心呢?其实我们最开始的贪心思路已经有些接近了,但是并不是最终答案,我们并不是说每次都取最大跳跃范围处跳跃,我们可以转而关心我们的能够跳跃的最大覆盖范围,如果这个覆盖范围到达了终点,那么返回true,不能我们则返回false,同时我们的移动检测范围不能超过最大的探测范围,最大探测范围应该是随着我们的for循环检测不断更新的

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

那么最终我们容易写入我们的代码如下

class Solution {
    public boolean canJump(int[] nums) {
        int length = nums[0];
        for (int i = 0; i < nums.length; i++) {
            if(i>length){
                break;
            }
            length = Math.max(length,nums[i]+i);
            if(length>=nums.length-1){
                return true;
            }
        }
        return false;
    }
}

实际上,我们还可以对我们上面的代码做进一步的简化

class Solution {
    public boolean canJump(int[] nums) {
        for (int i = 0,length = 0; i <= length; i++) {
            length = Math.max(length,nums[i]+i);
            if(length>=nums.length-1) return true;
        }
        return false;
    }
}

跳跃游戏 II

接着我们再来做做上一道题的升级版本,先来看看题目

那么这题我们的贪心思路应该是怎么样的呢?我们容易注意到我们这里要求的是最小的跳跃数,同时其保证我们这里总是能跳跃到最后一位(换言之,我们不用做跳跃不到的判断了),我们的最基本的贪心思路还是跟之前一样要维护我们的最大跳跃范围,但是同时我们还要维护一个当前我们可以跳跃的最大范围,如果我们的范围到达了这个最大范围,我们就执行跳跃,同时将我们的可以跳跃的最大范围更新为当前的最大跳跃范围,同时令步数+1即可

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

最后我们还需要做一个对特殊情况的判断,那就是当我们的最大覆盖范围到达了数组末尾时,此时说明我们只要再跳任意一步就可以直接到达终点,此时我们直接让步数+1然后退出循环即可

那么最终我们可以写出我们的代码如下

class Solution {
    public int jump(int[] nums) {
        if(nums.length==1){
            return 0;
        }
        int maxRight = 0,nowRight = 0,ans = 0;
        for (int i = 0; i < nums.length; i++) {
            maxRight=Math.max(maxRight,nums[i]+i);
            if(maxRight>=nums.length-1){
                ans++;
                break;
            }
            if(i==nowRight){
                nowRight=maxRight;
                ans++;
            }
        }
        return ans;
    }
}

实际上,我们还有第二种方法,其可以将我们的特殊情况普通处理,但是那个方法比较难理解,而且说实话我也觉得能理解方法一就行了,没必要说非要去理解第二种方法,毕竟是贪心的题目,无非是写法不同,因此这里就按下不表,有兴趣的可以自己去看看

这两道题的重点都在于,我们不是每次取最大的位置进行跳跃,而是将跳跃本身转化为覆盖范围,通过覆盖范围来解题,我们以后遇上其他题目的时候也可以利用这种思路,不是每次选取最大进行跳跃,而是将题目本身的数据转化为范围,通过不断更新范围的贪心做法来进行解题

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

做了前面的两道难题,我们来做一道简单的题目,这题的思路其实非常简单,我们既然要得到最大的数组和,那我们当然希望所有负数的值变为正数,那么我们首先就在k的数量范围内将我们的负数变为正数,全变完之后我们肯定是希望尽可能地对我们的正数不做变动,因此我们可以对同一个数做两次变换,这样数本身就没有任何变化,而k却减少了两次,我们可以按照这个方式直接求k/2的余数,然后我们判断k是否还有值,若无则不用处理,若有我们则将最小的正数变为负数,之后我们再统计数组的和即可

那么我们容易写出我们的代码如下

class Solution {
    public int largestSumAfterKNegations(int[] nums, int k) {
        Arrays.sort(nums);
        for (int i = 0; i < nums.length; i++) {
            if(k>0 && nums[i]<0){
                nums[i]*=-1;
                k--;
            }else if(nums[i]>0 || k>=0){
                break;
            }
        }
        k%=2;
        Arrays.sort(nums);
        int ans = 0;
        for (int i = 0; i < nums.length; i++) {
            if(i==0 && k==1){
                nums[i]*=-1;
            }
            ans+=nums[i];
        }
        return ans;
    }
}

最后我们值得一提的是,我们这一题里,我们就用了两次贪心思路,第一次的贪心思路的局部最优时让绝对值大的负数变为正数,第二次的贪心思路是找最小的正整数反转其值,这里都有对应的局部最优和整体最优,我们解题的时候,哪怕再简单,也要用局部最优和整体最优的思路来思考贪心题目,将难题分解为局部最优,然后通过局部最优求出对应的解,只有这么做我们才能对贪心算法有些许思路,否则就容易陷入贪心题目靠感觉做的误区

最后提一嘴,就是这一题也可以使用桶排序来做,但是代码上会复杂一些,我们这里还是给出桶排序的代码

class Solution {
    public int largestSumAfterKNegations(int[] nums, int k) {
        int[] arr = new int[201];
        for (int num : nums) {
            arr[num + 100]++;
        }

        for (int i = 0; i < 100; i++) {
            if(arr[i]==0){
                continue;
            }
            if(k>0){
                if(k>=arr[i]){
                    arr[(i-100)*-1+100]+=arr[i];
                    k-=arr[i];
                    arr[i]=0;
                }else {
                    arr[(i-100)*-1+100]+=k;
                    arr[i]-=k;
                    k=0;
                }
            }
        }

        k%=2;
        if(k!=0){
            for (int i = 0; i < arr.length; i++) {
                if(arr[i]!=0){
                    arr[(i-100)*-1+100]++;
                    arr[i]--;
                    break;
                }
            }
        }

        int ans = 0;
        for (int i = 0; i < arr.length; i++) {
            if(arr[i]!=0){
                ans+=(i-100)*arr[i];
            }
        }

        return ans;
    }
}

贪心算法的进阶学习

到这一个章节,我们来进行对贪心算法的更进一步的学习,上一节我们学会了用贪心的思维来思考问题,并且可以采用范围的方式来思考问题,而非每次取最大,本章我们来运用这些知识来解出更多的题目

加油站

首先提一下我们这题其实可以用暴力做出来的啊,但是没什么含金量,我就不提了

我们这题,我们容易想到的贪心思路是我们每次从最大起点中具有最大汽油量的地方出发,然后查看能不能到达目的地,如果能,我们就返回对应的坐标,若不能,就返回-1。根据这个思路我们容易写出其代码如下

class Solution {
    public int canCompleteCircuit(int[] gas, int[] cost) {
        int val = gas[0]-cost[0];
        Deque<Integer> list = new ArrayDeque<>();
        list.add(0);
        for (int i = 0; i < cost.length; i++) {
            int num = gas[i] - cost[i];
            if(num > val){
                list.clear();
                list.add(i);
                val=num;
            }else if(val==num){
                list.add(i);
            }
        }
        while (!list.isEmpty()){
            int index = list.pop();
            int size = gas.length;
            int now = 0;
            boolean judge = true;
            for (int i = index; size > 0; i++,size--) {
                if(i==gas.length){
                    i=0;
                }
                now+=gas[i];
                if(now>=cost[i]){
                    now-=cost[i];
                }else{
                    judge=false;
                    break;
                }
            }
            if(judge){
                return index;
            }
        }
        return -1;
    }
}

但是这个代码是行不通的,第32个例子过不去,因为实际上我们并不是从最拥有最多汽油量的地方出发就能到达目标位置,这就说明我们最开始的贪心思路,其实就是错误的,那么我们得寻找其他的贪心思路

首先我们可以确保的是,如果我们的总油量就是负数的,那么无论我们怎么走,都不可能绕一圈,这是大前提。其次是,我们可以从0开始累加计算其总油量,如果总油量到达了0,那么其我们的起始位置必然在其下标之后,这是当然的,因为前面的坐标无论怎么加到这个下标的油量一定会小于0,即无法到达,所以必然在其之和,而通过判断总油量是否大于0又可以判断出是否存在绕一圈的下标,两相结合,我们最终就可以通过这种方式来得到我们的目标下标

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

最终我们可以写入我们的代码如下

class Solution {
    public int canCompleteCircuit(int[] gas, int[] cost) {
        int sum = 0,now = 0;
        int index = 0;
        for (int i = 0; i < gas.length; i++) {
            sum+=gas[i]-cost[i];
            now+=gas[i]-cost[i];
            if(now<0){
                index=i+1;
                now=0;
            }
        }
        if(sum<0){
            return -1;
        }
        return index;
    }
}

分发糖果

现在我们来做一道重量级题目,来到困难题提提神,别总是做简单题目

那么这题我们的思路很简单,我们首先找到所有最小的值,该值肯定是要放置1颗糖果的,然后我们从该值开始往左赋值,并且往右赋值,然后统计数量,最后遍历所有结果,找到最小的值,该值就是符合结果的值

按照这个思路,我们容易写出我们的代码如下

class Solution {
    public int candy(int[] ratings) {
        int sum = ratings[0];
        int index = 0;
        for (int i = 0; i < ratings.length; i++) {
            if(ratings[i]<sum){
                sum=ratings[i];
                index=i;
            }
        }
        int ans = 1;
        int num = 1;
        //往左前进
        for (int i = index-1; i >= 0; i--) {
            if(ratings[i]>ratings[i+1]){
                ans+=num+1;
                num++;
            }else {
                ans++;
                num=1;
            }
        }
        num=1;
        //往右前进
        for (int i = index+1; i < ratings.length; i++) {
            if(ratings[i]>ratings[i-1]){
                ans+=num+1;
                num++;
            }else {
                ans++;
                num=1;
            }
        }
        return ans;
    }
}

然而,答案的结果是寄,我们结果最终无法通过一些例子,这些例子的显著特征是,左右两边具有至少一个相同的值,换言之,我们往左走贪心得到的结果是一边,往右走又是一边,但是最终得到的结果并不是需要的最小结果,因为总是会有一个一边走的方向最终得到的结果是少于一个的,没能让正确大于两边的值获得更多的糖果

后续即使我们稍微对我们的代码进行了修改,也只是治标不治本,我们只能通过更多的一些例子而已,然后就无了

所以说我们的思路一开始就出现的问题,需要修改。

不过还是有好事的,虽然我们的思路有问题,但是起码分两边处理我们是没搞错的,虽然结果不对,起码思路有些接近了,我们将本题分为两种情况处理

其遍历并分发糖果的过程如图

然后我们再来确定第二种情况

其实吧,这里为什么一定要从右往左遍历而不能从左往右遍历,说实话我现在也搞不太懂,我个人猜测是因为如果我们再来一次从左往右遍历,那么我们最终得到的结果还是跟之前一样的,这没啥意义。

我们需要的是跟之前不一样的,因此我们这里就需要利用前面定义的值,我们从右往左遍历,每次都取出当前值和当前值的前一个值+1进行对比,每次获得其最大值,这样我们就可以让我们的最终得到的分发糖果值符合我们的最终需求

那么最终我们可以写入我们的代码如下

class Solution {
    public int candy(int[] ratings) {
        int[] arr = new int[ratings.length];
        arr[0]=1;
        //从左往右
        for (int i = 1; i < ratings.length; i++) {
            if(ratings[i]>ratings[i-1]){
                arr[i]=arr[i-1]+1;
            }else {
                arr[i]=1;
            }
        }

        //从右往左
        for (int i = ratings.length-2; i >= 0; i--) {
            if(ratings[i]>ratings[i+1]){
                arr[i]=Math.max(arr[i],arr[i+1]+1);
            }
        }

        int ans = 0;
        for (int i : arr) {
            ans+=i;
        }
        return ans;
    }
}

当然说实话吧,这个原理我属实不是很懂,比如我个人就不是很明白为啥他就能保证这样做一定能获得最终所需要的值,最让我不明白的是凭啥他能设置最开始的那个就是1然后执行运算就能得到正确结果?

这玩意简单来说就是右遍历一遍左遍历一遍然后取两次结果最大值就是正确答案,我倒是不难理解这个算法,我难以理解的是凭啥他就知道这样做就是正确的啊?目前我们还不太懂,就先记着吧

另外值得一提的是,这题居然还是2021.8.21网易互联网笔试的第三道题,不过题目给成了围成一圈形成环,因此我们最后来看看围成环我们的解题算法

class Solution {
    public static int candy(int[] ratings) {
        //边界
        if(ratings == null || ratings.length == 0) return 0;
        if(ratings.length == 1) return 1;
        //左右规则数组
        int[] left = new int[ratings.length];
        int[] right = new int[ratings.length];
        //至少一个元素
        Arrays.fill(left, 1);
        Arrays.fill(right, 1);
        //统计结果
        int count = 0;
        //左遍历,保证左规则,即当前元素大于左边元素,就分配+1
        for (int i = 0; i < ratings.length; i++) {
            if (i == 0) {
                if (ratings[i] > ratings[ratings.length - 1]) left[i] = left[ratings.length - 1] + 1;
            } else {
                if (ratings[i] > ratings[i - 1]) left[i] = left[i - 1] + 1;
            }
        }
        //右遍历,保证右规则,即当前元素大于右边元素,就分配+1
        for (int i = ratings.length - 1; i >= 0; i--) {
            if (i == ratings.length - 1) {
                if (ratings[i] > ratings[0]) right[i] = right[0] + 1;
            } else {
                if (ratings[i] > ratings[i + 1]) right[i] = right[i + 1] + 1;
            }
            //满足左右规则最大值,则为最少分配糖果数
            count += Math.max(left[i], right[i]);
        }
        return count;
    }
}

可以看到其实本质上还是差不多的,成环了我们的解题过程无非是在左遍历时对0进行特殊处理,让0坐标下的值与倒数第一位坐标的值进行比较,然后其他做坐标我们照样处理

接着右遍历时我们对第一位的坐标进行特殊处理,令其与第一位比较,接着其他位置的值就照常比较即可

最终得到的最大值的结果就是我们所需要的结果

柠檬水找零

再做完一道难题之后,我们来做一道简单的题目练练手吧

这个没啥特别值得说的啊,就是找零的时候我们优先找大面额的给顾客就行了,无非就是多了些分情况讨论而已

class Solution {
    int[] arr = new int[21];
    public boolean lemonadeChange(int[] bills) {
        for (int bill : bills) {
            if (bill == 5) {
                arr[5]++;
            } else if (bill == 10) {
                arr[10]++;
                if (arr[5] == 0) {
                    return false;
                }
                arr[5]--;
            } else {
                arr[20]++;
                if (arr[10] == 0) {
                    if (arr[5] < 3) {
                        return false;
                    }
                    arr[5] -= 3;
                } else {
                    arr[10]--;
                    if (arr[5] == 0) {
                        return false;
                    }
                    arr[5]--;
                }
            }
        }
        return true;
    }
}

根据身高重建队列

这个贪心题目其实考的是我们的一个全新的知识面,那就是需要进行预处理,然后再使用我们的贪心法求解

我们首先我们要分情况讨论,先从一个维度去考虑,先确定一个维度,再去确定另外一个维度,如果两个维度一起考虑一定会顾此失彼。

那么本体我们到底是先确定哪个维度呢?是身高呢?还是排序呢?我们可以用这两个排序都试试,如果我们用排序k来进行排序,我们会发现排完之后好像啥也没变,没啥用处

但是如果我们用h来排序,那么身高就一定是从大到小排,如果身高一致,那么k小的站前面,这样我们就可以让高个子站在前面,这其实也符合我们的案例,虽然其不是严格按身高从大到小排序的,但是其大致上是遵从这个规律的

而且,哪怕只是从最简单的现实案例中来思考也是这样的,我们既然要插入按身高和前面人数的对应集合序列,那最高的不久应当在最前面吗?这样最高的那个才是只有0位在他前面的呀

综上所述我们决定让我们的数组按身高进行排序,如果身高一致则k值小的排前面

当然,只是让高个子站在前面并没有什么卵用,我们这里之所以这样排序,是为了给我们后面做铺垫,起码现在我们已经确定了一个维度了,这个维度能够让我们的身高从大到小排

接着我们要做的事情是,按照对象中的people的k来插入,最终中完成的我们所需要的集合

那么我们容易写出其代码如下

class Solution {
    public int[][] reconstructQueue(int[][] people) {
        Arrays.sort(people, (o1, o2) -> {
            if(o2[0]==o1[0]){
                return o1[1]-o2[1];
            }
            return o2[0]-o1[0];
        });
        List<int[]list = new LinkedList<>();
        for (int i = 0; i < people.length; i++) {
            list.add(people[i][1],people[i]);
        }
        return list.toArray(new int[list.size()][]);
    }
}

可以看到我们这里首先对我们的数组进行排序,让优先让身高高的排前面,若身高相同,则k值小的排前面

然后我们创建了一个list集合,内部存放int[]数组对象,然后我们遍历该集合,调用集合中在对应下标中添加的方法,其中第一个代表的是下表,第二个代表的是存放的对象,那么我们每次往里面存入对应的对象都是按照当前对象的第二个k的所代表的下标的顺序来插入的,最终得到的结果就是我们所需要的

最后我们调用list内部的转换为数组的方法即可,内部我们new一个我们要转换成的数组对象,大小直接设置成当前集合的大小即可,最终就能得到我们想要的二维数组

具体的插入过程请看下图

当然,我承认这有点抽象,刚开始可能想不到,但是我们学习了之后以后我们就要想到这个方法了是吧,第一次想不到大伙们都理解

用最少数量的箭引爆气球

现在我们就拿上一个学习的知识点拿来用用

首先我们分析题目,可以知道其需要的使用最小的箭来射中最多的气球,气球只能确定其直径,只要我们的最初的下标在其直径中即可射破

那么首先我们就要对我们的数组进行排序,我们让直径对应的下标小的数组排前面,大的排后面,如果最开始的下标都一样,那么我们就直径的结束下标从小到大排序,这样就能够保证我们的数组的最小排在起点,最大的排在后面按顺序排列,便于我们后面的处理

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

如果把气球排序之后,从前到后遍历气球,被射过的气球仅仅跳过,只要记录一下箭的数量就可以了。

然后我们遍历我们的数组,最开始我们取出第一个下标代表的直径,然后在不断的遍历中我们缩小第一个半径,直到我们判断出一个数组的起点就比其终点要高或者是终点比起点还低时我们就射出箭,然后重置当前保存的数组对象,继续执行该过程

最后由于我们肯定最起码要射出一个箭的,所以我们这里最开始设置的箭的数量就是为1

class Solution {
    public int findMinArrowShots(int[][] points) {
        Arrays.sort(points, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                if(o1[0]==o2[0]){
                    return o1[1]-o2[1];
                }
                return o1[0]-o2[0];
            }
        });
        int ans = 1;
        int[] arr = points[0];
        for (int i = 0; i < points.length; i++) {
            if(points[i][0]>arr[1] || points[i][0]<arr[0]){
                ans++;
                arr=points[i];
            }else {
                arr[0]=Math.max(arr[0],points[i][0]);
                arr[1]=Math.min(arr[1],points[i][1]);
            }
        }
        return ans;
    }
}

我们这里的贪心思路就是,找到半径重合最多的气球,然后射出箭,这是局部最优,通过这个局部最优可以获得全局最优

最后我们来看看官方题解的过程,其贪心过程也跟我们差不多,不过这里多了一个注意点,那就是如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭,具体请看下图

那么最终我们可以写入我们的代码如下

/**
 时间复杂度 : O(NlogN)  排序需要 O(NlogN) 的复杂度

 空间复杂度 : O(logN) java所使用的内置函数用的是快速排序需要 logN 的空间
 */
class Solution {
    public int findMinArrowShots(int[][] points) {
        if (points.length == 0) return 0;
        //用x[0] - y[0] 会大于2147483647 造成整型溢出
        Arrays.sort(points, (x, y) -> Integer.compare(x[0], y[0]));
        //count = 1 因为最少需要一个箭来射击第一个气球
        int count = 1;
        //重叠气球的最小右边界
        int leftmostRightBound  = points[0][1];
        //如果下一个气球的左边界大于最小右边界
        for(int i = 1; i < points.length; i++){
            if (points[i][0] > leftmostRightBound ) {
                //增加一次射击
                count++;
                leftmostRightBound  = points[i][1];
                //不然就更新最小右边界
            } else {
                leftmostRightBound  = Math.min(leftmostRightBound , points[i][1]);
            }
        }
        return count;
    }
}

我们这里首先进行排序,然后我们首先确定第一个气球的最小右边界,每次循环判断当前半径的起点是否大于最小边界,若大于,则进行射击然后更新最小边界为被设计气球的直径尾部。反之则更新最小边界,从当前值和当前对应气球的直径的尾部中进行更新,看看谁更小(其实吧,我觉得这代码还不如我的代码好理解呢)

无重叠区间

那么我们这里的思路其实很简单,既然其要我们移除所有的重叠区间,且使用的数目最小,那么我们就尽量移除掉所有重叠的区间即可,遇上重叠区间就直接移除掉,但是为了让我们的移除的数目最小,因此我们希望我们的区间分配尽可能按顺序覆盖到我们的所有位置,所以我们需要对我们的数组按照一定的规则进行排序

我们排序的思路就按照小区间在前的思路来排,那么我们首先判断其结束区间是否一致,若一致我们就让开始区间小的排在前面,若不一致我们就让结束区间大的排在后面,最终我们的得到的数组就是尽可能按区间由小到大顺序重叠好的数组

然后我们同样是取出第一个数组用于遍历,如果当前的区间的开始半径比不严格大于我们的当前的半径的结束半径或者是当前区间的结束区间不严格小于我们当前区间的起始半径,我们就直接移除这个,移除不需要真的移除,直接让我们的计数+1即可,反之我们就更新我们的区间到下一个区间,继续进行对应的遍历

class Solution {
    public int eraseOverlapIntervals(int[][] intervals) {
        Arrays.sort(intervals, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                if(o1[1]==o2[1]){
                    return o1[0]-o2[0];
                }
                return o1[1]-o2[1];
            }
        });
        int[] arr = intervals[0];
        int ans = 0;
        for (int i = 1; i < intervals.length; i++) {
            if(intervals[i][0]>=arr[1] || intervals[i][1]<=arr[0]){
                arr=intervals[i];
            }else {
                ans++;
            }
        }
        return ans;
    }
}

其实实际上我们哪怕是按照左边界排序也可以,只不过我们遍历的时候就要从右往左遍历了,这样只会更加麻烦,我们这里就不演示了,没什么必要属于是

合并区间

这题我们的做法也是跟之前的一样的,我们首先要将我们的区间进行排序,令其尽可能按顺序重叠,然后我们遍历整个区间,如果说当前区间终点小于之前记录的区间的起始值或者是其起点大于之前记录的区间的最大值,我们都可以判定没有可以合并的区间了,此时加入到我们的集合中,反之则继续扩大我们的区间范围

最后返回集合转换过来的二维数组即可

那么我们可以写入我们的代码如下

class Solution {
    public int[][] merge(int[][] intervals) {
        Arrays.sort(intervals, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                if(o1[0]==o2[0]){
                    return o1[1]-o2[1];
                }
                return o1[0]-o2[0];
            }
        });
        List<int[]> list = new LinkedList<>();
        int[] arr = intervals[0];
        list.add(arr);
        for (int i = 0; i < intervals.length; i++) {
            if(intervals[i][0]>arr[1] || intervals[i][1]<arr[0]){
                arr=intervals[i];
                list.add(arr);
            }else {
                arr[0]=Math.min(intervals[i][0],arr[0]);
                arr[1]=Math.max(intervals[i][1],arr[1]);
            }
        }
        return list.toArray(new int[list.size()][]);
    }
}

划分字母区间

这题我们可以结合Set集合来完成,首先既然我们希望每个最多的字母出现在同一个片段中,那么我们的贪心思路就是尽可能将相同的字母加到同一个集合中,第一次直接加入下标代表的一个,然后每次要加入新的字母前我们判断后面还有没有当前片段中存在的字母,若存在就继续加入,不存在则说明该片段就是局部上的最优的全部字母都存在的区间

那么根据上面的思路,我们容易写出我们的代码如下

class Solution {
    List<Integer> list = new ArrayList<>();
    public List<Integer> partitionLabels(String s) {
        Set<Character> set = new HashSet<>();
        int index = 0;
        for (int i = 0; i < s.length(); i++) {
            set.add(s.charAt(i));
            boolean judge = true;
            for (int j = i+1; j < s.length(); j++) {
                if(set.contains(s.charAt(j))){
                    judge=false;
                    break;
                }
            }
            if(judge){
                list.add(i-index+1);
                index=i+1;
                set.clear();
            }
        }
        return list;
    }
}

这份代码虽然是可行的,但是由于构建了双重循环,因此其效率并不算高,我们接下来来学习下效率更高的方式

这个思路简单来说就是先通过一次遍历得到所以字母最后出现的下标,最终我们得到的结果数组必然存放着所有字母的最远下标

接着我们再遍历一次,每次取出之前的对应的字母的最大下标,当我们当前的下标与出现的最大下标相等时,则说明此时我们到达了目标位置,此时只要将对应的结果加入到集合中即可

class Solution {
    public List<Integer> partitionLabels(String S) {
        List<Integer> list = new LinkedList<>();
        int[] edge = new int[26];
        char[] chars = S.toCharArray();
        for (int i = 0; i < chars.length; i++) {
            edge[chars[i] - 'a'] = i;
        }
        int idx = 0;
        int last = -1;
        for (int i = 0; i < chars.length; i++) {
            idx = Math.max(idx,edge[chars[i] - 'a']);
            if (i == idx) {
                list.add(i - last);
                last = i;
            }
        }
        return list;
    }
}

这个思路虽然说确实效率高,代码也比较简洁,但说实话,这个思路不太好想,而且说实话,这个正确性的证明我说实话我是不太搞得懂,所以,我觉得看看就得了,不是很具有参考价值说实话

贪心算法的深入学习

到这一段的题目就颇具难度了,这一节的题目说实话还是以理解为主,没必要强求一看就懂,毕竟说实话,很多思路我看了下我觉得真的是想不出来的,只能先记着,以后能用就用上

单调递增的数字

这题恶心就恶心在,你虽然能感觉到这里是要用贪心,但是你不知道具体怎么贪,也不知道咋整好

那么我们这题究竟应该要怎么贪呢?其实我们可以在遇见前一位大于后一位的情况时,只要令前一位-1,然后令后一位赋值为9即可,具体可以看下面的例子

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

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

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

那么根据上面的逻辑,我们很容易就可以写出我们的代码如下

class Solution {
    public int monotoneIncreasingDigits(int n) {
        String s = String.valueOf(n);
        char[] chars = s.toCharArray();
        int start = s.length();
        for (int i = s.length() - 2; i >= 0; i--) {
            if (chars[i] > chars[i + 1]) {
                chars[i]--;
                start = i+1;
            }
        }
        for (int i = start; i < s.length(); i++) {
            chars[i] = '9';
        }
        return Integer.parseInt(String.valueOf(chars));
    }
}

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

这题,我们尝试使用贪心算法做,我们的贪心思路是只要利润大于我们的手续费和成本之和我们就卖出,但结果是错的,因为有时候我们是要尽量少买,这样可以避免产生重复的手续费

那么我们这题应该要怎么做呢?其实我也不知道,我看了答案之后我发现答案我也看不太懂到底是在讲些什么几把,所以我们这里也是稍微过一过就好了吧,因为这题本来就是应该用动态规划来做才是正解的,直接看图吧

那么根据上图的逻辑,我们可以写入我们的代码如下

class Solution {
    public int maxProfit(int[] prices, int fee) {
        int ans = 0;
        int minPrice = prices[0];
        for (int i = 1; i < prices.length; i++) {
            //情况二相当于买入
            if(prices[i]<minPrice){
                minPrice=prices[i];
            }

            //情况三:保持原有状态(因为此时买不便宜,卖则亏本)
            if(prices[i]>=minPrice && prices[i]<=minPrice+fee){
                continue;
            }

            //计算利润,可能有多次计算利润,最后一次计算利润才是真正意义的卖出
            if(prices[i]>minPrice+fee){
                ans+=prices[i]-minPrice-fee;
                minPrice = prices[i]-fee;
            }
        }
        return ans;
    }
}

监控二叉树

最后我们来看一道真正意义上的重量级题目

对于这题,我们的思路是先构造一个全新的二叉树节点,该节点存在一个对父引用的指向,然后我们通过这个结构进行递归的求解,但是问题在于中间到底要怎么处理才能获得最好的一个贪心情况,这里我们卡住了,尝试了很多种办法都得不到理想中的最优解,最终还是放弃了,因为这里的逻辑处理这一段我就直接卡住了

最终我们的没能通过的代码如下所示

class Solution {
    int ans = 0;
    public int minCameraCover(TreeNode root) {
        MyTreeNode myTreeNode = create(root);
        link(myTreeNode);
        dfs(myTreeNode);
        return ans;
    }

    private void dfs(MyTreeNode myTreeNode) {
        if(myTreeNode==null){
            return;
        }
        MyTreeNode prev = myTreeNode.prev;
        MyTreeNode left = myTreeNode.left;
        MyTreeNode right = myTreeNode.right;

        //三个结点都不存在
        if(prev==null && left==null && right==null){
            ans++;
            myTreeNode.val=1;
            return;
        }

        //情况三,子结点中有不存在的结点
        if(judge(prev) && judge(left) || judge(prev) && judge(right)){
            ans++;
            myTreeNode.val=1;
        }

        dfs(myTreeNode.left);
        dfs(myTreeNode.right);
    }

    private boolean judge(MyTreeNode node) {
        if(node==null){
            return false;
        }
        return node.val==0;
    }

    //将所有节点连接其父节点的方法
    private void link(MyTreeNode myTreeNode) {
        if(myTreeNode==null){
            return;
        }

        MyTreeNode left = myTreeNode.left;
        if(left!=null){
            left.prev=myTreeNode;
        }
        MyTreeNode right = myTreeNode.right;
        if(right!=null){
            right.prev=myTreeNode;
        }

        link(myTreeNode.left);
        link(myTreeNode.right);
    }

    //构造同结构的MyTreeNode节点的递归方法
    private MyTreeNode create(TreeNode root) {
        if(root==null){
            return null;
        }
        MyTreeNode node = new MyTreeNode(root.val);
        node.left = create(root.left);
        node.right = create(root.right);
        return node;
    }
}

class MyTreeNode {
    public MyTreeNode left;
    public MyTreeNode right;
    public MyTreeNode prev;
    public int val;

    public MyTreeNode(MyTreeNode left, MyTreeNode right, MyTreeNode prev, int val) {
        this.left = left;
        this.right = right;
        this.prev = prev;
        this.val = val;
    }

    public MyTreeNode(MyTreeNode left, MyTreeNode right, int val) {
        this.left = left;
        this.right = right;
        this.val = val;
    }

    public MyTreeNode(int val) {
        this.val = val;
    }

    public MyTreeNode() {
    }
}

那么这题我们的贪心算法到底应该怎么做呢?我们的局部最优又是什么呢?其实我们可以从示例中得到答案

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

当然我知道可能有人会说这尼玛不扯淡呢,谁寄吧想得到啊,那我只能说,想不到就想不到,记着,下次往这个方向想就行了好吧

接着我们这里还有两个难点,分别是

首先我们要确定的是如何遍历,我们需要的是从下到上推导,那就当然需要用到回溯,而回溯的一个最基本的要求就是先获取对应的结点后处理,所以我们可以先获得两边节点,最后再进行处理,这样就可以实现我们的回溯了,这个思路其实也类似于后序遍历

接着我们来解决第二个问题,我们要如何隔两个结点就放置一个摄像头,这里我们需要记住最重要的一点,那就是节点一共有三种状态,而我们可以用数字来表示这三种状态

这时候有些聪明的同学可能会觉得还有第四种状态,那就是本节点无摄像头的状态,但其实该结点的状态定然是无覆盖或者是有覆盖的状态的,因此我们还是可以用三种状态表示完,那就没必要特别分出第四种状态,这样只会让问题复杂化

还有一个问题是在遍历树的过程中,我们会遇上空节点,那么空节点到底是属于哪一种状态呢?要解决这个问题,我们需要回归到问题的本质,我们的题目需求是希望我们让我们的摄像头数量最少,我们的贪心思路是尽量往叶结点的父节点放置摄像头,而空节点一般是存在于叶结点处的,因此我们可以让我们的空节点处于覆盖状态

那么如果我们遇到的结点为空,我们就创建一个新的结点,赋值为2传回,这样才能正确表示空节点的状态,也可以避免出现空指针异常

接着我们就要对我们的递归进行分情况讨论,一共可以分出四种情况,先来看看情况一

那么遇上这种情况,我们要做的事就是将该结点的值修改为2,然后直接传回该结点,再来看看情况二

情况二其实很好理解,因为无论是哪种情况,都一定会有一个孩子是没有覆盖到的,为了让其覆盖到,我们当然要将该结点赋予摄像头,再来看看情况三

一旦左右孩子已经有一个摄像头了,那么父节点就是被覆盖的状态,这很好理解,就不多提了

最后一种情况是在递归处理之后,头结点没有覆盖的情况

这种情况我们只需要单独再对头结点做一个判断即可,若头结点是未覆盖的状态,我们则手动将其覆盖

那么最终我们可以写入我们的代码如下

class Solution {
    int res=0;
    public int minCameraCover(TreeNode root) {
        // 对根节点的状态做检验,防止根节点是无覆盖状态 .
        if(minCame(root).val==0){
            root.val=1;
            res++;
        }
        return res;
    }
    /**
     节点的状态值:
     0 表示无覆盖
     1 表示 有摄像头
     2 表示有覆盖
     后序遍历,根据左右节点的情况,来判读 自己的状态
     */
    public TreeNode minCame(TreeNode root){
        if(root==null){
            // 空节点默认为 有覆盖状态,避免在叶子节点上放摄像头
            return new TreeNode(2);
        }

        TreeNode left=minCame(root.left);
        TreeNode right=minCame(root.right);


        // 如果左右节点都覆盖了的话, 那么本节点的状态就应该是无覆盖,没有摄像头
        if(left.val==2&&right.val==2){
            //(2,2)
            root.val=0;
            return root;
        }else if(left.val==0||right.val==0){
            // 左右节点都是无覆盖状态,那 根节点此时应该放一个摄像头
            // (0,0) (0,1) (0,2) (1,0) (2,0)
            // 状态值为 1 摄像头数 ++;
            res++;
            root.val=1;
            return root;
        }else{
            // 左右节点的 状态为 (1,1) (1,2) (2,1) 也就是左右节点至少存在 1个摄像头,
            // 那么本节点就是处于被覆盖状态
            root.val=2;
            return root;
        }
    }
}

这里值得一提的是,其实我们可以不必修改结点,因为我们实际进行判断的也只是结点中的值,那我们何必专门返回结点,我们直接把代表这个结点的值给返回就行了,连修改都省了,岂不美哉?

因此我们可以将我们的代码简化如下

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  right=minCame(root.right);
        int left=minCame(root.left);


        // 如果左右节点都覆盖了的话, 那么本节点的状态就应该是无覆盖,没有摄像头
        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;
        }
    }
}

我承认这题的难度有点大,尤其是最后的分四种情况,难得要死,但是我们起码可以先记住是吧,记住了,以后遇上类似题目起码就会做了不是