點擊上方“五分鐘學算法”,選擇“星標”公衆號
重磅乾貨,第一時間送達
轉自面向大象編程
換硬幣(Coin Change)問題是一道經典的動態規劃入門題,但是你可能不太知道,LeetCode 上的換硬幣問題其實是一個系列,共三道題目:
LeetCode 322. Coin Change(Medium)
LeetCode 377. Combination Sum IV(Medium)
LeetCode 518. Coin Change 2(Medium)
其中,377 題雖然不叫 Coin Change,但是本質上和換硬幣問題是一樣的。
這一系列是比較考驗技巧的三道題目,難度層層遞進。第一道題是大家都熟悉的動態規劃入門題;第二道題變爲求方案數,需要我們不重複不遺漏;第三道題更有難度,需要擴充爲二維動態規劃,才能準確求出方案數量。
沒有做過這個系列三道題的同學,不妨跟着本文看看三道題目的解法,理解其中的思路和技巧。下面我們一道一道地進行分析。
(一) 第 322 題:求最小硬幣數
LeetCode 322. Coin Change(Medium)
給定不同面額的硬幣
coins
和金額amount
,計算湊成總金額所需的最少的硬幣個數。如果沒有任何一種方案能組成該金額,返回 -1。每種硬幣的數量是無限的。示例:
輸入: coins = [1, 2, 5], amount = 11 輸出: 3 解釋: 11 = 5 + 5 + 1
這第一道題目大家應該都做過,網上的各種解析也很氾濫。這裏我們還是套用動態規劃的解題四步驟來求解。
第一步,定義子問題。
我們設硬幣面值的集合爲 。子問題 可以定義爲:「湊出金額 的最小硬幣數」。那麼原問題就是求 。
第二步,寫出子問題的遞推關係。
求「湊出金額 的最小硬幣數」,我們可以嘗試不同的硬幣。如果使用了金額爲 的硬幣,問題就變成了「湊出金額 的最小硬幣數」。我們要從各種嘗試中選擇硬幣數量最小的那個結果。
這樣我們就可以寫出子問題的遞推關係:
當然,不要忘記子問題的 base case:
它的含義是,當 amount
爲 0 時,不需要任何硬幣就已經湊出了目標金額。
第三步,確定 DP 數組的計算順序。
確定 DP 數組計算順序的重點是看子問題的依賴關係。我們以 爲例,即有 1 元、2 元、5 元的硬幣。這樣 依賴於 、、,全部在 的左邊。也就是說,DP 數組中的每個元素只依賴其左邊的元素。
既然 DP 數組中的依賴關係都是向右指的,那麼 DP 數組的計算順序就是從左到右。
處理 DP 數組中的無效元素。
至此,我們離寫出題解代碼已經很接近了,但還要處理一個編程上的細節:DP 數組中的無效元素。
可能存在一些金額是硬幣湊不出來的。例如只有 2 元、5 元的硬幣時,就湊不出 1 元、3 元的金額。這樣 、 就是無效元素,不能參與子問題的計算。
爲了計算方便,我們可以把無效的子問題用 (正無窮大)表示。這是因爲子問題的遞推關係求的是最小值 ,正無窮大的值顯然不會成爲最小值。這樣無效元素也能參與計算了。
在編程表示中,我們發現 DP 數組中的值最大也只能是 amount
(只有 1 元硬幣的情況,硬幣數量等於金額數),我們可以用 amount + 1
表示 。DP 數組中的值只要大於 amount
,就認爲是無效元素。
這樣,我們就可以寫出最終的題解代碼:
public int coinChange(int[] coins, int amount) {
// 子問題:
// f(k) = 湊出金額 k 的最小硬幣數
// f(k) = min{ 1 + f(k - c) }
// f(0) = 0
int[] dp = new int[amount+1];
Arrays.fill(dp, amount + 1); // DP 數組初始化爲正無窮大
dp[0] = 0;
for (int k = 1; k <= amount; k++) {
for (int c : coins) {
if (k >= c) {
dp[k] = Math.min(dp[k], 1 + dp[k-c]);
}
}
}
// 如果 dp[amount] > amount,認爲是無效元素。
if (dp[amount] > amount) {
return -1;
} else {
return dp[amount];
}
}
這道題進行空間優化很麻煩,所以我們忽略第四步空間優化的步驟。
(二) 第 377 題:求方案數
LeetCode 377 - Combination Sum IV(Medium)
給定一個由正整數組成且不存在重複數字的數組
nums
,找出和爲給定目標正整數target
的組合的個數。順序不同的序列視作不同的組合。示例:
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)
別看這道題表面上看起來跟換硬幣沒啥關係,但如果你把數字 nums
變成硬幣 coins
,把 target
變成 amount
,它就成了一道如假包換的換硬幣問題:
給定不同面額的硬幣
coins
和金額amount
,計算湊出該金額的方案的個數。順序不同的序列視作不同的方案。
這道題和上一題的不同在於,不是求「最小的硬幣數量」,而是求「方案的個數」。這樣問題的難度又上了一個臺階。
求「方案數」的難度在於要做到不重複、不遺漏。如果是求「最小值」,其實子問題之間可以有重複,也能求出正確的最小值;但是求「方案數」時,子問題之間的重複會導致方案數不正確。這一點一定要特別注意。
第一步,定義子問題。
我們還是設硬幣面值的集合爲 。子問題 可以定義爲:「湊出金額 的方案的個數」。那麼原問題就是求 。
第二步,寫出子問題的遞推關係。
我們可以把湊硬幣的方案看成硬幣的排列。對於 即「湊出金額 的方案數」,我們考慮第一個硬幣選哪個。以 爲例,第一個硬幣放 1、2 或 5 顯然都是不同的方案。如果第一個硬幣放的是 1,剩下的金額 的方案個數是 。這樣我們可以得出:
把這個公式推廣到一般的硬幣面值集合 ,就得到了子問題的遞推關係:
同樣的,不要忘記子問題的 base case:
它的含義是「湊出金額 0 的方案數爲 1」。這是求「方案數」時一個常見的技巧。所有的 最後都要轉化爲 求出來。
第三步,確定 DP 數組的計算順序。
這道題的 DP 數組及其計算順序和上一題是一樣的,這裏不再贅述。
DP 數組中不存在什麼無效元素。湊不出的金額我們可以用 來表示,即方案數爲 0。
這樣我們就可以寫出題解代碼了。這一題的主要難度在於遞推關係的細節,最終寫出的題解代碼還是挺簡單的:
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1]; // 默認初始化爲 0
dp[0] = 1; // 注意 base case
for (int k = 1; k <= target; k++) {
for (int n : nums) {
if (k >= n) {
dp[k] += dp[k-n];
}
}
}
return dp[target];
}
(三) 第 518 題:不重複的方案數
LeetCode 518. Coin Change 2(Medium)
給定不同面額的硬幣
coins
和金額amount
。寫出函數來計算可以湊成該金額的硬幣組合數。假設每一種面額的硬幣有無限個。示例:
輸入: amount = 5, coins = [1, 2, 5] 輸出: 4 解釋: 有四種方式可以湊成總金額: 5=5 5=2+2+1 5=2+1+1+1 5=1+1+1+1+1
這道題和上一題的區別在於,順序不同的序列視爲同一種方案。這一個小小的改動,讓題目的難度瞬間上升。我們在上一題中寫出的子問題遞推關係不再適用了。
例如要湊出金額 5,本題中將「2+2+1」和「1+2+2」視爲同一種方案。我們不能再像上一題中那樣,先考慮第一個硬幣是 1、2、5,再看後面的方案了。
那麼,如何去除像「2+2+1」和「1+2+2」這樣重複了的序列呢?其實,這非常像「排列」跟「組合」的關係:上一題中將順序不同的序列視爲不同的方案,類似「排列」問題;這一題中將順序不同的序列視爲相同的方案,類似「組合」問題。
我們已經在前面的文章中討論過排列問題與組合問題的關係:
LeetCode 例題精講 | 08 排列組合問題:回溯法的候選集合
要將順序不同的排列視爲同一個組合,只需要考慮所有有序的排列,丟棄其他的排列。
對於硬幣問題,就是限制硬幣選擇的次序,先選面額大的硬幣,再選面額小的硬幣。也就是說,我們只允許「2+2+1」這樣先大後小的硬幣序列出現,不允許「1+2+2」這樣的硬幣序列。
如何限制硬幣選擇的次序呢?答案是動態規劃增加一個維度,用參數 表示當前可選的硬幣。
第一步,定義子問題。
設硬幣面值的集合爲 。子問題 表示用前 個硬幣(即 )湊出金額 的方案數。
在一開始,,即可以選擇全部的 個硬幣。假如在當前一步選擇了面額第二大的硬幣 ,那麼接下來 變爲 ,限制後面只能選比 小的硬幣。
第二步,寫出子問題的遞推關係。
子問題的遞推關係是這樣的:
這個遞推關係是怎麼來的呢?考慮子問題 ,用硬幣 湊出金額 時,我們有兩種選擇:
第一種選擇:拿一個面額最大的硬幣 ,因爲一種硬幣可以重複拿多次,後面 還都可以隨便選,方案數爲 。
第二種選擇:決定不再拿面額最大的硬幣 ,後面只能選 ,方案數爲 。
你可以會問,爲什麼沒有 ?其實我們的公式已經把這種情況包括在裏面了。
這樣,原問題就是 ,即用全部的 個硬幣湊出金額 amount
的方案數。
另外別忘了子問題的 base case。這道題的 base case 同樣比較複雜:
當 時,。即「湊出金額 0 的方案數爲 1」,與上一題一樣的技巧。
當 時,。硬幣用完後,湊不出任何金額。
第三步,確定 DP 數組的計算順序。
我們發現,這一題增加了一個維度,變成了二維動態規劃問題。這樣我們就更需要確定 DP 數組的計算順序了。接下來爲了討論方便,我們設硬幣的種類爲 ,要湊出的金額(amount
)爲 。
首先,DP 數組的有效範圍是 。在 DP 數組中,base case 位於 DP 數組的最左側一列和最上方一行,而原問題則位於 DP 數組的右下角,如下圖所示。
這張圖中特意把 DP 數組畫成一個很扁的長方形,是因爲一般情況 要比 大很多。 表示要湊出的金額 amount
,而 表示硬幣的種類。
子問題的依賴關係在 DP 數組中是這樣子的:
可以看到,子問題的依賴方向是向右、向下的。那麼我們應該從左到右、從上到下遍歷 DP 數組,計算其中的元素。
最終,我們可以寫出這樣的題解代碼:
public int change(int amount, int[] coins) {
int m = coins.length;
int[][] dp = new int[m+1][amount+1];
for (int i = 0; i <= m; i++) {
for (int k = 0; k <= amount; k++) {
if (k == 0) {
dp[i][k] = 1; // base case
} else if (i == 0) {
dp[i][k] = 0; // base case
} else {
dp[i][k] = dp[i-1][k];
if (k >= coins[i-1]) {
dp[i][k] += dp[i][k-coins[i-1]];
}
}
}
}
return dp[m][amount];
}
眼尖的同學可能已經看出來,這其實是一道揹包問題。那麼,這道題可以用揹包問題的通用空間優化方法進行優化,把二維的 DP 數組變成一維的。不過考慮到這個空間優化比較難,大家也都不怎麼熟悉揹包問題,這裏我們省去了空間優化的步驟。後面會有專門的揹包問題文章講解相關的技巧。
總結
比較換硬幣系列三道問題,我們發現它們各有不同,總體難度循序漸進,其實非常適合作爲一個系列的練習題。
題目 | 計算目標 | 維度 |
---|---|---|
322. Coin Change | 最小硬幣數量 | 一維 |
377. Combination Sum IV | 方案數 | 一維 |
518. Coin Change 2 | 方案數 | 二維 |
如果三道題中有幾道你還沒有做過,強烈建議你在看完本文後按照這個順序依次做一遍題目,可以加深對這一系列題目的理解。
推薦閱讀
• 吳師兄實名吐槽 LeetCode 上的一道題目。。。• 面試字節跳動時,我竟然遇到了原題……• Leetcode 驚現馬化騰每天刷題 ? 爲啥大佬都這麼努力!• 爲什麼 MySQL 使用 B+ 樹• 一道簡簡單單的字節跳動算法面試題• 新手使用 GitHub 必備的兩個神器• 臥槽!紅警代碼竟然開源了!!!
歡迎關注我的公衆號“五分鐘學算法”,如果喜歡,麻煩點一下“在看”~