贪心算法
一、理论基础
1.什么是贪心?
贪心的本质就是选择每一阶段的局部最优,从而达到全局最优。
【举例】有一堆各种面额的钞票,一共拿十张,如何保证最终拿的钱最多?
显然每次都拿最大的,最终结果才能拿得钱最多。 " 每次拿最大的 "就是局部最优,最后拿走最大数额的钱就是推出全局最优。
显然每次都拿最大的,最终结果才能拿得钱最多。 " 每次拿最大的 "就是局部最优,最后拿走最大数额的钱就是推出全局最优。
2.贪心一般解题步骤
贪心算法一般分为如下四步:
- 将问题分解为若干个子问题
- 找出适合的贪心策略
- 求解每一个子问题的最优解
- 将局部最优解堆叠成全局最优解
二、相关题目推荐
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; } }
-
情况1:左右孩子都被覆盖了,那么当前节点就是无覆盖。