前几天的文章中我写到了一些关于零基础学习回溯算法的一些步骤和细节,在刷题的过程中发现了很多贪心算法的题很有趣,于是今天他来了,准备了好17道题来供大家共同学习,并附上了十分详细的题解,与附带了注释的优美代码,每个题的题解都可以说是隔壁牛大爷都看得懂了咯,相信聪明的小伙伴们一定可以快速上手拿下这个有趣的算法思想。有点长,建议收藏反复观看,文章附带进度条!!可视化你的进步~
贪心算法零基础到快速变成高手
假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。
对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
解题思路:
这是一道入门贪心算法十分基础的题目啦~
问题分析:首先我们要想满足更多的孩子,是不是想着尽量用最小尺寸的小饼干去满足孩子,这样就能匀出来尺寸大的小饼干去满足胃口比较大的孩子啦。
问题抽象:将两个数组进行排序,在同时扫描。
实现步骤:
1、排序两个数组。
2、扫描饼干尺寸数组,如果能狗满足胃口最小的,就将结果 + 1,并更新胃口数组的下标。
class Solution {
public int findContentChildren(int[] g, int[] s) {
int ans = 0;
if (s.length == 0 || g.length == 0) return ans;
// 使用Arrays类的方法,对数组进行排序
Arrays.sort(g);
Arrays.sort(s);
// 扫描两个数组(本质是扫描一个饼干尺寸数组)
for (int i = 0, j = 0; i < g.length && j < s.length; j ++) {
// 满足 存在最小尺寸的饼干 给胃口最小的孩子
if (s[j] >= g[i]) {
ans ++;
i ++;
}
}
return ans;
}
}
如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为 摆动序列 。第一个差(如果存在的话)可能是正数或负数。仅有一个元素或者含两个不等元素的序列也视作摆动序列。
例如, [1, 7, 4, 9, 2, 5] 是一个 摆动序列 ,因为差值 (6, -3, 5, -7, 3) 是正负交替出现的。
相反,[1, 4, 7, 2, 5] 和 [1, 7, 4, 5, 5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。
子序列 可以通过从原始序列中删除一些(也可以不删除)元素来获得,剩下的元素保持其原始顺序。
给你一个整数数组 nums ,返回 nums 中作为 摆动序列 的 最长子序列的长度 。
解题思路:
问题分析:先明确题目中摆动序列的概念,要保证大概类似下图的样子:
不过要是针对题目中删除元素来获得差值序列这种复杂的描述,我们可以更换一个新的思考方式,见下图:
发现了如果存在这种“中间值”,我们需要删掉,来保证数组中的每个元素都属于波峰或者波谷。 什么是波峰波谷呢?顾名思义 波峰就是在各点的两边的元素都比它小, 波谷就是两边都比它大。 这样我们不需要删除元素,仅仅需要忽略过这种不是波峰波谷的值,在扫描数组的时候根据坡度的变化来更新ans
就可以咯。
问题抽象:
扫描数组,根据差值确定是否更新ans
值。
实现步骤:
1、定义currDiff
表示当前元素与上一个元素的差值(也可以理解为坡度)
2、定义prevDiff
表示上一个坡度。
3、遍历数组,坡度相反的时候,更新ans
class Solution {
public int wiggleMaxLength(int[] nums) {
if(nums.length <= 1) return nums.length;
// 上一个坡度, 与 当前坡度初始化
int prevDiff = 0, currDiff = 0, ans = 1;
for (int i = 1; i < nums.length; i ++) {
currDiff = nums[i] - nums[i - 1];
// 当前坡度与上一个坡度相反,出现波峰或波谷
if ((currDiff > 0 && prevDiff <= 0) || (currDiff < 0 && prevDiff >= 0)) {
// 更新坡度
prevDiff = currDiff;
// 更新答案
ans ++;
}
}
return ans;
}
}
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
解题思路:
问题分析:
刚刚接触到这道题的时候,我们不难想出下面这样的暴力思维:根据不同的起始位置扫描后面全部数组元素,将最大值随时记录下来,于是就有了下面这种效率很低的暴力代码:
int ans = Integer.MIN_VALUE;
for (int i = 0; i < nums.length; i ++) {
int base = 0;
for (int j = i; j < nums.length; j ++) {
base += nums[j];
ans = Math.max(ans, base);
}
}
那么这道题可以优化成贪心的地方在哪里呢? 仔细观察我们的代码,在外循环每次更新的起始位置效率很低,每次只更新1,如果我们在扫描数组元素的时候根据数组元素的特性更新怎么样呢? 比如,当计数累加到遇到负数元素时,我们直接放弃当前序列(“拖油瓶”),遍历后面的元素,并对答案保持更新。这样,我们就少了一层确定起始位置的循环。
问题抽象:
一层循环控制数组下标,遍历的同时确定base
是否舍弃。
实现步骤:
1、将最大值置为最小的整数值,用来更新后续最大值。
2、扫描数组,base
不断累加当前元素。
3、如果base
大于最大值,更新ans
。
4、如果base
累加到负数,立即舍弃“拖油瓶”。
class Solution {
public int maxSubArray(int[] nums) {
if (nums.length == 1) return nums[0];
// 初始化变量
int ans = Integer.MIN_VALUE, base = 0;
for (int i = 0; i < nums.length; i ++) {
// 计数累加
base += nums[i];
// 时刻更新最大值
ans = Math.max(ans, base);
// 出现拖油瓶 立即舍弃
if (base <= 0) base = 0;
}
return ans;
}
}
给定一个数组 prices ,其中 prices[i] 是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
解题思路:
问题分析:
给了股票每天的价格,想要赚钱,那肯定就是便宜的时候买入,涨价了就卖出咯。这个时候,就能看出来当买入(数组元素)小于卖出(数组元素)的时候就能累加到我们的答案中,但是应该什么时候卖呢?
[1, 5, 3] 这种情况很显然在跌到3之前,火速先卖出去,赚一波大的
如果存在下面这种情况该怎么卖呢:
[1, 3, 5] 存不存在“小的”时候我先存着,等“大了”我在卖出去?我们不难发现,其实是一样的,我们可以在1买入3卖出,同时3买入,5卖出。这样子: [3 - 1 + 5 - 3] = 4 == [5 - 1] 是一样的!!
这样子问题就好办了,我们只需要在上升的时候累加,下降的时候更新什么时候买入就好啦。只要不能卖钱的时候,我们就一直更新最低价格的时候再买入。
问题抽象:
扫描数组,数组元素上升时,做差累加。减少时,更新base
。
实现步骤:
1、初始化base
,即买入时候的价格。
2、扫描数组,上升时候就卖出。
3、下降的时候更新base
,即重置买入的价格。
class Solution {
public int maxProfit(int[] prices) {
if (prices.length <= 1) return 0;
// 初始化
int ans = 0, base = prices[0];
for (int i = 1; i < prices.length; i ++) {
// 有赚头,火速卖掉
if (prices[i] > base) {
ans += prices[i] - base;
base = prices[i];
// 要亏了,不买不买,找到最便宜的时候买
} else {
// 注意这个位置,可以直接更新为最新值,因为如果存在比他大的,就直接卖掉了,不需要保持最小。
base = Math.min(base, prices[i]);
}
}
return ans;
}
}
给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
解题思路:
问题分析:
根据题目描述,我们要想跳到终点,需要怎么跳才能跳过去呢?怎么确定一次到底跳几个格子呢? 这样子想,问题就想复杂了!其实跳到哪里都无所谓,每次跳跃其实都只是获得了你最远能跳到哪里的信息,如图:
不难发现通过遍历可以跳到位置的时候每次更新可以跳到的最远距离,如果最远距离可以达到最后元素的位置,我们就可以直接返回。
问题抽象:
在可以跳到的最远距离内扫描数组,时刻更新最大距离,满足条件即返回true
,如果扫描完,说明最大距离无法达到数组末端,返回false
。
实现步骤:
1、初始化最远距离maxDist
为nums[0]
。
2、在最远距离范围内逐步扫描数组,并更新最远距离。
3、判断如果最远距离大于数组长度,返回true
。
4、返回false
。
class Solution {
public boolean canJump(int[] nums) {
if(nums.length == 1) return true;
// 初始化
int maxDist = nums[0], jumpDist = 0;
// 最远距离内逐步扫描
for (int i = 0; i <= maxDist; i ++) {
// 判断是否满足条件
if (maxDist >= nums.length - 1) return true;
// 更新最远距离
maxDist = Math.max(maxDist, i + nums[i]);
}
return false;
}
}
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。
假设你总是可以到达数组的最后一个位置。
解题思路:
问题分析:
这道题和上一道题有一点点不一样,要求返回的是最少的跳跃次数。这个时候可让大家绞尽了脑汁,该怎么确定啥时候计数器+1呢;不慌不慌,看看这个图就一目了然啦:
不难发现通过遍历可以跳到当前步数内最远位置的时候每次更新步数和下一个步数内可以跳到的最远距离,如果最远距离可以达到最后元素的位置,我们就可以直接返回。
问题抽象:
逐步扫描,在可以跳到当前步数内的最远距离时,更新步数和下一个步数内可以到达的最远距离,如果最远距离可以到达数组长度,返回步数。
实现步骤:
1、初始化最远距离maxDist
为0,当前步数可以跳到的最远距离currDist
为0。
2、在currDist
范围内更新maxDist
,确定下一个步数内的maxDist
。
3、到达currDist
时候更新步数。判断下一个步之内能否到达终点。
4、返回步数。
class Solution {
public int jump(int[] nums) {
if (nums.length == 1) return 0;
// 初始化步数
// 下一个步数可以到达的最远距离
// 当前步数内可以到达的最远距离
int ans = 0, maxDist = 0, currDist = 0;
for (int i = 0; i < nums.length; i ++) {
// 当前步数内确定下一步一步之内能跳到的最远距离
maxDist = Math.max(maxDist, i + nums[i]);
// 跳到当前步数能到达的最大位置了
if (i >= currDist) {
// 更新步数
ans ++;
// 更新下一步能跳到的最远距离
currDist = maxDist;
// 满足条件,返回
if (maxDist >= nums.length - 1) return ans;
}
}
return ans;
}
}
给定一个整数数组 A,我们只能用以下方法修改该数组:我们选择某个索引 i 并将 A[i] 替换为 -A[i],然后总共重复这个过程 K 次。(我们可以多次选择同一个索引 i。)
以这种方式修改数组后,返回数组可能的最大和。
解题思路:
问题分析:
这是一道简单题,根据题目要求,可以对多次同一个索引操作,我们可以知道,如果在一个数组中经过k次取相反数后能够获得最大sum,必须将最小的值进行取相反数。这样子如果是负数,那么sum增大的越多,如果是正数,那么扣除的就越少,我们采用这种贪心的方式后。如果条件是比较复杂的情况该怎么处理呢,下面给出一个较为一般化的例子来分析:
[1, -2 , 3, -1, 2, -3] K = 5,首先将这个复杂的数组排序,如图:
此时按照我们正常人的思考方式,肯定是将绝对值最大的负数进行反转。如果将所有负数都反转后k
还有盈余,这时候就可以对绝对值最小的那个数字进行不断的翻转,以求得最大的sum
。思路理清了,后面就是将解法转换成程序语言。
问题抽象:
排序数组后,在k
次操作内,如果数组中存在负数,就对最小的负数进行不断取相反数。没有盈余就结束,有盈余就对绝对值最小的数字进行操作。
实现步骤:
1、排序数组
2、k
次遍历数组,在范围内将最低值进行取相反数
3、如果有负数,就取相反数。
4、与下一位数的绝对值进行比较,来决定是否更新index
class Solution {
public int largestSumAfterKNegations(int[] nums, int k) {
Arrays.sort(nums);
int sum = 0;
// 需要对数组元素取相反数的index
int index = 0;
for(int i = 0; i < k; i ++) {
// 遇到负数
if (nums[index] < 0 && i < nums.length - 1) {
// 直接取反
nums[index] = - nums[index];
// 将index控制在绝对值最小的数组元素上
if (nums[index] >= Math.abs(nums[index + 1])) index ++;
continue;
}
// 非负数直接取相反数
nums[index] = - nums[index];
}
for (int i = 0; i < nums.length; i ++) sum += nums[i];
return sum;
}
}
在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。
你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。
如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。
说明:
1、如果题目有解,该答案即为唯一答案。
2、输入数组均为非空数组,且长度相同。
3、输入数组中的元素均为非负数。
解题思路:
问题分析:
根据题意,有两种情况,一种是存在这么一个加油站,使得可以完成一圈,另一种就是无法跑完一圈。那么如何界定能否跑完呢?我们可以这样想,把所有加油站的油都加起来,如果比所有需要的油加起来还多,那肯定就是能跑完了,只不过就是在哪里开始出发的问题啦。这样我们进入能跑完的分支,我们就得分析分析你跑完一个路程,所加上的油能否比消耗的油多,至少不能路上抛锚吧,所以我们需要记录车里所剩余的油量,通过它的正负来判断一开始选的位置能否满足跑一圈的需求。如果不满足,我们就根据当前加油站的位置,更换新的起点。
问题抽象:
遍历数组的同时记录当前剩余的油量是否为正数,并依次来更新,出发点的位置,同时还需要记录总油量的差值,如果跑完一圈下来总油量是负的,那就说明跑不完,返回-1
实现步骤:
1、定义rest
当前剩余油量,totalGas
总剩余油量,index
记录出发点。
2、遍历两个数组,记录差值为当前一趟所剩余的油量,累加到当前剩余油量以及总剩余油量。
3、如果当前剩余油量为负,更换起点,当前油量置0。
4、最后,如果总油量小于0,返回-1;否则返回起点。
class Solution {
public int canCompleteCircuit(int[] gas, int[] cost) {
int n = gas.length;
// 初始化油量
int rest = 0, totalGas = 0;
int index = 0; // 记录出发地点
for (int i = 0; i < n; i ++) {
// 计算从出发地点开始所剩余的油量
rest += gas[i] - cost[i];
// 计算从计位点0开始所剩余的总油量
totalGas += gas[i] - cost[i];
// 选点错误,更换出发地点
if (rest < 0) {
index = (i + 1) % n;
rest = 0; // 新出发地开始的油量置空
}
}
if (totalGas < 0) return -1; // 无法跑完行程
return index;
}
}
老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。
你需要按照以下要求,帮助老师给这些孩子分发糖果:
1、每个孩子至少分配到 1 个糖果。
2、评分更高的孩子必须比他两侧的邻位孩子获得更多的糖果。
那么这样下来,老师至少需要准备多少颗糖果呢?
解题思路:
问题分析:
leetcode上标了困难题,那么这道题难在哪里呢,难在它的贪心策略上,我们如果从头开始遍历数字,既要考虑这个数字会不会比左边大,又要考虑这是数字会不会比右边大,大家可以清晰的感觉到,啊,好难决策,万一后面的都很小呢,又要回过头来修改前面的数,简直难受到爆。那么根据这种情况,我们换一种思路,采用贪心策略中:多个局部最优解组成全局最优解的性质–> 采用左视角和右视角,只要两个视角都符合条件,那么结果一定符合条件。因为题目中仅有两点要求,第一点每个人都要有糖,用来初始化,第二点就是用来用来确定左右时图中的模样,有点像搭积木一样。左视图:保证后面越大,发的越多,小的看不见的就略过并重新开始搭积木。右视图:从小开始搭积木,遇到大的和自己看到的和左视图的拿来比较取最大的那个(否则将破坏左视角的最优局部解)。
问题抽象:
数组赋1,遍历评分数组,升序+1,降序置1重新升序。倒叙遍历评分数组,与升序数组比较,插入。
实现步骤:
1、将糖果数组赋1
2、遍历评分数组,如果后面的比前面的大,就让后面的是前面的糖果树+1
3、倒序遍历数组,如果前面的比后面的大,就让前面的是后面的糖果数加1 或者 为从步骤2中得到的更大的数字。
class Solution {
public int candy(int[] ratings) {
int ans = 0;
// 初始化,并赋1
int[] candies = new int[ratings.length];
for (int i = 0; i < candies.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); // 同时满足左视图
}
// 相加结果
for (int i = 0; i < candies.length; i ++) {
ans += candies[i];
}
return ans;
}
}
在柠檬水摊上,每一杯柠檬水的售价为 5 美元。
顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
解题思路:
问题分析:
这种找钱类型的题,应该是我们人类最熟练处理的贪心问题了吧。大家在找钱的时候肯定是先找最大的,然后在找最小的吧。(如果不是,那可能就有点尴尬了)。这样找钱可以确保找的钱的数量最少。这道题的找钱的那个抽屉里面只有5美元,10美元。根据顾客给钱的数量大致可以这样分为三个情况:
1、顾客给你5块: 欣然收下,5块个数 ++;
2、顾客给你10块:还可以,找他5块,5块个数 --,10块个数++;
3、顾客给你20块:很烦,找他10+5,或者5+5+5;分别10块个数-- 5块个数-- 和 5块个数 -= 3
明确了思路,答案其实已经就出来了,那么这道题的贪心策略在哪里呢?大家在做的时候可能不以为然的就做出来了而感觉没有用到贪心,其实这里的贪心在第三种情况,给你20的时候,你该怎么找钱,我们发现情况2,和情况3都必须使用5块的,为了不让5块的出现不够的情况,在第三种情况出现的时候我们选择找钱的时候,将只有第三种情况才会使用的10块钱先找给他,在找给他1个5块就可以了。能保证让“最有价值”的5块尽可能少的消耗,局部最优使得全局最优。
问题抽象:
遍历bills
数组遇到5块就加,遇到10块,判断后,减5个数,加10个数。遇到20块,边找钱,边判断返回。
实现步骤:
1、初始化5块、10块的个数。
2、遍历bills数组:三种情况:
- 如果是5块,就给5块钱的个数+1。
- 如果是10块,就找去5块钱,并给10块钱个数+1。
- 如果是20块,就先找10块,在找5块。
class Solution {
public boolean lemonadeChange(int[] bills) {
// 初始化5、10块的数量
int cent5 = 0, cent10 = 0;
for (int i = 0; i < bills.length; i ++) {
// 5块直接拿下!
if (bills[i] == 5) cent5 ++;
// 10块找他5块
if (bills[i] == 10) {
if (cent5 == 0) return false;
cent5 --;
cent10 ++;
}
// 20块
if (bills[i] == 20) {
// 没有5块还想找钱??
if(cent5 == 0) return false;
// 有10块就先上大的!!
if(cent10 > 0) {
cent10 --;
cent5 --;
} else {
cent5 -= 3;
if (cent5 < 0) return false;
}
}
}
return true;
}
}
假设有打乱顺序的一群人站成一个队列,数组
people
表示队列中一些人的属性(不一定按顺序)。每个people[i]
=[hi, ki]
表示第i
个人的身高为hi
,前面 正好 有ki
个身高大于或等于hi
的人。
请你重新构造并返回输入数组people
所表示的队列。返回的队列应该格式化为数组queue
,其中queue[j]
=[hj, kj]
是队列中第j
个人的属性(queue[0]
是排在队列前面的人)。
解题思路:
问题分析:
看了题目有点懵,不要慌,我们可以通过示例来了解题意,其实示例就展现的很清楚了。将一个已经排好序的人群队列的身高和参考值k
记录下来,随后进行打乱,让我们根据每个人的[hi, ki]
将队列还原。这道题和我们上一道题有共通之处的就是,hi
和ki
这样的两个参考点是相互制约的,我们无法通过一次遍历,就将问题处理的十分完美。这道题的贪心思想是什么呢? 我们在排序的时候可以根据身高进行第一波筛选,遇到身高相同的时候,我们根据 k 进行第二波筛选。这里,我们可以重写数组的比较器Comparator
,来实现我们自己的比较规则,然后进行排序:如果身高不同就将高的排在后面, 如果身高相同就将 k 大的排在后面。
问题抽象:
根据身高进行升序排序,在身高相同时,根据k进行降序排序,保证符合题目要求
class Solution {
public int[][] reconstructQueue(int[][] people) {
Arrays.sort(people, new Comparator<int[]>() {
public int compare(int[] person1, int[] person2) {
// 如果身高不同
if (person1[0] != person2[0]) {
// 按照身高升序排列
return person2[0] - person1[0];
} else {
// 身高相同 按照k降序排列
return person1[1] - person2[1];
}
}
});
LinkedList<int[]> que = new LinkedList<>();
// 将我们排列后的人加入队列
for (int[] p : people) {
que.add(p[1], p);
}
// 返回
return que.toArray(new int[people.length][]);
}
}
在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以纵坐标并不重要,因此只要知道开始和结束的横坐标就足够了。开始坐标总是小于结束坐标。
一支弓箭可以沿着 x 轴从不同点完全垂直地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
给你一个数组 points ,其中 points [i] = [xstart,xend] ,返回引爆所有气球所必须射出的最小弓箭数。
解题思路:
问题分析:
根据题目描述,想射穿越多的气球,肯定要在气球最密集的地方射箭啦:看下面这张图:
图中这一支箭射了4个,如何来找到这么一支箭呢?我们先人肉模拟一下这个过程,见下面这个图:
在起点这里可以保证射穿1个,然后继续向右边扫描
在第一个气球的范围内可以射到第二个了!继续向右扫描
找到解啦~,回顾我们的扫描过程,我们是如何扫描的?我们在第一个碰到的气球的屁股的范围内一直扫描,将碰到的气球都射穿,之后在化归到起始步骤就可以咯,这就是我们的贪心策略。那么如何操作呢?首先将气球按照气球的终点进行排序,这样,我们遍历的时候,就可以对第一个气球终点之前的气球进行操作了。如果下一个气球的起点超过了第一个气球的终点,说明无法一支箭射穿,弓箭数量++,更新下一支箭的位置。
问题抽象:
将气球按照气球屁股的位置进行排序。从头开始扫描,在每个屁股的范围内射穿尽可能多的气球,超过第一个气球的范围就更新箭的位置和数量。
实现步骤:
1、根据气球的终点位置升序排序。
2、确定第一支箭的位置。
3、扫描气球,通过起始位置,更新下一支箭范围。
class Solution {
public int findMinArrowShots(int[][] points) {
int n = points.length, ans = 1;
// 根据终点位置排序
Arrays.sort(points, new Comparator<int[]>() {
public int compare(int[] point1, int[] point2) {
if (point1[1] > point2[1]) {
return 1;
} else if (point1[1] < point2[1]) {
return -1;
} else {
return 0;
}
}
});
// 第一支箭在屁股最靠前的第一个气球的屁股上
int arrow = points[0][1];
for (int i = 1; i < n; i ++) {
// 如果后面的气球的头在前面气球屁股的后面,就更新
if (points[i][0] > arrow) {
ans ++;
// 下一个箭更新到下一个气球的屁股
arrow = points[i][1];
}
}
return ans;
}
}
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
注意:
1、可以认为区间的终点总是大于它的起点。
2、区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。
解题思路:
问题分析:
这道题和上一道题很类似。依然是通过区间终点来进行升序排序。至于为什么这么做,这就涉及到数学中对这个问题进行严格的推理和归纳总结了。有兴趣的小伙伴自己下去进行尝试叭~ 可以将结果分享在评论区哦~ 只需要记住,很多区间类的贪心策略都是根据区间的终点进行升序排序的就好。排序完扫描如果遇到下一个区间的起点在上一个区间终点的前面就把这个去掉并统计,如果下一个区间的起点在上一个区间终点的后面,就更新后面的区间终点为最新的“屁股”。画个图来理解一下:
问题抽象:
根据区间终点的位置进行升序排序,根据下一个区间的起点更新答案。
实现步骤:
1、根据区间终点的位置进行升序排序。
2、确定第一个区间的终点位置。
3、扫描区间,如果后面区间的起点在前面区间的终点之前就去掉,更新答案。
4、如果后面的起点在前面终点的后面,就更新起点。
class Solution {
public int eraseOverlapIntervals(int[][] intervals) {
// 根据区间的屁股进行升序排序
Arrays.sort(intervals, new Comparator<int[]>() {
public int compare(int[] interval1, int[] interval2) {
if (interval1[1] > interval2[1]) return 1;
if (interval2[1] > interval1[1]) return -1;
else return 0;
}
});
// 去掉的区间的数量
int count = 0;
// 第一个区间的终点位置
int right = intervals[0][1];
for (int i = 1; i < intervals.length; i ++) {
// 如果下一个区间起点在上一个区间终点之前就去掉
if (intervals[i][0] < right) {
count ++;
} else {
// 下一个区间起点在上一个区间终点后面就更新
right = intervals[i][1];
}
}
return count;
}
}
字符串 S 由小写字母组成。我们要把这个字符串划分为尽可能多的片段,同一字母最多出现在一个片段中。返回一个表示每个字符串片段的长度的列表。
解题思路:
问题分析:
这道题如果按照贪心的做法的话和青蛙跳台阶类似。首先我们可以对每个字母最后出现的位置进行存储。在遍历的时候更新已经遍历过的字母出现的最远位置,如果到了最远的位置就说明后面没有在出现前面的字母了,画个图理解一下:
后面同理,不画了,框框太多画脑溢血了。遍历的时候更新findLast字典,里面存储每个字母最后出现的位置。然后将最后出现的位置设置为第一区见遍历的最远位置,后续在遍历这个区间的时候对这个最远位置进行更新,如果标针i
到达了最远位置,说明前面的字符在后面字符中没有出现, 化归这个过程。
问题抽象:
第一次遍历:更新findLast字典,第二次遍历:更新当前区间最远边界如果满足标针到达区间终点,将长度加入结果集。
实现步骤:
1、遍历更新findLast字典。
2、遍历寻找每个区间的最远边界。
3、判断是否到达了最远边界。如果到达最远边界,就加入结果集。
class Solution {
public List<Integer> partitionLabels(String s) {
int[] findLast = new int[26];
List<Integer> ans = new ArrayList<>();
// 遍历更新findLast字典
for (int i = 0; i < s.length(); i ++) {
char c = s.charAt(i);
findLast[c - 'a'] = i;
}
// 初始化起点区间
int left = 0;
int right = findLast[s.charAt(0) - 'a'];
// 遍历更新最远边界
for (int i = 0; i < s.length(); i ++) {
char c = s.charAt(i);
// 更新最远边界
right = Math.max(right, findLast[c - 'a']);
// 到达最远边界 加入结果集
if (i == right) {
ans.add(right - left + 1);
// 更新下一个区间的起点
left = right + 1;
}
}
return ans;
}
}
特殊解法:
当然这道题我在一开始做的时候想到的方法并不是贪心,而是倒序遍历的方式,我搜了搜,官方解答以及大家都没有给出这个解法,大概是自己第一个发现的叭,嘿嘿嘿, 结果耗时和贪心耗时一样都是5ms。思路就是倒序遍历。通过hash数组存储每个字符的数量。在遍历的时候判断,如果遍历过的区间每个字符的数量都减到0了,就返回这个长度。
噫? 突然发现正序好像也能实现 … 好叭,时间复杂度其实是比贪心多的,因为每次要遍历字符串不过还好,最差是O(n2).代码贴下去了,感兴趣自己看一看,没有用到贪心。给出了详细的注释。
class Solution {
public List<Integer> partitionLabels(String s) {
int[] hash = new int[128];
// 存储每个字符的数量
for (int i = 0; i < s.length(); i ++) {
char c = s.charAt(i);
hash[c - '0'] ++;
}
// 存在双端队列中
Deque<Integer> ans = new LinkedList<>();
int count = 0;
int right = s.length() - 1;
// 倒叙遍历
for (int i = s.length() - 1; i >=0 ; i --) {
char c = s.charAt(i);
// 减去字符的数量
hash[c - '0'] --;
// 如果满足区间数量字符的个数都为0
if (checkAdd(s, hash, i, right)) {
// 加入结果集
ans.offerFirst(right - i + 1);
right = i - 1;
}
}
return new ArrayList<>(ans);
}
// 判断区间内字符串是否不存在hash字典中
public boolean checkAdd(String s, int[] hash, int left, int right) {
for (int i = left; i <= right; i ++) {
char c = s.charAt(i);
if (hash[c - '0'] > 0) {
return false;
}
}
return true;
}
}
给定一个非负整数 N,找出小于或等于 N 的最大的整数,同时这个整数需要满足其各个位数上的数字是单调递增。
(当且仅当每个相邻位数上的数字 x 和 y 满足 x <= y 时,我们称这个整数是单调递增的。)
解题思路:
问题分析:
我们根据正常思路来分析这道题,比如332这个例子来说:我们人眼扫描一下,大概这么分析的想让它变成单调递增的最大整数,第一眼就是把332变成329,这是第一步,第二步在变成229,满足条件的情况下,将中间可以调整的数字(2处在[2,9]中间)尽量的变到范围内的最大,即9.这样229就变成了299,满足单调递增条件的同时是最大的N,同理分析。遇到其他这种可以将每一位的数字都尽可能的“拉到最大”。是不是只需要将 xy… (其中x > y) 变成x-1 99…9就可以了。这样又满足单调递增,又满足最大N了。再给大家分析一个较为一般化例子:123321
我们发现在求解这个最终值的时候就是在尽可能的让后面的数字变成9,这样子既满足单调递增,又满足最大。那我们如何确定什么时候开始填充9呢?我们明确填充9的目的,并不是盲目填充,比如题目中的样例1234就很好的符合了单调最大的特点,不需要填充9, 只有遇到了前一位大于后一位产生了非单调递增的时候,我们进行填充工作,同时将前一位减小一避免超过了原来的值。分析完,我们只需要得知哪里开始不满足前一位比后一位小。找到了后开始我们的 - 1和填充 9 的工作,问题来了 在遍历序列的时候,我们该以什么方向遍历比较好呢? 为了能够更好的利用已经修改过的值以及避免重复工作,我们通过举的例子发现, 从后向前扫描可以在一次扫描内就可以完成替换工作,而不会出现前一位减1后又小于前前1位这样的尴尬局面。
问题抽象:
第一次遍历:从后向前遍历,确定哪里开始不满足前一位小于后一位,并将不满足的前一位减小1位,记录下来该位置,用来后续填充后面位置为9。
第二次遍历:从记录下来的位置开始,向后全部填充9。返回答案。
实现步骤:
1、将数字n转换成字符数组。
2、倒叙遍历数组,同时修改不满足条件的值,另前一位-1,并记录下来不满足条件的最后位置。
3、从不满足条件的最后的位置开始,将后面所有位置9。
4、返回数组转换成数字。
class Solution {
public int monotoneIncreasingDigits(int n) {
// 将数字转换成字符数组
char[] num = Integer.toString(n).toCharArray();
int start = num.length;
// 倒序遍历
for (int i = num.length - 1; i > 0; i --) {
// 找到不满足条件的位置
if (num[i - 1] > num[i]) {
// 更新并记录不满足条件的最前面的位置
start = i;
// 将前面数字减1
num[i - 1] --;
}
}
// 将记录下来最前面不满足条件位置后面所有的数字置9
for (int i = start; i < num.length; i ++) {
num[i] = '9';
}
// 转换成数字返回
return Integer.parseInt(new String(num));
}
}
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
解题思路:
问题分析:
老实说,这道题我刚刚看见的时候也有点懵,这也能贪心的吗?我自己举了两个例子,给大家一起分析一下这道题的思路:
例子一:
[1, 4, 9] fee = 2的情况:1的时候买入4的时候卖出,4的时候买入9的时候卖出, 总profit = 4 - 1 - 2 + 9 - 4 - 2 = 4 而更好的策略是 1买入, 9 卖出 : 总profit = 9 - 1 - 2 = 6
这个时候我们就要考虑,遇到这个时候我们该怎么办了?我们换一种思路,我们更新profit = 4 - 1 - 2,并且持有 4 - 2 = 2的股票,这样遇到9的时候就不会因为多扣除2块钱的手续费而亏损了。(相当于计算利润并继续持有,并没有完全的卖出)
例子二: [1, 4, 3, 9] fee = 2的情况:
按章上面的思路 1的时候买入,4的时候假装卖出。更新profit = 4 - 1 - 2 = 1;然后当前持有2 在 3的时候卖掉会亏1块钱,所以不卖,在9的时候卖 这样第二波就是 profit += 9 - 2 - 2 ; profit = 6 满足正确答案。
问题抽象:
确定买入价格,如果股票价格低于买入价格,就更新买入价格为更低的那个,如果遇到卖了能涨价的股票就更新利润,并持有卖出的便宜了fee
块钱的股票。化归初始状态。
实现步骤:
1、初始化买入价格。
2、循环遍历:遇到比初试价格便宜的就更新买入价格。
3、遇到卖了能赚钱的股票就卖出股票,并持有相当于少了fee
的等额股票。`
class Solution {
public int maxProfit(int[] prices, int fee) {
int n = prices.length;
// 初始化买入价格
int buy = prices[0];
int profit = 0;
for (int i = 1; i < n; i ++) {
// 遇到更便宜的股票, 买入
if (buy > prices[i]) {
buy = prices[i];
// 遇到卖了能赚钱的股票
} else if (buy < prices[i] - fee) {
// 卖出, 并计算利润
profit += prices[i] - buy - fee;
// 持有相当于减了手续费的股票
buy = prices[i] - fee;
}
}
return profit;
}
}
给定一个二叉树,我们在树的节点上安装摄像头。
节点上的每个摄影头都可以监视其父对象、自身及其直接子对象。
计算监控树的所有节点所需的最小摄像头数量。
解题思路:
问题分析:
这道题看上去比较复杂,不过我们通过示例可以看出来,要想覆盖所有的,又想是摄像头最少,肯定是从叶子结点开始考虑是否安插摄像头了,举个例子一看就明白了,上图:
有这么一个例子的话,如果从头节点开始看,没有被覆盖到,装一个摄像头后,叶子节点都没有被覆盖到,这样叶子结点们就得继续装摄像头,如图:
而我如果从叶子结点开始看,将摄像头放在叶子结点的上面,这样我们就可以大大减少了摄像头的使用:
这时候不管头节点有没有被覆盖到,顶多结果就差1,而从上往下看的话,就可能差2的h次方个摄像头了。因此,这道题的贪心策略是从下往上看的。那么如何遍历树才能实现从下往上看呢,不难知道肯定是二叉树的后续遍历了。那么如何确定结点是否需要安装摄像头呢?/ 这时候我们就得定义返回值类型,来通过左右孩子的状态来判断是否需要安装摄像头了。这里我们定义3种状态来描述所有可能的情况:
第一种:0 -> 表示没有被覆盖到。
第二种:1 -> 表示被覆盖到了。
第三种:2 -> 表示有摄像头(和第二种的区别就是儿子和父亲都是状态1)。
定义完状态,我们就可以根据状态来确定当前结点是否需要安装摄像头了,这里有这么几种情况:
第一种情况: 左右孩子都被覆盖到了,我们就不需要安装摄像头了,因为安装要浪费重叠的部分,我们返回0,让这个节点的父亲来安装摄像头。这样可以节省。
第二种情况: 左右孩子有没被覆盖到的,我们就必须安装摄像头,确保每个节点必须被覆盖到。
第三种情况: 左右孩子有摄像头,我们就返回1,表示这个节点被覆盖到了。
只要明确了贪心策略,状态集合,遍历方式,以及返回值类型,就可以轻松写出后续遍历的代码啦。
问题抽象:
定义后续遍历函数,确定空节点返回-1,进行左右递归,根据左右孩子的类型,进行相应的返回值,如果需要更新摄像头数量,就进行更新。
实现步骤:
1、定义后续遍历函数。
2、确定空节点返回类型。
3、递归左孩子与右孩子。
4、对后序遍历的结果来确定左右孩子的父亲是否需要安装摄像头,或者返回1,或者0。
5、递归这个过程,最后判断根节点是否需要安装摄像头。
class Solution {
private int count = 0;
public int minCameraCover(TreeNode root) {
// 最后判断头节点需不需要安装摄像头
if (postOrderTraverel(root) == 0) count++;
return count;
}
// 返回值,-1:表示空节点 0:表示无覆盖 1:表示有覆盖 2:表示放置摄像头
private int postOrderTraverel(TreeNode root) {
if (root == null) return -1;
// 后序遍历方式
int left = postOrderTraverel(root.left);
int right = postOrderTraverel(root.right);
// 只要有一个没覆盖到,就要返回2,并安装摄像头
if (left == 0 || right == 0) {
count++;
return 2;
}
// 只要有一个有摄像头,就返回1
// 注意这里不包括一个孩子没覆盖的情况,在上面已经优先返回了
if (left == 2 || right == 2) {
return 1;
}
// 0表示没有覆盖到,叶子节点也属于0
return 0;
}
}
恭喜你三连成功~
关注我,及时看到最新的算法题集详细归纳整合讲解!
让我们一起进步吧~
下一期:动态规划很难吗?大厂面试总是问?从此不再怕dp!