代码随想录 刷题记录-17 贪心算法(2)习题
1.134. 加油站
方法一:暴力搜索,但是会时间超限
如果跑了一圈,中途没有断油,而且最后油量大于等于0,说明这个起点是ok的。
暴力的方法思路比较简单,但代码写起来也不是很容易,关键是要模拟跑一圈的过程。
for循环适合模拟从头到尾的遍历,而while循环适合模拟环形遍历,要善于使用while!
class Solution {public int canCompleteCircuit(int[] gas, int[] cost) {int stationNum = gas.length;for(int i = 0 ; i < stationNum ; i++){int rest = gas[i] - cost[i];int startIndex = (i+1)%stationNum;while(startIndex!=i && rest > 0){rest += gas[startIndex] - cost[startIndex];startIndex = (startIndex+1)%stationNum;}if(rest >= 0 && startIndex == i) return i;}return -1;}
}
方法二:贪心算法
直接从全局进行贪心选择,情况如下:
-
情况一:如果gas的总和小于cost总和,那么无论从哪里出发,一定是跑不了一圈的
-
情况二:rest[i] = gas[i]-cost[i]为一天剩下的油,i从0开始计算累加到最后一站,如果累加没有出现负数,说明从0出发,油就没有断过,那么0就是起点。
-
情况三:如果累加的最小值是负数,汽车就要从非0节点出发,从后向前,看哪个节点能把这个负数填平,能把这个负数填平的节点就是出发节点。
C++代码如下:
class Solution {
public:int canCompleteCircuit(vector<int>& gas, vector<int>& cost) {int curSum = 0;int min = INT_MAX; // 从起点出发,油箱里的油量最小值for (int i = 0; i < gas.size(); i++) {int rest = gas[i] - cost[i];curSum += rest;if (curSum < min) {min = curSum;}}if (curSum < 0) return -1; // 情况1if (min >= 0) return 0; // 情况2// 情况3for (int i = gas.size() - 1; i >= 0; i--) {int rest = gas[i] - cost[i];min += rest;if (min >= 0) {return i;}}return -1;}
};
方法三:贪心算法
可以换一个思路,首先如果总油量减去总消耗大于等于零那么一定可以跑完一圈,说明 各个站点的加油站 剩油量rest[i]相加一定是大于等于零的。
每个加油站的剩余量rest[i]为gas[i] - cost[i]。
i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。
那有没有可能 [0,i] 区间 选某一个作为起点,累加到 i这里 curSum是不会小于零呢? 如图:
如果 curSum<0 说明 区间和1 + 区间和2 < 0, 那么 假设从上图中的位置开始计数curSum不会小于0的话,就是 区间和2>0。
区间和1 + 区间和2 < 0 同时 区间和2>0,只能说明区间和1 < 0, 那么就会从假设的箭头初就开始从新选择其实位置了。
局部最优:当前累加rest[i] 的区间和 < 0,至少要从 i+1 开始
全局最优:找到可以跑通一圈的起点。
代码如下:
class Solution {public int canCompleteCircuit(int[] gas, int[] cost) {int curSum = 0;int totalSum = 0;int startIndex = 0;for(int i = 0 ; i < gas.length ; i++){curSum += gas[i] - cost[i];totalSum += gas[i] - cost[i];if(curSum < 0){startIndex = i+1;curSum = 0;}}//不会出现 startIndex 为 gas.length && totalSum >= 0 的情况if(totalSum < 0) return -1;return startIndex;}
}
2.135. 分发糖果
本题的难点在于贪心的策略,如果两边同时考虑,就会顾此失彼,所以要分解问题,并且注意遍历顺序。
先考虑右边大于左边的情况,从前往后遍历。
局部最优:只要右边评分比左边大,右边的孩子就多一个糖果,全局最优:相邻的孩子中,评分高的右孩子获得比左边孩子更多的糖果
代码如下:
// 从前向后
for (int i = 1; i < ratings.size(); i++) {if (ratings[i] > ratings[i - 1]) candyVec[i] = candyVec[i - 1] + 1;
}
再确定左孩子大于右孩子的情况,从后往前遍历,这是为了保证单趟遍历,避免重复计算,在计算前面的时候需要用到后面的结果,所以从后往前遍历。
如果 ratings[i] > ratings[i + 1],局部最优:取candyVec[i + 1] + 1 和 candyVec[i] 最大的糖果数量,保证第i个小孩的糖果数量既大于左边的也大于右边的。全局最优:相邻的孩子中,评分高的孩子获得更多的糖果。
所以该过程代码如下:
// 从后向前
for (int i = ratings.size() - 2; i >= 0; i--) {if (ratings[i] > ratings[i + 1] ) {candyVec[i] = max(candyVec[i], candyVec[i + 1] + 1);}
}
整体代码如下:
class Solution {public int candy(int[] ratings) {int[] candies = new int[ratings.length];for(int i =0 ;i < ratings.length ;i++){candies[i] = 1;}for(int i = 1; i < ratings.length ;i++){if(ratings[i] > ratings[i-1]){candies[i] = candies[i-1] + 1;}}for(int i = ratings.length - 2 ; i >= 0 ; i--){if(ratings[i] > ratings[i+1]){candies[i] = Math.max(candies[i],candies[i+1]+1);//贪心策略,既要满足第一遍遍历时 右边比左边大的情况,还要满足本次遍历,左边比右边大的情况}}int sum = 0;for(int num : candies){sum += num;}return sum;}
}
3.860.柠檬水找零
有如下三种情况:
- 情况一:账单是5,直接收下。
- 情况二:账单是10,消耗一个5,增加一个10
- 情况三:账单是20,优先消耗一个10和一个5,如果不够,再消耗三个5
情况三这里是有贪心,因为美元10只能给账单20找零,而美元5可以给账单10和账单20找零,美元5更万能。
所以局部最优:遇到账单20,优先消耗美元10,完成本次找零。全局最优:完成全部账单的找零。
class Solution {public boolean lemonadeChange(int[] bills) {int five = 0, ten = 0;for(int i = 0 ; i < bills.length ; i++){if(bills[i] == 5){five++;}else if(bills[i] == 10){ten++;if(five <= 0) return false;five--;}else if(bills[i] == 20){if(ten > 0){ten--;if(five <=0) return false;five--;}else{if(five <3) return false;five -=3;}}}return true;}}
这个题目只能顺序收钱找钱,不能把后面收的钱找给前面。
4.406.根据身高重建队列
思路
本题有两个维度,h和k,看到这种题目一定要想如何确定一个维度,然后再按照另一个维度重新排列。
按照k从小到大排列,会发现k无法满足题意,h与k均没有处理好。
按照h从大到小排列,h相同,k小的排在前面,这样就处理好了h这个维度。
根据题意,再去调整k,遍历一遍,把person插入到对应第k个位置,因为它的前面都是比它高的,所以它的插入不会影响它到它所要插入为止的数据的k的正确性。
所以,在按照h从大到小排序后:
局部最优:按身高高的person有限处理,插入过后满足person应满足的队列属性
全局最优:整个队列满足队列属性。
代码如下:
class Solution {public int[][] reconstructQueue(int[][] people) {Arrays.sort(people,(a,b)->{if (a[0] == b[0])return a[1] - b[1];elsereturn b[0] - a[0];});List<int[]> list = new ArrayList<>();for(int[] person : people){list.add(person[1],person);}return list.toArray(new int[people.length][]);}
}
5.452. 用最少数量的箭引爆气球
题目要求射出尽可能少的箭,引爆气球。
思路
本题的贪心策略是尽可能射中重叠区域,并且只在不得不射箭时射出。
要找到重叠区域,需要对数组进行排序。
如果气球重叠了,重叠气球中右边边界的最小值 之前的区间一定需要一个弓箭。
以题目示例: [[10,16],[2,8],[1,6],[7,12]]为例,如图:(方便起见,已经排序)
局部最优:射出当前必须要射出的箭,同时尽量射在重叠区域
全局最优:遍历一次后,所发出的箭最少。
本题在遍历过程中对最小右边界的处理很有技巧性,因为求得最小右边界后,重叠气球的大的有边界已经无用了,后面需要下一个气球的左边界与最小右边界进行比较来判断该气球是否在重叠被射中区域,所以可以在遍历过程中直接将第i个气球的有边界改为目前的最小有边界,后面的气球只和前面气球的有边界比较就可以了(因为这就是前面确定的最小右边界)。
代码如下:
class Solution {public int findMinArrowShots(int[][] points) {if(points.length == 0) return 0;int result = 1;Arrays.sort(points, Comparator.comparingInt(a -> a[0]));for(int i = 1 ; i < points.length ; i++){if(points[i][0] > points[i-1][1]){//判断当前气球的左边界与前面确定的最小右边界关系,如果不挨着,则result++;result++;//本情况下不需要更新有边界,该气球的右边界就是当前的最小有边界}else{//挨着,需要确定最小右边界points[i][1] = Math.min(points[i-1][1],points[i][1]);}}return result;}
}
也可以把minRightBorder显化,要注意minRightBorder初始化成第一个气球的右边界:
class Solution {public int findMinArrowShots(int[][] points) {if(points.length == 0) return 0;int result = 1;Arrays.sort(points, Comparator.comparingInt(a -> a[0]));int minRightBorder = points[0][1];for(int i = 1 ; i < points.length ; i++){if(points[i][0] > minRightBorder){//判断当前气球的左边界与前面确定的最小右边界关系,如果不挨着,则result++;result++;//更新最小有边界minRightBorder = points[i][1];}else{//挨着,需要确定最小右边界minRightBorder = Math.min(points[i][1],minRightBorder);}}return result;}
}
6.435. 无重叠区间
题目要求删除尽可能少的区间,使得区间不重叠,返回要删除的区间数量。
首先进行排序,目的是尽可能分辨出重叠区间。
本题的贪心思想类似于射气球,都是基于重叠区间进行计算。
局部最优:在不得不删除时进行删除,同时应该删除覆盖范围大的以减轻对后面的影响(也就是说,我们记录的应该是最小右边界)。
全局最优:删除最少数目的区间,使得所有区间不重叠。
class Solution {public int eraseOverlapIntervals(int[][] intervals) {//按区间左端点排序,从左到右遍历Arrays.sort(intervals,Comparator.comparingInt(value -> value[0]));int result = 0;int minRightBorder = intervals[0][1];for(int i = 1 ; i < intervals.length ; i++){if(intervals[i][0] >= minRightBorder){//没有重叠,不需要删除,更新新的最小右边界minRightBorder = intervals[i][1];}else{//重叠了result++;//删除影响最大的,保留最小右边界minRightBorder = Math.min(minRightBorder,intervals[i][1]);}}return result;}
}
7.763.划分字母区间
题目要求把给定的字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。
本题的贪心思想:尽可能多地在保证题意的情况下划分字符串。
首先使用哈希思想记录每个字符最晚出现的位置,然后进行遍历:
局部最优:从当前i指向的字符开始,最近所能划分到的地方,这要求当前i能够走到最后的最远距离,在这过程中要不断根据i指向的字符动态更新最远距离。
全局最优:划分得到尽可能多的符合题意的字符串。
class Solution {public List<Integer> partitionLabels(String s) {int[] last = new int[26];List<Integer> res = new ArrayList<>();for(int i = 0 ;i < s.length() ; i++){last[s.charAt(i) - 'a'] = i;}int left = 0;int right = 0;for(int i = 0 ;i < s.length() ; i++){right = Math.max(right,last[s.charAt(i) - 'a']);if(i == right){res.add(right - left + 1);left = i + 1;}}return res;}
}
我认为这里有合并区间的思想,合并有交集的区间,最终对应所划分的字符串。
8.56. 合并区间
本题的要求是合并区间,并且新区间不覆盖原来所不能覆盖的地方。
和上面那道题有相似的感觉,要合并区间,并且不能多覆盖 与上面要求的 尽可能多 有类似之处。
首先根据左端点大小对数组进行排序,然后自左向右遍历:
局部最优:新区间覆盖当前区间及与当前区间覆盖的所有区间,这需要记录这个区间的最右端点,当遍历到的区间左端点与当前新区间不相邻时,这就不能把当前遍历的区间放到新区间了,记录新区间到res中,开辟下一个新区间。
全局最优:在不覆盖原来没有的位置的原则上尽可能合并区间。
代码如下:
class Solution {public int[][] merge(int[][] intervals) {List<int[]> res = new ArrayList<>();Arrays.sort(intervals,Comparator.comparingInt(value -> value[0]));int left = intervals[0][0],right = intervals[0][1];for(int i = 1 ; i < intervals.length ; i++){if(intervals[i][0] <= right){//合并right = Math.max(right,intervals[i][1]);}else{//不能合并res.add(new int[]{left,right});//更新新的 left 和 rightleft = intervals[i][0];right = intervals[i][1];}}res.add(new int[]{left,right});return res.toArray(new int[res.size()][]);}}
代码随想录的解释:
思路
本题的本质其实还是判断重叠区间问题。
大家如果认真做题的话,话发现和我们刚刚讲过的452. 用最少数量的箭引爆气球和 435. 无重叠区间都是一个套路。
这几道题都是判断区间重叠,区别就是判断区间重叠后的逻辑,本题是判断区间重贴后要进行区间合并。
所以一样的套路,先排序,让所有的相邻区间尽可能的重叠在一起,按左边界,或者右边界排序都可以,处理逻辑稍有不同。
按照左边界从小到大排序之后,如果 intervals[i][0] <= intervals[i - 1][1]
即intervals[i]的左边界 <= intervals[i - 1]的右边界,则一定有重叠。(本题相邻区间也算重贴,所以是<=
区间问题小结
关于贪心算法的区间问题,一大特征都有在满足某一限制条件的最大/最小,如果要求最大,那么限制条件往往限制局部的大;如果要求最小,那么限制条件往往限制局部的小。
总结参考:本周小结!(贪心算法系列四)
9.738.单调递增的数字
贪心策略:一旦出现strNum[i - 1] > strNum[i]的情况(非单调递增),首先想让strNum[i - 1]--,然后strNum[i]及strNum[i]之后的都赋值9。
遍历顺序:从后向前遍历,可以重复利用上次比较得出的结果
代码如下:
class Solution {public int monotoneIncreasingDigits(int n) {String s = String.valueOf(n);char[] num = s.toCharArray();int flag = s.length();for(int i = num.length - 2; i >= 0 ; i--){if(num[i] > num[i+1]){num[i]--;flag = i + 1;}}for(int i = flag ; i < num.length ; i++){num[i] = '9'; }return Integer.parseInt(String.valueOf(num));}
}
10.968.监控二叉树
本题是最优化问题,可以考虑贪心思想。
从下往上看,局部最优:让叶子节点的父节点安摄像头,所用摄像头最少,整体最优:全部摄像头数量所用最少
大体思路就是从低到上,先给叶子节点父节点放个摄像头,然后隔两个节点放一个摄像头,直至到二叉树头结点。
此时这道题目还有两个难点:
- 二叉树的遍历
- 如何隔两个节点放一个摄像头
确定遍历顺序
在二叉树中如何从低向上推导呢?
可以使用后序遍历也就是左右中的顺序,这样就可以在回溯的过程中从下到上进行推导了。
后序遍历代码如下:
int traversal(TreeNode* cur) {// 空节点,该节点有覆盖if (终止条件) return ;int left = traversal(cur->left); // 左int right = traversal(cur->right); // 右逻辑处理 // 中return ;
}
注意在以上代码中我们取了左孩子的返回值,右孩子的返回值,即left 和 right, 以后推导中间节点的状态.
如何隔两个节点放一个摄像头
此时需要状态转移的公式,大家不要和动态的状态转移公式混到一起,本题状态转移没有择优的过程,就是单纯的状态转移!
来看看这个状态应该如何转移,先来看看每个节点可能有几种状态:
有如下三种:
- 该节点无覆盖
- 本节点有摄像头
- 本节点有覆盖
我们分别有三个数字来表示:
- 0:该节点无覆盖
- 1:本节点有摄像头
- 2:本节点有覆盖
大家应该找不出第四个节点的状态了。
一些同学可能会想有没有第四种状态:本节点无摄像头,其实无摄像头就是 无覆盖 或者 有覆盖的状态,所以一共还是三个状态。
因为在遍历树的过程中,就会遇到空节点,那么问题来了,空节点究竟是哪一种状态呢? 空节点表示无覆盖? 表示有摄像头?还是有覆盖呢?
回归本质,为了让摄像头数量最少,我们要尽量让叶子节点的父节点安装摄像头,这样才能摄像头的数量最少。
那么空节点不能是无覆盖的状态,这样叶子节点就要放摄像头了,空节点也不能是有摄像头的状态,这样叶子节点的父节点就没有必要放摄像头了,而是可以把摄像头放在叶子节点的爷爷节点上。
所以空节点的状态只能是有覆盖,这样就可以在叶子节点的父节点放摄像头了
接下来就是递推关系。
那么递归的终止条件应该是遇到了空节点,此时应该返回2(有覆盖),原因上面已经解释过了。
代码如下:
// 空节点,该节点有覆盖
if (cur == NULL) return 2;
递归的函数,以及终止条件已经确定了,再来看单层逻辑处理。
主要有如下四类情况:
- 情况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;
}
代码如下:
/*** Definition for a binary tree node.* public class TreeNode {* int val;* TreeNode left;* TreeNode right;* TreeNode() {}* TreeNode(int val) { this.val = val; }* TreeNode(int val, TreeNode left, TreeNode right) {* this.val = val;* this.left = left;* this.right = right;* }* }*/
class Solution {int result = 0;public int minCameraCover(TreeNode root) {if(dfs(root) == 0) result++;return result;}public int dfs(TreeNode root){if(root == null) return 2;int left = dfs(root.left);int right = dfs(root.right);if(left == 0 || right == 0){result++;return 1;}else if(left == 2 && right == 2) return 0;else return 2;}
}
本题的难点首先是要想到贪心的思路,然后就是遍历和状态推导。
在二叉树上进行状态推导,其实难度就上了一个台阶了,需要对二叉树的操作非常娴熟。
总结
贪心算法总结参考:
贪心算法总结篇