转载文章,原文章来源于算法设计与分析基础系列--动态规划(一)--币值最大化问题(欢迎关注微信公众号,会定期更新内容)
============================================================
前言
本文内容基于书籍"算法设计与分析基础"(Introduction to The Design and Analysis of Algorithms,作者Anany Levitin),主要学习和讨论其中的动态规划算法。之前已经讨论过的币值最大化问题也是基于动态规划算法的,
可参考算法设计与分析基础系列--动态规划(一)--币值最大化问题
"如果问题是由交叠的子问题构成的,我们就可以用动态规划技术来解决它。一般来说,这样的子问题出现在对给定问题求解的递推关系中,这个递推关系中包含了相同类型的更小子问题的解。动态规划法建议,与其对交叠的子问题一次又一次地求解,还不如对较小的子问题只求解一次并把结果记录在表中,这样就可以从表中得到原始问题的解。"
上面对动态规划思想进行了一个比较"理论"的解释和说明,可能初学者会觉得稍微有些晦涩难懂。不过没有关系,随着不断地练习相关的题目,自然而然会对动态规划思想逐步形成更加直观或者"直觉"上的认知和理解,到时候再回来看这里的"理论"描述,就会建立更加深刻的认识。
问题描述--找零问题
需找零金额为n,最少要用多少面额为d[1]<d[2]<d[3]<...<d[m]的硬币?其中d[1]=1,且每种面值的硬币数量无限可得。
解答
这道题是一个较为经典的动态规划思想的入门题目,有时候也会在一些面试题中出现。
在讨论其动态规划解法之前,我们先考虑另一个潜在的解法,贪心算法。对于这道题,我们只对最简单的一种贪心算法进行讨论,即,每次都优先选择当前可选的最大面额硬币,直到凑够目标金额n。
上述贪心算法看上去很符合直觉,因为每次都优先选择最大金额硬币,所以直觉上最终所需消耗的硬币数量是最少的。但是,事实上该方法是错误的。我们先举个例子,比如硬币金额依次为{1,5,11},目标金额为n=15。那么,按照上述贪心算法,我们首先选择一个金额为11的硬币,然后会选择4个金额为1的硬币,此时使用硬币总数为5个。而显然,我们可以使用3个金额为5的硬币,所用数量更少。
上述贪心算法错误的原因就在于,每次选择当前可选的面额最大的硬币时,并未考虑是否会影响后续的最优选择。由于题目要求最终硬币金额之和要恰好等于n,这里"恰好等于"就导致了每次都选择当前面额最大的硬币时,很可能会使得最后要选择更多的"金额为1的硬币"来实现"恰好等于"。
通常来说,有些贪心算法虽然非常符合直觉却是错误的,只需要构造一些用例即可作为反例,而贪心算法的正确性是需要严格证明的,我们会在后续的文章里再进行详细讨论。
我们现在回到动态规划思想来尝试解决这个问题。和之前的币值最大化问题的思考方式类似,我们先来思考一下第一步应该选择哪个硬币才能使得总硬币数最少呢?
假设我们选择了金额为d[i]的硬币,那么我们的目标就从n变成了n-d[i]。即,我们接下来要面临的问题是,"需找零金额为n-d[i],最少要用多少面额为d[1]<d[2]<d[3]<...<d[m]的硬币?其中d[1]=1,且每种面值的硬币数量无限可得。"到了这一步,我们会发现,这不就是和原来目标金额为n时的问题完全类似嘛(可以说是完全一样),只是这一次目标变成了n-d[i](规模减小)。
上述观察完全符合"如果问题是由交叠的子问题构成的,我们就可以用动态规划技术来解决它。一般来说,这样的子问题出现在对给定问题求解的递推关系中,这个递推关系中包含了相同类型的更小子问题的解。动态规划法建议,与其对交叠的子问题一次又一次地求解,还不如对较小的子问题只求解一次并把结果记录在表中,这样就可以从表中得到原始问题的解。"
现在,我们来考虑如何"描述这类问题",即如何定义状态。我们可以将问题描述为"为了获得目标为n的总金额,我们需要使用最少的硬币数量是多少"(这个描述其实和原题几乎没有差别)。我们用数组dp[n]表示,为了获得目标为n的总金额时所需要使用的最少硬币数量。那么,当我们第一步做出"选择d[i]"的时候,我们会发现dp[n] = dp[n - d[i]] + 1。(别忘了,dp[n - d[i]]就是目标金额为n-d[i]时的最少使用数量,而+1是因为我们这次选择了硬币d[i],消耗了一个硬币)。
最后,因为其实我们并不知道第一步的最优选择,所以需要枚举所有可行的d[i]才能知道答案,即如下代码
dp[0] = 0; // 目标为0时不需要使用任何硬币,所以是0个
for (int t = 1; t <= n; t++) { // 从小到大枚举目标金额直到n
dp[t] = m; // 初始化dp[t]=m,因为最多用m个d[1]也就够了
for (int i = 1; i <= m; i++) { // 枚举每个可选的硬币
if(t - d[i] >= 0) { // 所选的硬币不能比当前的目标金额还大
dp[t] = min(dp[t], dp[t - d[i]] + 1); // 更新答案
}
}
}
cout<<dp[n]<<endl; // 输出目标金额为n时的最少使用硬币数量
总结
1,一个问题是否可以使用动态规划算法求解的关键点在于"如果问题是由交叠的子问题构成的,我们就可以用动态规划技术来解决它",这里需要慢慢体会。
2,动态规划问题在状态定义上是有些技巧的,好的定义可以让转移公式变得简洁明了,这个也只能通过不断地练习来获得更加深刻的理解。
欢迎大家多多转载并关注后续更新,如果有其他算法相关的问题也欢迎留言讨论~