C++算法 —— 动态规划(2)路径问题


每一种算法都最好看完第一篇再去找要看的博客,因为这样会帮你梳理好思路,看接下来的博客也就更轻松了。当然,我也会尽量在写每一篇时都可以让不懂这个算法的人也能边看边理解。

1、动规思路简介

动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。

状态表示
状态转移方程
初始化
填表顺序
返回值

动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。

状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定,这点在下面的题解中慢慢领会。

状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。

要确定方程,就从最近的一步来划分问题。

初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。

第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护,也就是下标的变化。

填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。

返回值,要看题目要求。

2、不同路径

62. 不同路径

在这里插入图片描述
在这里插入图片描述

这是一个很经典的题目了,还有类似的题目是走迷宫,可走的路径上都是0,代表墙的就是1。

每一步都可以是向右或者向下。动规(1)中状态的表示我们分为以i位置为结尾或者起点,但这里是一个二维的空间,所以就变成以[i, j]为结尾或起点,这道题我们把[i, j]当作结尾。

题目中需要求到达右下角时有多少条路径。在分析状态转移方程时,我们采用最近的一步来分析问题。到达一个点只能是左边的节点向右走,或者上面的节点向下走,才能走到这个节点,而左边或者上面的节点也同样是这两种情况走过来的,所以对于这个题,状态的表示就可以理解成dp[i, j]等于到达这个位置的路径数,而状态转移方程要如何确定?如果是从左边节点向右移动过来的,那么这个节点的路径数应当是左边这个节点的路径数,为什么不加1?要注意,走一步和路径是两个概念,左边节点代表着来到这个节点的路径数,这些路径都只需要再往右走一步即可,不需要再新增一个路径,所以只是路径改变了,但是数量不变,所以dp[i][j] = dp[i][j - 1]。上面的节点向下走一步也同理,dp[i][j] = dp[i - 1][j]。所以状态转移方程就是dp[i][j - 1] + dp[i - 1][j] = dp[i][j]。

第一行和第一列的节点可不全是由这两种情况演变过来的,第一列的节点的左边就越界了,所以我们可以先把第一行第一列的所有位置给初始化固定的数值,然后再一步步向右下角计算。另一种方法就是设立虚拟节点,创建数组时多创建一行一列,初始化新表的第一行第一列,那么旧表的第一行第一列就可以通过新表的第一行第一列来计算得到,计算公式就是dp[i][j - 1] + dp[i - 1][j] = dp[i][j]。,然后向右下角逐渐计算。

现在看貌似第二种方法也不是很高效,这只是因为现在这样的题没法看出来,有些题目用第二种方式代码会更简洁。

这道题我们就用第二种方式来写。根据上面的分析,状态表示就是dp[i, j]代表到达这个位置的路径数,状态转移方程是dp[i][j - 1] + dp[i - 1][j] = dp[i][j]。,初始化时多创建一行一列,填表顺序是从上往下填写每一行,每一行从左到右填写,返回值就是右下角那个位置的值。

这里面还剩初始化的问题没有解决,原数组中第一行第一列,它们的每个位置应当都是1,它们只可能是从左边或者从上面没移动过来的,所以只能是1,而新增的第一行第一列要如何设置数值来让它们都是1?其实我们只需要确定新表中第二行第二列的那个数就行,因为除去第一行第一列,其它数字都可以通过它来得到,所以可以这样设置

在这里插入图片描述

分析完成,写代码

    int uniquePaths(int m, int n){
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[0][1] = 1;
        for(int i = 1; i <= m; i++)
        {
            for(int j = 1;j <= n; j++)
            {
                dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
            }
        }
        return dp[m][n];
    }

3、不同路径Ⅱ

63. 不同路径 II

在这里插入图片描述
在这里插入图片描述

状态表示是到达[i, j]时一共有多少种方法。状态转移方程有所改变。如果[i, j]是障碍物,那么就没法到这个位置,也就是0;如果不是障碍物,那就和上一个题一样,dp[i][j - 1] + dp[i - 1][j] = dp[i][j],但如果上面或者左边是障碍物怎么办?其实没影响,因为如果是障碍物,那个位置就是0,加上0不影响最终结果。其实和上一个题比较,唯一的不同就是某个位置为障碍物就为0。

初始化时设立虚拟节点,增加一行一列,和上一道题一样,保证第二行第二列那个值正确就行。所以和上面的代码一样,dp[0][1] = 1或者dp[1][0] = 1就行。

填表顺序和上面一样,映射关系要处理好,返回值就是右下角那个值。

    int uniquePathsWithObstacles(vector<vector<int>>& ob) {
        int m = ob.size(), n = ob[0].size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        dp[1][0] = 1;
        for(int i = 1; i <= m ; i++)
        {
            for(int j = 1; j <= n; j++)
            {
                if(ob[i - 1][j - 1] == 0)//体现了映射关系,我们需要都减去1才能找到原表的元素
                    dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
                else dp[i][j] = 0;
            }
        }
        return dp[m][n];
    }

4、礼物的最大价值

剑指 Offer 47. 礼物的最大价值

在这里插入图片描述

这个题和不同路径那个题相似。题目要求计算走到右下角最多能拿到多少,只能走右边或者下边。对于右下角那个位置的数值,它能拿到的最大价值要从上面节点和左面节点中选择,也就是这两个中的较大值,再加上右下角位置的本身数值,就是最大的价值。既然是这样的思路,那么上面和左面的节点就得是到这个节点时最大的价值,所有状态表示就是dp[i][j]是到达这个位置时的最大价值。

根据上面的分析,当前位置的最大价值是上面和左面节点的较大值,再加上当前位置的价值,所以状态转移方程就是dp[i][j] = max(dp[i - 1][j] + dp[i][j - 1]) + 当前位置的价值。

初始化时,我们还是采用多加一行多加一列的方式,这个方法的注意事项就是新增行列的值要保证后面位置的值要正确,以及新表和旧表的映射关系。原表已经在每个位置都放上了对应的数值,如果新增行列的话,应当不影响原先的数值,所以都设置为0,而vector默认初始化为0。

填表顺序就是每一列从上到下,每一行从左到右,返回值则是右下角那个值。

    int maxValue(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        for(int i = 1; i <= m; i++)
        {
            for(int j = 1; j <= n; j++)
            {
                dp[i][j] = max(dp[i][j - 1], dp[i - 1][j]) + grid[i - 1][j - 1];
            }
        }
        return dp[m][n];
    }

5、下降路径最小和

931. 下降路径最小和

在这里插入图片描述
在这里插入图片描述

看题目可能比较懵,但好在题目最后给了公式。从公式中看出来,一个值和要比较的三个值之间呈现一个三角形,一个位置可能由上面3个位置而来,虽然题目中给的是一个值和下面三个值的关系,但我们把它反过来也一样,就变成了一个值和上面3个值。

状态表示还是到达[i, j]位置时最小的下降路径。状态转移方程题目已经给了,dp[i][j] = 上面三个位置的最小值再加上当前位置的数值,这三个位置分别是dp[i - 1][j - 1],dp[i - 1][j],dp[i - 1][j + 1],找出这个三个的最小值就行。

初始化是为了防止越界问题,这次的初始化有所不同,不能直接一行,一列,而是要让新加的行列把整个表包围起来,左右边各新增一列,上面新增一行。如何初始化新增的行列?对于最上面一行来说,也就是新增的行,它不能影响原表第一行的元素,所以都为0;而对于新增的列来说,不能为0,因为我们要的是最小值,新增的列的数值不应当影响比较,所以我们把它初始化为正无穷大。初始化时先都设为正无穷,然后再把第一行全设为0就行。新增行列后,原表中的[0, 0]变为新表的[1, 1],所以新表的[i, j]对应原表的[i - 1][j - 1]。

填表顺序是从上到下。返回值不是右下角的值,因为这个题要求到达最后一行,所以要取最后一行的最小值就行。

    int minFallingPathSum(vector<vector<int>>& matrix) {
        int n = matrix.size();
        vector<vector<int>> dp(n + 1, vector<int>(n + 2, INT_MAX));
        for(int j = 0; j < n + 2; j++)
        {
            dp[0][j] = 0;
        }
        for(int i = 1; i <= n; i++)
        {
            for(int j = 1; j <= n; j++)
            {
                dp[i][j] = min(min(dp[i - 1][j], dp[i - 1][j + 1]), dp[i - 1][j - 1]) + matrix[i - 1][j - 1];
            }
        }
        int ret = INT_MAX;
        for(int j = 1; j <= n; j++)
        {
            ret = min(ret, dp[n][j]);
        }
        return ret;
    }

6、最小路径和

64. 最小路径和

在这里插入图片描述

这道题和礼物的最大价值是一个题,只不过一个求最大,一个求最小。不过需要注意的是,如果是求最大,我们把新增行列初始化为0,然后找特殊位置;而求最小,则是初始化为正无穷,然后找特殊位置。这个题的特殊位置就是像上图中的左上角位置,它的数值是1,即使加上新增行列中的对应的两个位置的较小值也不能被改变数值,所以新表dp[0][1] = dp[1][0] = 1。其余新增位置设为正无穷即可。

    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));
        dp[0][1] = dp[1][0] = 0;
        for(int i = 1; i <= m; i++)
        {
            for(int j = 1; j <= n; j++)
            {
                dp[i][j] = min(dp[i][j - 1], dp[i - 1][j]) + grid[i - 1][j - 1];
            }
        }
        return dp[m][n];
    }

7、地下城游戏

174. 地下城游戏

在这里插入图片描述
在这里插入图片描述

这个题看起来稍有点不一样。状态表示应该是什么?还是以某个位置为结尾或者为起点。如果是为结尾,意思是从起点出发,到[i, j]位置时,所需的最低初始健康点数是多少。我们需要用到上面和左面位置来更新当前位置的值,但实际上却不止这点,因为即使我们算出来当前位置的值,我们还需要考虑右面或者下面节点的值,如果它们是个-100,那么很有可能直接死亡,那又得重新计算了,可以自己列举几个数字,按照一条路线试着走,会发现需要不断再改变初始值,再走一遍。所以这个题适合以当前节点为起点,从[i, j]位置出发,到达终点,所需的最低初始健康值,每一步都可能是往右或者往下,当前位置的值要选择右边位置和下边位置的较小值,并且还要考虑当前位置的值,因为当前位置还没过去,所以假设x是最低初始值,那么仅看一条路线,x + 原数组[i][j] >= dp[i][j + 1],也就是x >= dp[i][j + 1] - 原数组[i][j],那么往下走就是x >= dp[i + 1][j] - 原数组[i][j],求右边和下边的较小值就可,但这里仍然有坑点,如果当前位置的值是个负数呢?我们走到这里,可能直接死亡了,所以我们要更换一下dp[i][j],让它和1取较大值,把一个负数变成1,因为我们还要走下去,要至少有一点血量才行。如果走到当前位置,dp[i][j]是正数,这没问题,但如果是负数,说明挂了,我们就得改为1,这样就能保证一路上挂不了,也能求出来最低初始值。

初始化时还是多加一列多加一行,但因为比较的值是右边和下边的,所以我们要在旧表的右边和下边添加一列一行,并且这时候就不用考虑映射关系了,因为旧表的元素在新表的下标还是一样。特殊位置是在右下角那个值,走到右下角时,我们要保证血量还剩至少1,那么它的右边和下边就得是1,而其他位置,因为求最小值,所以为了不影响计算,就初始化成正无穷。

填表顺序和之前不一样,应当是每列从下往上,每行从右往左填。返回值是左上角那个值。

    int calculateMinimumHP(vector<vector<int>>& dg) {
        int m = dg.size(), n = dg[0].size();
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));
        dp[m][n - 1] = dp[m - 1][n] = 1;
        for(int i = m - 1; i >= 0; i--)
        {
            for(int j = n - 1; j >= 0; j--)
            {
                dp[i][j] = min(dp[i + 1][j], dp[i][j + 1]) - dg[i][j];
                dp[i][j] = max(1, dp[i][j]);
            }
        }
        return dp[0][0];
    }

结束。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值