給定一個非負整數數組,你最初位於數組的第一個位置。
數組中的每個元素代表你在該位置可以跳躍的最大長度。
判斷你是否能夠到達最后一個位置。
示例 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
此題很經典,可用回溯、動態規划、貪心求解並對比,我看題第一反應用回溯結果超時
我的代碼(超時):
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]
最多為 n,n 是 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]
最多為 n,n 是 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),不需要額外的空間開銷。
總結
最后一個問題是,如何在面試場景中想到這個做法。我的建議是“酌情考慮”。最好的解法當然和別的解法相比更簡單也更短,但是不那么容易直接想到。
遞歸回溯的版本最容易想到,所以在思考更復雜解法的時候可以順帶提及一下這個解法,你的面試官實際上可能會想要看到這個解法。但如果沒有,請提及可以使用動態規划的解法,並試想一下如何用記憶表來實現。如果你發現面試官希望你回答自頂向下的方法,那么就不太需要思考自底向上的版本,但我推薦在面試中提及一下自底向下的優點。
很多人會在將自頂向下的動態規划轉成自底向上版本時出現困難,多做一些相關的練習可以對你有所幫助。