【动态规划篇】:动态规划解决路径难题--思路,技巧与实例

✨感谢您阅读本篇文章,文章内容是个人学习笔记的整理,如果哪里有误的话还请您指正噢✨
✨ 个人主页:余辉zmh--CSDN博客
✨ 文章所属专栏:动态规划篇--CSDN博客

文章目录

一.动态规划中的路径问题

动态规划中的路径问题通常涉及在网格或图中寻找最优路径,如最短路径,路径数目或者最大和最小和路径等。

1.核心思路

  • 状态表示:

    如果是由前状态推导出后状态,dp[i] [j]表示以[i,j]为终点,到达该位置的状态(如路径个数,最小和或者最大和等);如果是由后状态推出前状态,dp[i] [j]表示以[i,j]为起点,到达终点的状态(如路径个数,最小和或者最大和等)。

  • 状态转移方程:

    如果是由前状态推导出后状态,找到当前位置[i,j]可以通过哪些下标达到,也就是前一个状态;如果是由后状态推出前状态,找到当前位置[i,j]可以达到哪些下标,也就是后一个状态。不同类型的路径问题的状态转移方程格式也不同,需要根据题意来决定。

  • 初始化:

    根据状态转移方程方程处理边界条件,防止越界或者状态值出现误差,因为数组下标从0开始,所以通常在处理状态表时,添加第0行和第0列作为虚拟下标,部分题中可能还需要添加最后一行或者最后一列,需要根据题意来决定。

  • 填表顺序:

    通常就是两种情况,因为状态表是二维数组,如果是由前状态推导出后状态,从第一行到最后一行,其中每一行的顺序是从左往右;如果是由后状态推出前状态,从最后一行到第一行,其中每一行的顺序是从右往左。

  • 返回值:

    根据题意决定,假设以网格中最右下角[m,n]为终点,如果是由前状态推导出后状态,返回dp[m] [n];如果是由后状态推导出前状态,返回dp[0] [0]。

2.注意事项

简单总结一下,动态规划处理路径问题中需要注意一下几点:

  • 状态表示是否合理,能否覆盖所有可能的路径情况。

  • 状态转移方程是否正确,是否覆盖了当前位置所有的移动方向。

  • 初始化是否正确,特别是边界处理,如果添加虚拟下标,能否保证后面边界状态值的正确以及下标的映射是否对应。

  • 特殊情况的处理,比如当前路径上存在障碍,起点终点不可达等情况。

路径问题的动态规划"大多"都是涉及到二维数组,因此在学习路径问题时建议先了解涉及一维数组的简单练习题,比如斐波那契数列模型的动态规划类题,有了前面的经验,在处理二维数组的问题时,状态表示和状态转移方程其实就能更好理解,个人认为,路径问题涉及的难点在于对初始化的处理,也就是边界处理,所以在接下来的例题讲解中会重点讲解每道题的边界处理情况。

二.例题讲解

1.不同路径

题目:


算法原理:

本道题就是典型的路径个数问题,这里用前状态推导出后状态的方式来定义状态表示,dp[i] [j]表示以[i,j]为结尾 到达该位置的路径总数,填表顺序就是从第一行到最后一行,其中每一行的顺序是从左往右,题意要求右下角为终点,所以最后返回的是dp[m][n](m表示行数,n表示列数)。

根据题意要求,每次移动可以向下或者向右移动,因此想要到达下标[i,j]的位置有两种情况,一种是下标为[i-1,j]的位置向下移动(重复子问题,用dp[i-1] [j]表示);另一种是下标为[i,j-1]的位置向右移动(重复子问题,用dp[i] [j-1]表示),两种情况的路径和就是dp[i] [j]的值,因此状态转移方程就是dp[i] [j] = dp[i - 1] [j] + dp[i] [j - 1]

重点:初始化处理

代码实现:

cpp 复制代码
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 - 1][j] + dp[i][j - 1];
        }
    }

    //返回值
    return dp[m][n];
}

2.不同路径2

题目:

算法原理:

本道题也是属于路径个数问题,但是和上一道题不同的是,本题在网格中增加了障碍下标,也就是说可能会存在某些路径无法通过的情况。

本题的整体思路都和上一道题相同(包括初始化的处理)这里就不再重复讲解,唯一不同的是需要处理障碍下标对应的状态值,如果当前下标是障碍,说明不管上一个状态总共有多少种路径,遇到障碍就会全都不能通过,所以相当于路径变为0,因此障碍下标对应的状态值直接设置为0即可,不用再找前状态。

代码实现:

cpp 复制代码
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid){
    int m = obstacleGrid.size(), n = obstacleGrid[0].size();

    //状态表示,dp[i][j]表示以[i,j]为结尾,到达该位置的路径总数,起点从[1,1]开始
    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++){
            //判断当前位置是否是障碍
            if(obstacleGrid[i-1][j-1]==1){
                dp[i][j] = 0;
            }
            else{
                //状态转移方程
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
    }

    //返回值
    return dp[m][n];
}

3.珠宝的最高价值

题目:

算法原理:

根据题意要求,最后需要找到价值最高的路径,因此本道题属于路径问题中的最大和路径,同样这里还是用前状态推导出后状态,dp[i][j]表示以[i,j]为终点,到达该位置时的最高价值(最大和),填表顺序就是从第一行到最后一行,其中每一行的顺序是从左往右,题意要求右下角为终点,所以最后返回的是dp[m][n](m表示行数,n表示列数)。

根据题意要求,每次移动可以向下或者向右移动,因此想要到达下标[i,j]的位置有两种情况,一种是下标为[i-1,j]的位置向下移动(重复子问题,用dp[i-1] [j]表示到达[i-1][j]下标的最高价值);另一种是下标为[i,j-1]的位置向右移动(重复子问题,用dp[i] [j-1]表示表示到达[i][j-1]下标的最高价值),通过贪心策略每次移动都选择价值最高的那种情况然后加上当前下标[i][j]对应的价值(注意因为状态表添加了虚拟下标,起点从[1][1]开始,所以当前下标映射到价值数组时是下标[i-1][j-1])。

重点:初始化处理

代码实现:

cpp 复制代码
int jewelleryValue(vector<vector<int>>& frame){
    int m = frame.size(), n = frame[0].size();

    //状态表示 以[i,j]为结尾 dp[i][j]表示到达[i,j]位置时的最高价值 起点从[1,1]开始
    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 - 1][j], dp[i][j - 1]) + frame[i - 1][j - 1];
        }
    }

    //返回值
    return dp[m][n];
}

4.最小路径和

题目:

算法原理:

本道题和上面那道最大和问题可以归为一类,并且本题和上一题的题意要求大致相同,所以整体思路还是大致相同(这里就不再过多重复),只不过本题需要找到的是最小和路径,所以相较于上一题,本题有两个地方需要修改。

第一个:因为需要找的是最小和,所以状态转移方程那里,根据贪心策略每次移动都选择最小和的那种情况,因此需要使用min函数取小的而不是取大的。

第二个:初始化处理时,上一道题需要使用max取较大的,所以虚拟下标对应的状态值用0填充,保证边界的状态值正确;而本题需要用min函数取较小的,所以虚拟下标对应的状态值用整形的最大值(INT_MAX)填充,这样才能保证边界的状态值正确,而这里还要将下标为[0][1]用0填充,保证起点下标的状态值正确。

代码实现:

cpp 复制代码
int minPathSum(vector<vector<int>>& grid){
    int m = grid.size(), n = grid[0].size();

    //状态表示,以[i,j]为结尾,dp[i][j]表示到达[i,j]位置时的最小路径和,起点从[1,1]开始
    vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));

    //初始化
    dp[0][1] = 0;

    //填表
    for (int i = 1; i <= m; i++){
        for (int j = 1; j <= n; j++){
            //状态转移方程
            dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
        }
    }

    //返回值
    return dp[m][n];
}

5.下降路径最小和

题目:

算法原理:

本道题和最小路径和是同一类问题,但是这道题有点不一样,起点和终点不再局限于左上角和右下角,而是起点变成第一行,终点变成最后一行,并且移动方向也不再是向下或者向右两种方向移动,而是变成了左下方向,正下方向,右下方向三种移动方向。

以下标[i,j]为结尾,dp[i][j]表示到达该位置时的最小路径和,有前状态推出后状态,填表顺序就是从第一行到最后一行,其中每一行的顺序是从左往右,注意因为最后一行中的每个下标都可以作为终点,所以在填完状态表后,遍历最后一行,找到最小路径和返回。

根据题意要求,每次移动可以向下,向左下或者向右下移动,因此想要到达下标[i,j]的位置有3种情况,第一种是下标为[i-1,j]的位置向下移动(重复子问题,用dp[i-1] [j]表示到达[i-1][j]下标的最小路径和);第一种是下标为[i-1,j-1]的位置向右下移动(重复子问题,用dp[i-1] [j-1]表示表示到达[i-1][j-1]下标的最小路径和);第三种是下标为[i-1,j+1]的位置向左下移动(重复子问题,用dp[i-1] [j+1]表示表示到达[i-1][j+1]下标的最小路径和);通过贪心策略每次移动都选择路径和最小的那种情况然后加上当前下标[i][j]对应的路径值(注意因为状态表添加了虚拟下标,起点从[1][1]开始,所以当前下标映射到路径值数组时是下标[i-1][j-1])。

重点:初始化处理

代码实现:

cpp 复制代码
int minFallingPathSum(vector<vector<int>>& matrix){
    int m = matrix.size(), n = matrix[0].size();

    //状态表示,以[i,j]为结尾,dp[i][j]表示到达[i,j]位置时的下降路径最小和 起点是下标为1的行
    //学到个新的点,二维数组指定值初始化
    vector<vector<int>> dp(m + 1, vector<int>(n + 2, INT_MAX));

    //初始化
    for (int j = 0; j <= n + 1; j++){
        dp[0][j] = 0;
    }

    //填表
    for (int i = 1; i <= m; i++){
        for (int j = 1; j <= n; j++){
            //状态转移方程
            dp[i][j] = min(min(dp[i - 1][j - 1], dp[i - 1][j]), 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[m][j]);
    }

    return ret;
}

6.地下城游戏

题目:

算法原理:

本道题和上面的几道题不同,属于较复杂的路径问题,难点在于转换思想,不再局限于由前状态推出后状态(我当时自己写的就是一直局限于前推后,各种复杂情况需要处理,后来发现反着推会非常简单)。

如果是由前推后,dp[i][j]表示到达[i,j]位置时所需的最小初始血量,以示例一为例,起点位置的健康值为-2,说明初始血量最小需要为3才能通过,但是,如果设置为3,往下移动是-5,或者往右移动是-3,此时的最小初始血量都不能保证通过,所以,最小初始血量不光受当前位置前的状态影响,还受当前位置后的状态影响,所以无法由前状态推出后状态。这时候就需要反向思考。

dp[i][j]表示以下标[i,j]为起点,到达终点时所需的最小初始血量,有后状态推出前状态,填表时就是从最后一行到第一行,其中每一行的顺序是从右向左,题中的起点位置为下标[0,0],所以最后返回的结果是dp[0,0],表示以下标[0,0]为起点,到达重点是所需的最小血量。

假设当前下标为[i,j],从该位置到下一个位置有两种情况,第一种是向右移动到下标[i,j+1]位置(重复子问题,用dp[i][j+1]表示该位置到达终点所需的最小初始血量);第二种是向下移动到下标[i+1,j]位置(重复子问题,用dp[i+1][j]表示该位置到达终点所需的最小初始血量);根据贪心思想,选择两种情况中的最小值,假设当前位置所需的最小初始血量dp[i][j]为x,下一个位置的为y(dp[i][j+1]或者dp[i+1][j]),x+当前位置消耗的血量(dungeon[i][j])需要大于等于y才能到达下一个位置,所以x需要大于等于y减去当前位置消耗的血量(dungeon[i][j]),取最小的就是等于。注意点如果y减去当前位置消耗的血量(dungeon[i][j])小于等于0,说明当前不用消耗血量,而是治疗,但是治疗过量变为负数,说明初始血量最小为1就能通过当前位置。

重点:初始化处理

代码实现:

cpp 复制代码
int calculateMinimumHP(vector<vector<int>>& dungeon){
    int m = dungeon.size(), n = dungeon[0].size();

    //状态表示,以[i,j]为起点,dp[i][j]表示到达终点时需要的最小健康值,终点为[m-1,n-1]
    //由后状态推出前状态
    vector<vector<int>> dp(m + 1, vector<int>(n + 1,INT_MAX));

    //初始化
    dp[m][n - 1] = 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]) - dungeon[i][j];
            if(dp[i][j]<=0){
                dp[i][j] = max(1, dp[i][j]);
            }
        }
    }

    //返回值
    return dp[0][0];
}

以上就是关于动态规划中的路径问题讲解,如果哪里有错的话,可以在评论区指正,也欢迎大家一起讨论学习,如果对你的学习有帮助的话,点点赞关注支持一下吧!!!

相关推荐
蓝色洛特17 分钟前
【matlab优化算法-17期】基于DBO算法的微电网多目标优化调度
开发语言·算法·matlab
豆豆酱1 小时前
强化学习到大模型训练理论概要(三)
算法
Strive_Sun1 小时前
Windows 下搭建 googletest 测试框架(C/C++)
c语言·开发语言·c++·windows
小禾苗_1 小时前
C++ ——基础进阶
开发语言·c++
别开生面的阿杰1 小时前
C++--iomanip库
开发语言·c++
羽觞醉月112 小时前
C++基础 | 线程`std::thread`
开发语言·c++·算法
南宫生2 小时前
力扣动态规划-26【算法学习day.120】
java·数据结构·算法·leetcode·动态规划
不想编程小谭2 小时前
从小白开始的动态规划
c++·算法·动态规划
一只码代码的章鱼3 小时前
数据结构与算法-动态规划-状态机(股票问题,密码设计)
算法·动态规划·代理模式
surtr13 小时前
【C++】RBTree(红黑树)模拟实现
数据结构·c++·算法·stl·map·红黑树·rbtree