動態規劃的套路 【leetcode 494 爲例】

動態規劃的套路

leetcode 上練DP的順序

動態規劃核心就是定義出狀態,然後思考狀態轉移方程。可以按照如下題冊一道一道的練習,題冊如下:
第 5 題、
第 53 題、
第 300 題、
第 72 題、
第 1143 題、
第 62 題、
第 63 題、
揹包問題(第 416 題,第 494 題)、
硬幣問題(第 322 題、第 518 題)、
打家劫舍問題(做頭兩題即可)、
股票問題、
第 96 題、
第 139 題、
第 10 題、
第 91 題、
第 221 題。
這些都是比較經典的問題,思路都可以“自底向上”。
祝學有所成

開始正題

記一道非常折騰很久的動態規劃題

494. 目標和

難度中等213

給定一個非負整數數組,a1, a2, …, an, 和一個目標數,S。現在你有兩個符號 +-。對於數組中的任意一個整數,你都可以從 +-中選擇一個符號添加在前面。

返回可以使最終數組和爲目標數 S 的所有添加符號的方法數。

示例 1:

輸入: nums: [1, 1, 1, 1, 1], S: 3
輸出: 5
解釋: 

-1+1+1+1+1 = 3
+1-1+1+1+1 = 3
+1+1-1+1+1 = 3
+1+1+1-1+1 = 3
+1+1+1+1-1 = 3

一共有5種方法讓最終目標和爲3。

注意:

  1. 數組非空,且長度不會超過20。
  2. 初始的數組的和不會超過1000。
  3. 保證返回的最終結果能被32位整數存下。

思路

首先對於從給定的例子入手分析 這道題 從 3 開始看 對於 數組裏最後一個數 這裏是 1,
有 3 +1 或者 3 -1 ,會有兩個結果, 4 和 2. 後面的數也是同樣的操作,不斷的往前走。
這樣就可以走到最後 看看 是否 爲 結果 0, 如果結果爲 0 就進行一次 計數,這是一種解,
如果結果不爲0,這次的操作不滿足 返回 0。
這裏採用 DFS 深度搜索 的方法進行解決。

代碼【DFS版本 】

import java.util.*;
class Solution {
    public int findTargetSumWays(int[] nums, int S) {
        //return backTracking(nums, S, 0, 0);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTracking2(nums, S, nums.length - 1);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTrackingMap(nums, S, nums.length - 1, new HashMap<Integer,Integer>());
        // 
    
    }
   // 這裏函數名不更改了 改成 dfs.. 更好,本來想用回溯法解決的。。。
    public int backTracking2(int []nums, int S, int pos){
        // 從頭開始 向後找
        if(pos == -1 && S == 0){
            return 1;
        }
        if(pos == -1){
            return 0;
        }
        return backTracking2(nums, S + nums[pos], pos-1) + backTracking2(nums, S - nums[pos], pos-1);
    }

    public int backTracking(int []nums, int S, int res, int pos){
        // 從尾開始 向前找 進行優化判斷
        if(pos == nums.length && S == res){
            return 1;
        }
        
        return backTracking(nums, S ,res + nums[pos], pos+1) + backTracking(nums, S, res - nums[pos], pos+1);
    }
}
/*
其實畫出 遞歸樹可以發現, 這其實就是 二叉樹的遍歷(因爲只有+,- 兩個操作),求葉子節爲 0 (或者爲 S )的路線計數一次。
*/

上面兩個解法都會比較耗時, 嘗試用DP的思路解決

先來看看 DP 的套路:

動態規劃問題的一般形式就是求最值

既然是要求最值,核心問題是什麼呢?求解動態規劃的核心問題是窮舉。因爲要求最值,肯定要把所有可行的答案窮舉出來,然後在其中找最值唄。

首先,動態規劃的窮舉有點特別,因爲這類問題存在「重疊子問題」,如果暴力窮舉的話效率會極其低下,所以需要「備忘錄」或者「DP table」來優化窮舉過程,避免不必要的計算。

而且,動態規劃問題一定會具備「最優子結構」,才能通過子問題的最值得到原問題的最值。

另外,雖然動態規劃的核心思想就是窮舉求最值,但是問題可以千變萬化,窮舉所有可行解其實並不是一件容易的事,只有列出正確的「狀態轉移方程」才能正確地窮舉。

以上提到的重疊子問題、最優子結構、狀態轉移方程就是動態規劃三要素。具體什麼意思等會會舉例詳解,但是在實際的算法問題中,寫出狀態轉移方程是最困難的,這也就是爲什麼很多朋友覺得動態規劃問題困難的原因,我來提供我研究出來的一個思維框架,輔助你思考狀態轉移方程:

明確「狀態」 -> 定義 dp 數組/函數的含義 -> 明確「選擇」-> 明確 base case。

  • 舉個燙手的栗子:湊零錢問題

先看下題目:給你 k 種面值的硬幣,面值分別爲 c1, c2 ... ck,每種硬幣的數量無限,再給一個總金額 amount,問你最少需要幾枚硬幣湊出這個金額,如果不可能湊出,算法返回 -1 。

  1. 發現最優子結構首先這道題 可以很快找到最優子結構:即 11 的 子問題 10 同樣可以用 和 解決 11 的方法解決, 其他子問題 如9 8 7 … 都可以同樣的解決。

找到最優子結構了。那就要看接下來的幾個重點步驟:

  1. 先確定「狀態」,也就是原問題和子問題中變化的變量。由於硬幣數量無限,所以唯一的狀態就是目標金額 amount這裏硬幣數量是無法確定的,因爲給定的不限量而不是限定範圍,因此原問題和子問題都可以看成目標金額作爲變量。

  2. 然後確定 dp 函數的定義:當前的目標金額是 n,至少需要 dp(n) 個硬幣湊出該金額。這裏的函數是關鍵,也就是所謂的狀態轉移方程。這裏需要總結。

函數的定義 也就是 dp的狀態轉移方程

這裏 先看是否能寫出遞歸解決方案,如果可以就很容易找到狀態轉移方程。

比如這裏 dp(n) = dp(n-1) + 1 , 比如 n = 11, dp(11) = dp(10) + 1

即 求 11 的問題可以去求解 dp(10) 的解決方案, 這裏 + 1 是使用了 11 -1 = 10 正好有 1 的硬幣;

由於 取最少 所以會有 $ dp(n) = min( dp(n), 1+dp(n-1) ) $ 這裏 min 裏面的 dp(n) 可以在其他方案中 比如 找 8 的時候已經解決 6 的問題,那麼 找 7 的時候就也可以使用了。因此 完整狀態轉移方程:

$ dp(n) = min( dp(n), 1+dp(n-1) ) $

  1. 最後明確 base case,顯然目標金額爲 0 時,所需硬幣數量爲 0;當目標金額小於 0 時,無解,返回 -1:

    即 相當於 使用遞歸的時候 遞歸啥時候退出。

    湊零錢問題

    dp 數組的迭代解法

    當然,我們也可以自底向上使用 dp table 來消除重疊子問題,dp 數組的定義和剛纔 dp 函數類似,定義也是一樣的:

    dp[i] = x 表示,當目標金額爲 i 時,至少需要 x 枚硬幣

    參考解決代碼

    int coinChange(vector<int>& coins, int amount) {
        // 數組大小爲 amount + 1,初始值也爲 amount + 1
        vector<int> dp(amount + 1, amount + 1);
        // base case
        dp[0] = 0;
        for (int i = 0; i < dp.size(); i++) {
            // 內層 for 在求所有子問題 + 1 的最小值
            for (int coin : coins) {
                // 子問題無解,跳過
                if (i - coin < 0) continue;
                dp[i] = min(dp[i], 1 + dp[i - coin]);
            }
        }
        return (dp[amount] == amount + 1) ? -1 : dp[amount];
    }
    

嘗試解決最開始的 目標和問題 先放代碼 再解釋

 public int findTargetSumWays(int[] nums, int S) {
        // return backTracking(nums, S, 0, 0);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTracking2(nums, S, nums.length - 1);// + backTracking(nums, S, 1, nums.length - 1);
        // return backTrackingMap(nums, S, nums.length - 1, new HashMap<Integer,Integer>());
        // 下面嘗試用 DP 解決問題
        int expand = 0;
        for(int ele:nums) expand += ele;
        //expand = expand * 2 + 1; //擴展數組列的大小 相當於 以 expand 爲對稱位(0)進行存儲數據 
        /*
            最開始列出的狀態轉移方程是                  1                     (S==0) 即找到一個目標和 計數 爲 1
                                         dp(S) =     0                      (S!=0 && pos == -1) 找到最後未找到符合的 返回 0
                                                     dp(S) + nums[i]  + dp(S) - nums[i]     (S!=0)     狀態轉移方程(這是根據遞歸寫的)
            由於 是個不定式方程,因此需要轉爲定式方程。
            再加上不是跑一邊循環就 能搞定的事,需要讓當前即每個 當前位置 記下 到他這裏所能得到的結果。因此有:
            dp[i][j] 這裏 i 表示用數組中的前 i 個元素,組成和爲 j 的方案數。
            dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]]
            寫成遞推形式:
            dp[i][j + nums[i]] = dp[i][j + nums[i]] + dp[i-1][j]
            dp[i][j - nums[i]] = dp[i][j - nums[i]] + dp[i-1][j]
            這裏 由於 每一個子問題 都有不是唯一的 組成方案, 所以要用 二維數組 [j] 這裏正是用於記錄 前面 i 各元素所 +/- 得到的和。
            即 每個循環 都要計算兩個和
        */
        // 先初始化 0 位
        int[][] dp = new int[nums.length][expand * 2 + 1];
        dp[0][0 + nums[0] + expand] += 1;  // 初始化 此時 S == 0 默認爲已經找到一條
        dp[0][0 - nums[0] + expand] += 1;  // 同理 這裏不同的是 上面一條 那個 += 中的 + 不是必須 這裏必須是 += 即先初始化誰誰就不用 +=
        // 這是防止一開頭 nums 前面幾個數組一堆 0 , 以免 +/- nums[i]  都佔了同樣的位置
        // 下面開始 進行窮舉 搜索所有符合條件的結果
        for(int i = 1; i < nums.length; i++){
            // 尋找基數
            // 這裏需要注意一點 sum <= expand 即要能達到 expand 邊界值 可能會出現 nums 中最後一位爲 0 的情況 在這裏要計算
            for(int sum = -expand; sum <= expand; sum++ ){  
                if(dp[i-1][sum + expand]>0){
                    // 這裏說明找到了基數 可以從此開始快樂的尋找 它往後 +/-得到的兩個數
                    // 調用上面的兩個公式
                    dp[i][sum + nums[i] + expand] += dp[i-1][sum + expand];  
                    dp[i][sum - nums[i] + expand] += dp[i-1][sum + expand];
                }
            }
        }
        // 最後進行判斷返回
        return S > expand ? 0 : dp[nums.length - 1][S + expand];
    
    }

看看題中所給的樣例:[1,1,1,1,1] 3

  1. 看是否有最優子結構: 即 3 可以轉化爲 3 - 1 = 2 或者 3 + 1 = 4 (3+/- 數組的最後一個數)

    可以看到 子結構 與 原問題可以使用同樣的方法進行解決;

  2. 確定狀態: 子問題(子結構)和原問題中都存在的變量 就是 當前值 S 和 位置 index(下標/索引);

  3. 確定DP函數: 這裏比較難定義

    dp(S)={1,S==0i==00S!=0i==0dp(S)+nums[i1]+dp(S)nums[i1]others dp(S)=\left\{ \begin{aligned} 1 &&,S==0 && i == 0 \\ 0 && S!=0 && i == 0 \\ dp(S)+nums[i-1]\\+dp(S)-nums[i-1] && others \end{aligned} \right.
    根據上面的公式可以寫出 狀態轉移方程

    不過由於上面的狀態轉移方程比較粗略,而且 從第2步知道 有兩個變量,

    因此考慮考慮使用二維數組 dp[ i ][ j ] ,

    其中 i 表示l兩個變量中的 位置 indxj 則表示兩個變量中的 S

    dp[ i ][ j ] : 則表示 當前 前 i 個元素 組合成 j 的方案個數有多少。

    因此 **可以確定狀態轉移方程: **

    dp[i][j]=dp[i1][j+nums[i]]+dp[i1][jnums[i]]dp[i][j] = dp[i-1][j + nums[i]] + dp[i-1][j - nums[i]]

    note: 這裏的 dp 不能和上面的 dp(S) 一一直接簡單對應,變通理解。

    上面是個不定式 進行改寫成 兩個遞推定式:

    dp[i][j+nums[i]]=dp[i][j+nums[i]]+dp[i1][j]dp[i][j + nums[i]] = dp[i][j + nums[i]] + dp[i-1][j]

    dp[i][jnums[i]]=dp[i][jnums[i]]+dp[i1][j]dp[i][j - nums[i]] = dp[i][j - nums[i]] + dp[i-1][j]

    上面的意思 表示 當前已有的方案數 是由 自身當前已有的方案數 + 自己前一個數的方案數。

    打個比方: 當前可能 0個或者只有1個方案數(可能在其他計算中獲得的), 現在自己前面的那個數nums[i-1] 又有了方案數,給自己送過來,那當然是要接收了。

    1. 最後是base 基數問題: 也就相當於寫成遞歸時的退出條件,也就是 上面公式中S==0 事的條件,

      這裏由於使用DP 解決問題,數組初始化皆爲0,所以不用再考慮 0 的情況,直接考慮 1 的情況,

      從尾巴 開始往前找的話, 如果總等找到 一個 相加和 符合的,就把 進行 +1 或者說返回 1,又或者說 這條方案 獲得 1;現在開始是從頭開始往後找,相當於對遞歸直接剪枝了。這裏 是 初始化 起始的方案數爲 1, 即 dp[0][0] = 1 .

      由於不允許數組下標爲負數, 比如[0 - 1] = [-1] 下標 爲負數 所以 考慮不以 0 爲對稱位, 調整大小進行上下擴展。[ -5 , -4, -3, -2, -1, 0, 1, 2, 3, 4, 5] 可以擴展 5 ->[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

      比如 nums=[2, 3, 5] 進行 sum = 2+3+5 = 10 最終的擴展則是 2*10+1 = 21

      int[][] dp = new int[nums.length][2*sum+1];
      

      至此,大致如上,具體一些的細節可以參見代碼中的部分註釋。歡迎討論進行糾正。

    參考鏈接1:動態規劃解題套路框架
    參考鏈接2:LeetCode 494 官方題解

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