动态规划详解

这篇文章是我们号半年前一篇 200 多赞赏的成名之作 动态规划详解 的进阶版。由于账号迁移的原因,旧文无法被搜索到,所以我润色了本文,并添加了更多干货内容,希望本文成为解决动态规划的一部「指导方针」。
>>>>

再说句题外话,我们的公众号开号至今写了起码十几篇文章拆解动态规划问题,我都整理到了公众号菜单的「文章目录」中,它们都提到了动态规划的解题框架思维,本文就系统总结一下。我总结的套路可能不适合各路大神,但是应该适合大众,毕竟我自己也是一路摸爬滚打过来的。

算法技巧就那几个套路,如果你心里有数,就会轻松很多,本文就来扒一扒动态规划的裤子,形成一套解决这类问题的思维框架。废话不多说了,上干货。

动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离呀等等。

既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。

动态规划就这么简单,就是穷举就完事了?我看到的动态规划问题都很难啊!

首先,动态规划的穷举有点特别,因为这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算。

而且,动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。

另外,虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的「状态转移方程才能正确地穷举。

以上提到的重叠子问题、最优子结构、状态转移方程就是动态规划三要素。具体什么意思等会会举例详解,但是在实际的算法问题中,写出状态转移方程是最困难的,这也就是为什么很多朋友觉得动态规划问题困难的原因,我来提供我研究出来的一个思维框架,辅助你思考状态转移方程:

明确「状态」 -> 定义 dp 数组/函数的含义 -> 明确「选择」-> 明确 base case。

下面通过斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。前者主要是让你明白什么是重叠子问题(斐波那契数列严格来说不是动态规划问题),后者主要集中于如何列出状态转移方程。

请读者不要嫌弃这个例子简单,只有简单的例子才能让你把精力充分集中在算法背后的通用思想和技巧上,而不会被那些隐晦的细节问题搞的莫名其妙。想要困难的例子,历史文章里有的是。

一、斐波那契数列

1、暴力递归

斐波那契数列的数学形式就是递归的,写成代码就是这样:

int fib(int N) {
    if (N == 1 || N == 2) return 1;
    return fib(N - 1)   fib(N - 2);
}

这个不用多说了,学校老师讲递归的时候似乎都是拿这个举例。我们也知道这样写代码虽然简洁易懂,但是十分低效,低效在哪里?假设 n = 20,请画出递归树。

PS:但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助。

这个递归树怎么理解?就是说想要计算原问题f(20),我就得先计算出子问题f(19)f(18),然后要计算f(19),我就要先算出子问题f(18)f(17),以此类推。最后遇到f(1)或者f(2)的时候,结果已知,就能直接返回结果,递归树不再向下生长了。

递归算法的时间复杂度怎么计算?子问题个数乘以解决一个子问题需要的时间。

子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)。

解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) f(n - 2) 一个加法操作,时间为 O(1)。

所以,这个算法的时间复杂度为 O(2^n),指数级别,爆炸。

观察递归树,很明显发现了算法低效的原因:存在大量重复计算,比如f(18)被计算了两次,而且你可以看到,以f(18)为根的这个递归树体量巨大,多算一遍,会耗费巨大的时间。更何况,还不止f(18)这一个节点被重复计算,所以这个算法及其低效。

这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题。

2、带备忘录的递归解法

明确了问题,其实就已经把问题解决了一半。即然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了。

一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典),思想都是一样的。

int fib(int N) {    if (N < 1) return 0;    // 备忘录全初始化为 0    vector<int> memo(N   1, 0);    // 初始化最简情况    return helper(memo, N);}

int helper(vector<int>& memo, int n) {    // base case     if (n == 1 || n == 2) return 1;    // 已经计算过    if (memo[n] != 0) return memo[n];    memo[n] = helper(memo, n - 1)                   helper(memo, n - 2);    return memo[n];}

现在,画出递归树,你就知道「备忘录」到底做了什么:

实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数。

递归算法的时间复杂度怎么算?子问题个数乘以解决一个子问题需要的时间。

子问题个数,即图中节点的总数,由于本算法不存在冗余计算,子问题就是f(1),f(2),f(3)f(20),数量和输入规模 n = 20 成正比,所以子问题个数为 O(n)。

解决一个子问题的时间,同上,没有什么循环,时间为 O(1)。

所以,本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击。

至此,带备忘录的递归解法的效率已经和迭代的动态规划一样了。实际上,这种解法和迭代的动态规划思想已经差不多,只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」。

啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说f(20),向下逐渐分解规模,直到f(1)f(2)触底,然后逐层返回答案,这就叫「自顶向下」。

啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的f(1)f(2)开始往上推,直到推到我们想要的答案f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。

3、dp 数组的迭代解法

有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉!

int fib(int N) {
    vector<int> dp(N   1, 0);
    // base case
    dp[1] = dp[2] = 1;
    for (int i = 3; i <= N; i )
        dp[i] = dp[i - 1]   dp[i - 2];
    return dp[N];
}

画个图就很好理解了,而且你发现这个 DP table 特别像之前那个「剪枝」后的结果,只是反过来算而已。实际上,带备忘录的递归解法中的「备忘录」,最终完成后就是这个 DP table,所以说这两种解法其实是差不多的,大部分情况下,效率也基本相同。

这里,引出「状态转移方程」这个名词,实际上就是描述问题结构的数学形式:

为啥叫「状态转移方程」?为了听起来高端。你把 f(n) 想做一个状态 n,这个状态 n 是由状态 n - 1 和状态 n - 2 相加转移而来,这就叫状态转移,仅此而已。

你会发现,上面的几种解法中的所有操作,例如 return f(n - 1) f(n - 2),dp[i] = dp[i - 1] dp[i - 2],以及对备忘录或 DP table 的初始化操作,都是围绕这个方程式的不同表现形式。可见列出「状态转移方程」的重要性,它是解决问题的核心。很容易发现,其实状态转移方程直接代表着暴力解法。

千万不要看不起暴力解,动态规划问题最困难的就是写出状态转移方程,即这个暴力解。优化方法无非是用备忘录或者 DP table,再无奥妙可言。

这个例子的最后,讲一个细节优化。细心的读者会发现,根据斐波那契数列的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1):

int fib(int n) {    if (n == 2 || n == 1)         return 1;    int prev = 1, curr = 1;    for (int i = 3; i <= n; i  ) {        int sum = prev   curr;        prev = curr;        curr = sum;    }    return curr;}

有人会问,动态规划的另一个重要特性「最优子结构」,怎么没有涉及?下面会涉及。斐波那契数列的例子严格来说不算动态规划,因为没有涉及求最值,以上旨在演示算法设计螺旋上升的过程。

下面,看第二个例子,凑零钱问题。

二、凑零钱问题

先看下题目:给你k种面值的硬币,面值分别为c1, c2 ... ck,每种硬币的数量无限,再给一个总金额amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1 。算法的函数签名如下:

// coins 中是可选硬币面值,amount 是目标金额
int coinChange(int[] coins, int amount);

比如说k = 3,面值分别为 1,2,5,总金额amount = 11。那么最少需要 3 枚硬币凑出,即 11 = 5 5 1。

你认为计算机应该如何解决这个问题?显然,就是把所有肯能的凑硬币方法都穷举出来,然后找找看最少需要多少枚硬币。

1、暴力递归

首先,这个问题是动态规划问题,因为它具有「最优子结构」。要符合「最优子结构」,子问题间必须互相独立。啥叫相互独立?你肯定不想看数学证明,我用一个直观的例子来讲解。

比如说,你的原问题是考出最高的总成绩,那么你的子问题就是要把语文考到最高,数学考到最高…… 为了每门课考到最高,你要把每门课相应的选择题分数拿到最高,填空题分数拿到最高…… 当然,最终就是你每门课都是满分,这就是最高的总成绩。

得到了正确的结果:最高的总成绩就是总分。因为这个过程符合最优子结构,“每门科目考到最高”这些子问题是互相独立,互不干扰的。

但是,如果加一个条件:你的语文成绩和数学成绩会互相制约,此消彼长。这样的话,显然你能考到的最高总成绩就达不到总分了,按刚才那个思路就会得到错误的结果。因为子问题并不独立,语文数学成绩无法同时最优,所以最优子结构被破坏。

回到凑零钱问题,为什么说它符合最优子结构呢?比如你想求amount = 11时的最少硬币数(原问题),如果你知道凑出amount = 10的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案,因为硬币的数量是没有限制的,子问题之间没有相互制,是互相独立的。

那么,既然知道了这是个动态规划问题,就要思考如何列出正确的状态转移方程

先确定「状态」,也就是原问题和子问题中变化的变量。由于硬币数量无限,所以唯一的状态就是目标金额amount

然后确定dp函数的定义:函数 dp(n)表示,当前的目标金额是n,至少需要dp(n)个硬币凑出该金额。

然后确定「选择」并择优,也就是对于每个状态,可以做出什么选择改变当前状态。具体到这个问题,无论当的目标金额是多少,选择就是从面额列表coins中选择一个硬币,然后目标金额就会减少:

# 伪码框架def coinChange(coins: List[int], amount: int):    # 定义:要凑出金额 n,至少要 dp(n) 个硬币    def dp(n):        # 做选择,需要硬币最少的那个结果就是答案        for coin in coins:            res = min(res, 1   dp(n - coin))        return res    # 我们要求目标金额是 amount    return dp(amount)

最后明确 base case,显然目标金额为 0 时,所需硬币数量为 0;当目标金额小于 0 时,无解,返回 -1:

def coinChange(coins: List[int], amount: int):

def dp(n):
        # base case
        if n == 0: return 0
        if n < 0: return -1
        # 求最小值,所以初始化为正无穷
        res = float('INF')
        for coin in coins:
            subproblem = dp(n - coin)
            # 子问题无解,跳过
            if subproblem == -1: continue
            res = min(res, 1   subproblem)

return res if res != float('INF') else -1

return dp(amount)

至此,状态转移方程其实已经完成了,以上算法已经是暴力解法了,以上代码的数学形式就是状态转移方程:

至此,这个问题其实就解决了,只不过需要消除一下重叠子问题,比如amount = 11, coins = {1,2,5}时画出递归树看看:

时间复杂度分析:子问题总数 x 解决每个子问题的时间

子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别。

2、带备忘录的递归

只需要稍加修改,就可以通过备忘录消除子问题:

def coinChange(coins: List[int], amount: int):    # 备忘录    memo = dict()    def dp(n):        # 查备忘录,避免重复计算        if n in memo: return memo[n]

        if n == 0: return 0        if n < 0: return -1        res = float('INF')        for coin in coins:            subproblem = dp(n - coin)            if subproblem == -1: continue            res = min(res, 1   subproblem)

        # 记入备忘录        memo[n] = res if res != float('INF') else -1        return memo[n]

    return dp(amount)

不画图了,很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)。

3、dp 数组的迭代解法

当然,我们也可以自底向上使用 dp table 来消除重叠子问题,dp数组的定义和刚才dp函数类似,定义也是一样的:

dp[i] = x表示,当目标金额为i时,至少需要x枚硬币

int coinChange(vector<int>& coins, int amount) {
    // 数组大小为 amount   1,初始值也为 amount   1
    vector<int> dp(amount   1, amount   1);
    // base case
    dp[0] = 0;
    for (int i = 0; i < dp.size(); i ) {
        // 内层 for 在求所有子问题   1 的最小值
        for (int coin : coins) {
            // 子问题无解,跳过
            if (i - coin < 0) continue;
            dp[i] = min(dp[i], 1   dp[i - coin]);
        }
    }
    return (dp[amount] == amount   1) ? -1 : dp[amount];
}

PS:为啥dp数组初始化为amount 1呢,因为凑成amount金额的硬币数最多只可能等于amount(全用 1 元面值的硬币),所以初始化为amount 1就相当于初始化为正无穷,便于后续取最小值。

三、最后总结

第一个斐波那契数列的问题,解释了如何通过「备忘录」或者「dp table」的方法来优化递归树,并且明确了这两种方法本质上是一样的,只是自顶向下和自底向上的不同而已。

第二个凑零钱的问题,展示了如何流程化确定「状态转移方程」,只要通过状态转移方程写出暴力递归解,剩下的也就是优化递归树,消除重叠子问题而已。

如果你不太了解动态规划,还能看到这里,真得给你鼓掌,相信你已经掌握了这个算法的设计技巧。

计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”。

列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整。

备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活?

(0)

相关推荐

  • Python|动态规划问题--斐波那契数列

    斐波那契数列斐波那契数列其表达式如下: 递归算法通过公式我们不难看出,其第一项和第二项为1,当x>=3时,斐波那契数列的第x项就等于其前两项的和.所以我们可以得出代码如下:public stat ...

  • 动态规划(DP)

    本课程是从少年编程网转载的课程,目标是向中学生详细介绍计算机比赛涉及的编程语言,数据结构和算法.编程学习最好使用计算机,请登陆 www.3dian14.org (免费注册,免费学习). 本月7月13日 ...

  • 动态规划之状态转移方程-NOIP提高组历年高频考点(4)

    通过分析NOIP2011-2018年提高组的试题我们就会发现,考察最多的考点前三名就是模拟,动态规划和贪心算法.今天我们来介绍一下动态规划常用的状态转移方程. 动态规划之状态压缩DP-NOIP提高组历 ...

  • (1条消息) 动态规划就此一篇 全网最详细, 逐步理解, 万字总结

    文章目录 动态规划 - 超详细系列 首先,先大致列下这篇文章会讲到什么 1.相较于暴力解法,动态规划带给我们的是什么?为什么会有重叠子问题以及怎么去避免的? 2.用不同难度的动态规划问题举例说明, 最 ...

  • 动态规划之武林秘籍

    听到 动态规划 这个响亮的大名你可能已经望而却步,那是因为这个响亮的名字真的真的很具有迷惑性,不像递归.回溯和贪心等等算法一样,其文即其意,而动态规划则不同,很容易望文生义,真可谓害人不浅,今天我就带 ...

  • 详解最长公共子序列问题,秒杀三道动态规划题目

    作者:labuladong 公众号:labuladong 读完本文,可以去力扣解决如下题目: 1143.最长公共子序列(Medium) 583. 两个字符串的删除操作(Medium) 712.两个字符 ...

  • 胎元命宫详解

    胎元命宫详解 胎元命宫 8.1 胎元 胎, 指人受精怀胎的月份. 其起法是: 人生月后紧接着这个月的天干与生月后第三个月的地支相配, 就为胎元. 如1998年八月生人, 八月为辛酉, 辛后一干是壬, ...

  • 批八字算婚姻详解

    批八字算婚姻详解 很多人喜欢在孩子一出生的时候就给他们算一下八字,因为他们相信孩子的八字和命运是相对注定了的,通过算命之后可以顺利的避免一些可能在生活中遇到的一些问题和坎坷,也可以顺利度过一些&quo ...

  • 电视选购12个重要参数详解,看完你就是专家,附:爆款推荐

    本内容来源于@什么值得买APP,观点仅代表作者本人 |作者:白云上的鱼 创作立场声明:分享电视选购知识,重要参数详解,轻松搞定电视选购. 目前电视的选择太多太多了,品牌百花齐放琳琅满目,各种高科技加成 ...

  • 倪海厦:病是问出来的|问诊十法详解

    倪海厦,美国经方中医,被喻为当代少见的"命.相.卜.山.医"五术兼备之旷世奇人. (倪师)中医的问诊十个法则 我们经方家的问诊非常重要,因此有必要为读者说明一下,如何找经方家看病, ...

  • 为何医生让他把氨氯地平换成缬沙坦?药师详解两类降压药的好与坏

    硝苯地平.氨氯地平.缬沙坦.氯沙坦等等,这些降压药都是高血压患者常用的降压药.从名字中也可以看出这些降压药属于两类不同的降压药,一种是地平类,即为钙离子拮抗剂(CCB),另外一种是沙坦类,即为血管紧张 ...

  • 几何探究类压轴题:精编20例及详解

    成才路上 初中精品学习资料 104篇原创内容 公众号 / END /

  • 高考物理11类重点题型全解析! 附经典例题&详解

    高考理科综合卷中,物理部分选择题有单项和双项选择题两种题型.从最近几年的试题看: 4道单项选择难度低,考查的考点相对稳定且相对单一,涉及的知识点主要有共点力平衡.热力学第一定律.气体状态方程.分子动理 ...

  • 【同步讲练】七年级下册:二元一次方程组七种典型例题详解,一次解决应用问题!

    【同步讲练】七年级下册:二元一次方程组七种典型例题详解,一次解决应用问题!