贪心算法
贪心算法
应用场景: 解决一个问题需要多个步骤,每一步骤有多种选择。使用贪心算法,每一步只需要解决一个子问题,只做出一种选择,就可以完成任务。
关于贪心算法与回溯算法、动态规划的区别
- 「回溯算法」需要记录每一个步骤、每一个选择,用于回答所有具体解的问题;
- 「动态规划」需要记录的是每一个步骤、所有选择的汇总值(最大、最小或者计数);
- 「贪心算法」由于适用的问题,每一个步骤只有一种选择,一般而言只需要记录与当前步骤相关的变量的值。
使用「贪心算法」的问题需要满足的条件
- 最优子结构:规模较大的问题的解由规模较小的子问题的解组成,区别于「动态规划」,可以使用「贪心算法」的问题「规模较大的问题的解」只由其中一个「规模较小的子问题的解」决定;
- 无后效性:后面阶段的求解不会修改前面阶段已经计算好的结果;
- 贪心的实质: 从局部最优到全局最优。
实例引入:
对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。
1 | 输入: g = [1,2,3], s = [1,1] |
思路:
为了尽可能满足最多数量的孩子,从贪心的角度考虑,应该按照孩子的胃口从小到大的顺序依次满足每个孩子,且对于每个孩子,应该选择可以满足这个孩子的胃口且尺寸最小的饼干。
使用贪心的话:首先对数组 g 和 s 排序,然后从小到大遍历 g 中的每个元素,对于每个元素找到能满足该元素的 s 中的最小的元素。具体而言,令 i 是 g 的下标,j 是 s 的下标,初始时 i 和 j 都为 0,进行如下操作。
1 | class Solution{ |
典型题型分析:
找零钱问题
具体: 在生活中,我们找给别人零钱,通常都是按照「先给出尽可能多的面值较大的纸币(硬币),然后再给出尽可能多的面值第二大的纸币(硬币)」,直到凑成了我们需要凑出的金额为止,这样找零钱得到的纸币(硬币)的张数(个数)最少。能够这样做,与 可选的硬币(纸币)的面值密切相关。
例题:
描述: 在柠檬水摊上,每一杯柠檬水的售价为 5 美元。
顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。
每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。
注意,一开始你手头没有任何零钱。
如果你能给每位顾客正确找零,返回 true ,否则返回 false 。
1 | 示范: |
解题思路:
由于顾客只可能给你三个面值的钞票,而且我们一开始没有任何钞票,因此我们拥有的钞票面值只可能是 5 美元,10 美元和 20 美元三种。基于此,我们可以进行如下的分类讨论。
- 5 美元,由于柠檬水的价格也为 5 美元,因此我们直接收下即可。
- 10 美元,我们需要找回 5 美元,如果没有 5 美元面值的钞票,则无法正确找零。
- 20 美元,我们需要找回 15 美元,此时有两种组合方式,一种是一张 10 美元和 5 美元的钞票,一种是 3 张 5 美元的钞票,如果两种组合方式都没有,则无法正确找零。当可以正确找零时,两种找零的方式中我们更倾向于第一种,即如果存在 5 美元和 10 美元,我们就按第一种方式找零,否则按第二种方式找零。
代码如下:
1 | class Solution{ |
总结: 这道题的贪心思想,完全是因为可以选择的钞票面值只有 5 美元,10 美元和 20 美元。
区域选择问题
- 无重叠区间 :
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。(注意: 可以认为区间的终点总是大于它的起点;区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。)
1
2
3
4
5
6示例:
输入: [ [1,2], [2,3], [3,4], [1,3] ]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。
解法一:动态规划
由于选出的区间互不重叠,因此我们可以将它们按照端点从小到大的顺序进行排序,并且无论我们按照左端点还是右端点进行排序,得到的结果都是唯一的。 这样一来,我们可以先将所有的 n 个区间按照左端点(或者右端点)从小到大进行排序,随后使用动态规划的方法求出区间数量的最大值。设排完序后这 n 个区间的左右端点分别为 l_i 和 r_i,然后可以令 f_i 表示 「以区间 i 为最后一个区间,可以选出的区间数量的最大值」。
代码如下:
1 | class Solution{ |
解法二:贪心
可以不断地寻找右端点在首个区间右端点左侧的新区间,将首个区间替换成该区间。那么当我们无法替换时,首个区间就是所有可以选择的区间中右端点最小的那个区间。因此我们将所有区间按照右端点从小到大进行排序,那么排完序之后的首个区间,就是我们选择的首个区间。
当确定了首个区间之后,所有与首个区间不重合的区间就组成了一个规模更小的子问题。由于我们已经在初始时将所有区间按照右端点排好序了,因此对于这个子问题,我们无需再次进行排序,只要找出其中与首个区间不重合并且右端点最小的区间即可。用相同的方法,我们可以依次确定后续的所有区间。
代码如下:
1 | class Solution{ |
- 用最少数量的箭引爆气球
在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以纵坐标并不重要,因此只要知道开始和结束的横坐标就足够了。开始坐标总是小于结束坐标。
一支弓箭可以沿着 x 轴从不同点完全垂直地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart ,xend,且满足 xstart ≤ x ≤ x``end,则该气球会被引爆。可以射出的弓箭的数量没有限制。弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
给你一个数组 points ,其中 points [i] = [xstart,xend] ,返回引爆所有气球所必须射出的最小弓箭数。
1 | 示例: |
分析:
存在一种最优(射出的箭数最小)的方法,使得每一支箭的射出位置都恰好对应着某一个气球的右边界。
对于其中的任意一支箭,我们都通过上面描述的方法,将这支箭的位置移动到它对应的「原本引爆的气球中最靠左的右边界位置」,那么这些原本引爆的气球仍然被引爆。这样一来,所有的气球仍然都会被引爆,并且每一支箭的射出位置都恰好位于某一个气球的右边界了。
有了这样一个有用的断定,我们就可以快速得到一种最优的方法了。考虑所有气球中右边界位置最靠左的那一个,那么一定有一支箭的射出位置就是它的右边界(否则就没有箭可以将其引爆了)。当我们确定了一支箭之后,我们就可以将这支箭引爆的所有气球移除,并从剩下未被引爆的气球中,再选择右边界位置最靠左的那一个,确定下一支箭,直到所有的气球都被引爆。
伪代码:
1 | let points := [[x(0), y(0)], [x(1), y(1)], ... [x(n-1), y(n-1)]],表示 n 个气球 |
完整代码如下:
1 | class Solution{ |
- 合并区间
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi]。请你合并所有重叠的区间,并返回一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间。
1 | 示范: |
思路:
首先,我们将列表中的区间按照左端点升序排序。然后我们将第一个区间加入 merged 数组中,并按顺序依次考虑之后的每个区间:
- 如果当前区间的左端点在数组 merged 中最后一个区间的右端点之后,那么它们不会重合,我们可以直接将这个区间加入数组 merged 的末尾;
- 否则,它们重合,我们需要用当前区间的右端点更新数组 merged 中最后一个区间的右端点,将其置为二者的较大值。
代码如下:
1 | class Solution{ |
跳跃游戏
- 描述:
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个位置。1
2
3
4
5
6
7
8
9
10示例1:
输入: [2,3,1,1,4]
输出: true
解释: 从位置 0 到 1 跳 1 步, 然后跳 3 步到达最后一个位置。
示例2:
输入: [3,2,1,0,4]
输出: false
解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 , 所以你永远不可能到达最后一个位置。
思路:
对于数组中的任意一个位置 y , 我们如何判断它是否可以到达?根据题目的描述,只要存在一个位置 x,它本身可以到达,并且它跳跃的最大长度为 x + nums[x],这个值大于等于 y ,那么位置 y 就可达到。
换句话说,对于每一个可以到达的位置 x,它使得 x+1, x+2, ⋯,x+nums[x] 这些连续的位置都可以到达。
这样一来,我们依次遍历数组中的每一个位置,并且实时维护 最远可达位置 ,对于当前遍历到的位置 x ,如果他在最远可以达到的位置的范围内,我们就可以从起点通过若干次跳跃到达该位置,可以用 x+nums[x] 更新 最远可以到达的位置。
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class Solution{
public boolean canJump(int[] nums){
int n = nums.length;
int rightmost = 0;
for(int i=0;i<n;i++){
if(i<=rightmost){
rightmost = Math.max(rightmost,i+nums[i]);
if(rightmost >= n-1){
return true;
}
}
}
return false;
}
}跳跃进阶
给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。
1 | 输入: [2,3,1,1,4] |
- 方法一: 反向查找出发位置
我们的目标是到达数组的最后一个位置,因此我们可以考虑最后一步跳跃前所在的位置,该位置通过跳跃能够到达最后一个位置。
如果有多个位置通过跳跃都能够到达最后一个位置,那么我们应该如何进行选择呢?直观上来看,我们可以「贪心」地选择距离最后一个位置最远的那个位置,也就是对应下标最小的那个位置。因此,我们可以从左到右遍历数组,选择第一个满足要求的位置。
找到最后一步跳跃前所在的位置之后,我们继续贪心地寻找倒数第二步跳跃前所在的位置,以此类推,直到找到数组的开始位置。
代码如下:
1 | public class Solution{ |
- 方法二: 正向查找可达到的最大位置
如果我们「贪心」地进行正向查找,每次找到可到达的最远位置,就可以在线性时间内得到最少的跳跃次数。
例如,对于数组 [2,3,1,2,4,2,3],初始位置是下标 0,从下标 0 出发,最远可到达下标 2。下标 0 可到达的位置中,下标 1 的值是 3,从下标 1 出发可以达到更远的位置,因此第一步到达下标 1。
从下标 1 出发,最远可到达下标 4。下标 1 可到达的位置中,下标 4 的值是 4 ,从下标 4 出发可以达到更远的位置,因此第二步到达下标 4。
具体实现:
我们维护当前能够到达的最大下标位置,记为边界。我们从左到右遍历数组,到达边界时,更新边界并将跳跃次数增加 1。
在遍历数组时,我们不访问最后一个元素,这是因为在访问最后一个元素之前,我们的边界一定大于等于最后一个位置,否则就无法跳到最后一个位置了。
代码如下:
1 | public class Solution{ |