写在前面
博客来源:翻译自youtube高赞技术视频,并精加工和细化。
适合阅读:想要搞懂动态规划的小伙伴~
动态规划是一项杰出的计算技术,它既保留了穷举法的精确性,又吸收了贪心算法的高效率。
它主要应用于两个领域:
- 一是寻找问题的最优解,比如计算某个问题的最小值或最大值;
- 二是计算问题可能的解的总数。
尽管动态规划能够处理的问题类型远不止这些,但这两种情况是最典型的。
动态规划的起源
动态规划技术由理查德·贝尔曼在20世纪50年代提出,其名称虽然听起来有些奇特,但背后有着一段趣闻——贝尔曼为了掩盖自己正在进行的数学研究,特意选用了这样一个含糊的术语,以此来规避当时对“研究”一词抱有极度恐惧和厌恶情绪的华盛顿国防部长。
面试题中很常见
动态规划在众多面试题中频繁出现,常被视为一个颇具挑战性的主题。然而,一旦你掌握了它的基础原理并解决了一定数量的问题,你将变得擅长解决这类问题,甚至可能爱上它们。
动态规划的精髓在于,识别并解决所有子问题(如下图所展示)。虽然总体思路并不复杂,但有时识别出需要解决的具体问题却颇具难度。
幸运的是,人类大脑擅长模式识别,因此,随着解决的问题数量增加,我们会越来越快地识别出需要解决的子问题。
在本文,我们将从基础出发,探讨四个问题。
- 斐波那契数列问题:如何高效地计算第n个斐波那契数,避免递归方法中的重复计算问题。
- 最少硬币问题:给定不同面额的硬币和目标金额,如何找出组成目标金额所需的最少硬币数量。
- 硬币组合问题:不同于最少硬币问题,这个变体要求计算组成给定总和的不同硬币组合方式的总数。
- 兔子在网格中的路径问题:在一个N乘以M的网格中,兔子只能向下或向右移动,需要计算兔子从左上角到右下角的所有可能路径数量。
下一篇博客,我们还会进一步深入探讨更多的动态规划问题,请大家关注/点赞/收藏一下本博客,谢谢。
动态规划基础:斐波那契数列
让我们从一个经典的斐波那契数列问题开始。假如,我们希望编写一个名为Fib的函数,它能够返回第n个斐波那契数。
斐波那契数列的前两个数字都是1,每个后续数字都是前两个数字的和。
例如,第六个斐波那契数是8,它是前两个数字3和5的和。
我们首先尝试用递归的方法来解决这个问题。在递归的逻辑中,我们定义了两个基本情况:
情况1:当n为1或2时,函数直接返回1。
情况2:对于其他的n值,函数则返回第n-1个和第n-2个斐波那契数的和。
当我们尝试计算第七个斐波那契数时,结果是13,看起来一切正常。然而,一旦我们尝试计算第50个斐波那契数,程序的运行速度就会变得极其缓慢。因此,这种方法虽然简洁,但它的性能却存在明显的问题。
递归问题
让我们通过可视化这个函数的执行过程,来理解其中的原因。
要计算Fib(5),我们需要先计算Fib(4)。要计算Fib(4),我们又需要计算Fib(3)。
要计算Fib(3),我们又需要计算Fib(2)。最后,由于Fib(2)是基本情况之一,所以返回1。
我们继续计算Fib(3),这需要Fib(1),那也是1。最终,我们可以说Fib(3)是2。
我们回到计算Fib(4),现在我们再次需要Fib(2),我们知道这是1,所以Fib(4)现在是2+1=3。
现在我们回到计算Fib(5),我们需要知道Fib(3)。要计算Fib(3),我们再次经历相同的过程。所以我们需要Fib(2)和Fib(1)。
现在,我们可以将这些加起来得到2。最后,我们可以计算Fib(5),结果是5。
正如你看到的,这是一个非常漫长的过程,为了得到Fib(5)的结果,需要大量的迭代。
你能告诉我这个函数的时间复杂度是多少吗?
时间复杂度
在讨论这个函数的时间复杂度时,我们可以假设所处理的数字相对较小,这样可以使得加法操作的执行速度非常快。
这个假设的目的是为了简化分析,因为我们主要关注的是算法在输入规模增加时的性能表现,而不是具体数字的大小对运行时间的影响。
假设运行时间是T(n),即T(n-1)加上T(n-2)加上O(1)。
我们来解释一下这个表示法以及它背后的逻辑。
时间复杂度推导
假设我们用 T(n) 表示计算 Fib(n) 的时间复杂度。根据斐波那契数列的定义:
首先,我们需要计算 Fib(n-1),这需要时间 T(n−1) 。
其次,我们还需要计算 Fib(n-2),这需要时间 T(n−2) 。
此外,计算这两个结果后,我们还需要进行一次加法操作,这个操作在时间上是常数级别的 O(1) 。
因此,我们可以将T(n)表示为:T(n) = T(n-1) + T(n-2) + O(1)
通过递推关系,我们可以分析该算法的复杂度。由于 Fibonacci 数列的递归性质,计算的时间会随着 n 的增加而急剧增长,形成一个指数级的增长模式。
为什么它会导致指数级的增长?
我们可以想一想,这其实就像构建了一个递归树。每次调用 Fibonacci 函数时,都会生成两个新的调用F(n-1)和F(n-2),从而形成一个树状结构。递归树的结构如下:
- 1. 根节点:代表计算Fib(n)。
- 2. 第一层:根节点会产生两个子节点,一个是Fib(n-1),另一个是Fib(n-2)。
- 3. 第二层:每个子节点又会继续产生两个子节点。例如,Fib(n-1)会产生Fib(n-2)和Fib(n-3),而Fib(n-2) 会产生 Fib(n-3)和Fib(n-4)。
随着递归的深入,节点数量会迅速增加。如果我们观察这个递归树的高度,会发现:
树的高度大约为n,即从n到 0 需要n次递归。每一层的节点数,大约是前两层节点数的总和,这与斐波那契数列的定义一致:
- 第一层有 1 个节点(根节点)。
- 第二层有 2 个节点。
- 第三层有 3 个节点。
- 第四层有 5 个节点。
依此类推。由于每个节点会生成两个子节点,树的深度和分支模式,导致了节点数量的快速增长。这意味着 Fibonacci 数列的值随着n的增加而接近于指数级增长。
指数增长,意味着,计算非常慢。
另一种看待这个问题的方式是,T(n)至少是T(n-2)的两倍,因为n越大,我们需要做的工作就越多。
- 备注:虽然,也可以说 T(n) 至少是 T(n−1) 的两倍,因为计算 Fib(n) 的时候不仅需要计算 Fib(n−2),还需要计算Fib(n−1),但是,T(n−1) 实际上包含了 T(n−2) 的计算。
我们可以推测,T(n)的复杂度至少是O(2^(n/2))。
问题1:n/2 如何计算的?
回答:由于 T(n) 至少是 T(n−2) 的两倍。也就是说,每次调用, n 会减到 n−2。
因此,整体的递归深度大约是 n/2。
问题2:2^n 如何计算的?
在递归树中,每个节点的子节点数量是不同的,但我们可以从树的结构推导出节点数量的上界。
由于 每个节点最多产生两个子节点:
理论上,考虑最坏的情况(即每个节点都不重复),每个节点可以产生两个子节点,形成一个完全二叉树。
在高度为 n 的完全二叉树中,节点的数量是:
节点数量节点数量=1+2+4+…+2(n−1)=2n−1
请注意,我们多次执行了相同的计算!!!有一个通用的方法,可以改进这样的算法,这个方法叫做记忆化。
记忆化:改进算法的方法
让我们回到我们刚才的话题。这个想法是:记住每个斐波那契数的计算,并最多计算一次。
我们可以通过将计算值,存储在字典中来做到这一点。我们可以称之为memo。
Memo最初是空的。下次我们想要返回第n个斐波那契数时,我们首先检查它是否在字典中,如果是,我们就不需要运行整个计算。计算被单个字典查找替换。注意,记忆化解决方案是一种简单解决方案这意味着你可以将任何递归算法,转换为记忆化方法。
记忆化斐波那契数列的运行时间复杂度
我们看看记忆化的斐波那契数列,运行时间复杂度。也就是说,记忆化是如何提升算法性能的?
如果我们运行 Fib(50),结果会非常快速地出来。那么,记忆化解决方案的运行时间复杂度到底是多少呢?
实际上,在记忆化的情况下,递归的调用次数,是很少的。每个值n,只会在第一次调用时,进行递归计算。之后,所有结果都会从缓存(字典)中快速检索。这意味着,非记忆化的调用次数是 n 次。每次非记忆化调用,除了递归部分,其余部分的计算是常数时间复杂度。因此,最终的运行时间复杂度是线性的,即 O(n)。
这就是动态规划的核心思想:记住解决方案,并尝试重用它们来解决更大的问题。
设计动态规划解决方案时,最大的挑战是:找出哪些子问题可以帮助你高效地解决整体问题。
自底向上方法
自底向上方法,就是说,我们从最基本的子问题开始计算,逐步解决更大的问题。
这种方法,比自顶向下的记忆化方法更为常见,因为它避免了递归调用,通常在实践中更高效。同时,它还能够在某些情况下节省内存。例如,计算斐波那契数时,我们只需保存最后两个值,当不再需要它们时,可以将其删除以节省空间。
这种方法与记忆化方法的主要区别在于,我们不再使用字典来存储每个计算值,而是通过 for 循环迭代子问题。
这就意味着我们必须仔细思考子问题的求解顺序,因为子问题之间的依赖关系很重要。我们必须确保按照拓扑排序的顺序解决问题 —— 也就是先解决依赖的子问题,避免出现循环依赖。
斐波那契数列的依赖关系很简单,第 `n` 个数依赖于 n-1 和 n-2,这种依赖关系很容易看出,因此,对于更复杂的问题,理解依赖顺序尤为关键。
最少硬币问题
让我们继续讨论下一个问题:最少硬币问题。给定一组硬币面额 C1 到 Ck ,以及目标金额 M 。我们需要找出可以组成目标金额的最少硬币数量。
例如,假设我们要用欧元硬币给客户找零734美分。可用的硬币面额有:1、2、5、10、20、50、100 和 200 美分。最优解是选择3个200美分硬币,1个100美分硬币,1个20美分硬币,1个10美分硬币,以及2个2美分硬币。
你有没有注意到什么有趣的现象?我们总是选择尽可能大的硬币,直到达到目标金额。这种方法被称为贪心算法,因为:我们每次都选择,当前最有利的选项。在欧元硬币的例子中,这种方法是有效的。
然而,贪心算法并不是在所有情况下都有效。事实上,在一般情况下,它并不能保证最优解。例如,假设硬币面额是1、4和5美分,目标金额是13美分。使用贪心方法时,我们会选择2个5美分硬币和3个1美分硬币,总共5个硬币。但最优解是使用2个4美分硬币和1个5美分硬币,总共只需要3个硬币。
这种现象表明,贪心方法并不总是能找到最优解,因此,在某些情况下,我们需要使用其他更为复杂的算法来解决最少硬币问题。
我们可以使用动态规划来解决这个问题吗?如果可以,我们需要解决的子问题是什么?
动态规划解决
在动态规划中,通常考虑最终问题和其子问题之间的关系。在这个问题中,我们的最终目标是找出组成金额 m所需的最少硬币数量。这个问题,可以被视为一个子问题,而解决它的递归公式是什么呢?
假设我们定义一个函数 `最少硬币(m)`,表示为金额m所需的最少硬币数量。
显然,基本情况是:`最少硬币(0) = 0`,因为金额为0时不需要任何硬币。
现在,我们来考虑一般情况,假设m>0。记住,动态规划有点像穷举法。要通过穷举法解决这个问题,我们该如何操作呢?
假设我们想达到总和13,我们可以选择任何一种硬币。比如,如果我们选择一个5美分的硬币,我们剩下的问题就是求解总和8。类似地,如果我们选择4美分硬币,我们就要解决总和9的问题;如果选择1美分硬币,那么我们需要解决总和12的问题。我们将继续这样的选择,直到最终达到基本情况,即总和为0。
需要注意的是,我们不能选择低于0的金额,所以,我们只会选择那些能产生合法状态的硬币。每个子问题(即树中的每个节点)都代表一种选择硬币的决策,我们会尝试所有可能的硬币面额,并选择其中硬币数量最少的方案作为最佳解。
基于此,我们可以定义一个递归解决方案来找到最少硬币数。如果目标金额m = 0,显然不需要任何硬币,解为0。如果 m > 0,我们需要检查所有可能的硬币面额,选择使总和 m成立的最少硬币组合。这种递归过程,可以通过动态规划进行优化,避免重复计算,从而提高效率。
递归实现
我们首先尝试用简单的递归方法,来实现这个功能。我们编写一个名为“最少硬币”的函数。
在基本情况下,如果目标金额为0,函数就返回0;对于其他情况,我们会考虑所有可能的子问题,找到其中的最小值,并加上一个额外的硬币,来凑成目标金额m。我们需要忽略那些子问题的结果为负值的情况。
为了避免无解的子问题,我们可以使用一个辅助函数min_ignore_none,来跳过没有解决方案的情况。
例如,当我们用硬币面额1、4和5来凑成总和13时,函数会返回3,因为可以使用组合4 + 4 + 5,来得到13。
然而,如果我们尝试用这些硬币凑成总和150时,递归过程就会变得非常慢,类似于递归计算斐波那契数列。
每次我们计算一个子问题时,都会重复计算已经解决过的子问题,这使得时间复杂度非常高。因此,虽然递归方法能够正确解决问题,但效率并不理想。
记忆化解决方案
幸运的是,我们掌握了记忆化的技术,可以利用它来加速解决方案的计算。我们将计算结果存储在记忆化字典中,一旦计算过某个值,就将其保存起来,以便下次直接使用。这种方法使得解决方案的计算速度快了许多,对于总和150,它能够迅速返回结果30。
这个函数之所以效率高,是因为每个子问题的答案只计算一次,之后就可以被迅速检索。这个算法的时间复杂度是m乘以k,其中k是硬币的种类数,m是目标总和,因为我们遍历每种硬币,为每个子问题找到最佳解决方案。
自底向上解决方案
现在,我们来探讨一个自底向上的解决方案,它通过循环计算从0到m的所有可能总和的解决方案。我们首先确定基本情况的解决方案,即0。然后,我们遍历每个子问题和每种硬币类型,忽略那些负值的子问题,并保留最佳解决方案。我们使用字典的get函数来检索值,如果键不存在,则返回none,而不是抛出错误。
记忆化的自底向上和自上向下的方法,都是有效的解决方案。但自底向上的方法常数因子更低,代码更简洁,这也是我更倾向于使用它的原因。
因此,从现在起,我将只在最终解决方案中,采用自底向上的方法。
然而,通常以递归函数的形式思考动态规划问题会更容易,因此思考过程将保持不变。
硬币组合问题的变体
接下来,我们来看硬币组合问题的一个变体:我们不再关注组成目标金额 m 所需的最少硬币数量,而是要找出可以组成给定总和 m 的不同方式有多少种。
例如,给定硬币面额 1、4 和 5,我们可以用几种不同的方式组成总和 5?有四种方式:
- 全部使用 1 美分硬币(1 + 1 + 1 + 1 + 1)。
- 使用 1 美分和 4 美分硬币(1 + 4)。
- 使用 4 美分和 1 美分硬币(4 + 1)。
- 直接使用一个 5 美分硬币(5)。
我们将尝试构建一个递归解决方案来解决这个问题。首先,选择一个 5 美分硬币,可以直接将目标金额减少到 0,因为 0 是递归的基本情况,这就意味着选择 5 美分硬币是一个有效的选择。那么,基本情况的答案是什么呢?我们有多少种方式可以组成 0?显然,唯一的方式就是不使用任何硬币,因此组成 0 的方法只有一种。
接下来,我们可以考虑其他硬币的选择。比如选择 4 美分硬币,这会将目标金额减少到 1,我们需要解决子问题:“如何用 1 美分、4 美分和 5 美分硬币组成总和 1?”我们通过递归解决这个子问题。对于每个硬币,我们都会重复这个过程,直到我们把目标金额减少到 0。
在递归过程中,我们会记录每种方式并将它们汇总,最终得到组成目标金额 m 的所有不同组合数。我们可以通过求和所有子问题的结果,得到最终答案。
总的来说,这个递归方法会探索所有可能的组合,并将每个子问题的结果累加,最终得出可以组成目标金额的所有方式数。
让我们实现自底向上的解决方案。注意,这与之前的问题非常相似,唯一的区别在于:我们关注的是求和而不是最小值。
在结束这个视频之前,让我们看另一个计数问题。
兔子在网格中的路径问题
假设有一个 N×M 的网格,兔子从左上角出发,目标是到达右下角。它每次只能向下或向右移动。那么,兔子有多少种不同的方式可以到达右下角呢?
例如,在一个2×3的网格中,兔子有三种方式到达右下角:
- 先向右、再向右、再向下;
- 先向右、再向下、再向右;
- 先向下、再向右、再向右。
为了求解这个问题,我们首先定义子问题。我们希望找出兔子在给定N×M的网格中有多少种方式能从左上角到达右下角。这个问题可以分解为更小的子问题,帮助我们逐步找到解决方案。
假设我们有一个更大的 6×9 的网格。如果兔子选择向下移动,它就把问题简化为一个5×9的网格(因为它不能回到上面)。同样,如果兔子选择向右移动,那么问题就变成了一个5×8的网格。
由于兔子只能向下或向右移动,我们的任务就是通过递归不断地将问题简化为一个更小的网格。
需要注意的是,当兔子到达网格的最后一行或最后一列时,它只能继续朝一个方向移动,直到到达右下角。因此,当兔子到达这些边界时,问题的解是直接确定的。
在尝试构建基本情况时,考虑一些特殊情况非常有帮助。比如,假设我们有一个1×1的网格,那么兔子从左上角到达右下角只有一种方式,那就是不动,直接到达目标。这个简单的情况可以作为递归的基本情况。
好的,让我们回到构建递归解决方案。
递归解决方案
如果我们想要解决一个 4×6 的网格问题,兔子有多少种不同的方式可以到达右下角呢?由于兔子可以向下或向右移动,我们需要同时考虑这两种选项。
如果兔子选择向下移动,那么剩下的问题就是一个3×6的网格,解决方案的数量与此相同。
如果兔子选择向右移动,那么问题变成了一个4×5的网格,解决方案的数量与此相同。
由于兔子可以选择向下或向右,我们可以将这两个子问题的解决方案相加,得到总的路径数。
需要注意的是,当网格的一个维度为1时,我们的递归公式可能不适用,因为它会导致我们遇到大小为0的网格,但我们尚未为这种情况定义解决方案。
为了解决这个问题,我们可以扩展我们的基本情况。例如,当网格的任一维度为1时,我们可以设定为基本情况,因为一旦兔子到达最后一行或最后一列,它只能继续朝一个方向移动,最终到达右下角,这时,只有一种路径可走。
自底向上解决方案
让我们尝试将这个问题转换为代码实现。我们的目标是填充一个记忆化字典,使得字典中的 `mem[n][m]` 能够给出从左上角到右下角的路径数。
首先,我们需要初始化基本情况,即填充所有至少有一个维度为1的网格的解。我们可以使用两个 `for` 循环来完成这一步骤。对于这些网格,我们知道兔子只有一种路径可走,即沿着唯一的方向一直移动。
初始化了基本情况之后,我们可以使用递归公式来填充记忆化字典中其他网格的解。我们从 2×2 的网格开始计算,因为包含大小为1的网格已经作为基本情况处理了。
最后,返回 `mem[n][m]` 的值,这将是我们问题的解,即从左上角到右下角的所有可能路径的数量。
你认为一个18乘以6的网格有多少条路径?75乘以19呢?
结语
OK,到此为止,我们已经解决了一些基础的动态规划问题。在下一篇博客中,我们将探讨更复杂的动态规划问题。
最后,我们总结几个关键点:当你尝试解决动态规划问题时,首先要定义子问题。对于较简单的问题,子问题通常与原问题具有相同的结构。定义了子问题后,接下来要找出基本情况是什么。最后,提出一个递归公式来解决一般情况。记住,一个个的枚举,对解决问题很有帮助,可以帮助你更清晰地理解问题和递归关系。
如果这篇博客让你明白了一些动态规划,可以点赞/收藏一下,这将鼓励我继续创作优质好文。不要忘记关注,谢谢,我们下一篇博客,再见!