文章目录
-
- [从一维到二维:网格图 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,我们的五步法依然适用,但有两个固定套路:
- 状态定义 :通常定义
dp[i][j]为"从起点走到坐标(i, j)时的 xxx"。 - 空间技巧 :为了处理第 0 行和第 0 列的边界问题,我们通常多开一行一列 (辅助节点),这样代码会极其清爽,不需要写一堆
if(i==0)。
二、 基础篇:路径计数
2.1 不同路径 (Medium)
1. 题目描述
题目链接 :62. 不同路径
描述 :
一个机器人位于一个
m x n网格的左上角。机器人每次只能向下 或者向右 移动一步。机器人试图达到网格的右下角。问总共有多少条不同的路径?
示例 :
输入:
m = 3, n = 7输出:
28
2. 解题思路
这是网格 DP 的 Hello World。
-
状态表示 :
dp[i][j]表示:走到[i, j]这个位置,一共有多少种走法。 -
状态转移方程 :
既然只能"向下"或"向右"走,那么想要到达
[i, j],只有两条路:- 从上面下来:即从
[i-1, j]走过来。 - 从左边过来:即从
[i, j-1]走过来。
所以:dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 从上面下来:即从
-
初始化(核心技巧:虚拟边框) :
如果我们直接从
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 了。
- 当计算起点
-
-
填表顺序 :
从上往下,从左往右。
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. 解题思路
这题是上一题的"补丁版"。
-
状态表示 :
同上,
dp[i][j]表示走到该位置的方法数。 -
状态转移方程(分类讨论) :
在填表通过
(i, j)时,先看一眼原图obstacleGrid。- 如果有障碍物 :
dp[i][j] = 0。(此路不通,方法数为 0)。 - 如果没有障碍物 :照常计算
dp[i][j] = dp[i-1][j] + dp[i][j-1]。
- 如果有障碍物 :
-
初始化 :
同样利用
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. 解题思路
从"方案数"变成了"价值累加",核心逻辑不变,只是计算符号变了。
-
状态表示 :
dp[i][j]表示:走到[i, j]位置时,身上累计能拿到的最大价值。 -
状态转移方程 :
为了让当前手里的价值最大,我肯定要贪心:是上一步(上方)给我的多,还是上一步(左方)给我的多?
dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i][j]
(选一条肥路走,然后加上当前格子的钱) -
初始化 :
多开一行一列。因为礼物价值都是正数,辅助边框里的值初始化为
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. 解题思路
这题和上一题完全是镜像关系:求最大值变成了求最小值。
-
状态表示 :
dp[i][j]表示:走到[i, j]时,最小的路径和。 -
状态转移方程 :
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j] -
初始化(这里有坑!) :
因为我们要求的是
min,如果我们把辅助行(第0行、第0列)都初始化为0,那完蛋了。
min(0, 500)会选 0,结果你的路径就会"穿墙"去选那个虚拟的 0。
正确做法:- 把辅助行、辅助列都初始化为 无穷大 (
INT_MAX)。 - 唯独 起点的入口 需要特殊处理。让
dp[0][1] = 0或dp[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个方向),且起点不固定(第一行哪里开始都行),终点也不固定(最后一行哪里结束都行)。
-
状态表示 :
dp[i][j]表示:到达[i, j]位置时的最小下降和。 -
状态转移方程 :
到达
[i, j]可以从上一行的三个位置过来:- 正上方:
dp[i-1][j] - 左上方:
dp[i-1][j-1] - 右上方:
dp[i-1][j+1]
dp[i][j] = min(正上, 左上, 右上) + matrix[i][j]
- 正上方:
-
初始化(核心技巧:左右扩列) :
这题如果你只扩充上边和左边是不够的,因为还有"右上方"。最右边的一列会去访问
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
我们从终点往回推 。
假设我们已经到了公主面前,然后一步步倒退回起点,看沿途至少需要多少血。
-
状态表示 :
dp[i][j]表示:从[i, j]出发,到达终点,至少需要的初始血量。 -
状态转移方程 :
从
[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]) - 如果往右走:需要
-
初始化与填表:
- 因为是反向,所以我们要多开最后一行、最后一列。
- 初始化为 无穷大(边界墙)。
- 终点出口 :
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 并不难,
状态定义是关键。
边界多开一行列,
初始化里藏玄机。
正常路径左上推,
生命游戏右下逆。