給定一個非負整數數組,你最初位於數組的第一個位置。面試
數組中的每一個元素表明你在該位置能夠跳躍的最大長度。算法
判斷你是否可以到達最後一個位置。數組
示例 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
(超時)
這是一個低效的解決方法。咱們模擬從第一個位置跳到最後位置的全部方案。從第一個位置開始,模擬全部能夠跳到的位置,而後從當前位置重複上述操做,當沒有辦法繼續跳的時候,就回溯。
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),回溯法只須要棧的額外空間。
(實際上這個方法也超時)
自頂向下的動態規劃能夠理解成回溯法的一種優化。咱們發現當一個座標已經被肯定爲好 / 壞以後,結果就不會改變了,這意味着咱們能夠記錄這個結果,每次不用從新計算。
所以,對於數組中的每一個位置,咱們記錄當前座標是好 / 壞,記錄在數組 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]
最多爲 n,n 是 nums
數組的大小。
空間複雜度:O(2n)=O(n),第一個 n 是棧空間的開銷,第二個 n 是記憶表的開銷
底向上和自頂向下動態規劃的區別就是消除了回溯,在實際使用中,自底向下的方法有更好的時間效率由於咱們再也不須要棧空間,能夠節省不少緩存開銷。更重要的事,這可讓以後更有優化的空間。回溯一般是經過反轉動態規劃的步驟來實現的。
這是因爲咱們每次只會向右跳動,意味着若是咱們從右邊開始動態規劃,每次查詢右邊節點的信息,都是已經計算過了的,再也不須要額外的遞歸開銷,由於咱們每次在 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]
最多爲 n,n 是 nums
數組的大小。
空間複雜度:O(n),記憶表的存儲開銷。
當咱們把代碼改爲自底向上的模式,咱們會有一個重要的發現,從某個位置出發,咱們只須要找到第一個標記爲 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; } }
複雜度分析
nums
數組一遍,共 nn 個位置,nn 是 nums
數組的長度。總結
最後一個問題是,如何在面試場景中想到這個作法。個人建議是「酌情考慮」。最好的解法固然和別的解法相比更簡單也更短,可是不那麼容易直接想到。
遞歸回溯的版本最容易想到,因此在思考更復雜解法的時候能夠順帶說起一下這個解法,你的面試官實際上可能會想要看到這個解法。但若是沒有,請說起可使用動態規劃的解法,並試想一下如何用記憶表來實現。若是你發現面試官但願你回答自頂向下的方法,那麼就不太須要思考自底向上的版本,但我推薦在面試中說起一下自底向下的優勢。
不少人會在將自頂向下的動態規劃轉成自底向上版本時出現困難,多作一些相關的練習能夠對你有所幫助。