在本篇文章當中主要通過介紹兩個演演算法題,從最基本的問題開始深入淺出零錢兌換問題,幫助大家從動態規劃的本源深入理解問題當中的原理,並且學會自己分析問題,分析資料之間的依賴關係,通過分析這種關係自己推導演演算法的優化過程,再也不怕類似於揹包問題的演演算法題了。
給你一個整數陣列 coins ,表示不同面額的硬幣;以及一個整數 amount ,表示總金額。
計算並返回可以湊成總金額所需的 最少的硬幣個數 。如果沒有任何一種硬幣組合能組成總金額,返回 -1 。
你可以認為每種硬幣的數量是無限的。
範例1
輸入:coins = [1, 2, 5], amount = 11
輸出:3
解釋:11 = 5 + 5 + 1
範例2
輸入:coins = [2], amount = 3
輸出:-1
在求解動態規劃問題的時候通常的步驟有以下幾個:
dp
,即我們需要尋找dp
的含義,分析需要用幾緯陣列表示具體的狀態。在揹包問題當中通常都是用一個二維陣列表示資料的狀態,在這個問題當中我們使用一個二維陣列dp
表示我們需要的狀態:
dp[i][j]
表示使用coins
前i
種面額的硬幣表示金額等於j
時使用的最少的金幣,那麼我們最終答案就是dp[N][amount]
,他表示使用coins
陣列當中所有面額的硬幣表示amount
需要的最少的硬幣個數。
在確定了狀態表示的陣列之後,現在我們就需要分析出動態轉移方程了,在這個問題當中對於每一種面額的硬幣我們都有兩種選擇:選和不選,但是在這個問題當中題目已經說明了對於每一種貨幣都可以認為是無限的,如果我們不選擇,那這種情況比較簡單,但是如果選擇了這種情況就比較複雜了:
dp[i][j]
,如果第i
種面額的貨幣不選擇,那麼說明只使用前i - 1
種面額的貨幣,那麼dp[i][j] = dp[i - 1][j]
,也就是說明如果使用前i
種面額的貨幣去表示總額為j
,但是不選擇第i
種面額的貨幣,就相當於使用前i-1
種貨幣去表示j
,那麼需要的貨幣個數跟使用前i-1
種貨幣去表示j
需要的貨幣數目是相等的。dp[i][j] = dp[i][j - coins[i]] + 1
,我們仔細分析一下這個公式,相當於在總金額等於j
的情況下先使用一次第i
個面額的硬幣,但是因為我們的硬幣是無限的,現在我們還是可以選擇第i
個硬幣,相當於總金額等於j - coins[i]
而且可以使用前i
個硬幣的情況下,需要的最少的硬幣個數,這就解決了是選一次還是選N次的問題了,而在上面的公式當中加一的原因是使用了一次第i
種硬幣。很顯然我們需要從上面兩種情況當中選擇需要的硬幣最少的一種方法,因此綜合上面的結果又如下的動態轉移方程:
其實上面這個問題的分析過程跟完全揹包可以說是一模一樣,如果你對完全揹包感興趣,你可以閱讀這篇文章完全揹包。
上面的問題分析過程當中,我們已經分析出來了動態轉移方程,這個過程和完全揹包非常相似,但是這個問題比完全揹包還稍微複雜一點,因為不一定能夠尋找到這樣一種組合湊成的總金額等於題目當中規定的數目。我們用-1
表示找不到這樣一種組合能夠表示。
dp
陣列第一行當中的資料全部初始化為-1。for (int i = 0; i * coins[0] <= amount; i++) {
dp[0][i * coins[0]] = i;
}
dp
陣列的第一行表示只使用第一種面額的硬幣,因此只有第一種硬幣面額的整數倍總金額才能使用第一種硬幣進行表示,而且對應的硬幣個數等於\(\frac{amout}{coins[0]}\)。
dp[i][j - coins[i]] == -1
,那麼就不能通過選擇第i
種硬幣進行表示,在這種情況下,我們只能通過選擇前i-1
一種貨幣進行表示,即dp[i][j] = dp[i - 1][j]
。可你你會有疑問,如果也不能使用前i-1
種物品進行表示呢?沒關係,如果不能表示那麼dp[i - 1][j] == -1
,那麼賦值之後dp[i][j]
也等於-1,也是不能表示的。dp[i][j - coins[i]]
不等於-1,但是dp[i - 1][j]
等於-1,那麼dp[i][j] = dp[i][j - coins[i]] + 1
。class Solution {
public int coinChange(int[] coins, int amount) {
int[][] dp = new int[coins.length][amount + 1];
Arrays.fill(dp[0], -1);
for (int i = 0; i * coins[0] <= amount; i++) {
dp[0][i * coins[0]] = i;
}
for (int i = 1; i < coins.length; i++) {
for (int j = 0; j <= amount; j++) {
// 如果要使用對應的硬幣
// 總金額數目肯定要大於硬幣的面額
if (j >= coins[i]) {
if (dp[i][j - coins[i]] == -1)
dp[i][j] = dp[i - 1][j];
else if (dp[i - 1][j] == -1)
dp[i][j] = dp[i][j - coins[i]] + 1;
else
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - coins[i]] + 1);
} else {
// 否則只能使用前 i-1 種硬幣
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[coins.length - 1][amount];
}
}
上面的程式碼體現的就是完全揹包 的思想,在題目當中硬幣可以使用無限次,如果只能使用一次的話,問題就轉換成01揹包了,那麼動態轉移方程就為:
根據動態轉移方程\(dp[i][j] = min(dp[i - 1][j], dp[i][j - coins[i]] + 1)\),我們可以得到dp
陣列當中資料之間的依賴關係,他們的關係如上圖所示,dp[i][j]
依賴的資料為它上一行同列的位置,和第i
行前面的某些資料,事實上我們可以使用單行陣列去進行實現,我們使用的迴圈還是一樣的,但是使用的陣列有所變化,從之前的二維陣列變成一維陣列。當我們遍歷到單行陣列第j
個資料的時候,第j
個資料還是上一行的狀態,但是單行陣列的下標從0到j-1
的位置資料的狀態已經從上一行更新了,這些資料的狀態相當於二維陣列的dp[i]
這一行的狀態,而這正好可以滿足動態轉移方程的需求,因為在動態轉移方程當中,dp[i-1][j]
依賴的資料全部符合條件,單行陣列當中的下標為j
資料等於dp[i][j]
,單行陣列下標為x
的資料等於dp[i][x]
,其中\(0 \le x \le j\),這裡你可以結合程式碼、文字和圖片進行理解,理解效果會更加好一點。
class Solution {
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, -1);
dp[0] = 0;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
if (dp[j - coins[i]] != -1) {
if (dp[j] == -1) {
dp[j] = dp[j - coins[i]] + 1;
}else
dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
}
}
}
return dp[amount];
}
}
在上面的文章當中,我們是使用-1去表示不能夠找到一個組合滿足總金額數目。我們可以先將陣列當中所有的資料全部初始化成amount + 1
,這個是硬幣的上界,如果我們全部使用一塊的硬幣進行兌換,結果是amount
,因此最大的值不會超過amount + 1
,因為在動態轉移方程當中求的是最小值,因此在進行狀態轉移的時候不會選擇這個值,因此下面的程式碼也是正確的!!!
class Solution {
public int coinChange(int[] coins, int amount) {
int max = amount + 1;
int[] dp = new int[amount + 1];
for (int j = 0; j < dp.length; j++) {
dp[j] = max;
}
dp[0] = 0;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++) {
if (dp[j - coins[i]] != max) {
dp[j] = Math.min(dp[j], dp[j - coins[i]] + 1);
}
}
}
return dp[amount] == max ? -1 : dp[amount];
}
}
給你一個整數陣列 coins 表示不同面額的硬幣,另給一個整數 amount 表示總金額。
請你計算並返回可以湊成總金額的硬幣組合數。如果任何硬幣組合都無法湊出總金額,返回 0 。
假設每一種面額的硬幣有無限個。
題目資料保證結果符合 32 位帶符號整數。
範例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 。
在這個問題當中我們也是使用一個二維陣列表示我們的狀態dp[i][j]
。這個表示使用前i
個硬幣,總金額為j
的情況下,能夠找到多少種組合方式,是硬幣的和等於總金額數。
在這道題目當中我們也可以使用無數次硬幣,因此這也是一個完全揹包問題。
對於每一種硬幣同樣的有兩種情況選擇和不選擇,每一種情況都有不同的組合,因此最終的組合數目就是將這兩個結果相加:
dp[i][j - coins[i]]
,這個程式碼的含義就是選擇一次第i
個硬幣,因為有無數個硬幣,因此這個結果就等於使用前i
個硬幣組合總金額為j-coins[i]
時,一共有多少個組合。i - 1
個硬幣,組合總金額為j
時,一共有多少個組合。因此最終的組合數的個數就是上面兩種方式的不同組合個數相加,因此我們的動態轉移方程為:
在初始化dp
陣列的第一行的時候,只有是第一個硬幣的整數倍的總金額才能進行匹配,如果不能匹配,那麼不同的組合的數目就等於0,能夠進行匹配的組合數也只有一個,那就是使用的硬幣全是第一種硬幣。在這裡需要注意的是當總金額等於0的時候,也是有一種組合方式的,那就是沒有一個硬幣,這也是一種組合方式,就相當於集合的空基,因此初始化程式碼如下:
for (int i = 0; i * coins[0] <= amount; i++) {
dp[0][i * coins[0]] = 1;
class Solution {
public int change(int amount, int[] coins) {
int[][] dp = new int[coins.length][amount + 1];
// dp[i][j] 的含義:前 i 個錢 容量 j 有多少種方法
for (int i = 0; i * coins[0] <= amount; i++) {
dp[0][i * coins[0]] = 1;
}
for (int i = 1; i < coins.length; i++) {
for (int j = 0; j <= amount; j++) {
if (j >= coins[i]) {
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
} else
dp[i][j] = dp[i - 1][j];
}
}
return dp[coins.length - 1][amount];
}
}
其實這道題也有對應的01揹包問題,在這道題目當中是完全揹包問題的變種,如果所有的硬幣只能夠使用一次的話,那麼動態轉移方程如下:
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
// dp[i][j] 的含義:前 i 個錢 容量 j 有多少種方法
dp[0] = 1;
for (int i = 0; i < coins.length; i++) {
for (int j = coins[i]; j <= amount; j++)
dp[j] += dp[j - coins[i]];
}
return dp[amount];
}
}
這個優化的原理和第一題也是一樣的,通過分析動態轉移方程,看單行陣列是否能夠滿足動態轉移方程的要求,如果能夠滿足的話,那就能夠進行單行陣列優化,反之不能進行優化,而在這個問題當中,資料依賴關係和第一題是一樣的,dp[i][j]
依賴的資料是dp[i - 1][j]
和dp
陣列第i
行前j - 1
個資料,根據第一題的分析,我們是可以使用單行陣列進行優化的!!!
在本篇文章當中主要給大家介紹了兩道零錢兌換的問題,在本文當中的這兩道題目都是屬於完全揹包的變種,如果要徹底弄懂這個問題的話就需要好好分析動態轉移方程和資料之間的依賴關係,通過分析資料之間的依賴關係,我們自己也可以從零推導優化過程。在這兩道問題當中硬幣都可以使用無數次,如果將能夠使用的硬幣只能夠使用一次的話,那麼這個問題就變成01揹包的變種問題了。
更多精彩內容合集可存取專案:https://github.com/Chang-LeHung/CSCore
關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演演算法與資料結構)知識。