阶乘和斐波那契数列相信各位都很熟悉,典型的教材式递归例子,因为简单退化为循环也没啥难度。不过依赖于递归的问题并不都是那么简单的,比如最少硬币问题,需要称为动态规划(DP)的方式来实现。
所谓动态规划个人理解就是缓存一下递归的结果。比如斐波那契数列,简单的实现方式是:
public int fib(int n) { if(n == 0 || n == 1) return 1; return fib(n - 2) + fib(n - 1); }
假如计算fib(5),事实上计算了一次fib(4),两次fib(3),三次fib(2),四次fib(1),两次fib(0),换句话说,重复计算了(1 – 1) + (2 – 1) + (3 – 1) + (4 – 1) + (2 – 1)有7次之多。理想情况下每个fib(n)只要计算一次。改进方法很简单,增加cache,其实就是简单的映射数组:
public int fib(int n) { if(fib_cache[n] > 0) return fib_cache[n]; // fib_cache = [1, 1, 0 ... 0] return (fib_cache[n] = fib(n - 2) + fib(n - 1)); }
这里用0代表fib值未知。
经过上述改造后,一般递归的深度优先逻辑会“缓存”前一次计算的值,大大降低了深层次的计算量。
现在考虑另外一个问题:最少硬币问题。假设你有1,5,10,25,50五种硬币,现在要你支付63,最少需要多少硬币?
问题看似很简单,不断用最大可能的硬币组合即可,63 = (50 + 10 + 1 x 3),即5个硬币。
现在假设你有1, 5, 10, 21, 50这五种硬币,仍旧让你支付63,最少需要多少硬币?如果仍旧按照原有方法计算,答案是5个,但实际上最佳的组合是3个21硬币。更明确点讲,原有方法其实是贪心算法,但是某些时候不会得到最佳的解。
因为问题可以分解为让你支付62和1,分别需要多少硬币的两个问题,这些问题还可以继续分成61 + 1或者52 + 10等等。你可以想想一下,计算63的最少硬币数,实际要尝试1-62所有的最少硬币数,或者说这些都是63的子问题。这和斐波那契数列是不是很像?斐波那契数列的公式是:
fib(n) = 0 if n = 0 or n = 1 fib(n - 2) + fib(n - 1)
最少硬币问题的公式可以归纳为:
min_coin(n) = 0 if n = 0 min_coin(n - 1) + 1 if n >= 1 (coin 1) min_coin(n - 5) + 1 if n >= 5 (coin 5) min_coin(n - 10) + 1 if n >= 10 (coin 10) min_coin(n - 21) + 1 if n >= 21 (coin 21) min_coin(n - 50) + 1 if n >= 50 (coin 50)
下面的5个公式,可以合并为min_coin(n – c) + 1 if n >= c and c in coins
有了公式,我们就可以开始计算了。代码如下:
private static final int UNAVAILABLE = -1; private static final int UNKNOWN = -2; private int[] minCoinCache; public int minCoin(int[] coins, int amount) { minCoinCache = new int[amount + 1]; Arrays.fill(minCoinCache, UNKNOWN); minCoinCache[0] = 0; return doMinCoin(coins, amount); } private int doMinCoin(int[] coins, int amount) { if (minCoinCache[amount] != UNKNOWN) return minCoinCache[amount]; int min = UNAVAILABLE; for (int c : coins) { if (amount == c) { min = 1; } else if (amount > c) { int _min = doMinCoin(coins, amount - c); if (_min == UNAVAILABLE) continue; _min += 1; // add current coin if (min == UNAVAILABLE || _min < min) min = _min; } } minCoinCache[amount] = min; return min; } public static void main(String[] args) { MinCoinDP dp = new MinCoinDP(); System.out.println(dp.minCoin(new int[] {50, 21, 10, 5, 1}, 63)); System.out.println(Arrays.toString(dp.minCoinCache)); }
看起来似乎很简单,但是UNKNOWN和UNAVAILABLE是什么?UNKNOWN和之前斐波那契数列的0很像,就是表示未确定值的意思。而UNAVAILABLE是无法用给定的硬币算出需要的amount,比如你无法使用加法和硬币[10, 20]算出35来。
公开方法minCoin负责初始化动态规划(DP)的缓存和设置amount为0时最少硬币数为0。私有方法是实际的递归逻辑。初始认为无法算出指定的amount。然后遍历可用的coin,如果当前coin == amount,根据经验,代表最少硬币数就是1,否则在amount大于当前硬币的时候,计算amount减去当前硬币的最少硬币数,假设这个差值无法得到可用的最少硬币组合,那么就跳过这个硬币,否则在最少硬币未确定或者当前方案总硬币数(加上当前硬币的个数1)小于其他方案时设置最少硬币数,最后是回写最少硬币数缓存。
整体来说,动态规划并不是很难,特别是在你找到公式之后。不过实现上的一些细节问题还是要到实现时才能了解。