【动态规划篇】专题(二):路径问题——在网格图中的决策艺术

文章目录

    • [从一维到二维:网格图 DP 模型](#从一维到二维:网格图 DP 模型)
    • [一、 前言:什么是网格图模型?](#一、 前言:什么是网格图模型?)
      • [1.1 升维打击](#1.1 升维打击)
      • [1.2 核心套路](#1.2 核心套路)
    • [二、 基础篇:路径计数](#二、 基础篇:路径计数)
      • [2.1 不同路径 (Medium)](#2.1 不同路径 (Medium))
        • [1. 题目描述](#1. 题目描述)
        • [2. 解题思路](#2. 解题思路)
        • [3. 代码实现 (C++)](#3. 代码实现 (C++))
      • [2.2 不同路径 II (Medium)](#2.2 不同路径 II (Medium))
        • [1. 题目描述](#1. 题目描述)
        • [2. 解题思路](#2. 解题思路)
        • [3. 代码实现 (C++)](#3. 代码实现 (C++))
    • [三、 进阶篇:最值路径](#三、 进阶篇:最值路径)
      • [3.1 礼物的最大价值 (Medium)](#3.1 礼物的最大价值 (Medium))
        • [1. 题目描述](#1. 题目描述)
        • [2. 解题思路](#2. 解题思路)
        • [3. 代码实现 (C++)](#3. 代码实现 (C++))
      • [3.2 最小路径和 (Medium)](#3.2 最小路径和 (Medium))
        • [1. 题目描述](#1. 题目描述)
        • [2. 解题思路](#2. 解题思路)
        • [3. 代码实现 (C++)](#3. 代码实现 (C++))
      • [3.3 下降路径最小和 (Medium)](#3.3 下降路径最小和 (Medium))
        • [1. 题目描述](#1. 题目描述)
        • [2. 解题思路](#2. 解题思路)
        • [3. 代码实现 (C++)](#3. 代码实现 (C++))
    • [四、 终极挑战:反向 DP](#四、 终极挑战:反向 DP)
      • [4.1 地下城游戏 (Hard)](#4.1 地下城游戏 (Hard))
        • [1. 题目描述](#1. 题目描述)
        • [2. 为什么正向 DP 会失败?](#2. 为什么正向 DP 会失败?)
        • [3. 正确思路:反向 DP](#3. 正确思路:反向 DP)
        • [4. 代码实现 (C++)](#4. 代码实现 (C++))
    • [五、 总结:网格 DP 的心法](#五、 总结:网格 DP 的心法)

从一维到二维:网格图 DP 模型

一、 前言:什么是网格图模型?

1.1 升维打击

💬 开篇:上一篇我们搞定了斐波那契数列,那是一维的跳台阶。今天我们要"升维"了!

🚀 循序渐进 :想象一下,你不再是在一条线上跳,而是站在一个棋盘(矩阵)上。你可以向下走,也可以向右走。这种在 m * n 的网格中移动,求路径数最小路径和最大价值 的问题,统称为网格图模型

👍 点赞、收藏与分享:本篇内容涵盖了 6 道高频面试题,特别是最后一道"地下城游戏",是很多人的噩梦。学会这一篇,网格 DP 不再怕!

1.2 核心套路

对于网格类 DP,我们的五步法依然适用,但有两个固定套路:

  1. 状态定义 :通常定义 dp[i][j] 为"从起点走到坐标 (i, j) 时的 xxx"。
  2. 空间技巧 :为了处理第 0 行和第 0 列的边界问题,我们通常多开一行一列 (辅助节点),这样代码会极其清爽,不需要写一堆 if(i==0)

二、 基础篇:路径计数

2.1 不同路径 (Medium)

1. 题目描述

题目链接62. 不同路径

描述

一个机器人位于一个 m x n 网格的左上角。机器人每次只能向下 或者向右 移动一步。机器人试图达到网格的右下角。

问总共有多少条不同的路径?

示例

输入:m = 3, n = 7

输出:28

2. 解题思路

这是网格 DP 的 Hello World

  1. 状态表示
    dp[i][j] 表示:走到 [i, j] 这个位置,一共有多少种走法。

  2. 状态转移方程

    既然只能"向下"或"向右"走,那么想要到达 [i, j],只有两条路:

    • 从上面下来:即从 [i-1, j] 走过来。
    • 从左边过来:即从 [i, j-1] 走过来。
      所以:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化(核心技巧:虚拟边框)

    如果我们直接从 dp[0][0] 开始算,处理第一行时 dp[i-1] 会越界,处理第一列时 dp[j-1] 会越界。
    技巧

    • 申请 dp[m+1][n+1] 的空间。

    • 原来的 (0, 0) 对应现在的 (1, 1)

    • 关键点dp[0][1] = 1。这是一个"引子"。

      • 当计算起点 (1, 1) 时,dp[1][1] = dp[0][1] + dp[1][0] = 1 + 0 = 1。这样起点的方法数就正确初始化为 1 了。
  4. 填表顺序

    从上往下,从左往右。

3. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int uniquePaths(int m, int n) {
        // 1. 创建 dp 表,多开一行一列
        // 初始值设为 0
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));

        // 2. 初始化辅助位置
        // 这是为了保证 dp[1][1] 能算出来是 1
        dp[0][1] = 1; 

        // 3. 填表
        // 注意:实际网格从 row=1, col=1 开始对应题目中的 0,0
        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];
            }
        }

        // 4. 返回结果
        return dp[m][n];
    }
};

2.2 不同路径 II (Medium)

1. 题目描述

题目链接63. 不同路径 II

描述

和上一题一样,但是网格中出现了障碍物 (用 1 表示)。

遇到障碍物不能走,问现在有多少条路径?

示例

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]

输出:2

2. 解题思路

这题是上一题的"补丁版"。

  1. 状态表示

    同上,dp[i][j] 表示走到该位置的方法数。

  2. 状态转移方程(分类讨论)

    在填表通过 (i, j) 时,先看一眼原图 obstacleGrid

    • 如果有障碍物dp[i][j] = 0。(此路不通,方法数为 0)。
    • 如果没有障碍物 :照常计算 dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化

    同样利用 dp[0][1] = 1 或者 dp[1][0] = 1 来启动。

    注意:原数组 obstacleGrid 下标是 0 ~ m-1,对应的 DP 数组下标是 1 ~ m。所以判断障碍物时要用 obstacleGrid[i-1][j-1]

3. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        int m = obstacleGrid.size();
        int n = obstacleGrid[0].size();
        
        // 1. 建立 dp 表
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));
        
        // 2. 初始化启动资金
        // 这样计算 dp[1][1] 时,如果是空地,就会得到 1
        dp[0][1] = 1; 

        // 3. 填表
        for(int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                // 注意下标映射:dp的(i,j) 对应 原图的(i-1, j-1)
                // 如果当前位置是障碍物,保持默认值 0,直接跳过
                if(obstacleGrid[i - 1][j - 1] == 1) {
                    continue; 
                }
                
                // 否则,正常转移
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
        
        return dp[m][n];
    }
};

三、 进阶篇:最值路径

3.1 礼物的最大价值 (Medium)

1. 题目描述

题目链接剑指 Offer 47. 礼物的最大价值

描述

棋盘的每一格都有礼物(价值 > 0)。从左上角出发,每次向右或向下移动。

请计算你最多能拿到多少价值的礼物?

2. 解题思路

从"方案数"变成了"价值累加",核心逻辑不变,只是计算符号变了。

  1. 状态表示
    dp[i][j] 表示:走到 [i, j] 位置时,身上累计能拿到的最大价值。

  2. 状态转移方程

    为了让当前手里的价值最大,我肯定要贪心:是上一步(上方)给我的多,还是上一步(左方)给我的多?
    dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i][j]
    (选一条肥路走,然后加上当前格子的钱)

  3. 初始化

    多开一行一列。因为礼物价值都是正数,辅助边框里的值初始化为 0 即可(表示边界外没有礼物)。

3. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int maxValue(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        // 1. 多开一行一列,默认初始化为 0
        vector<vector<int>> dp(m + 1, vector<int>(n + 1));

        // 2. 填表
        for(int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                // 状态转移:选上面和左边较大的那个 + 当前礼物价值
                int fromUp = dp[i - 1][j];
                int fromLeft = dp[i][j - 1];
                int currentGift = grid[i - 1][j - 1]; // 注意下标映射
                
                dp[i][j] = max(fromUp, fromLeft) + currentGift;
            }
        }
        
        return dp[m][n];
    }
};

3.2 最小路径和 (Medium)

1. 题目描述

题目链接64. 最小路径和

描述

给定一个 m x n 网格,网格里是非负整数。找出一条从左上角到右下角的路径,使得路径上的数字总和最小

只能向下或向右。

2. 解题思路

这题和上一题完全是镜像关系:求最大值变成了求最小值。

  1. 状态表示
    dp[i][j] 表示:走到 [i, j] 时,最小的路径和。

  2. 状态转移方程
    dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]

  3. 初始化(这里有坑!)

    因为我们要求的是 min,如果我们把辅助行(第0行、第0列)都初始化为 0,那完蛋了。
    min(0, 500) 会选 0,结果你的路径就会"穿墙"去选那个虚拟的 0。
    正确做法

    • 把辅助行、辅助列都初始化为 无穷大 (INT_MAX)
    • 唯独 起点的入口 需要特殊处理。让 dp[0][1] = 0dp[1][0] = 0。这样算起点 dp[1][1] 时,min(INF, 0) + grid[0][0] 就能正确得到起点的数值。
3. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int minPathSum(vector<vector<int>>& grid) {
        int m = grid.size();
        int n = grid[0].size();
        
        // 1. 初始化为最大整数,防止干扰 min 计算
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));
        
        // 2. 开个口子,让起点能正确计算
        // 这里的 0 表示到达起点之前没有花费
        dp[0][1] = 0; 
        
        // 3. 填表
        for(int i = 1; i <= m; i++) {
            for(int j = 1; j <= n; j++) {
                // 取上面和左边的较小值
                int val = grid[i - 1][j - 1];
                dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + val;
            }
        }
        
        return dp[m][n];
    }
};

3.3 下降路径最小和 (Medium)

1. 题目描述

题目链接931. 下降路径最小和

描述

给你一个 n x n 的方阵。从第一行 任意一个元素开始,每次往下走。

下一步可以是:正下方左下方右下方

求到达最后一行的最小路径和。

2. 解题思路

这题的特点是:方向变多了(3个方向),且起点不固定(第一行哪里开始都行),终点也不固定(最后一行哪里结束都行)。

  1. 状态表示
    dp[i][j] 表示:到达 [i, j] 位置时的最小下降和。

  2. 状态转移方程

    到达 [i, j] 可以从上一行的三个位置过来:

    • 正上方:dp[i-1][j]
    • 左上方:dp[i-1][j-1]
    • 右上方:dp[i-1][j+1]
      dp[i][j] = min(正上, 左上, 右上) + matrix[i][j]
  3. 初始化(核心技巧:左右扩列)

    这题如果你只扩充上边和左边是不够的,因为还有"右上方"。最右边的一列会去访问 j+1,导致越界。
    方案

    • 左右各多加一列,初始化为 无穷大(墙壁)。
    • 上面多加一行,初始化为 0(因为题目说可以从第一行任意位置开始,意味着第一行作为起点的代价就是它本身,上一行的虚拟代价为0)。
3. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int minFallingPathSum(vector<vector<int>>& matrix) {
        int n = matrix.size();
        
        // 1. 申请 dp 表
        // 行:n + 1 (多一行顶部的虚拟行)
        // 列:n + 2 (左右各多一列虚拟墙)
        vector<vector<int>> dp(n + 1, vector<int>(n + 2, INT_MAX));
        
        // 2. 初始化第一行(虚拟行)为 0
        // 这样计算实际第一行时,就是 0 + matrix[0][j]
        for(int j = 0; j < n + 2; j++) dp[0][j] = 0;
        
        // 3. 填表
        for(int i = 1; i <= n; i++) {
            for(int j = 1; j <= n; j++) {
                // 三个方向找最小
                // 注意:dp 表的列 j 对应 matrix 的列 j-1
                int val = matrix[i - 1][j - 1];
                
                int min_prev = min(dp[i - 1][j],       // 正上
                               min(dp[i - 1][j - 1],   // 左上
                                   dp[i - 1][j + 1])); // 右上
                
                dp[i][j] = min_prev + val;
            }
        }
        
        // 4. 返回值
        // 答案是最后一行 dp[n][1...n] 中的最小值
        int result = INT_MAX;
        for(int j = 1; j <= n; j++) {
            result = min(result, dp[n][j]);
        }
        
        return result;
    }
};

四、 终极挑战:反向 DP

4.1 地下城游戏 (Hard)

1. 题目描述

题目链接174. 地下城游戏

描述

骑士在左上角,公主要在右下角。网格里有怪兽(负数,扣血)和血瓶(正数,回血)。

骑士从左上走到右下,要求路途上任何时刻血量都 > 0

问骑士出发时至少需要多少初始血量?

2. 为什么正向 DP 会失败?

很多同学上来就定义:dp[i][j] 表示走到 (i, j) 剩余的最大血量。

这是错的!
原因

假设在这个格子里,你剩余 100 滴血,但接下来面对的是个 -999 的大BOSS;

另一条路你剩余 5 滴血,但接下来全是血瓶。

正向推导时,你不知道未来的路有多难走,所以无法判断当前哪个状态是"最优"的。这道题具有"后效性"。

3. 正确思路:反向 DP

我们从终点往回推

假设我们已经到了公主面前,然后一步步倒退回起点,看沿途至少需要多少血。

  1. 状态表示
    dp[i][j] 表示:从 [i, j] 出发,到达终点,至少需要的初始血量。

  2. 状态转移方程

    [i, j] 出发,你可以往 走,也可以往 走。

    为了活命,我们肯定选一条门槛更低(需要血量更少)的路。

    • 如果往右走:需要 dp[i][j+1] 的血。
    • 如果往下走:需要 dp[i+1][j] 的血。
    • min_need = min(往右, 往下)

    那么在当前 [i, j] 位置,我们需要多少血呢?
    dp[i][j] + dungeon[i][j] >= min_need

    移项得:dp[i][j] = min_need - dungeon[i][j]

    关键修正

    如果算出来 dp[i][j] 是负数或者 0(说明这里全是血瓶,甚至不需要血就能过),但这不符合题目规则(血量必须 > 0)。所以最低也要有 1 滴血。
    dp[i][j] = max(1, min(dp[i+1][j], dp[i][j+1]) - dungeon[i][j])

  3. 初始化与填表

    • 因为是反向,所以我们要多开最后一行、最后一列。
    • 初始化为 无穷大(边界墙)。
    • 终点出口dp[m][n-1]dp[m-1][n] 初始化为 1。这表示到达公主之后,至少还要剩 1 滴血不死。
4. 代码实现 (C++)
cpp 复制代码
class Solution {
public:
    int calculateMinimumHP(vector<vector<int>>& dungeon) {
        int m = dungeon.size();
        int n = dungeon[0].size();
        
        // 1. dp 表,多开一行一列
        // 初始化为 INT_MAX,表示墙壁(走不通,需要的血量无穷大)
        vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));
        
        // 2. 初始化终点的出口
        // 也就是到了公主那里,至少要留 1 滴血
        dp[m][n - 1] = 1;
        dp[m - 1][n] = 1;
        
        // 3. 填表:从右下角开始,往左上角推
        for(int i = m - 1; i >= 0; i--) {
            for(int j = n - 1; j >= 0; j--) {
                // 1. 找后路中要求最低的那条
                int min_future_need = min(dp[i + 1][j], dp[i][j + 1]);
                
                // 2. 倒推当前位置需要的血
                int current_need = min_future_need - dungeon[i][j];
                
                // 3. 修正:如果算出来是负数或0,说明这里血瓶管够,
                // 但活着至少需要 1 滴血
                dp[i][j] = max(1, current_need);
            }
        }
        
        // 4. 返回起点需要的血量
        return dp[0][0];
    }
};

五、 总结:网格 DP 的心法

💬 总结:恭喜你!这 6 道题刷完,你已经攻克了二维 DP 的半壁江山。

📚 核心知识点回顾:

题目 核心逻辑 边界初始化技巧
不同路径 上 + 左 dp[0][1]=1, 其余 0
礼物最大值 max(上, 左) + val 全 0
最小路径和 min(上, 左) + val 全 INF, dp[0][1]=0
下降路径 min(上, 左上, 右上) + val 左右列 INF, 顶行 0
地下城 反向 min(右, 下) - val 边框 INF, 终点出口 1

🧠 记忆口诀

网格 DP 并不难,

状态定义是关键。

边界多开一行列,

初始化里藏玄机。

正常路径左上推,

生命游戏右下逆。

相关推荐
张李浩15 小时前
Leetcode 054螺旋矩阵 采用方向数组解决
算法·leetcode·矩阵
big_rabbit050215 小时前
[算法][力扣101]对称二叉树
数据结构·算法·leetcode
美好的事情能不能发生在我身上16 小时前
Hot100中的:贪心专题
java·数据结构·算法
myloveasuka16 小时前
Java与C++多态访问成员变量/方法 对比
java·开发语言·c++
2301_8217005316 小时前
C++编译期多态实现
开发语言·c++·算法
奥地利落榜美术生灬16 小时前
c++ 锁相关(mutex 等)
开发语言·c++
xixihaha132416 小时前
C++与FPGA协同设计
开发语言·c++·算法
小小怪75017 小时前
C++中的函数式编程
开发语言·c++·算法
xixixiLucky17 小时前
编程入门算法题---小明爬楼梯求爬n层台阶一共多少种方法
算法
剑锋所指,所向披靡!17 小时前
数据结构之线性表
数据结构·算法