5.7 如何运用贪心思想玩跳跃游戏
贪心算法可以理解为一种特殊的动态规划问题,拥有一些更特殊的性质,可以进一步降低动态规划算法的时间复杂度。
5.7.1 跳跃游戏1
题目描述如下:
给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
判断你是否能够到达最后一个下标。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/jump-game
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
样例解析:
输入nums = [2,3,1,1,4]
,算法应该返回true,我们可以从nums[0]
的位置向前跳一步到达nums[1]
然后再跳nums[i]=3
到达最后一个位置
输入nums= [3,2,1,0,4]
,算法应该返回false,i从index=0,1,2
的位置依次尝试发现最终只能到达index=3的位置就无法前进了
有关动态规划的问题,大多都是让你求最值,贪心算法作为特殊的动态规划也是如此,也一定是让你求一个最值,这道题可以这样进行转换:通过题目中的跳跃规则,最多能跳多远?如果能够越过最后一格,返回true,否则返回true
public boolean canJump(int[] nums) {
int n = nums.length;
int farthest = 0;
for(int i = 0;i<n-1;i++){
//不断计算能跳到的最远距离
farthest = Math.max(farthest,i+nums[i]);
//可能碰到了0,卡住跳不动了
if(farthest <= i){
return false;
}
}
return farthest >= n-1;
}
5.7.2 跳跃游戏2
给你一个非负整数数组 nums ,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。
假设你总是可以到达数组的最后一个位置。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/jump-game-ii
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
- 输入是
nums=[2,3,1,1,4]
算法应该要返回2,先从nums[0]=2
跳一步到nums[1]
,然后再跳3步直接到最后的位置,这样的跳法能够得到的跳跃次数是最少的,通过该样例可知,并不是每次跳的越多就越好,而是还要做一次选择
,最优化选择 - 根据题意:状态:你站的位置索引位置p,选择就是你可以跳跃的步数从
0到nums[p]
- base-case:当p越过了或者到达了n的时候,不需要跳跃,return0
private int[] memo;
public int jump(int[] nums) {
memo = new int[nums.length];
Arrays.fill(memo,nums.length);
return dp(nums,0);
}
//定义dp函数:从索引p跳到最后一格,至少需要dp(nums,p)步
private int dp(int[] nums,int p){
//base-case
if(p>=nums.length-1){
return 0;
}
if(memo[p] != nums.length){
return memo[p];
}
int step = nums[p];
//穷举每一个选择,你可以选择跳1步,2步,...,nums[p]步
for(int i = 1;i<step;i++){
memo[p] = Math.min(memo[p],dp(nums,p+i)+1);//从p再跳i步到达位置,跳跃次数+1
}
return 0;
}
递归深度最差是会有n次递归,每次递归可能需要n次的循环检测,O(N^2)
- 贪心算法比动态规划多了一个性质,贪心选择性质,那么在之前的动态规划思路中,其核心思路是穷举所有子问题,然后取其中最小的作为结果,n越多,子问题就越多,而且每次递归都需要计算
- 这时候就可以用贪心的思想来进行剪枝了
我们以样例:nums=[3,1,4,2]
为例,我们一开始可以跳nums[1],nums[2],nums[3],稍加观察,这时候选择nums[2]的收益是最高的,因为nums[2]的可跳跃区间涵盖了索引区间[3…6]比其他的都大,那么往索引2跳肯定是最优的选择
- 这就是贪心选择的性质,不需要递归地计算出所有选择的具体结果,然后进行比较,而是只需要做出那个最具有潜力、看起来最优的选择即可
public int jump(int[] nums) {
int n = nums.length;
//站在索引i上最多能跳到索引end
int end = 0;
//从索引[i...end]起跳,最远能到的距离
int farthest = 0;
//记录跳跃次数
int jumps = 0;
for(int i = 0;i<n-1;i++){
farthest = Math.max(nums[i]+i,farthest);
if(end == i){
jumps++;
end = farthest;
}
}
return jumps;
}
- 其中i和end限定了可以选择的跳跃步数,farthest标记了所有选择,[i…end]中能够跳到的最远距离,jumps记录了跳跃次数
- 个人认为这个解法对于上述的算法表达不是很明确,这里改写一下
int jump(vector<int> &nums)
{
int ans = 0;
int start = 0;
int end = 1;
while (end < nums.size())
{
int maxPos = 0;
for (int i = start; i < end; i++)
{
// 能跳到最远的距离
maxPos = max(maxPos, i + nums[i]);
}
start = end; // 下一次起跳点范围开始的格子
end = maxPos + 1; // 下一次起跳点范围结束的格子
ans++; // 跳跃次数
}
return ans;
}
- 这里解释一下
- 首先我们的跳跃索引区间定义为
[start,end)
,注意都是右端点是开区间 - 这个跳跃索引区间的具体定义是:
index[i]:站在i上最多能够到达的索引
- 那么我们从0开始,可以得到一个新的跳跃区间:
[1,maxPos+1)
,这是因为最少一定都要跳1步,然后最多能够到达maxPos
,由于是开区间,因此maxPos要+1做限制,当然了也可以改成闭区间,思路也是一样的 - 然后每次迭代的时候这个start和end得是如何迭代的咧?
- 首先我们知道在上一次的遍历迭代中,
[start,end)
中的每个索引都被检索过了,那么没有被检索的是不是[end,maxPos+1)
这种区间?这时候start=end,end = maxPos+1,然后这时候跳了一步ans++ - 这个算法一直算下去,直到算出来的end到达了nums.length,就代表start可以到nums.length-1了
- 首先我们的跳跃索引区间定义为
- 为了加深理解,我们编写一套基于闭区间的代码,其思路是完全一致的
public int jump(int[] nums) {
int jumps = 0;
int start = 0,end = 0;
//定义跳跃索引区间[start,end]
while (end < nums.length-1) {
//一旦大于等于了,就证明找到了
int maxPos = Integer.MIN_VALUE;
for(int i = start;i<=end;i++){
maxPos = Math.max(maxPos,nums[i]+i);
}
start = end+1;
end = maxPos;
jumps++;
}
return jumps;
}
5.8 如何运用贪心算法做时间管理
5.8.1 贪心算法概述
贪心算法可以认为是动态规划中算法的一个特例,相比于动态规划,使用贪心算法需要满足更多的条件(贪心选择性质),但是效率比动态规划要高
比如一个算法问题使用暴力解法需要指数级别的时间,如果能使用动态规划消除重叠子问题,就可以降到多项式级别的时间复杂度,如果满足贪心选择性质,那么可以进一步降低时间复杂度到达线性级别
贪心选择性质:每一步都做出一个局部最优的旋转,最终的结果就是全局最优,一个比较浅显的例子就是,现在给你100张人民币,要求获得最多钱,那么我只要在接下来的每次选择中都选择面额最大的那张纸币就可以了
5.8.2 时间管理问题描述
以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间 。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/merge-intervals
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
- 给你很多形如
[start,end]
的闭区间,请设计一个算法,算出这些区间中最多有几个互不相交的区间
5.8.3 贪心算法思路
intervalSchedule问题:你今天有好几个活动,每个活动都可以用区间[start,end]
来进行表示其开始的时间和结束的时间,请问你今天最多可以参加几个活动呢?
样例分析:
输入一个数组intvs = [[1,3],[2,4],[3,6]]
这些区间最多有两个区间互不相交,也就是[1,3],[3,6]
,你的算法应该返回2,注意,边界相同并不算相交
思路分为以下三步:
- [1] 从区间
intvs
中选择一个区间x
,这个x
是在当前区间所有区间中结束最早的(end最小)
- [2] 把所有与
x
区间相交的区间从区间集合intvs
删除 - [3] 重复步骤1和步骤2,直到
intvs
为空为止,之前选出的那些x
就是最大不相交子集
这个思路的立足点在于:所有与x
相交的区间必然会与x
的end相交,如果一个区间不想和x
的end
相交,那么它的start
必须大于等于x
的end
选择end的最小的x是很容易的,关键在于,如何去除与x相交的区间,选择下一轮循环的x呢?
public int intervalSchedule(int[][] intvs){
if(intvs.length == 0){
return 0;
}
//按end排序,重写compare方法
Arrays.sort(intvs, new Comparator<int[]>() {
@Override
public int compare(int[] a, int[] b) {
return a[1]-b[1];//两个end进行比较
}
});
//至少会有一个区间不相交,也就是一天至少会参加一个活动
int count = 0;
//排序后,第一个区间就是x
int xEnd = intvs[0][1];
for (int[] intv : intvs) {
int start = intv[0];
if(start >= xEnd){
count++;
xEnd = intv[0];
}
}
return count;
}
5.8.4 应用举例
给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠 。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/non-overlapping-intervals
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
public int eraseOverlapIntervals(int[][] intervals) {
return intervals.length - intervalSchedule(intervals);
}
private int intervalSchedule(int[][] intvs){
if(intvs.length == 0){
return 0;
}
//按end排序,重写compare方法
Arrays.sort(intvs, new Comparator<int[]>() {
@Override
public int compare(int[] a, int[] b) {
return a[1]-b[1];//两个end进行比较
}
});
//至少会有一个区间不相交,也就是一天至少会参加一个活动
int count = 1;
//排序后,第一个区间就是x
int xEnd = intvs[0][1];
for (int[] intv : intvs) {
int start = intv[0];
if(start >= xEnd){
count++;
xEnd = intv[1];
}
}
return count;
}
- 用最少的箭头射爆气球
有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstart 和 xend之间的气球。你不知道气球的确切 y 坐标。
一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。
给你一个数组 points ,返回引爆所有气球所必须射出的 最小 弓箭数 。
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/minimum-number-of-arrows-to-burst-balloons
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
public int findMinArrowShots(int[][] points) {
if(points.length == 0){
return 0;
}
int count = 1;
//默认射出了第一支箭,这只箭射向的是end最小的元素
Arrays.sort(points, new Comparator<int[]>() {
@Override
public int compare(int[] o1, int[] o2) {
if(o1[1] >o2[1]){
return 1;
}else if(o1[1] == o2[1]){
return 0;
}
return -1;
}
});
int xEnd = points[0][1];
for (int[] point : points) {
if(xEnd < point[0]){
count++;
xEnd = point[1];
}
}
return count;
}
最后回顾一下做这一类题目的特点,其特点是存在重复区间,解题的时候,将剔除重叠区间这个事情转换成了端点的数学比较,其核心思路是,将端点的起点和终点作为一个参照点,如果两个区间不重叠,那么将会有一个特征,也就是参照区间的结束端点要比对比区间的开始端点要早,那么这样就可以认为这两个区间是不重叠的,要这样做的前提是,这个数组集合已经被提前排好序,才能依次比较下去而不漏掉区间。