【1】【经典回溯、动态规划、贪心】【leetcode-55】跳跃游戏

给定一个非负整数数组,你最初位于数组的第一个位置。面试

数组中的每一个元素表明你在该位置能够跳跃的最大长度。算法

判断你是否可以到达最后一个位置。数组

示例 1:缓存

输入: [2,3,1,1,4]
输出: true
解释: 从位置 0 到 1 跳 1 步, 而后跳 3 步到达最后一个位置。
示例 2:测试

输入: [3,2,1,0,4]
输出: false
解释: 不管怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 , 因此你永远不可能到达最后一个位置。优化

来源:力扣(LeetCode)
连接:https://leetcode-cn.com/problems/jump-game

spa

此题很经典,可用回溯、动态规划、贪心求解并对比,我看题第一反应用回溯结果超时code

个人代码(超时):blog

public class Solution55 { boolean res = false; public boolean canJump(int[] nums) { canJump(nums,0); return res; } public void canJump(int[] nums,int begin) { if (nums[begin] >= nums.length-1-begin) { res = true; } for (int i=begin+1;i<=Math.min(nums.length-1,begin + nums[begin]);i++) { canJump(nums,i); if (res == true) { break; } } } }

 

下面是leetcode官方题解,很详细!递归

定义
若是咱们能够从数组中的某个位置跳到最后的位置,就称这个位置是“好坐标”,不然称为“坏坐标”。问题能够简化为第 0 个位置是否是“好坐标”。
题解
这是一个动态规划问题,一般解决并理解一个动态规划问题须要如下 4 个步骤:

1.利用递归回溯解决问题
2.利用记忆表优化(自顶向下的动态规划)
3.移除递归的部分(自底向上的动态规划)
4.使用技巧减小时间和空间复杂度
下面的全部解法都是正确的,但在时间和空间复杂度上有区别。

实际上leetcode的测试用例方法一回溯法和方法二自顶向下的动态规划都超时,只有方法三自底向上的动态规划和方法四贪心可AC

方法 1:回溯

(超时)
这是一个低效的解决方法。咱们模拟从第一个位置跳到最后位置的全部方案。从第一个位置开始,模拟全部能够跳到的位置,而后从当前位置重复上述操做,当没有办法继续跳的时候,就回溯。

public class Solution { public boolean canJumpFromPosition(int position, int[] nums) { if (position == nums.length - 1) { return true; } int furthestJump = Math.min(position + nums[position], nums.length - 1); for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) { if (canJumpFromPosition(nextPosition, nums)) { return true; } } return false; } public boolean canJump(int[] nums) { return canJumpFromPosition(0, nums); } }

一个快速的优化方法是咱们能够从右到左的检查 nextposition ,理论上最坏的时间复杂度复杂度是同样的。但实际状况下,对于一些简单场景,这个代码可能跑得更快一些。直觉上,就是咱们每次选择最大的步数去跳跃,这样就能够更快的到达终点。

// Old
for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) // New
for (int nextPosition = furthestJump; nextPosition > position; nextPosition--)

比方说,对于下面的例子,咱们从下标 0 开始跳,第一次跳到 1,第二次跳到 6。这样用 3 步就发现坐标 0 是一个“好坐标”。

下面的例子解释了上述优化没有办法解决的状况,坐标 6 是不能从任何地方跳到的,可是全部的方案组合都会被枚举尝试。

前几回回溯访问节点以下:0 -> 4 -> 5 -> 4 -> 0 -> 3 -> 5 -> 3 -> 4 -> 5 -> 等等。

复杂度分析

时间复杂度:O(2^n),最多有 2^n 种从第一个位置到最后一个位置的跳跃方式,其中 n 是数组 nums 的元素个数

空间复杂度:O(n),回溯法只须要栈的额外空间。

 

方法 2:自顶向下的动态规划

(实际上这个方法也超时)

自顶向下的动态规划能够理解成回溯法的一种优化。咱们发现当一个坐标已经被肯定为好 / 坏以后,结果就不会改变了,这意味着咱们能够记录这个结果,每次不用从新计算。

所以,对于数组中的每一个位置,咱们记录当前坐标是好 / 坏,记录在数组 memo 中,定义元素取值为 GOOD ,BAD,UNKNOWN。这种方法被称为记忆化。

例如,对于输入数组 nums = [2, 4, 2, 1, 0, 2, 0] 的记忆表以下,G 表明 GOOD,B 表明 BAD。咱们发现不能从下标 2,3,4 到达最终坐标 6,但能够从 0,1,5 和 6 到达最终坐标 6。

 

步骤

1.初始化 memo 的全部元素为 UNKNOWN,除了最后一个显然是 GOOD (本身必定能够跳到本身)
2.优化递归算法,每步回溯前先检查这个位置是否计算过(当前值为:GOOD / BAD)
  1.若是已知直接返回结果 True / False
  2.不然按照以前的回溯步骤计算
3.计算完毕后,将结果存入memo表中

enum Index { GOOD, BAD, UNKNOWN } public class Solution { Index[] memo; public boolean canJumpFromPosition(int position, int[] nums) {
    //优化递归算法,每步回溯前先检查这个位置是否计算过(当前值为:GOOD / BAD
if (memo[position] != Index.UNKNOWN) { return memo[position] == Index.GOOD ? true : false; } int furthestJump = Math.min(position + nums[position], nums.length - 1); for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) { if (canJumpFromPosition(nextPosition, nums)) { memo[position] = Index.GOOD; return true; } } memo[position] = Index.BAD; return false; } public boolean canJump(int[] nums) { memo = new Index[nums.length]; for (int i = 0; i < memo.length; i++) { memo[i] = Index.UNKNOWN; } memo[memo.length - 1] = Index.GOOD; return canJumpFromPosition(0, nums); } }

 

 复杂度分析

 时间复杂度:O(n^2),数组中的每一个元素,假设为 i,须要搜索右边相邻的 nums[i] 个元素查找是否有 GOOD 的坐标。 nums[i] 最多为 nn 是 nums 数组的大小。

空间复杂度:O(2n)=O(n),第一个 n 是栈空间的开销,第二个 n 是记忆表的开销

 

 

方法 3:自底向上的动态规划

底向上和自顶向下动态规划的区别就是消除了回溯,在实际使用中,自底向下的方法有更好的时间效率由于咱们再也不须要栈空间,能够节省不少缓存开销。更重要的事,这可让以后更有优化的空间。回溯一般是经过反转动态规划的步骤来实现的。

这是因为咱们每次只会向右跳动,意味着若是咱们从右边开始动态规划,每次查询右边节点的信息,都是已经计算过了的再也不须要额外的递归开销,由于咱们每次在 memo 表中均可以找到结果。

enum Index { GOOD, BAD, UNKNOWN } public class Solution { public boolean canJump(int[] nums) { Index[] memo = new Index[nums.length]; for (int i = 0; i < memo.length; i++) { memo[i] = Index.UNKNOWN; } memo[memo.length - 1] = Index.GOOD; for (int i = nums.length - 2; i >= 0; i--) { int furthestJump = Math.min(i + nums[i], nums.length - 1); for (int j = i + 1; j <= furthestJump; j++) { if (memo[j] == Index.GOOD) { memo[i] = Index.GOOD; break; } } } return memo[0] == Index.GOOD; } }

复杂度分析

时间复杂度:O(n^2),数组中的每一个元素,假设为 i,须要搜索右边相邻的 nums[i] 个元素查找是否有 GOOD 的坐标。 nums[i] 最多为 nn 是 nums 数组的大小。

空间复杂度:O(n),记忆表的存储开销。

 

方法 4:贪心

 

当咱们把代码改为自底向上的模式,咱们会有一个重要的发现,从某个位置出发,咱们只须要找到第一个标记为 GOOD 的坐标(由跳出循环的条件可得),也就是说找到最左边的那个坐标。若是咱们用一个单独的变量来记录最左边的 GOOD 位置,咱们就能够避免搜索整个数组,进而能够省略整个 memo 数组。

从右向左迭代,对于每一个节点咱们检查是否存在一步跳跃能够到达 GOOD 的位置(currPosition + nums[currPosition] >= leftmostGoodIndex)。若是能够到达,当前位置也标记为 GOOD ,同时,这个位置将成为新的最左边的 GOOD 位置,一直重复到数组的开头,若是第一个坐标标记为 GOOD 意味着能够从第一个位置跳到最后的位置。

模拟一下这个操做,对于输入数组 nums = [9, 4, 2, 1, 0, 2, 0],咱们用 G 表示 GOOD,用 B 表示 BAD 和 U 表示 UNKNOWN。咱们须要考虑全部从 0 出发的状况并判断坐标 0 是不是好坐标。因为坐标 1 是 GOOD,咱们能够从 0 跳到 1 而且 1 最终能够跳到坐标 6,因此尽管 nums[0] 能够直接跳到最后的位置,咱们只须要一种方案就能够知道结果。

public class Solution { public boolean canJump(int[] nums) { int lastPos = nums.length - 1; for (int i = nums.length - 1; i >= 0; i--) { if (i + nums[i] >= lastPos) { lastPos = i; } } return lastPos == 0; } }

复杂度分析

  • 时间复杂度:O(n)O(n),只须要访问 nums 数组一遍,共 nn 个位置,nn 是 nums 数组的长度。
  • 空间复杂度:O(1)O(1),不须要额外的空间开销。

总结
最后一个问题是,如何在面试场景中想到这个作法。个人建议是“酌情考虑”。最好的解法固然和别的解法相比更简单也更短,可是不那么容易直接想到。

递归回溯的版本最容易想到,因此在思考更复杂解法的时候能够顺带说起一下这个解法,你的面试官实际上可能会想要看到这个解法。但若是没有,请说起可使用动态规划的解法,并试想一下如何用记忆表来实现。若是你发现面试官但愿你回答自顶向下的方法,那么就不太须要思考自底向上的版本,但我推荐在面试中说起一下自底向下的优势。

不少人会在将自顶向下的动态规划转成自底向上版本时出现困难,多作一些相关的练习能够对你有所帮助。

相关文章
相关标签/搜索