動態規劃:第 3 節:理解「無後效性」(自己的草稿,內容不嚴謹,與之前有重複,不用看)

我們在第 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] = 0dp[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);
    }
}

複雜度分析

  • 時間複雜度:O(N)O(N)NN 是數組的長度;
  • 空間複雜度:O(N)O(N),狀態數組的大小爲 2N2N

參考代碼 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]);
    }
}

複雜度分析

  • 時間複雜度:O(N)O(N)NN 是數組的長度;
  • 空間複雜度:O(N)O(N),狀態數組的大小爲 2(N+1)2(N + 1),記爲 O(N)O(N)

第 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]);
    }
}

複雜度分析

  • 時間複雜度:O(N)O(N)NN 是數組的長度;
  • 空間複雜度:O(1)O(1),狀態數組的大小爲 44,常數空間。

總結

「狀態」和「狀態轉移方程」得到以後,這個問題其實就得到了解決,剩下的一些細節的問題在編碼的時候只要稍微留意一點就行了。

到這裏「重複子問題」、「最優子結構」、「無後效性」我們就都向大家介紹完了。「動態規劃」告訴我們可以「自底向上」去考慮一件事情,並且記錄下求解問題的中間過程。

「動態規劃」問題沒有套路,我們只有通過不斷地聯繫,去掌握狀態設計的一般方法和技巧,體會上面所說的「動態規劃」的基本概念和基本特徵。

練習

1、「力扣」第 62 題、第 63 題:不同路徑。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章