「LeetCode 322」零钱兑换

硬币兑换

来源LeetCode, 题目地址<https://leetcode-cn.com/problems/coin-change/>

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

暴力递归(回溯)

每一种硬币都有选择和不选两种情况,因此最简单的方法就是穷举所有可能性,从中找到最小的选择,递归树如下:

状态树

每次节点的子节点都会出现3个分支,因此时间复杂度是指数级。

尽管明知这个代码是不能通过LeetCode,我还是把代码给写完了。

class Solution1 {
public:
    int MIN_COMB = INT_MAX;
    int coinChange(vector<int>& coins, int amount) {
        DFS(0, amount, coins);
        return MIN_COMB;
    }

    void DFS(int depth, int amount, vector<int>& coins){
        if (amount == 0){
            MIN_COMB = min(depth, MIN_COMB);
            return ;
        }
        for (auto coin : coins){
            if (amount - coin < 0 ) continue; //减枝
            DFS(depth+1, amount - coin, coins);
            
        }
        return ;
    }
};

对于一些比较正常的数据,这个代码都能正常运行,但是对于LeetCode中的[186,419,83,408] 6249,标准答案是20,也就是至少有20层,因此计算量至少是3^20,但是递归深度可以达到75层,计算量就是一个天文数字了。

递归的广度优先搜索

递归树其实是一种深度优先的搜索方法,我们可以采用广度优先搜索方法,对递归树按层进行遍历,第一次遇到amount=0的时候,也就是最小的遍历层数。

class Solution2 {
public:
    int coinChange(vector<int>& coins, int amount) {

        queue<pair<int,int>> q; //level and amount
        q.push({0, amount});

        while ( !q.empty()) {

            int level = q.front().first;
            int surplus = q.front().second;
            q.pop();

            for (auto c : coins){
                if ( surplus - c < 0 ) continue;
                if ( surplus - c == 0 ) return level + 1;
                q.push({level+1, surplus-c});
            }
        }
        return -1;


    }
};

尽管看起来, BFS或许比DFS能够更快的找到最终的解(不需要遍历所有的状态),但是由于递归树本身就是指数增长,因此只要层数过大,时间就会惊人的增长。

你可以保持[1,2,5]不变,然后依次测试,10,20,50,80,100. 你会发现前4个解决速度都比较快,但是到了100,时间就上天了。

贪心算法

暴力递归的问题在于,节点的增长是指数级别的。如果我们每次都选择当前的最优解,也就是对于11,从1,2,5中找交换的硬币的时候,都以5,2,1这种顺序进行选择,就避免了指数级别的增长。

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        sort(coins.begin(), coins.end(), std::greater<>());
        return DFS(0, amount, coins);
    }

    int DFS(int level, int amount, vector<int>& coins){
        if (amount <= 0){
            return amount == 0 ? level : -1;
        }
        for (auto coin : coins){
            int res = DFS(level+1, amount - coin, coins);
            if (res > 0 ) return res;
        }
        return -1;
    }
};

但是贪心算法有一个弊端,你得要证明能够从局部最优一直推导出全局最优解,才能保证你的结果是正确的。如果无法保证,那么贪心算法不一定保证最终结果就是最优解,所以这里贪心算法也是无法通过。

递归+记忆化

在我们之前的递归树中,无论采用何种遍历方式(DFS或BFS),都无法逃脱时间指数级别增长的命运。

不过当我们仔细观察递归树的时候,我们会发现一些节点是被重复计算的。如果能避免这些重复计算,那么时间复杂度就会降低到O(n^2)

在原来递归的代码上加上记忆化,我写了很久,主要是不知道在递归的时候,如何记录当前情况下,如何挑选最优子问题。

先放上正确的答案

class Solution {
public:
    unordered_map<int, int> dict;
    int coinChange(vector<int>& coins, int amount) {
        if (amount < 1) return 0;
        return DFS(coins, amount);
    }
    int DFS(vector<int>& coins, int amount){
        if (amount < 0 ) return -1;
        if (amount == 0 ) return 0;
        if (dict.find(amount) != dict.end()) return dict[amount];

        int min = INT_MAX; //足够大的值
        for (int coin : coins){
            int res = DFS(coins, amount-coin);
            if (res >= 0 && res < min){
                min = res + 1;
            }
        }
        dict[amount] = (min == INT_MAX ? -1 : min);
        return dict[amount];
    }
};

递归返回的从最后一层到当前层的所需的步数。而之前的递归程代码则是每次记录当前的深度,最终拿深度和全局最小值进行比较。

下面则是我的错误示范。我的代码主题也是想算出最后位置到当前位置的经历的步数,但是我想用最后一层的深度减去当前的深度。结果每一个amount对应都是1.

// 递归+记忆化
class Solution4 {
public:
    unordered_map<int, int> dict;
    int coinChange(vector<int>& coins, int amount) {
    
        return DFS(0, amount, coins);
    }

    int DFS(int depth, int amount, vector<int>& coins){
        if (amount == 0){
            return depth;
        }

        if (dict.find(amount) != dict.end() ) {
            return dict[amount];
        }

        int local_min = INT_MAX;

        for (auto coin : coins){
            if (amount - coin < 0 ) continue; 
            local_min = min(local_min, DFS(depth+1, amount - coin, coins) -depth);
            
        }
        dict[amount] = (local_min == INT_MAX ? -1 :   local_min);
        return dict[amount];
    }
};

自底向上的动态规划

事实上递归加记忆化就是一种动态规划,只不过递归是一种自顶向下的策略。并且有了之前递归加记忆化的经验,我们写递推就变得简单了。

第一步: 定义子问题。我们求解组成金额为n的最少硬币数,也就是求解一系列 n-k (k=coins) 子问题中的最小选择加1。以amount=11,coins=1,2,5为例。求解amount=11的最少硬币,也就是在10,9,6这三种选择中挑选其中所需硬币最小的子问题,然后加1.

第二步:定义DP数组. f(n) = min{f(n-k), k = 1,2,5} + 1

第三步: 定义DP方程: dp[i] = min(dp[i], dp[i-coins[j] ] )+ 1

最终代码如下

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        int MAX = amount + 1;// 只要保证比amount大即可, 因为后续要和当前最小的选择比较。
        vector<int> dp(amount+1, MAX); //DP数组
        dp[0] = 0;
        for (int i = 1; i <= amount; i++){
            for (int j = 0; j < coins.size(); j++){
                if (coins[j] <= i){ //举例, i = 2, 只能考虑coin=1,2, 排除5
                    dp[i] = min(dp[i], dp[i-coins[j]] + 1); 
                }
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }
};

代码中的细节,

  • 初始化的数组大小为amount+1, 不能是amount, 否则只能表示0到amount-1
  • 初始化的数组存放的值,要比最坏情况下的最少硬币数大,例如amount=10, 硬币只有11,那么数组内容就会是原本情况,最终的结果是比amount大,说明无解。因此,MAX = INT_MAX-1 也是可以的。

最终来看,自底向上的动态递归的代码反而是最简洁的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容

  • 322. 零钱兑换 给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所...
    傅晨明阅读 263评论 0 0
  • 题目描述 零钱兑换 给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所...
    一只可爱的柠檬树阅读 525评论 0 0
  • 给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。...
    桐桑入梦阅读 48评论 0 0
  • 题目描述: 思路:参考自《程序员代码面试指南》--左程云著 这是一道经典的动态规划方法,我们可以构造一个dp数组,...
    小北觅阅读 1,360评论 0 1
  • 题目描述 给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的...
    1只特立独行的猪阅读 45评论 0 0