我們在第 1 節向大家介紹過「無後效性」的兩層含義:
-
在推導後面階段的狀態的時候,我們只關心前面階段的狀態值,不關心這個狀態是怎麼一步一步推導出來的。
-
某階段狀態一旦確定,就不受之後階段的決策影響。
下面我們就通過具體的例子向大家進行說明。
這道問題是經典的「力扣」第 198 題:打家劫舍。題目只問最優值,並沒有問最優解,因此絕大多數情況下可以考慮使用「動態規劃」的方法。
如果我們直接將問題的問法定義成狀態,會發現,當前這個房子「偷」和「不偷」會影響到後面的房子「偷」與「不偷」。
一般的情況是,只要有約束,就可以增加一個維度消除這種約束帶來的影響,還是上一節和大家介紹的方法:把「狀態」定義得清楚、準確,「狀態轉移方程」就容易得到了。
第 1 步:設計狀態
「狀態」這個詞可以理解爲「記錄了求解問題到了哪一個階段」。
由於當前這一個房屋是否有兩種選擇:(1)偷;(2)不偷。
dp[i][0]
表示:考慮區間 [0,i]
,並且下標爲 i
的這個房間偷,能夠偷竊到的最高金額;
dp[i][1]
表示:考慮區間 [0,i]
,並且下標爲 i
的這個房間不偷,能夠偷竊到的最高金額。
說明:這個定義是有前綴性質的,即當前的狀態值考慮了(或者說綜合了)之前的相關的狀態值,第 2 維保存了當前最優值的決策,這種通過增加維度,消除後效性的操作在「動態規劃」問題裏是非常常見的。
強調:
無後效性的理解:1、後面的決策不會影響到前面的決策; 2、之前的狀態怎麼來的並不重要。
再聯繫狀態的定義:狀態是一個概括的值,這個值是怎麼來的,並不記錄。因爲狀態定義更細緻,後面的決策纔不會影響到前面的決策。
第 2 步:狀態轉移方程
「狀態轉移方程」可以理解爲「不同階段之間的聯繫」。
今天只和昨天的狀態相關,依然是分類討論:
- 下標爲
i
的房屋不偷:或者是上一間不偷,或者是上一間偷,取二者最大值,即:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1])
; - 下標爲
i
的房屋偷:只需要從上一間不偷,這一間偷,即:dp[i][1] = dp[i - 1][0] + nums[i]
。
第 3 步:考慮初始化
從第 2 天開始,每天的狀態值只與前一天有關,因此第 1 天就只好老老實實算了。好在不難判斷:dp[0][0] = 0
與 dp[0][1] = nums[0]
;
這裏有一種技巧,可以把狀態數組多設置一行,這樣可以減少對第 1 天的初始化,這樣的代碼把第 1 天的情況考慮了進去,但編碼的時候要注意狀態數組下標的設置, 請見題解最後的「參考代碼 3」。
第 4 步:考慮輸出
由於狀態值的定義是前綴性質的,因此最後一天的狀態值就考慮了之前所有的天數的情況。下標爲 len - 1
這個房屋可以偷,也可以不偷,取二者最大值。
參考代碼 1:
Java 代碼:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i][0]:考慮區間 [0, i] ,並且下標爲 i 的這個房屋不偷
// dp[i][1]:考慮區間 [0, i] ,並且下標爲 i 的這個房屋偷
int[][] dp = new int[len][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i];
}
return Math.max(dp[len - 1][0], dp[len - 1][1]);
}
public static void main(String[] args) {
Solution solution = new Solution();
// int[] nums = {1, 2, 3, 1};
// int[] nums = {2, 7, 9, 3, 1};
int[] nums = {2, 1, 4, 5, 3, 1, 1, 3};
int res = solution.rob(nums);
System.out.println(res);
}
}
複雜度分析:
- 時間複雜度:, 是數組的長度;
- 空間複雜度:,狀態數組的大小爲 。
參考代碼 2:根據方法一:狀態數組多設置一行,以避免對極端用例進行討論。
Java 代碼:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
int[][] dp = new int[len + 1][2];
// 注意:外層循環從 1 到 =len,相對 dp 數組而言,引用到 nums 數組的時候就要 -1
for (int i = 1; i <= len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i - 1];
}
return Math.max(dp[len][0], dp[len][1]);
}
}
複雜度分析:
- 時間複雜度:, 是數組的長度;
- 空間複雜度:,狀態數組的大小爲 ,記爲 。
第 5 步:考慮是否可以狀態壓縮
由於我們只關心最後一個狀態值。並且
dp[i]
只參考了 dp[i - 1]
的值,狀態可以壓縮,可以使用「滾動數組」完成。
值得說明的是:狀態壓縮的代碼丟失了一定可讀性,也會給編碼增加一點點難度。
參考代碼 3:使用「滾動數組」技巧,將空間優化到常數級別
在編碼的時候,需要注意,只要訪問到 dp
數組的時候,需要對下標 % 2
,等價的寫法是 & 1
。
Java 代碼:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
int[][] dp = new int[2][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i & 1][0] = Math.max(dp[(i - 1) & 1][0], dp[(i - 1) & 1][1]);
dp[i & 1][1] = dp[(i - 1) & 1][0] + nums[i];
}
return Math.max(dp[(len - 1) & 1][0], dp[(len - 1) & 1][1]);
}
}
複雜度分析:
- 時間複雜度:, 是數組的長度;
- 空間複雜度:,狀態數組的大小爲 ,常數空間。
總結
「狀態」和「狀態轉移方程」得到以後,這個問題其實就得到了解決,剩下的一些細節的問題在編碼的時候只要稍微留意一點就行了。
到這裏「重複子問題」、「最優子結構」、「無後效性」我們就都向大家介紹完了。「動態規劃」告訴我們可以「自底向上」去考慮一件事情,並且記錄下求解問題的中間過程。
「動態規劃」問題沒有套路,我們只有通過不斷地聯繫,去掌握狀態設計的一般方法和技巧,體會上面所說的「動態規劃」的基本概念和基本特徵。
練習
1、「力扣」第 62 題、第 63 題:不同路徑。