動態規划---完全背包問題詳解
鳴謝:本次的學習是跟着Carl的筆記來的,原創作者為Carl,可以在b站或者公眾號關注Carl,搜索代碼隨想錄。
完全背包理論基礎
1、問題
背包最大容量為4,現有下面的物品各無限個。
重量 | 價值 | |
---|---|---|
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
問:背包能背的最大物品價值是多少?
2、與01背包的區別
01背包遍歷順序的核心思路
for(int i = 0; i < weight.size(); i++) { // 遍歷物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍歷背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
內層的循環,從大到小遍歷,為了保證每個物品僅被添加一次。
而完全背包中的物品是可以添加多次的,所以需要我們從小到大去遍歷。即:
// 先遍歷物品,再遍歷背包
for(int i = 0; i < weight.size(); i++) { // 遍歷物品
for(int j = weight[i]; j < bagWeight ; j++) { // 遍歷背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
3、一維dp和二維dp
01背包中⼆維dp數組的兩個for遍歷的先后循序是可以顛倒了,⼀位dp數組的兩個for循環先后循序⼀定是先遍歷物品,再遍歷背包容量。
在完全背包中,對於⼀維dp數組來說,其實兩個for循環嵌套順序同樣無所謂!
因為dp[j] 是根據 下標j之前所對應的dp[j]計算出來的。 只要保證下標j之前的dp[j]都是經過計算的就可以了
// 先遍歷背包,再遍歷物品
for(int j = 0; j <= bagWeight; j++) { // 遍歷背包容量
for(int i = 0; i < weight.size(); i++) { // 遍歷物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
二維dp的話,其實和之前01背包是一樣的
// 初始化
// 當j=0時,背包容量為0,最大價值為0;當i=0時,也就是前0件物品,也就是沒有物品,最大價值也是0
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (j - weight[i-1] < 0) // 如果當前背包容量放不下第i件物品,那么前i件物品放入背包得到的最大價值就是前i-1件物品放入獲得的最大價值
dp[i][j] = dp[i-1][j];
else { // 如果能放下,從放和不放兩種選擇里取最大值,這里要注意,其實完全背包二維數組的代碼跟一維只有下面一個下標不同,那就是“放i”這個選擇,因為是可以重復放的,所以是dp[i]
dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i-1]] + value[i-1]);
}
}
一、LeetCode-518.零錢兌換II
1、題干
2、動規思路
①確定dp數組即下標的含義
dp[j]表示當前amount為j,有dp[j]種方法可以湊成j。
②遞推公式
dp[j]的來源:
dp[j] (考慮coins[i]的組合總和) 就是所有的dp[j - coins[i]](不考慮coins[i])相加。
所以遞推公式:dp[j] += dp[j - coins[i]]
求裝滿背包有⼏種⽅法,⼀般公式都是:dp[j] += dp[j - nums[i]]
③初始化
首先dp[0]要初始化為1,dp[0] = 1是遞歸公式的基礎。
從dp[i]的含義上來講就是,湊成總⾦額0的貨幣組合數為1
下標⾮0的dp[j]初始化為0,這樣累計加dp[j - coins[i]]的時候才不會影響真正的dp[j]
④遍歷順序
-
題目中要求的時組合數
- 要區分組合數和排列數的區別
- 比如2+2+1=5,1+2+2=5,如果是組合數,這就是一種情況,如果是排列數,這就是兩種情況。
- 要區分組合數和排列數的區別
-
那么本題中,內外層的循環順序是否可以對調呢?
- 不可以
- 因為在完全背包問題中,我們求的時一個總和,即不管元素之間的順序,和順序沒有關系。
- 而本題中要求方案數,也就是組合數,內外層的循環就很有講究了。
- 不可以
-
外層遍歷物品(錢幣),內層遍歷背包(金錢總額)情況(求組合數)
-
代碼
-
for(int i=0;i<coins.length;i++){ for(int j=coins[i];j<=amount;j++){ dp[j] += dp[j-coins[i]]; } }
-
對於面額為coins[i]的硬幣,當coins[i]<=j<=amount時候,如果存在一種硬幣組合的金額之和等於j-coins[j],則在該硬幣組合中增加一個面額為coins[i]的硬幣,更新數組dp中每個大於或等於該面額的元素的值。
例如:dp[5] = dp[4]+dp[3]+dp[0]
-
-
上述做法不會重復的計算不同的排列,因為外層循環是遍歷數組coins的值,內層循環是遍歷不同金額之和,在計算dp[j]的值的時候,可以確保金額之和等於 j 的硬幣面額的順序.
-
-
外層遍歷背包(金額),內層遍歷物品(錢幣)情況(求排列數)
-
代碼
-
for (int j = 0; j <= amount; j++) { // 遍歷背包容量 for (int i = 0; i < coins.size(); i++) { // 遍歷物品 if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]]; } }
-
-
在這種情況下,會出現每一個金額,都會遍歷所有的錢幣。此時dp[j]算出來的就是排列數。
-
3、代碼實現
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0] = 1;
for(int i=0;i<coins.length;i++){
for(int j=coins[i];j<amount+1;j++){
dp[j] += dp[j-coins[i]];
}
}
return dp[amount];
}
}
或者:
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0] = 1;
for(int i=0;i<coins.length;i++){
for(int j=0;j<amount+1;j++){
//判斷條件要把控好
if(j-coins[i]>=0)
dp[j] += dp[j-coins[i]];
}
}
return dp[amount];
}
}
二、LeetCode-377.組合總和IV
1、題干
2、思路
從這道題的要求就可以看出,要計算的是排列數,所以我們應該把背包的容量,放在外層循環。
3、代碼實現
class Solution {
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0] = 1;
for(int j=0;j<=target;j++){
for(int i=0;i<nums.length;i++){
if(j-nums[i]>=0)
dp[j] += dp[j-nums[i]];
}
}
return dp[target];
}
}
三、LeetCode-70.爬樓梯
1、題干
2、動規思路與題目變形
這道題在動規的時候,是按照類似斐波那契數列直接推導做的。由於當時沒有學到完全背包問題,所以簡單就結束了。
題目變形
題目簡單的變形之后,就成為了一個完全背包的問題。
①確定dp數組下標及其含義
dp[j]:爬到第j個台階,有dp[j]中組合(方法)
②確定遞推公式
在本題之中,dp[j]的來源有:
dp[j-1]、dp[j-2]、dp[j-3]、...dp[j-m]
遞推公式為
dp[j] += dp[j-i]
③初始化
既然遞歸公式是 dp[i] += dp[i - j],那么dp[0] ⼀定為1,dp[0]是遞歸中⼀切數值的基礎所在,如果dp[0]
是0的話,其他數值都是0了。
下標⾮0的dp[i]初始化為0,因為dp[i]是靠dp[i-j]累計上來的,dp[i]本身為0這樣才不會影響結果
④確定遍歷順序
這里求的是排列問題!!
因為1、2 步 和 2、1 步都是上三個台階,但是這兩種⽅法不⼀樣。
3、代碼
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n+1];
dp[0] = 1;
for(int j=0;j<=n;j++){
for (int i=1;i<=2;i++){//這里把2換成m就可以AC題目變形
if(j-i>=0)
dp[j] += dp[j-i];
}
}
return dp[n];
}
}
四、LeetCode-322.零錢兌換
1、題干
2、動規思路
上面我們已經兌換過一次零錢了,計算的是組合數。我們這次遇到的問題,也是一個完全背包問題,因為我們可以看到硬幣的數量是無限的。
①確定dp數組及下標含義
dp[j]:湊出總數j,所用到的最少錢幣個數是dp[j]。
②遞推公式
遞推公式:dp[j] = Math.min(dp[j - coins[i]] + 1, dp[j])
解釋:
得到dp[j]的方式有:當前這個硬幣拿不拿兩種情況
如果當前這個硬幣拿了,那么dp[j] = dp[j-coins[i]]+1 (只加1,在拿了這個硬幣之后所需要的最小的硬幣個數上加1(當前))
如果當前這個硬幣不拿,那么dp[j] = dp[j],(上一次遍歷硬幣的結果)
③初始化
⾸先湊⾜總⾦額為0所需錢幣的個數⼀定是0,那么dp[0] = 0;
其他下標對應的數值呢?
考慮到遞推公式的特性,dp[j]必須初始化為⼀個最⼤的數,
否則就會在Math.min(dp[j - coins[i]] + 1, dp[j])⽐較的過程中被初始值覆蓋。
所以下標⾮0的元素都是應該是最⼤值
④遍歷順序
外層遍歷硬幣
內層遍歷背包
順序從左到右
實際上,本題求得不是排列或者組合,只是一個個數的問題,所以內外層可以互換。
⑤舉例推導
3、代碼實現
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount+1];
for (int i = 1; i <=amount ; i++)
dp[i] = Integer.MAX_VALUE;
for (int coin : coins) {
for(int j=coin;j<=amount;j++){//從當前這個硬幣的值開始遍歷
if (dp[j-coin]!=Integer.MAX_VALUE)
dp[j] = Math.min(dp[j],dp[j-coin]+1);
}
}
if (dp[amount]==Integer.MAX_VALUE)
return -1;
return dp[amount];
}
}
或者
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount+1];
for (int i = 1; i <=amount ; i++)
dp[i] = Integer.MAX_VALUE;
for (int coin : coins) {
for(int j=0;j<=amount;j++){
if (j-coin>=0 && dp[j-coin]!=Integer.MAX_VALUE)//注意此處的條件是j-coin>=0
dp[j] = Math.min(dp[j],dp[j-coin]+1);
}
}
if (dp[amount]==Integer.MAX_VALUE)
return -1;
return dp[amount];
}
}
五、LeetCode-279.完全平方數
1、題干
2、動規思路
①確定dp數組及其下標的含義
dp[j]:和為j(背包容量)的完全平方數的最少數量為dp[j](填滿j所需要的最少物品)
②確定遞推公式
首先小於n的完全平方數,也就是物品,就是i*i(用i代表物品)
那么,dp[j]的來源為:
當前這個完全平方數取不取
如果不取的話,dp[j] = dp[j]
如果取了的話,dp[j] = dp[j-i*i]+1
所以,dp[j] = Math.min(dp[j],dp[j-i*i]+1)
③初始化
dp[0]表示 和為0的完全平⽅數的最⼩數量,那么dp[0]⼀定是0。
有同學問題,那0 * 0 也算是⼀種啊,為啥dp[0] 就是 0呢?
看題⽬描述,找到若⼲個完全平⽅數(⽐如 1, 4, 9, 16, ...),題⽬描述中可沒說要從0開始,dp[0]=0完全是為了遞推公式。
⾮0下標的dp[j]應該是多少呢?
從遞歸公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要選最⼩的,所以⾮0下標的dp[i]⼀
定要初始為最⼤值,這樣dp[j]在遞推的時候才不會被初始值覆蓋
④遍歷順序
我們知道這是完全背包,
如果求組合數就是外層for循環遍歷物品,內層for遍歷背包。
如果求排列數就是外層for遍歷背包,內層for循環遍歷物品。
在動態規划:322. 零錢兌換中我們就深⼊探討了這個問題,本題也是⼀樣的,是求最⼩數!
所以本題外層for遍歷背包,⾥層for遍歷物品,還是外層for遍歷物品,內層for遍歷背包,都是可以的!
⑤距離推導
3、代碼實現
①先遍歷背包,再遍歷物品。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n+1];
for (int i=1;i<=n;i++)
dp[i] = Integer.MAX_VALUE;
for(int j=0;j<=n;j++){//遍歷背包
for(int i=1;i*i <= j;i++){//遍歷物品(i*i就是我們的物品,組合數),注意此時的小於等於號
dp[j] = Math.min(dp[j],dp[j-i*i]+1);
}
}
return dp[n];
}
}
②先遍歷物品,再遍歷背包。
class Solution {
public int numSquares(int n) {
int[] dp = new int[n+1];
for (int i=1;i<=n;i++)
dp[i] = Integer.MAX_VALUE;
for(int i=1;i*i<=n;i++){
for(int j=1;j<=n;j++){
if(j-i*i>=0)//注意此時的大於等於號
dp[j] = Math.min(dp[j],dp[j-i*i]+1);
}
}
return dp[n];
}
}
六、LeetCode-139.單詞拆分
1、題干
2、動規思路
單詞就是物品,字符串s就是背包,單詞能否組成字符串s,就是問能不能 把背包裝滿。由於拆分的時候可以重復使用字典中的殘次,說明就是一個完全背包!
①確定dp數組以及下標的含義
dp[j]:字符串長度為j的話,dp[j]為true,表示可以拆分為一個或者多個在字典中出現的單詞
②確定遞推公式
如果確定dp[j]是true,且[i,j]這個區間的字出現在字典里,那么dp[j]一定是true.
所以遞推公式為:
if([i,j]這個區間的字串出現在字典里 && dp[i]是true)
那么dp[j] = true
③初始化
從遞歸公式中可以看出,dp[i] 的狀態依靠 dp[j]是否為true,那么dp[0]就是遞歸的根基,dp[0]⼀定要為 true,否則遞歸下去后⾯都都是 false了。
那么dp[0]有沒有意義呢?
dp[0]表示如果字符串為空的話,說明出現在字典⾥。
但題⽬中說了“給定⼀個⾮空字符串 s” 所以測試數據中不會出現i為0的情況,
那么dp[0]初始為true完全就是為了推導公式。
下標⾮0的dp[i]初始化為false,只要沒有被覆蓋說明都是不可拆分為⼀個或多個在字典中出現的單詞。
④確定遍歷順序
本題使用求排列的方式,還是求組合的方式都可以。
但是本題具有特殊性,因為是要求字串
最好是遍歷背包放在外循環,將遍歷物品放在內循環
如果要是外層for循環遍歷物品,內層for遍歷背包,就需要把所有的⼦串都預先放在⼀個容器⾥。
內循環從前向后
⑤舉例推導
3、代碼實現
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
Set<String> wordSet = new HashSet(wordDict);
boolean[] dp = new boolean[s.length()+1];
dp[0] = true;
for(int j=1;j<=s.length();j++){//遍歷背包
for (int i=0;i<j;i++){//遍歷物品
String word = s.substring(i,j);
if(wordSet.contains(word) && dp[i]){
dp[j] = true;
}
}
}
return dp[s.length()];
}
}
4、代碼分析
-
首先題目給我們的是一個列表
- 列表存儲的是有序的元素
- 列表中元素可以重復
- 列表中元素的順序關系由添加到列表的前后順序而來
-
將列表轉化為集合
- 集合是無序的
- 集合中沒有重復的元素
-
題目特征
- 我們需要判斷,物品(當前這個單詞)是不是在集合當中
- 恰好,使用Set的contains方法可以快捷的實現。