經典動態規劃:「換硬幣」系列三道問題詳解

點擊上方“五分鐘學算法”,選擇“星標”公衆號

重磅乾貨,第一時間送達

轉自面向大象編程

換硬幣(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 數組中的無效元素。

至此,我們離寫出題解代碼已經很接近了,但還要處理一個編程上的細節: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 數組中的依賴關係

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 數組的特殊值

這張圖中特意把 DP 數組畫成一個很扁的長方形,是因爲一般情況 要比   大很多。 表示要湊出的金額 amount,而 表示硬幣的種類。

子問題的依賴關係在 DP 數組中是這樣子的:

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 必備的兩個神器•   臥槽!紅警代碼竟然開源了!!!


歡迎關注我的公衆號“五分鐘學算法”,如果喜歡,麻煩點一下“在看”~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章