[LeetCode] 322. Coin Change 硬幣找零


 

You are given coins of different denominations and a total amount of money amount. Write a function to compute the fewest number of coins that you need to make up that amount. If that amount of money cannot be made up by any combination of the coins, return -1.

Example 1:
coins = [1, 2, 5], amount = 11
return 3 (11 = 5 + 5 + 1)

Example 2:
coins = [2], amount = 3
return -1.

Note:
You may assume that you have an infinite number of each kind of coin.

Credits:
Special thanks to @jianchao.li.fighter for adding this problem and creating all test cases.

 

這道題給我們了一些可用的硬幣值,又給了一個錢數,問我們最小能用幾個硬幣來找零。根據題目中的例子可知,不是每次都會給全 1,2,5 的硬幣,有時候沒有1分硬幣,那么有的錢數就沒法找零,需要返回 -1。這道題跟 CareerCup 上的那道 9.8 Represent N Cents 美分的組成 有些類似,那道題給全了所有的美分, 25,10,5,1,然后給我們一個錢數,問所有能夠找零的方法,而這道題只讓求出最小的那種。沒啥特別好的思路就首先來考慮 brute force 吧,暴力搜索如果也沒思路腫么辦 -.-|||。還是來看例子1,如果不考慮代碼實現,你怎么手動找出答案。博主會先取出一個最大的數字5,比目標值 11 要小,由於這里的硬幣是可以重復使用的,所以博主會再取個5出來,現在是 10,還是比 11 要小,這是再取5會超,那就往前取,取2,也會超出,於是就取1,剛好是 11。那么我們的暴力搜索法也是這種思路,首先要給數組排個序,因為想要從最大的開始取,遞歸函數需要一個變 量start,初始化為數組的最后一個位置,當前目標值 target,還有當前使用的硬幣個數 cur,以及最終結 果res。在遞歸函數,首先判斷如果 target 小於0了,直接返回。若 target 為0了,說明當前使用的硬幣已經組成了目標值,用 cur 來更新結果 res。否則就從 start 開始往前遍歷硬幣,對每個硬幣都調用遞歸函數,此時 target 應該減去當前的硬幣值,cur 應該自增1,代碼參見評論區七樓。但是暴力搜索 Brute Force 的方法會超時 TLE,所以我們考慮一下其他的方法吧。

如果大家刷題有一陣子了的,那么應該會知道,對於求極值問題,主要考慮動態規划 Dynamic Programming 來做,好處是保留了一些中間狀態的計算值,可以避免大量的重復計算。我們維護一個一維動態數組 dp,其中 dp[i] 表示錢數為i時的最小硬幣數的找零,注意由於數組是從0開始的,所以要多申請一位,數組大小為 amount+1,這樣最終結果就可以保存在 dp[amount] 中了。初始化 dp[0] = 0,因為目標值若為0時,就不需要硬幣了。其他值可以初始化是 amount+1,為啥呢?因為最小的硬幣是1,所以 amount 最多需要 amount 個硬幣,amount+1 也就相當於當前的最大值了,注意這里不能用整型最大值來初始化,因為在后面的狀態轉移方程有加1的操作,有可能會溢出,除非你先減個1,這樣還不如直接用 amount+1 舒服呢。好,接下來就是要找狀態轉移方程了,沒思路?不要緊!回歸例子1,假設我取了一個值為5的硬幣,那么由於目標值是 11,所以是不是假如我們知道 dp[6],那么就知道了組成 11 的 dp 值了?所以更新 dp[i] 的方法就是遍歷每個硬幣,如果遍歷到的硬幣值小於i值(比如不能用值為5的硬幣去更新 dp[3])時,用 dp[i - coins[j]] + 1 來更新 dp[i],所以狀態轉移方程為:

dp[i] = min(dp[i], dp[i - coins[j]] + 1);

其中 coins[j] 為第j個硬幣,而 i - coins[j] 為錢數i減去其中一個硬幣的值,剩余的錢數在 dp 數組中找到值,然后加1和當前 dp 數組中的值做比較,取較小的那個更新 dp 數組。先來看迭代的寫法如下所示:

 

解法一:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> dp(amount + 1, amount + 1);
        dp[0] = 0;
        for (int i = 1; i <= amount; ++i) {
            for (int j = 0; j < coins.size(); ++j) {
                if (coins[j] <= i) {
                    dp[i] = min(dp[i], dp[i - coins[j]] + 1);
                }
            }
        }
        return (dp[amount] > amount) ? -1 : dp[amount];
    }
};

 

迭代的 DP 解法有一個好基友,就是遞歸+記憶數組的解法,說其是遞歸形式的 DP 解法也沒錯,但博主比較喜歡說成是遞歸加記憶數組。其目的都是為了保存中間計算結果,避免大量的重復計算,從而提高運算效率,思路都一樣,僅僅是寫法有些區別:

 

解法二:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        vector<int> memo(amount + 1, INT_MAX);
        memo[0] = 0;
        return coinChangeDFS(coins, amount, memo);
    }
    int coinChangeDFS(vector<int>& coins, int target, vector<int>& memo) {
        if (target < 0) return - 1;
        if (memo[target] != INT_MAX) return memo[target];
        for (int i = 0; i < coins.size(); ++i) {
            int tmp = coinChangeDFS(coins, target - coins[i], memo);
            if (tmp >= 0) memo[target] = min(memo[target], tmp + 1);
        }
        return memo[target] = (memo[target] == INT_MAX) ? -1 : memo[target];
    }
};

 

再來看一種使用 HashMap 來當記憶數組的遞歸解法:

 

解法三:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        unordered_map<int, int> memo;
        memo[0] = 0;
        return coinChangeDFS(coins, amount, memo);
    }
    int coinChangeDFS(vector<int>& coins, int target, unordered_map<int, int>& memo) {
        if (target < 0) return - 1;
        if (memo.count(target)) return memo[target];
        int cur = INT_MAX;
        for (int i = 0; i < coins.size(); ++i) {
            int tmp = coinChangeDFS(coins, target - coins[i], memo);
            if (tmp >= 0) cur = min(cur, tmp + 1);
        }
        return memo[target] = (cur == INT_MAX) ? -1 : cur;
    }
};

 

難道這題一定要 DP 來做嗎,我們來看網友 hello_world00 提供的一種解法,這其實是對暴力搜索的解法做了很好的優化,不僅不會 TLE,而且擊敗率相當的高!對比 Brute Force 的方法,這里在遞歸函數中做了很好的優化。首先是判斷 start 是否小於0,因為需要從 coin 中取硬幣,不能越界。下面就是優化的核心了,看 target 是否能整除 coins[start],這是相當叼的一步,比如假如目標值是 15,如果當前取出了大小為5的硬幣,這里做除法,可以立馬知道只用大小為5的硬幣就可以組成目標值 target,那么用 cur + target/coins[start] 來更新結果 res。之后的 for 循環也相當叼,不像暴力搜索中的那樣從 start 位置開始往前遍歷 coins 中的硬幣,而是遍歷 target/coins[start] 的次數,由於不能整除,只需要對余數調用遞歸函數,而且要把次數每次減1,並且再次求余數。舉個例子,比如 coins=[1,2,3],amount=11,那么 11 除以3,得3余2,那么i從3開始遍歷,這里有一步非常有用的剪枝操作,沒有這一步,還是會 TLE,而加上了這一步,直接擊敗百分之九十九以上,可以說是天壤之別。那就是判斷若 cur + i >= res - 1 成立,直接 break,不調用遞歸。這里解釋一下,cur + i 自不必說,是當前硬幣個數 cur 加上新加的i個硬幣,這里 cur+i 如果大於等於 res 的話,那么 res 是不會被更新的,那么為啥這里是大於等於 res-1 呢?因為能運行到這一步,說明之前是無法整除的,那么余數一定存在,所以再次調用遞歸函數的 target 不為0,那么如果整除的話,cur 至少會加上1,所以又跟 res 相等了,還是不會使得 res 變得更小。解釋到這里應該比較明白了吧,有疑問的請在下方留言哈,參見代碼如下:

 

解法四:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int res = INT_MAX, n = coins.size();
        sort(coins.begin(), coins.end());
        helper(coins, n - 1, amount, 0, res);
        return (res == INT_MAX) ? -1 : res;
    }
    void helper(vector<int>& coins, int start, int target, int cur, int& res) {
        if (start < 0) return;
        if (target % coins[start] == 0) {
            res = min(res, cur + target / coins[start]);
            return;
        }
        for (int i = target / coins[start]; i >= 0; --i) {
            if (cur + i >= res - 1) break;
            helper(coins, start - 1, target - i * coins[start], cur + i, res);
        }
    }
};

 

Github 同步地址:

https://github.com/grandyang/leetcode/issues/322

 

類似題目:

Coin Change 2

9.8 Represent N Cents 美分的組成

 

參考資料:

https://leetcode.com/problems/coin-change/

https://leetcode.com/problems/coin-change/discuss/77360/C%2B%2B-O(n*amount)-time-O(amount)-space-DP-solution

https://leetcode.com/problems/coin-change/discuss/77368/*Java*-Both-iterative-and-recursive-solutions-with-explanations

 

LeetCode All in One 題目講解匯總(持續更新中...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM