动态规划是一种通过“大而化小”的思路解决问题的算法。区别于一些固定形式的算法,如二分法,宽度优先搜索法,动态规划没有实际的步骤来规定第一步做什么第二步做什么。
所以更加确切的说,动态规划是一种解决问题的思想。这种思想的本质是,将一个规模比较大的问题,通过规模比较小的若干问题的结果来得到的。
从另一方面来说,计算机是很笨的,我们最开始都是通过穷举法来解决问题,但随着数据量的增大,特别是信息爆炸的今天,如果一个页面不能很快速地给用户加载出来,那么用户很有可能会选择放弃打开这个页面。而穷举法消耗的算力、时间其实已经不能满足我们的需求了,因此,我们需要更高效的算法来解决问题。
但是穷举法应该算是思路最简单的方法了,可以说是基础中的基础,因此,在学习动态规划前,我们应该好好掌握这一方法。
下面,我们通过硬币找零问题来认识一下贪心算法。
移动支付已经成为了我们日常生活当中的主流支付方式,无论是在便利店购买一瓶水,还是在超市或菜市场购买瓜果蔬菜等生活用品,无处不在的二维码让我们的支付操作变得异常便捷。
但在移动支付成为主流支付方式之前,我们常常需要面对一个简单问题,就是找零的问题。
虽然说硬币找零在日常生活中越来越少,但它仍然活跃在编程领域和面试问题当中,主要还是因为它极具代表性,也能多方面考察一个开发人员或面试者解决问题的能力。下面来看看这个算法问题的具体描述。
给定 n 种不同面值的硬币,分别记为 c[0], c[1], c[2], … c[n],同时还有一个总金额 k,编写一个函数计算出最少需要几枚硬币凑出这个金额 k?
每种硬币的个数不限,且如果没有任何一种硬币组合能组成总金额时,返回 -1。
首先,题目有两个个关键点:
第一个关键点是每种硬币的个数不限,这就意味着我们可以重复使用每一种硬币第二个关键点是最少,如果没有最少这个字眼,那么这道题可能就会有很多答案举个简单的例子,按照示例 1 的题设,有三种不同面值的硬币,分别为 c 0 = 1 c_0=1 c0=1, c 1 = 2 c_1=2 c1=2, c 2 = 5 c_2=5 c2=5,在没有“最少”这一前提条件下你能罗列出几种不同的答案?
我在这里随意列出几个:
解1:输出:3,因为5 + 5 + 2 = 12 解2:输出:5,因为 5 + 2 + 2 + 2 + 1 = 12 解3:输出:6,因为 2 + 2 + 2 + 2 + 2 + 2 = 12 解4:输出:12,因为 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 = 12所以,这是一个求最值的问题。而求最值的核心问题是是穷举,显然,就是把所有可能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币,那么最少的凑法,就是这道题目的答案。
在面试中,一般来说穷举从来都不是一个好方法。除非你要的结果就是所有的不同组合,而不是一个最值。但即便是求所有的不同组合,在计算的过程中也仍然会出现重复计算的问题,这种现象称之为重叠子问题。
换句话说,所谓重叠子问题就是:我们在罗列所有可能答案的过程中,可能存在重复计算的情况。
所谓贪心算法,就是指它的每一步计算作出的都是在当前看起来最好的选择,也就是说它所作出的选择只是在某种意义上的局部最优选择,并不从整体最优考虑。
这两种选择的思路也称作局部最优解和整体最优解。
既然这道题问的是最少需要几枚硬币凑出金额 k,那么是否可以尝试使用贪心的思想来解这个问题呢?从面值最大的硬币开始兑换,最后得出的硬币总数很有可能就是最少的。
我们从 c[0]=5, c[1]=3 且 k=11 的情况下寻求最少硬币数。按照“贪心原则”,我们先挑选面值最大的,即为 5 的硬币放入钱包。接着,还有 6 元待解(即 11-5 = 6)。这时,我们再次“贪心”,放入 5 元面值的硬币。
结合代码理解一下:
def getMinCoinCount(total, values, valueCount): rest = total # 可用余额 count = 0 # 从大到小遍历所有面值 for item in range(valueCount): currentCount = rest // values[item] # 计算当前面值最多能用多少个 rest -= currentCount * values[item] count += currentCount # 增加当前面额用量 if (rest == 0): return count # 如果无法凑出总价,则返回-1 return -1 def main(): values = [5, 3] total = 11 result = getMinCoinCount(total, values, 2) print(result) if __name__ == "__main__": main()这段代码就是简单地从最大的面值开始尝试,每次都会把当前面值的硬币尽量用光,然后才会尝试下一种面值的货币。
但是这样做有一个问题,那就是还剩 1 元零钱待找,但是我们只有 c[0]=5, c[1]=3 两种面值的硬币,怎么办?这个问题无解了,该返回 -1 了吗?显然不是。
我们把第 2 步放入的 5 元硬币取出,放入面值为 3 元的硬币试试看。这时,你就会发现,我们还剩 3 元零钱待找: 正好我们还有 c[1]=3 的硬币可以使用,因此解是 c[0]=5, c[1]=3, c[1]=3,即最少使用三枚硬币凑出了 k=11 这个金额。
我们对贪心算法做了改进,引入了回溯来解决前面碰到的“过于贪心”的问题。
下面是改进后的代码,可以再看看跟之前算法实现的区别:
def getMinCoinCountBackTracking(total, values): if (len(values) == 0): return -1 # 当前币值 currentCoin = values[0] # 使用当前币值的数量 useCurrentCoinCount = total // currentCoin restTotal = total - useCurrentCoinCount * currentCoin # 如果restTotal为0,表示余额已除尽,组合完成 if (restTotal == 0): return useCurrentCoinCount # 其他币种数量 coinCount = -1 # 剩余的币种 restCoins = values[1 :] while (useCurrentCoinCount >= 0): # 尝试用剩余面值求当前余额的硬币总数 coinCount = getMinCoinCountBackTracking(restTotal, restCoins) # 如果后续没有有可用组合,退一步,当前useCurrentCoinCount币数减1 if (coinCount == -1): useCurrentCoinCount -= 1 restTotal = total - useCurrentCoinCount * currentCoin else: return useCurrentCoinCount + coinCount return -1 def main(): values = [5, 3] total = 11 result = getMinCoinCountBackTracking(total, values) print(result) if __name__ == "__main__": main()改进后的算法实现在之前的基础上增加上了一个回溯过程。简单地说就是多了一个递归,不断尝试用更少的当前面值来拼凑。只要有一个组合成功,我们就返回总数,如果所有组合都尝试失败,就返回 -1。
从上面这个例子我们可以看出,如果只是简单采用贪心的思路,那么到用完 2 个 5 元硬币的时候我们就已经黔驴技穷了——因为剩下的 1 元无论如何都没法用现在的硬币凑出来。
这就是贪心算法所谓的局部最优导致的问题,因为我们每一步都尽量多地使用面值最大的硬币,因为这样数量肯定最小,但是有的时候我们就进入了死胡同,就好比上面这个例子。
所谓局部最优,就是只考虑“当前”的最大利益,既不向前多看一步,也不向后多看一步,导致每次都只用当前阶段的最优解。那么如果纯粹采用这种策略我们就永远无法达到整体最优,也就无法求得题目的答案了。至于能得到答案的情况那就是我们走狗屎运了。
虽然纯粹的贪心算法作用有限,但是这种求解局部最优的思路在方向上肯定是对的,毕竟所谓的整体最优肯定是从很多个局部最优中选择出来的,因此所有最优化问题的基础都是贪心算法。
回到前面的例子,我只不过是在贪心的基础上加入了失败后的回溯,稍微牺牲一点当前利益,仅仅是希望通过下一个硬币面值的局部最优达到最终可行的整体最优。
所有贪心的思路就是我们最优化求解的根本思想,所有的方法只不过是针对贪心思路的改进和优化而已。回溯解决的是正确性问题,而动态规划则是解决时间复杂度的问题。
贪心算法是求解整体最优的真正思路源头,这就是为什么我们要在认识动态规划时就从贪心算法讲起的原因。
硬币找零问题本质上是求最值问题。事实上,动态规划问题的一般形式就是求最值,而求最值的核心思想是穷举。这是因为只要我们能够找到所有可能的答案,从中挑选出最优的解就是算法问题的结果。在没有优化的情况下,穷举从来就不算是一个好方法。
贪心算法是一种使用局部最优思想解题的算法(即从问题的某一个初始解出发逐步逼近给定的目标,以尽可能快的速度去求得更好的解,当达到算法中的某一步不能再继续前进时,算法停止)。
但是通过硬币找零问题,我们也发现了贪心算法本身的局限性:
不能保证求得的最后解是最佳的;不能用来求最大或最小解问题;只能求满足某些约束条件的可行解的范围。我们往往需要使用回溯来优化贪心算法,否则就会导致算法失效。因此,在求解最值问题时,我们需要更好的方法来解:考虑整体最优的问题。