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

2021-02-20 五分鐘學算法

重磅乾貨,第一時間送達

轉自面向大象編程

換硬幣(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 數組計算順序的重點是看子問題的依賴關係。我們以

DP 數組中的依賴關係

既然 DP 數組中的依賴關係都是向右指的,那麼 DP 數組的計算順序就是從左到右。

處理 DP 數組中的無效元素。

至此,我們離寫出題解代碼已經很接近了,但還要處理一個編程上的細節:DP 數組中的無效元素。

可能存在一些金額是硬幣湊不出來的。例如只有 2 元、5 元的硬幣時,就湊不出 1 元、3 元的金額。這樣

為了計算方便,我們可以把無效的子問題用

在編程表示中,我們發現 DP 數組中的值最大也只能是 amount(只有 1 元硬幣的情況,硬幣數量等於金額數),我們可以用 amount + 1 表示

這樣,我們就可以寫出最終的題解代碼:

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,計算湊出該金額的方案的個數。順序不同的序列視作不同的方案。

這道題和上一題的不同在於,不是求「最小的硬幣數量」,而是求「方案的個數」。這樣問題的難度又上了一個臺階。

求「方案數」的難度在於要做到不重複、不遺漏。如果是求「最小值」,其實子問題之間可以有重複,也能求出正確的最小值;但是求「方案數」時,子問題之間的重複會導致方案數不正確。這一點一定要特別注意。

第一步,定義子問題。

我們還是設硬幣面值的集合為

第二步,寫出子問題的遞推關係。

我們可以把湊硬幣的方案看成硬幣的排列。對於

把這個公式推廣到一般的硬幣面值集合

同樣的,不要忘記子問題的 base case:

它的含義是「湊出金額 0 的方案數為 1」。這是求「方案數」時一個常見的技巧。所有的

第三步,確定 DP 數組的計算順序。

這道題的 DP 數組及其計算順序和上一題是一樣的,這裡不再贅述。

DP 數組中的依賴關係

DP 數組中不存在什麼無效元素。湊不出的金額我們可以用

這樣我們就可以寫出題解代碼了。這一題的主要難度在於遞推關係的細節,最終寫出的題解代碼還是挺簡單的:

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」這樣的硬幣序列。

如何限制硬幣選擇的次序呢?答案是動態規劃增加一個維度,用參數

第一步,定義子問題。

設硬幣面值的集合為

在一開始,

第二步,寫出子問題的遞推關係。

子問題的遞推關係是這樣的:

這個遞推關係是怎麼來的呢?考慮子問題

第一種選擇:拿一個面額最大的硬幣

第二種選擇:決定不再拿面額最大的硬幣

你可以會問,為什麼沒有

這樣,原問題就是

另外別忘了子問題的 base case。這道題的 base case 同樣比較複雜:

第三步,確定 DP 數組的計算順序。

我們發現,這一題增加了一個維度,變成了二維動態規劃問題。這樣我們就更需要確定 DP 數組的計算順序了。接下來為了討論方便,我們設硬幣的種類為

首先,DP 數組的有效範圍是

DP 數組的特殊值

這張圖中特意把 DP 數組畫成一個很扁的長方形,是因為一般情況

子問題的依賴關係在 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 必備的兩個神器•   臥槽!紅警代碼竟然開源了!!!

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

相關焦點

  • 經典動態規劃:打家劫舍系列問題
    有好幾位讀者私下問我 LeetCode 「打家劫舍」系列問題(英文版叫 House Robber)怎麼做,我發現這一系列題目的點讚非常之高,是比較有代表性和技巧性的動態規劃題目,今天就來聊聊這道題目。打家劫舍系列總共有三道,難度設計非常合理,層層遞進。
  • 詳解最長公共子序列問題,秒殺三道動態規劃題目
    動態規劃系列問題也是一樣,尤其是子序列相關的問題。本文從「最長公共子序列問題」展開,總結三道子序列問題,解這道題仔細講講這種子序列問題的套路,你就能感受到這種思維方式了。最長公共子序列計算最長公共子序列(Longest Common Subsequence,簡稱 LCS)是一道經典的動態規劃題目,大家應該都見過:給你輸入兩個字符串s1和s2,請你找出他們倆的最長公共子序列,返回這個子序列的長度。
  • LeetCode 例題精講 | 14 打家劫舍問題:動態規劃的解題四步驟
    House Robber 問題(翻譯為小偷問題、打家劫舍問題)是一道非常經典的動態規劃入門題目。如果你對於動態規劃還不是很了解,或者沒怎麼做過動態規劃的題目的話,那麼我非常建議你用 House Robber 這道題來入門。
  • 計算機解決問題沒有奇技淫巧,但動態規劃還是有點套路
    而且,當你去看用動態規劃解決某個問題的代碼時,你會覺得這樣解決問題竟然如此巧妙,但卻難以理解,你可能驚訝於人家是怎麼想到這種解法的。實際上,動態規劃是一種常見的「算法設計技巧」,並沒有什麼高深莫測,至於各種高大上的術語,那是嚇唬別人用的,只要你親自體驗幾把,這些名詞的含義其實顯而易見,再簡單不過了。
  • 打家劫舍問題:動態規劃的解題步驟
    而這些歷史記錄,我們得需要一些變量來保存,一般是用一維數組或者二維數組來保存,綜上所述的動態規劃問題其實可以簡化成為三個步驟,用於解決大部分的動態規劃問題。本期例題:LeetCode 198. House Robber 打家劫舍(Easy)你是一個專業的小偷,計劃偷竊沿街的房屋。
  • 「算法與數據結構」一張腦圖帶你看動態划算法之美
    如果你還不了解,或者知道動態規劃,但是還沒有開始刷題的話,可能這篇文章適合你。公眾號「前端UpUp」,回復dp,即可獲取腦圖。聯繫👉TianTianUp,遇到問題的話,可以聯繫作者噢,願意陪你一起學習一起探討問題。腦圖👇什麼是動態規划動態規劃(Dynamic Programming),因此常用 DP 指代動態規劃。
  • javascript經典算法之最小硬幣找零問題實戰
    正文筆者抽空總結了幾個比較經典且實用的算法, 最少硬幣找零問題 是本文介紹的第一道算法題:問題:給出要找零的錢數amount以及可用的硬幣面額c1, c2, c3, ..., 求所需的最少硬幣個數。思考這道問題可以有很多不同的思路, 筆者主要採用兩種方法來解決這個問題: 1. 動態規劃法 2. 貪心算法接下來筆者具體介紹這兩種算法的思路和實現代碼.1.
  • 豁然開朗經典算法之「動態規劃」
    《算法導論》是這樣解釋動態規劃的:動態規劃與分治法相似,都是通過組合子問題的解來求解原問題答案,將問題劃分為互不相交的子問題,遞歸的求解子問題,最後合併子問題的答案,得到原問題的答案。翻譯成人話就是:計算並存儲小問題的解,並將這些解組合成大問題的解。
  • 拒絕遺忘:高效的動態規划算法
    這句話放在問題求解過程中也同樣適用。不懂動態規劃的人會在解決過的問題上再次浪費時間,懂的人則會事半功倍。那麼什麼是動態規劃?這種算法有何神奇之處?本文作者給出了初步的解答。假設你正在使用適當的輸入數據進行一些計算。你在每個實例中都進行了一些計算,以便得到一些結果。當你提供相同的輸入時,你不知道會有相同的輸出。這就像你在重新計算之前已經計算好的特定結果一樣。那麼問題出在哪裡呢?
  • 關於動態規劃,你該了解這些!
    2021年的第一個工作日,我們正式開啟動態規劃系列,老錄友們都知道「代碼隨想錄」的傳統,新系列開始,一定是先講理論基礎! 剛來的錄友可能會著急想刷題,別急哈,耐心把基礎篇看完,你一定會有所收穫!什麼是動態規划動態規劃,英文:Dynamic Programming,簡稱DP,如果某一問題有很多重疊子問題,使用動態規劃是最有效的。
  • 深入理解動態規划算法 | 最長公共子序列LCS
    通過歡迎點擊「算法與編程之美」↑關注我們!
  • 經典算法研究系列 之 動態規划算法
    數經典算法研究系列 之基本概念動態規划過程是:每次決策依賴於當前狀態,又隨即引起狀態的轉移。一個決策序列就是在變化的狀態中產生出來的,所以,這種多階段最優化決策解決問題的過程就稱為動態規劃。由於動態規劃解決的問題多數有重疊子問題這個特點,為減少重複計算,對每一個子問題只解一次,將其不同階段的不同狀態保存在一個二維數組中。與分治法最大的差別是:適合於用動態規劃法求解的問題,經分解後得到的子問題往往不是互相獨立的(即下一個子階段的求解是建立在上一個子階段的解的基礎上,進行進一步的求解)。
  • LeetCode 例題精講 | 16 最大子數組和:子數組類問題的動態規劃技巧
    接下來的文章中,我會講解動態規劃問題中針對不同類型問題的小技巧。今天要講的是關於「子數組」類題目的常見技巧。在講具體問題之前,我們要明確一下「子數組」和「子序列」的概念。子序列 (subsequence) 可以是不連續的。
  • LeetCode 例題精講 | 15 最長公共子序列:二維動態規劃的解法
    在上一篇文章中,我們以打家劫舍(House Robber)問題為例講解了動態規劃問題的一般解題步驟。不過,打家劫舍問題是一維動態規劃問題,而還有很多題目屬於二維動態規劃。今天我們就以一道經典的「最長公共子序列」問題講解二維動態規劃的解法。
  • 動態規劃之 KMP 算法詳解
    為什麼我認為 KMP 算法就是個動態規劃問題呢,等會有解釋。對於動態規劃,之前多次強調了要明確dp數組的含義,而且同一個問題可能有不止一種定義dp數組含義的方法,不同的定義會有不同的解法。讀者見過的 KMP 算法應該是,一波詭異的操作處理pat後形成一個一維的數組next,然後根據這個數組經過又一波複雜操作去匹配txt。時間複雜度 O(N),空間複雜度 O(M)。
  • 如何掌握動態規划算法的套路?
    實際上,它並不是不是一種確定的算法,它是一種最優化的方法求解問題的思想或方法。它是由美國數學家貝爾曼(Bellman)在研究多階段決策過程的優化問題時提出。不過,與之對應的還有一些與時間無關的靜態規劃,如:線性規劃、非線性規劃等。在運籌學中,動態規劃是的非常重要的內容,在各個行業領域都有著廣泛的應用。我們如何理解動態規劃?
  • 「求職系列三」面試攻略之常見面試問題及回答技巧
    承接前兩個系列「求職系列一」漫漫求職路,你準備好了嗎?「求職系列二」面試攻略之自我介紹今天來講一講面試環節中面試官習慣會問的問題,問題背後的意圖,以及回答技巧。你的職業規劃這個問題要了解你的進取心,個人天花板,判斷力等,回答時可以分兩部分短期規劃,時間在5年左右比較合適。比如繼續在xx行業的xx崗位探索,在xx方面更加精進。
  • 你也懷疑過那個「硬幣實驗」是套路?我們研究了一下
    7 月30 日,在廣州燕塘地鐵站出口突然出現了一籃子硬幣,還有豎著的牌子,上面寫著:「如果你急需用錢,請自取,每人最多5 元。」這個籃子就放在路邊上,無人人值守,人人可以拿硬幣。你覺得會發生什麼? 3 分鐘的社會實驗視頻中,從小學生到中年大叔,鏡頭中的每個人都遵守了規則,甚至還有人反過來投幣。
  • 東京奧運會「可能」會推遲,但日本獨特美學「不可能」遲到
    這 73 個動態圖標,凝結了 33 個奧林匹克運動項目和 22 個帕運會運動項目,從空手道到衝浪,從排球到滑板,它們個個「動起來」,以個體動態形式從白色背景中躍現,完成運動後又以個體形式消失在白色背景中,回到最初的空白定格中,讓我們失神凝視好久。
  • 一套代碼解決 Combination Sum 系列問題(LeetCode 39/40/216)
    Combination Sum IV(Medium)我們的系列文章已經有三期回溯法的內容了。前面的兩篇文章中,我們詳細講解了回溯法的子集問題、排列問題、組合問題這些經典問題,講解了回溯法的候選集合概念以及剪枝方法:回溯法的基本方法講解就到此為止了。