動態規劃的套路
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。
注意:
- 數組非空,且長度不會超過20。
- 初始的數組的和不會超過1000。
- 保證返回的最終結果能被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 。
- 發現最優子結構首先這道題 可以很快找到最優子結構:即 11 的 子問題 10 同樣可以用 和 解決 11 的方法解決, 其他子問題 如9 8 7 … 都可以同樣的解決。
找到最優子結構了。那就要看接下來的幾個重點步驟:
先確定「狀態」,也就是原問題和子問題中變化的變量。由於硬幣數量無限,所以唯一的狀態就是目標金額
amount
。這裏硬幣數量是無法確定的,因爲給定的不限量而不是限定範圍,因此原問題和子問題都可以看成目標金額作爲變量。然後確定
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) ) $
最後明確 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
看是否有最優子結構: 即 3 可以轉化爲 3 - 1 = 2 或者 3 + 1 = 4 (3+/- 數組的最後一個數)
可以看到 子結構 與 原問題可以使用同樣的方法進行解決;
確定狀態: 子問題(子結構)和原問題中都存在的變量 就是 當前值 S 和 位置 index(下標/索引);
確定DP函數: 這裏比較難定義
根據上面的公式可以寫出 狀態轉移方程不過由於上面的狀態轉移方程比較粗略,而且 從第2步知道 有兩個變量,
因此考慮考慮使用二維數組 dp[ i ][ j ] ,
其中 i 表示l兩個變量中的 位置 indx 而 j 則表示兩個變量中的 S
dp[ i ][ j ] : 則表示 當前 前 i 個元素 組合成 j 的方案個數有多少。
因此 **可以確定狀態轉移方程: **
note: 這裏的 dp 不能和上面的 dp(S) 一一直接簡單對應,變通理解。
上面是個不定式 進行改寫成 兩個遞推定式:
上面的意思 表示 當前已有的方案數 是由 自身當前已有的方案數 + 自己前一個數的方案數。
打個比方: 當前可能 0個或者只有1個方案數(可能在其他計算中獲得的), 現在自己前面的那個數nums[i-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 官方題解