上一篇文章上提到硬币找零的例子,现在我们实战动态规划就从硬币找零开始
问题描述:
给定 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];
}
我们来简单分析一下,第二段代码与第一段代码之间对比,提高在了哪里:
首先每一次循环都包含一个小循环,这个小循环会遍历所有的面值,这一点跟前面的迭代一致,
小循环内先看当前面额总值是否小于当前硬币面额。如果是,说明组合不存在,直接进入下一轮循环(这一步在递归中也能够做到)
当到选取硬币的地方,这里没有任何判断,直接比较最小值,这一步比递归那边少去了一层调用,因为我们是从最小值开始逐步向上求解,因而可以确信子问题在当前问题之前肯定已经得出了解
到这里,我们可以算是使用动态规划完美地解决了硬币找零问题。
我们再回顾一下解题思路
-
找到初始化状态,初始化状态其实就是迭代的起点,或者递归的终点。
-
明确问题的状态参数,这个状态参数其实就是在子问题与原问题之间变化的变量。
-
找到决策方式,状态参数在子问题与原问题之间变化的方式。
-
结合一下缓存表,解决子问题的重复计算。
基本上做到这些,状态转移方程我们就可以顺利地写出来了。
关于硬币找零问题就分享到这里,后续动态规划实战,会接着从背包问题开始逐步切入,有兴趣的小兄弟可以持续关注下
如果觉得本文有帮助可以分享给自己的小伙伴们!邀请他们关注下博主的微信公众号
小哥爱code