使用动态规划完美解决硬币找零问题(Python)

您所在的位置:网站首页 解决问题用假设方法解决问题 使用动态规划完美解决硬币找零问题(Python)

使用动态规划完美解决硬币找零问题(Python)

2024-05-26 04:37| 来源: 网络整理| 查看: 265

使用动态规划解决硬币找零问题 前情提要动态规划的问题描述重叠子问题无后效性最优子结构互相独立——满足最优子结构互相干扰——不满足最优子结构深入理解最优子结构 使用动态规划求解硬币找零问题递归与动态规划状态缓存与循环通用的动态规划从贪心算法到动态规划总结与升华

前情提要

在前面几篇文章中,我们使用了贪心算法求解硬币找零的问题,并从中发现了贪心算法本身的局限性:

贪心算法几乎只考虑了局部最优,因此无法应对需要考虑整体最优的算法问题。

针对这一问题,我们重新思考了解决方案,用递归的方法来穷举出所有可能的组合,从这些可能组合中找出最优解。虽然递归考虑了整体最优,而且真的可以解决问题,但效率太低。

因此,为了解决低效问题,我们又提出了备忘录的概念。你应该发现了,我们在解决硬币找零问题时的思路是一以贯之的:发现问题,找解决方案;如果方案有局限性,那么就看如何扩展视野,找寻更优的方法。

含有备忘录的递归算法已经与动态规划思想十分相似了,从效率上说也是如此。事实上,你已经在使用动态规划的思想解决问题了。但“真正”的动态规划解法跟备忘录法又有一些区别。

动态规划的问题描述

我们曾不止一次提到重叠子问题,其实,重叠子问题是考虑一个问题是否为动态规划问题的先决条件,除此之外,我还有无后效性。动态规划问题一定具备以下三个特征:

重叠子问题:在穷举的过程中(比如通过递归),存在重复计算的现象;无后效性:子问题之间的依赖是单向性的,某阶段状态一旦确定,就不受后续决策的影响;最优子结构:子问题之间必须相互独立,或者说后续的计算可以通过前面的状态推导出来。 重叠子问题

斐波那契数列没有求最值的问题,因此严格来说它不是最优解问题,当然也就不是动态规划问题。但它能帮助你理解什么是重叠子问题。首先,它的数学形式即递归表达是这样的: 在这里插入图片描述 如果要计算原问题 F(10),就需要先计算出子问题 F(9) 和 F(8),如果要计算 F(9),就需要先计算出子问题 F(8) 和 F(7),以此类推。这个递归的终止条件是当 F(1)=1 或 F(0)=0 时结束。

在这里插入图片描述

看完斐波那契数列的求解树之后,你发现问题没有:

用红色标出的两个区域中,它们的递归计算过程完全相同!

这意味着,第 2 个红色区域的计算是“完全没有必要的”,它是重复的计算。因为我们已经在求解 F(7) 的时候把 F(6) 的所有情况计算过了。因此我们把第 2 个红色区域的计算称为重叠子问题。

无后效性

有些问题虽然看起来像包含“重叠子问题”的子问题,但是这类子问题可能具有后效性,但我们追求的是无后效性。

所谓无后效性,指的是在通过 A 阶段的子问题推导 B 阶段的子问题的时候,我们不需要回过头去再根据 B 阶段的子问题重新推导 A 阶段的子问题,即子问题之间的依赖是单向性的。

换句话说,如果一个问题可以通过重叠子问题缓存进行优化,那么它肯定都能被画成一棵树。

最优子结构

用一个例子来说明一下最优子结构:

互相独立——满足最优子结构

假设你在外卖平台购买 5 斤苹果和 3 斤香蕉。由于促销的缘故,这两种水果都有一个互相独立的促销价。如果原问题是让你以最低的价格购买这些水果,你该怎么买?

显然,由于这两种水果的促销价格相互独立、互不影响,你只需直接购买就能享受到最低折扣的价格。

现在我们得到了正确的结果:最低价格就是直接购买这两种折扣水果。因为这个过程符合最优子结构,打折的苹果和香蕉这两个子问题是互相独立、互不干扰的。

互相干扰——不满足最优子结构

但是,如果平台追加一个条件:折扣不能同时享用。即购买了折扣的苹果就不能享受折扣的香蕉,反之亦然。

这样的话,你肯定就不能同时以最低的苹果价格和最低的香蕉价格享受到最低折扣了。按刚才那个思路就会得到错误的结果。因为子问题并不独立,苹果和香蕉的折扣价格无法同时达到最优,这时最优子结构被破坏。

深入理解最优子结构

所谓后续的计算可以通过前面的状态推导,是指:如果你准备购买了 5 斤折扣苹果,那么这个价格(即子问题)就被确定了,继续在购物车追加 3 斤折扣香蕉的订单,只需要在刚才的价格上追加折扣香蕉的价格,就是最低的总价格(即答案)。

现在,回到硬币找零的问题上来,它满足最优子结构吗?

满足。假设有两种面值的硬币 c[0]=5, c[1]=3,目标兑换金额为 k=11。原问题是求这种情况下求最少兑换的硬币数。如果你知道凑出 k=6 最少硬币数为 “2”(注意,这是一个子问题),那么你只需要再加 “1” 枚面值为 c[0]=5 的硬币就可以得到原问题的答案,即 2 + 1 = 3。

原问题并没有限定硬币数量,你应该可以看出这些子问题之间没有互相制约的情况,它们之间是互相独立的。因此,硬币找零问题满足最优子结构,可以使用动态规划思想来进行求解。

使用动态规划求解硬币找零问题

当动态规划最终落到实处,其实就是一个状态转移方程,这同样是一个吓唬人的名词。不过没关系,其实我们已经具备了写出这个方程的所有工具。现在,就让我带你一起看看如何写出这个状态转移方程。

首先,任何穷举算法(包括递归在内)都需要一个终止条件。那么对于硬币找零问题来说,终止条件是什么呢?当剩余的金额为 0 时结束穷举,因为这时不需要任何硬币就已经凑出目标金额了。在动态规划中,我们将其称之为初始化状态。

接着,我们按照上面提到的凑硬币的思路,找出子问题与原问题之间会发生变化的变量。原问题指定了硬币的面值,同时没有限定硬币的数量,因此它们俩无法作为“变量”。唯独剩余需要兑换的金额是变化的,因此在这个题目中,唯一的变量是目标兑换金额 k。在动态规划中,我们将其称之为状态参数。

同时,你应该注意到了,这个状态在不断逼近初始化状态。而这个不断逼近的过程,叫做状态转移。

接着,既然我们确定了状态,那什么操作会改变状态,并让它不断逼近初始化状态呢?每当我们挑一枚硬币,用来凑零钱,就会改变状态。在动态规划中,我们将其称之为决策。

终于,我们构造了一个能解决问题的思路:

初始化状态 -> 确定状态参数 -> 设计决策

现在万事俱备,只欠东风,让我们一起来写这个状态转移方程。通常情况下,状态转移方程的参数就是状态转移过程中的变量,即状态参数。而函数的返回值就是答案,在这里是最少兑换的硬币数。

这里先描述一下状态转移的过程,这跟我们在上面讨论的挑硬币的过程是一致的: 在这里插入图片描述

递归与动态规划

带备忘录的递归算法与你现在看到的动态规划解法之间,有着密不可分的关系。它们要解决的核心问题是一样的,即消除重叠子问题的重复计算。事实上,带备忘录的递归算法也是一种动态规划解法。但是,一般不把这种方法作为动态规划算法题的常规解法。

首先,从理论上说,虽然带备忘录的递归算法与动态规划解法的时间复杂度是相同规模的,但在计算机编程的世界里,递归是依赖于函数调用的,而每一次函数调用的代价非常高昂。

递归调用是需要基于堆栈才能实现的。而对于基于堆栈的函数调用来说,在每一次调用的时候都会发生环境变量的保存和还原,因此会带来比较高的额外时间成本。这是无法通过时间复杂度分析直接表现出来的。

更重要的是,即便我们不考虑函数调用带来的开销,递归本身的处理方式是自顶向下的。所谓自顶向下,是指访问递归树的顺序是自顶向下的,把递归处理斐波那契数列的顺序画出来,你就明白了: 在这里插入图片描述 如果从解路径的角度看递归的自顶向下处理,那么它的形式可以用由左至右的链表形式表示: 在这里插入图片描述 因此每次都需要查询子问题是否已经被计算过,如果该子问题已经被计算过,则直接返回备忘录中的记录。

也就是说,在带备忘录的递归解法中,无论如何都要多处理一个分支逻辑,只不过这个分支的子分支是不需要进行处理的。

这样的话,我们就可以预想到,如果遇到子问题分支非常多,那么肉眼可见的额外时间开销在所难免。我们不希望把时间浪费在递归本身带来的性能损耗上。那么,我们需要设计一种新的缓存方式,并考虑使用迭代来替换递归。

状态缓存与循环

在带备忘录的递归算法中,每次都需要查询子问题是否已经被计算过。针对这一问题,我们可以思考一下,是否有方法可以不去检查子问题的处理情况呢?在执行 A 问题的时候,确保 A 的所有子问题一定已经计算完毕了。

仔细想一想,这不就是把处理方向倒过来用自底向上嘛!那么我们具体要怎么做呢?回顾一下自顶向下的方法,我们的思路是从目标问题开始,不断将大问题拆解成子问题,然后再继续不断拆解子问题,直到子问题不可拆解为止。通过备忘录就可以知道哪些子问题已经被计算过了,从而提升求解速度。

那么如果要自底向上,我们可以首先求出所有的子问题,然后通过底层的子问题向上求解更大的问题。还是通过斐波那契数列来画出自底向上的处理方式:

在这里插入图片描述 如果从解路径的角度看动态规划的自底向上处理方式,那么它的形式可以用一个数组来进行表示,而这个数组事实上就是实际的备忘录存储结构: 在这里插入图片描述

当求解大问题的时候,我们已经可以确保该问题依赖的所有子问题都已经计算过了,那么我们就无需检查子问题是否已经求解,而是直接从缓存中取出子问题的解。

通过自底向上,我们完美地解决掉了递归中由于“试探”带来的性能损耗。有了思路之后,让我们把上一篇文章中的递归代码做些修改,变成新的迭代实现:

def getMinCountsLoop(k, values): memo = [-1] * (k + 1) memo[0] = 0 # 初始化状态 for item in range(1, k + 1): minCount = k + 1 # 模拟无穷大 for iter in range(len(values)): currentValue = values[iter] # 如果当前面值大于硬币总额,那么跳过 if (currentValue > item): continue # 使用当前面值,得到剩余硬币总额 rest = item - currentValue restCount = memo[rest] # 如果返回-1,说明组合不可信,跳过 if (restCount == -1): continue # 保留最小总额 itemCount = 1 + restCount if (itemCount


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3