最適合初學者的“動態規劃”教程

我在刷leetcode時發現了一篇對“動態規劃”講的非常透徹的文章,迫切的想分享給大家。幾經波折終於獲得@liweiwei1419的轉載授權原文地址,在此表示感謝,下文會以leetcode的一道算法題“按摩師”來教大家使用“動態規劃”來解題,解題語言爲java,我相信前端應該也能看懂。

leetcode題目介紹

一個有名的按摩師會收到源源不斷的預約請求,每個預約都可以選擇接或不接。在每次預約服務之間要有休息時間,因此她不能接受相鄰的預約。給定一個預約請求序列,替按摩師找到最優的預約集合(總預約時間最長),返回總的分鐘數。

方法一:設計二維狀態變量

第 1 步:設計狀態

「狀態」這個詞可以理解爲「記錄了求解問題到了哪一個階段」。

由於當前這一天有按摩師有兩種選擇:(1)接預約;(2)不接預約。但根據題意,今天是否接預約,是受到昨天影響的。爲了消除這種影響,我們在狀態數組要設置這個維度。

dp[i][0] 表示:區間 [0,i] 裏接受預約請求,並且下標爲 i 的這一天不接受預約的最大時長; dp[i][1] 表示:區間 [0,i] 裏接受預約請求,並且下標爲 i 的這一天接受預約的最大時長。

說明:這個定義是有前綴性質的,即當前的狀態值考慮了(或者說綜合了)之前的相關的狀態值,第 2 維保存了當前最優值的決策,這種通過增加維度,消除後效性的操作在「動態規劃」問題裏是非常常見的。

無後效性的理解:1、後面的決策不會影響到前面的決策; 2、之前的狀態怎麼來的並不重要。

一般的情況是,只要有約束,就可以增加一個維度消除這種約束帶來的影響,再具體一點說,就是把「狀態」定義得清楚、準確,「狀態轉移方程」就容易得到了。「力扣」的幾道股票問題基本都是這個思路,而且設置狀態的思想和這道題是完全一致的。

第 2 步:狀態轉移方程

「狀態轉移方程」可以理解爲「不同階段之間的聯繫」。

今天只和昨天的狀態相關,依然是分類討論:

今天不接受預約:或者是昨天不接受預約,或者是昨天接受了預約,取二者最大值,即:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1]); 今天接受預約:只需要從昨天不接受預約轉移而來,加上今天的時常,即: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 步:考慮輸出

由於狀態值的定義是前綴性質的,因此最後一天的狀態值就考慮了之前所有的天數的情況。按摩師最後一天可以接受預約,也可以不接受預約,取二者最大值。

第 5 步:考慮是否可以狀態壓縮

由於今天只參考昨天的值,狀態可以壓縮,可以使用「滾動數組」完成,狀態壓縮的代碼丟失了一定可讀性,也會給編碼增加一點點難度,請見題解後的「參考代碼 4」。

參考代碼 1:

public class Solution {

    public int massage(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.massage(nums);
        System.out.println(res);
    }
}
複製代碼

複雜度分析:

時間複雜度:O(N)O(N),NN 是數組的長度; 空間複雜度:O(N)O(N),狀態數組的大小爲 2N2N,可以優化到常數級別,請見題解後的「參考代碼 4」。 以上是中規中矩的寫法。在這裏根據問題本身的特點,狀態可以不用設置那麼具體,就將題目問的設計成狀態,狀態轉移方程依然好寫。

方法二:設計一維狀態變量

第 1 步:定義狀態

dp[i]:區間 [0,i] 裏接受預約請求的最大時長。

第 2 步:狀態轉移方程

這個時候因爲不限定下標爲 i 這一天是否接受預約,因此需要分類討論:

接受預約,那麼昨天就一定休息,由於狀態 dp[i - 1] 的定義涵蓋了下標爲 i - 1 這一天接收預約的情況,狀態只能從下標爲 i - 2 的狀態轉移而來:dp[i - 2] + nums[i]; 不接受預約,那麼昨天可以休息,也可以不休息,狀態從下標爲 i - 1 的狀態轉移而來:dp[i - 1]; 二者取最大值,因此狀態轉移方程爲 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])。

第 3 步:思考初始化

看狀態轉移方程,下標最小到 i - 2,因此初始化的時候要把 dp[0] 和 dp[1] 算出來,從 dp[2] 開始計算。

dp[0]:只有 1 天的時候,必須接受預約,因此 dp[0] = nums[0]; dp[1]:頭 2 天的時候,由於不能同時接受預約,因此最優值是這兩天接受預約時長的最大值 dp[1] = max(nums[0], nums[1]);

第 4 步:思考輸出

由於定義的狀態有前綴性質,並且對於下標爲 i 的這一天也考慮了接受預約與不接受預約的情況,因此輸出就是最後一天的狀態值。

第 5 步:思考狀態壓縮

看狀態轉移方程。當前狀態只與前兩個狀態相關,我們只關心最後一天的狀態值,因此依然可以使用「滾動變量」的技巧,這個時候滾動起來的就是 3 個變量了。這樣的代碼依然是丟失了可讀性,也存在一定編碼錯誤的風險,請見題解後的「參考代碼 5」。

參考代碼 2:

public class Solution {

    public int massage(int[] nums) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
        if (len == 1) {
            return nums[0];
        }

        // dp[i]:區間 [0, i] 裏接受預約請求的最大時長
        int[] dp = new int[len];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);

        for (int i = 2; i < len; i++) {
            // 今天在選與不選中,選擇一個最優的
            dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
        }
        return dp[len - 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.massage(nums);
        System.out.println(res);
    }
}
複製代碼

複雜度分析:

時間複雜度:O(N)O(N),NN 是數組的長度; 空間複雜度:O(N)O(N),狀態數組的大小爲 NN,可以優化到 33,請見題解後的「參考代碼 5」。 我們看到解決這個問題的複雜程度與如何定義狀態是相關的,定義狀態的角度沒有固定的模式,但有一個方向是可以考慮的,那就是從「狀態轉移方程」容易得到的角度去考慮如何設計狀態。

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

總結

「動態規劃」其實不是什麼特別難懂的東西(只是說思想),只是這一類問題剛接觸的時候有點不太適應,並且這類問題容易被包裝得很過分,而且沒有明顯的套路,題型多樣,所以學習「動態規劃」會有一些些喫力,這沒有辦法,見多了就好。如果是準備面試,不需要掌握特別複雜的「動態規劃」問題(當然前提是你沒有在簡歷上說你是算法競賽高手)。

「動態規劃」告訴了我們另一種求解問題的思路。我們學習編程,習慣了自頂向下求解問題(遞歸),在自頂向下求解問題的過程中,發現了重複子問題,我們再加上緩存。而「動態規劃」告訴我們,其實有一類問題我們可以從一個最簡單的情況開始考慮,通過逐步遞推,每一步都記住當前問題的答案,得到最終問題的答案,即「動態規劃」告訴了我們「自底向上」思考問題的思路。

也就是說「動態規劃」告訴我們的新的思路是:不是直接針對問題求解,由於我們找到了這個問題最開始的樣子,因此後面在求解的過程中,每一步都可以參考之前的結果(在處理最優化問題的時候,叫「最優子結構」),由於之前的結果有重複計算(「重複子問題」),因此必須記錄下來。

這種感覺不同於「記憶化遞歸」,「記憶化遞歸」是直接面對問題求解,遇到一個問題解決了以後,就記下來,隨時可能面對新問題。而「動態規劃」由於我們發現了這個問題「最初」的樣子,因此每一步參考的以前的結果都是知道的,就像我們去考試,所有的考題我們都見過,並且已經計算出了答案一樣,我們只需要參考以前做題的答案,就能得到這一題的答案,這是「狀態轉移」。應用「最優子結構」是同一回事,即:綜合以前計算的結果,直接得到當前的最優值。

「動態規劃」的內涵和外延很豐富,不是幾句話和幾個問題能夠理解清楚的,需要我們做一些經典的問題去慢慢理解它,和掌握「動態規劃」問題思考的方向。

參考代碼 3:根據方法一:狀態數組多設置一行,以避免對極端用例進行討論。

public class Solution {

    public int massage(int[] nums) {
        int len = nums.length;

        // dp 數組多設置一行,相應地定義就要改變,遍歷的一些細節也要相應改變
        // dp[i][0]:區間 [0, i) 裏接受預約請求,並且下標爲 i 的這一天不接受預約的最大時長
        // dp[i][1]:區間 [0, i) 裏接受預約請求,並且下標爲 i 的這一天接受預約的最大時長
        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)。

參考代碼 4:根據方法一,使用「滾動數組」技巧,將空間優化到常數級別 在編碼的時候,需要注意,只要訪問到 dp 數組的時候,需要對下標 % 2,等價的寫法是 & 1。

public class Solution {

    public int massage(int[] nums) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
        if (len == 1) {
            return nums[0];
        }

        // dp[i & 1][0]:區間 [0, i] 裏接受預約請求,並且下標爲 i 的這一天不接受預約的最大時長
        // dp[i & 1][1]:區間 [0, i] 裏接受預約請求,並且下標爲 i 的這一天接受預約的最大時長
        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,常數空間。

參考代碼 5:根據方法二,使用 3 個變量滾動完成計算,將空間優化到常數級別。

在實現上可以在取下標的時候對 3 取模。

class Solution {

    public int massage(int[] nums) {
        int len = nums.length;
        if (len == 0) {
            return 0;
        }
        if (len == 1) {
            return nums[0];
        }

        // dp[i % 3]:區間 [0,i] 裏接受預約請求的最大時長
        int[] dp = new int[3];
        dp[0] = nums[0];
        dp[1] = Math.max(nums[0], nums[1]);

        for (int i = 2; i < len; i++) {
            // 今天在選與不選中,選擇一個最優的
            dp[i % 3] = Math.max(dp[(i - 1) % 3], dp[(i - 2) % 3] + nums[i]);
        }
        return dp[(len - 1) % 3];
    }
}
複製代碼

複雜度分析:

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


 

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