vlambda博客
学习文章列表

动态规划之找零钱问题

找零钱是一个经典的动态规划问题。这种问题,我建议,首先学会暴力解法,然后从暴力解法中优化出动态规划的解法,这样,更能体会动态规划的魅力。

问题描述

有n种不同币值的硬币,硬币数量无限。给定一个数量T,求用给定硬币凑出T的方法数量。

举个例子:

假设币值是: {1,2,3}

给定的T值: 5

输出所有的组合数量: 5

为啥是5呢,因为有5种不同的组合可以得到数值5,如下所示:

  1. {1,1,1,1,1}
  2. {1,1,1,2}
  3. {1,2,2}
  4. {1,1,3}
  5. {2,3}

暴力解法

既然这是要找所有的组合数量,暴力搜索就是很自然的想法了。暴力搜索算法写起来有两个关键点:

  • 记下已经搜索到的结果,并且越简单越好,因为空间占用小啊。
  • 记下剩余可用的搜索空间

简单说,就是我已经有了什么,我还可以有什么。

这两点,会因为具体问题不同而有不同的表现形式。

对这道凑币值的问题。我们的暴力搜索问题,可以看作是往一个布袋中放硬币,直到布袋中硬币的币值之和等于要求的数量。

看起来,布袋中的硬币就是我们已经搜索到的结果。剩余可选的硬币和还需要凑的币值就是我们可用的搜索空间。

这个虽然没有问题。但是我们需要一个数组来存储布袋中的硬币,这个空间开销还是不小。而且,这个问题并不要求我们给出具体的组合方案,而是只要所有可能的组合数量。鉴于此,我们可以做一个优化:将已经搜索到的结果用还需要凑的币值来表示显然,这个值可以完美代表我们已有的搜索成果。

将所有可选的硬币币值放入一个数组,那么剩余可选的硬币就可以用一个index来代替。

最后,暴力搜索的终点,无非两种,一种就是找到了一种拼凑方案,一种就是没有。

把这些问题想清楚。写代码时就轻松了。

代码实现

class CoinChange {
'''
@denominations:代表所有可用的币值
@total:代表要拼凑的数值
'''
public int countChange(int[] denominations, int total) {
return this.countChangeRecursive(denominations, total, 0);
}

'''
@total:代表剩余还要拼凑的币值
@currentIndex:代表剩余可用的硬币币值
'''
private int countChangeRecursive(int[] denominations, int total, int currentIndex) {
# 找到一个拼凑方案,返回1,用于累加
if (total == 0)
return 1;
# 到达搜索终点,不是可用的拼凑方案,返回0
if(denominations.length == 0 || currentIndex >= denominations.length)
return 0;

# 递归进行暴力搜索,如果currentIndex处的币值小于total
# 则有两个选择:
# 将该币值放入布袋,即从total中减去该币值,然后继续搜索,
# 此时剩余可用币值不减少
# 不使用该币值,继续搜索,此时剩余可用币值要减去currentInde # x处的币值
int sum1 = 0;
if( denominations[currentIndex] <= total )
sum1 = countChangeRecursive(denominations, total - denominations[currentIndex], currentIndex);

// recursive call after excluding the coin at the currentIndex
int sum2 = countChangeRecursive(denominations, total, currentIndex + 1);

return sum1 + sum2;
}

public static void main(String[] args) {
CoinChange cc = new CoinChange();
int[] denominations = {1, 2, 3};
System.out.println(cc.countChange(denominations, 5));
}
}

从上到下记忆

理解了暴力搜索算法,将里面的重复计算消除,就是动态规划了。观察上面的代码,可以发现,递归调用的两个关键参数 total和currentIndex一旦确定,则该递归调用的结果就是确定的。这两个参数在暴力搜索中显然被会有重复的使用。这就是我们要消除的重复计算。

方法很简单,做个缓存就可以了。因为两个参数,用二维数组自然很合适。

class CoinChange {

public int countChange(int[] denominations, int total)
{
Integer[][] dp = new Integer[denominations.length][total + 1];
return this.countChangeRecursive(dp, denominations, total, 0);
}

private int countChangeRecursive(Integer[][] dp, int[] denominations, int total, int currentIndex)
{

if (total == 0)
return 1;

if(denominations.length == 0 || currentIndex >= denominations.length)
return 0;

# 缓存中有结果,直接返回即可
if(dp[currentIndex][total] != null)
return dp[currentIndex][total];

# 缓存中没有,继续进行递归计算
int sum1 = 0;
if( denominations[currentIndex] <= total )
sum1 = countChangeRecursive(dp, denominations, total - denominations[currentIndex], currentIndex);


int sum2 = countChangeRecursive(dp, denominations, total, currentIndex + 1);

dp[currentIndex][total] = sum1 + sum2;
return dp[currentIndex][total];
}

public static void main(String[] args) {
CoinChange cc = new CoinChange();
int[] denominations = {1, 2, 3};
System.out.println(cc.countChange(denominations, 5));
}
}

自下而上动态规划

有了以上两种方法的铺垫,再来看真正的动态规划。我们就可以理解动态规划是要更彻底的解决问题。在我们从上到下加记忆的方法中,我们使用了缓存,来存储中间结果。动态规划的思路,其实就是将缓存作为关键,将问题的求解转化为填充缓存的过程。

比如,我们现在要填充缓存dp[currentIndex][t]。这个缓存代表的问题,有两类可能的组合方案:

  • 没有currentIndex币值的方案,这样的方案的数量就是dp[currentIndex-1][t]

  • 至少包含一个currentIndex币值的方案,这样的方案的数量就是dp[currentIndex][t-denominations[currentIndex]]

将这两种方案的数量加起来就是dp[currentIndex][t]的值了。

有了上面的分析,代码就是手到擒来。

def count_change(denominations, total):
n = len(denominations)
dp = [[0 for _ in range(total+1)] for _ in range(n)]

# 填充total为0时数值,始终有1个方案
for i in range(n):
dp[i][0] = 1

# 双重循环填充缓存
for i in range(n):
# total=0的情况已经专门填充,这里从1开始
for t in range(1, total+1):
if i > 0:
dp[i][t] = dp[i - 1][t]
if t >= denominations[i]:
dp[i][t] += dp[i][t - denominations[i]]

# 右下角就是最终结果
return dp[n - 1][total]


def main():
print(count_change([1, 2, 3], 5))


main()

总结

动态规划是面试中的常考技能点。我推荐的学习路径就是这种,先学会暴力算法,然后利用缓存,去掉重复计算,然后学习以缓存填充为核心的动态规划。