【LeetCode動態規劃#08】完全揹包問題實戰與分析(零錢兌換II--求組合、組合總和IV--求排列)

2023-04-19 12:01:57

零錢兌換II

力扣題目連結(opens new window)

給定不同面額的硬幣和一個總金額。寫出函數來計算可以湊成總金額的硬幣組合數。假設每一種面額的硬幣有無限個。

範例 1:

  • 輸入: amount = 5, coins = [1, 2, 5]
  • 輸出: 4

解釋: 有四種方式可以湊成總金額:

  • 5=5
  • 5=2+2+1
  • 5=2+1+1+1
  • 5=1+1+1+1+1

範例 2:

  • 輸入: amount = 3, coins = [2]
  • 輸出: 0
  • 解釋: 只用面額2的硬幣不能湊成總金額3。

範例 3:

  • 輸入: amount = 10, coins = [10]
  • 輸出: 1

注意,你可以假設:

  • 0 <= amount (總金額) <= 5000
  • 1 <= coin (硬幣面額) <= 5000
  • 硬幣種類不超過 500 種
  • 結果符合 32 位符號整數

思路

經典揹包問題

關鍵字眼:錢幣數量不限-->完全揹包

注意題目要求,本題是要求:可以湊成總金額的硬幣組合數,是組合個數;而單純的完全揹包(或者說揹包問題)要求的是能夠湊成揹包的最大價值

因此,本題屬於揹包問題在求排列組合時的應用(那就與目標和那題一樣),具體到本題是求組合

組合與排列的區別

例如範例一:

5 = 2 + 2 + 1

5 = 2 + 1 + 2

這是一種組合,都是 2 2 1。

如果問的是排列數,那麼上面就是兩種排列了。

組合不強調元素之間的順序,排列強調元素之間的順序

詳見:確定遍歷順序(目標和)

五步走

1、確定dp陣列含義

dp[j]:能湊成總金額為j的貨幣組合個數為dp[j]

對應到題目中,j就是amount,即揹包容量

而coins陣列中的元素就是物品

2、確定遞推公式

和目標和中的遞推公式完全一樣,這裡再簡要推導一下

推導過程一切遵循dp陣列含義

假設amount = 3,即揹包容量是3

那麼在還沒往裡面放東西的時候,此時還有3的容量,那麼就還能夠有dp[3]種能湊出總金額為3的貨幣組合

若已經確定要放入1個貨幣,那此時容量就要減1,還剩2個容量,那麼就還能有dp[2]種能湊出總金額為3的貨幣組合

若已經確定要放入2個貨幣,那此時容量就要減2,還剩1個容量,那麼就還能有dp[1]種能湊出總金額為3的貨幣組合

若已經確定要放入3個貨幣,那此時容量就要減3,還剩0個容量,那麼就還能有dp[0]種能湊出總金額為3的貨幣組合

從下往上看,以上描述就是將貨幣陣列coins中的物品一個一個放入揹包的過程

最後能夠得到dp[3],因此dp[3]是累加而來的

所以遞推公式是:dp[j] += dp[j - coins[i]];

3、初始化dp陣列

還是和目標和那題一樣,當j為0時,需要初始化為1,也就是說,容量為0也有一種組合

dp[0]=1;

其餘情況初始化為0,使後面遞推得到的值能夠累加起來

4、確定遍歷順序

完全揹包問題是正序遍歷的;(因為物品不可以重複使用)

01揹包問題是倒序遍歷的;(因為物品可以重複使用)

這裡還需要討論一下兩層for迴圈中遍歷物品和揹包容量先後順序的問題

情況1

情況1:

for(int i = 0; i < coins.size(); ++i){//先遍歷物品
 for(int j = coins[i]; j <= amount; ++i){//再遍歷揹包容量
     dp[j] += dp[j - coins[i]];
 }
}

來模擬一下物品放入,並遍歷揹包容量的過程

假設amount = 5,coins = [1, 2, 5]

從1開始遍歷,遍歷到2滿足條件,此時組合為{1,2},這種順序下不會遍歷得到{2,1},因為2永遠在1後面被用來嘗試放入揹包

所以先物品後容量的順序得到的是物品放入揹包的組合數

這麼說可能有點抽象,我其實一直不清楚:為什麼第二層for迴圈中迴圈變數的初始值是coins[i],以及為什麼每輪迴圈後其都要遞增

所以嘗試模擬推導一下dp陣列:

    0  1  2  3  4  5(揹包容量)
    1  0  0  0  0  0 (沒有硬幣的時候)
=======================
    0  1  2  3  4  5(揹包容量)
1   1  1  1  1  1  1
=======================
    0  1  2  3  4  5(揹包容量)
1   1  1  1  1  1  1
2         2  2  3  3
有了面值為2的硬幣後,哎,我就是不用,所以方案數還是dp[j]種;
但是我如果用了,那我看看在放入這枚硬幣前,也就是揹包容量為[j-coins[i]]的時候有幾種方案;
兩種情況加起來,所以就是  dp[j] = dp[j]+dp[j-coins[i]];
========================
    0  1  2  3  4  5(揹包容量)
1   1  1  1  1  1  1
2         2  2  3  3
5                  4

上述解釋摘自https://leetcode.cn/problems/coin-change-ii/solution/gong-shui-san-xie-xiang-jie-wan-quan-bei-6hxv/979973

這裡解釋了為什麼第二層for迴圈每次都要從coins[i]開始遍歷

拿coins[1],即2來舉例,硬幣面值是2,那揹包容量為0和1時自然不可能放下,所以至少要等揹包容量大於等於2時才能考慮使用面值為2的硬幣

而當第二層for迴圈的迴圈變數以coins[1]為初始值時,就意味著要開始找面值為2的硬幣放入揹包的組合有幾種了

還是拿上面的來看,當有了面值為2的硬幣後,如果揹包容量大於等於2,那麼可以用面值為2的硬幣配合之前獲得的面值為1的硬幣來組合出當前揹包容量(面值)。

這件事情怎麼說會比較好呢?

也就是說,如果我們現在有了面值為2的硬幣,那麼在湊容量大於2的揹包時就可以用這個面值的硬幣配合之前面值的硬幣來湊出揹包容量

但是,實際上不用面值為2的硬幣我們也能用之前的硬幣湊出對應的揹包容量

只是說用了新面值的硬幣之後,湊出揹包容量的組合數量就增加了

聯絡dp陣列的含義dp[j]表示能湊成總金額為j的貨幣組合個數為dp[j]

那dp[2]就是能湊出總金額為2的貨幣組合個,

我們用面值為1的硬幣也能湊出來總金額2,且組合有1+1+1+1+1=5種,即dp[1];

在此基礎上加入面值為2的硬幣,也能湊出總金額2,組合數量在上面的基礎上增加1+1+2+2+3+3=10種,即dp[2]

所以,dp[2] = dp[2] + dp[1];

這就和遞推公式dp[j] += dp[j - coins[i]];對應上了

回到最開始的疑問,為什麼第二層for迴圈每次都要從coins[i]開始遍歷

現在能夠理解了,其實就是為了分開計算組合的情況,從coins[i]開始之後計算得到的dp陣列的值(也就是組合種類),是包含了使用當前新加入的coins[i]面值的硬幣之後的組合種類

而程式碼就是在模擬這個操作,至於j為什麼每次都要遞增。

因為j其實只是在遍歷揹包容量時的一個指標,j的遞增跟coins[i]沒有關係

情況2

情況2:

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

還是模擬一下,

遍歷揹包容量0,都放不下

遍歷揹包容量1,可以放一個1,此時能夠湊齊容量的集合有:{1}

遍歷揹包容量2,此時coins中的1和2都可以放入,此時能夠湊齊容量的集合有:{1,1}、{2}

遍歷揹包容量3,此時coins中的1和2都可以放入,此時能夠湊齊容量的集合有:{1,1,1}、{1,2}、{2,1}

...之後的集合以此類推

為什麼這裡會出現{1,2}、{2,1},因為先遍歷揹包容量時,相當於拿揹包容量去嘗試套硬幣的面值

此時我們是預設有所有面值的硬幣的,因此一旦有能夠用揹包容量套下的硬幣集合,那麼該集合的所有方式都會被遍歷出來

因此,先容量後物品的順序得到的是物品放入揹包的排列數

程式碼

程式碼其實就是按照模板寫就好了,其中比較"細思恐極"的部分再上面已經詳細解釋了

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        //定義並初始化dp陣列
        vector<int> dp(amount + 1, 0);
        dp[0] = 1;
        //遍歷dp陣列
        for(int i = 0; i < coins.size(); ++i){//遍歷物品
            //第二層for中迴圈變數的初始值j是第一層for迴圈遍歷到的物品的重量(容量)
            //用於定位到大於等於當前新加入硬幣面值的揹包容量位置
            for(int j = coins[i]; j <= amount; ++j){//遍歷揹包容量
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
};

組合總和IV

力扣題目連結(opens new window)

難度:中等

給定一個由正整陣列成且不存在重複數位的陣列,找出和為給定目標正整數的組合的個數。

範例:

  • nums = [1, 2, 3]
  • target = 4

所有可能的組合為: (1, 1, 1, 1) (1, 1, 2) (1, 2, 1) (1, 3) (2, 1, 1) (2, 2) (3, 1)

請注意,順序不同的序列被視作不同的組合。

因此輸出為 7。

思路

題目給的範例中有一個注意事項,"順序不同的序列被視作不同的組合"

也就是說,本題要求的是排列而不是組合(求組合的完全揹包可以看 零錢兌換II

根據之前題目的分析可以知道,dp陣列的遍歷順序會影響所求的結果

先物品後容量,得到的是組合;

先容量後物品,得到的是排列;

程式碼

本題五步走分析和之前的完全揹包是一樣的,包括dp陣列的含義也都是一樣的,直接來看程式碼就行

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        //定義dp陣列
        vector<int> dp(target + 1, 0);
        //初始化
        dp[0] = 1;
        //遍歷dp陣列
        for(int j = 0; j <= target; ++j){//先遍歷物品
            for(int i = 0; i < nums.size(); ++i){//後遍歷揹包容量
                if(j - nums[i] >= 0 && dp[j] < INT_MAX - dp[j - nums[i]]) dp[j] += dp[j - nums[i]];
            }
        }
        return dp[target];
    }
};

一些解釋:

1、j - nums[i] >= 0

拓展:本題與爬樓梯的聯絡

回憶一下 爬樓梯 ,本題的要求是我們一步可以爬1個或2個臺階,問爬到樓頂(也就是n個臺階)一共有幾種方法

如果題目條件變一下,一步可以爬3個臺階、4個、m個呢?

那其實「一步可以爬幾個臺階」,「幾個臺階」就相當於本題nums陣列中的元素,即物品

如果是一步可以爬3個臺階,那就是陣列從1到3

假設爬到樓頂有n階樓梯,那這個n就是target,即揹包容量

而我們要求的就是所有爬到樓頂方法的排列(假設n=3,先爬1步再爬2步和先爬2步再爬1步顯然是兩種不同的上樓方法)