动态规划实战--硬币找零问题

上一篇文章上提到硬币找零的例子,现在我们实战动态规划就从硬币找零开始

问题描述:

给定 n 种不同面值的硬币,分别记为 c[0], c[1], c[2], … c[n],同时还有一个总金额 k,编写一个函数计算出最少需要几枚硬币凑出这个金额 k?每种硬币的个数不限,且如果没有任何一种硬币组合能组成总金额时,返回 -1。

这里我们先回忆一下动态规划问题的处理过程:

我们处理动态规划问题的时候需要分为这么几步:

1)确定初始化状态,初始化状态作为整个求解链路的原点,需要优先明确;

2)状态参数,中间状态在一步一步推导出最终状态的过程中会发生变化的变量;

3)明确决策方式,即:如何通过前面的状态推导出后面的状态;

4)中间状态存储,子问题存在大量重复计算的情况,我们将中间状态存储入“备忘录”。

确定初始化状态和状态参数

原文中明确指定出了硬币的面值(多个固定值),没有限制硬币的总个数(这是我们需要求解的结果),因而这两个无法作为我们的状态参数,那么状态参数只剩下的总金额k了。

显而易见,我们可以得出结论初始化状态是总金额为0的状态

决策方式

原文要求的是使用硬币凑出金额k,那么在我们每挑出一个硬币的时候就会改变状态,这就是这道题目的决策方式

中间状态的存储

当我们需要求解k=11的时候,k=11-c[n] 会被计算,以此类推,因而我们需要有一个中间状态的决策表存储子问题的解.

状态转移方程

现在我们可以尝试得出状态转移方程

		 -1 (k<0) 异常情况也考虑下

f(k)= 0 (k=0)
min(1+f(k-c[i])) 0<i<n

到这里,感觉我们几乎可以直接写出代码了,别急,如果只是盲目的根据状态转移方程编写程序,那程序中依然存在着许多重复计算的子问题

可能从第一眼我们看这就是一个递归逻辑,子问题的答案是在不断向上返回,为什么还存在子问题的重复计算,其实我们可以展开来思考下这个程序,画个图方便理解重复计算出现在哪里

假定硬币面值是1,3,5 ,目标总金额是11

在这里插入图片描述

如图所示:由于递归是自顶向下的一个求解过程,F(7),F(5)等等在不同的分支中都会被重复计算,有一个解决方法就是将子问题缓存到中间表中,这样我们每遇到一个子问题,就到中间表中去尝试获取已知解,代码如下所示:


    public int collect(int[] coins, int target) {
        Map<Integer, Integer> cache = Maps.newHashMap();

        int result = recursionCollect(coins, target, cache);
        return result==Integer.MAX_VALUE?-1:result;
    }

    private int recursionCollect(int[] coins, int target, Map<Integer, Integer> cache) {
        if(target<0){
            return Integer.MAX_VALUE;
        }
        if(target == 0) {
            return 0;
        }
        if(cache.getOrDefault(target, 0)!=0) {
            return cache.getOrDefault(target, 0);
        }
        int result = Integer.MAX_VALUE;
        for(int coin: coins) {
            int currentValue = recursionCollect(coins, target-coin, cache);
            if(currentValue<result) {
                result = currentValue+1;
            }
        }
        cache.put(target, result);
        return result;
    }

如代码所示,创建了中间缓存map,将子问题的解缓存到map中,这样保证当我们需要即将做重复计算的时候,通过缓存判断可以立即得到值。

那么,还能换一个方式吗?

在回答这个问题前,可以先考虑一下为什么要换一个方式?

前文说过,递归本身是一个自顶而下的一个过程(如求解的树结构图所示),虽然在上面的代码中,我们加了缓存,但是实际上缓存key对应的那个分支依然还是走了,只是这个分支的子分支被我们避开了。

现在只是3种硬币,如果种类更多那么分支的情况就更多,那我们可以预测到,还会有更多直接命中缓存的分支会出现。

作为一个爱钻牛角尖的程序员,我们可以考虑一下,是不是可以采用其他方式来规避这个问题。

我们要规避这种直接命中缓存的分支,其实就是希望不要每次都去判断这个子问题是否已经解决。

不希望判断,那就最好能提前确保子问题已经解决了,并且能快速的拿到子问题的解。

如何保证子问题一定提前解决了,那就是改变方向,由自顶向下改为自底向上

自底向上就是先从子问题开始,逐步向上求解更大的问题,这就可以考虑使用迭代(从最小值到目标值)取代递归。

基于这个思路,我们可以将代码做一下调整


    public int collectNew(int[] coins, int target) {
        int[] dp = new int[target+1];
        Arrays.fill(dp, Integer.MAX_VALUE);
        dp[0]=0;
        for(int sum = 1; sum <= target; sum++) {
            for(int coin:coins) {
                if(sum<coin) {
                    continue;
                }
                dp[sum] = Math.min(dp[sum], dp[sum-coin]+1);
            }
        }
        return dp[target]==Integer.MAX_VALUE?-1:dp[target];
    }

我们来简单分析一下,第二段代码与第一段代码之间对比,提高在了哪里:

首先每一次循环都包含一个小循环,这个小循环会遍历所有的面值,这一点跟前面的迭代一致,

小循环内先看当前面额总值是否小于当前硬币面额。如果是,说明组合不存在,直接进入下一轮循环(这一步在递归中也能够做到)

当到选取硬币的地方,这里没有任何判断,直接比较最小值,这一步比递归那边少去了一层调用,因为我们是从最小值开始逐步向上求解,因而可以确信子问题在当前问题之前肯定已经得出了解

到这里,我们可以算是使用动态规划完美地解决了硬币找零问题。

我们再回顾一下解题思路

  1. 找到初始化状态,初始化状态其实就是迭代的起点,或者递归的终点。

  2. 明确问题的状态参数,这个状态参数其实就是在子问题与原问题之间变化的变量。

  3. 找到决策方式,状态参数在子问题与原问题之间变化的方式。

  4. 结合一下缓存表,解决子问题的重复计算。

基本上做到这些,状态转移方程我们就可以顺利地写出来了。

关于硬币找零问题就分享到这里,后续动态规划实战,会接着从背包问题开始逐步切入,有兴趣的小兄弟可以持续关注下

如果觉得本文有帮助可以分享给自己的小伙伴们!邀请他们关注下博主的微信公众号

小哥爱code

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值