哈喽,大家好,我是前端小L。
我们的动态规划之旅,已经走过了平坦的网格,也探索了奇特的金字塔。我们发现,无论地形如何变化,DP的核心思想——寻找最优子结构和状态转移——始终是我们手中最锋利的宝剑。
今天,我们将面对一个新的挑战。它既有我们熟悉的 m x n
方格地图,又引入了更自由的移动规则。这就像我们学会了走路和爬山之后,开始尝试“滑雪”——从山顶一路向下,从三条雪道中动态选择最快的那一条!
准备好了吗?让我们一起从山顶“一滑到底”!
力扣 931. 下降路径最小和
https://leetcode.cn/problems/minimum-falling-path-sum/
题目分析: 这次我们不再是从左上角走到右下角了,而是可以从第一行的任意位置出发,目标是到达最后一行的任意位置。每一步,可以从 (i, j)
移动到下一行的 (i+1, j-1)
, (i+1, j)
, 或 (i+1, j+1)
。目标是找到那条“下降路径”的最小和。
这个移动规则是关键!它和之前只能“向右、向下”的模式完全不同。对于一个点 (i, j)
,它的“父节点”不再是固定的2个,而是动态的3个。
(i-1, j-1) (i-1, j) (i-1, j+1)
\ | /
\ | /
\ | /
(i, j)
思路一:原教旨主义DP,稳扎稳打 (O(m*n) 空间)
面对任何DP问题,我们首先要做的,就是定义清楚 dp
状态。
1. 定义 dp
数组含义: dp[i][j]
表示:从第一行出发,一直下降到 (i, j)
这个位置的最小路径和。
2. 寻找状态转移方程: 要到达 (i, j)
,我们上一步(在 i-1
行)可能在哪?根据上面的图,有三种可能:
-
左上方
(i-1, j-1)
-
正上方
(i-1, j)
-
右上方
(i-1, j+1)
既然要路径和最小,我们肯定会选择从这三个可能的来源中,路径和最小的那一个转移过来。所以,状态转移方程呼之欲出: dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i-1][j+1]) + matrix[i][j]
3. 处理边界 (这个很重要!): 上面的方程在矩阵中间畅通无阻,但在左右边界呢?
-
当
j = 0
(第一列):它没有左上方,只有正上方和右上方两个来源。 方程变为dp[i][0] = min(dp[i-1][0], dp[i-1][1]) + matrix[i][0]
-
当
j = n-1
(最后一列):它没有右上方,只有正上方和左上方两个来源。 方程变为dp[i][n-1] = min(dp[i-1][n-1], dp[i-1][n-2]) + matrix[i][n-1]
4. 初始化: 因为我们可以从第一行的任意位置出发,所以 dp
数组的第一行,就等于 matrix
的第一行。dp[0][j] = matrix[0][j]
。
代码实现 (O(m*n) 空间):
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& matrix) {
int n = matrix.size();
if (n == 0) return 0;
// dp[i][j] 定义为下降到 (i, j) 的最小路径和
vector<vector<int>> dp(n, vector<int>(n));
// 初始化第一行
for (int j = 0; j < n; ++j) {
dp[0][j] = matrix[0][j];
}
// 从第二行开始状态转移
for (int i = 1; i < n; ++i) {
for (int j = 0; j < n; ++j) {
int minPrev = dp[i-1][j]; // 正上方
if (j > 0) { // 如果有左上方
minPrev = min(minPrev, dp[i-1][j-1]);
}
if (j < n - 1) { // 如果有右上方
minPrev = min(minPrev, dp[i-1][j+1]);
}
dp[i][j] = minPrev + matrix[i][j];
}
}
// 最终答案在最后一行的所有结果中取最小值
int result = dp[n-1][0];
for (int j = 1; j < n; ++j) {
result = min(result, dp[n-1][j]);
}
return result;
}
};
这个解法思路清晰,稳扎稳打。但 dp
数组占用了 O(n²) 的额外空间,我们能做得更好吗?
思路二:空间优化,高手过招 (O(n) 空间)
是的,你没看错,又是我们熟悉的空间优化环节!
观察状态转移方程,我们发现计算第 i
行时,只依赖于第 i-1
行的数据。这熟悉的配方,意味着我们可以用一个一维数组来解决问题!
让一维数组 dp[n]
“滚动”地存储上一行的结果。但这里有一个非常非常重要的陷阱!
陷阱分析: 当我们计算新的 dp[j]
时,公式是 min(旧dp[j-1], 旧dp[j], 旧dp[j+1])
。 如果我们从左到右 j = 0, 1, 2...
更新 dp
数组,当我们计算 dp[j]
时,dp[j-1]
已经被本轮循环更新成新值了!但我们此刻需要的是上一轮的旧值 dp[j-1]
。怎么办?
解决方案:请个外援! 在开始一行的计算时,我们用一个临时变量 prev
(或者叫 topLeft
) 来保存上一轮的 dp[j-1]
的值。
优化后的流程: 遍历第 i
行时,我们内层循环遍历 j = 0 to n-1
:
-
用一个
temp
变量保存当前dp[j]
的旧值。 -
计算新
dp[j]
。此时它需要的“左上角”的值,就是我们之前保存的prev
。它需要的“正上方”和“右上方”的值,分别是旧的dp[j]
和dp[j+1]
,它们还没被更新,可以直接用。 -
更新完
dp[j]
后,把刚才保存的temp
(也就是旧dp[j]
的值) 赋给prev
,供下一次j+1
的循环使用。
最终代码实现 (O(n) 空间):
class Solution {
public:
int minFallingPathSum(vector<vector<int>>& matrix) {
int n = matrix.size();
if (n == 0) return 0;
// dp数组只用一行大小,初始化为矩阵的第一行
vector<int> dp = matrix[0];
// 从第二行开始状态转移
for (int i = 1; i < n; ++i) {
// prev 用于保存上一行的 dp[j-1],也就是左上角的值
int prev = dp[0];
// 单独处理第一个元素
dp[0] = min(dp[0], dp[1]) + matrix[i][0];
for (int j = 1; j < n - 1; ++j) {
int temp = dp[j]; // 保存当前位置的旧值 (即上一行的 dp[j])
dp[j] = min({prev, dp[j], dp[j+1]}) + matrix[i][j];
prev = temp; // 更新 prev,供下一次循环使用
}
// 单独处理最后一个元素
dp[n-1] = min(dp[n-1], prev) + matrix[i][n-1];
}
// 最终答案在dp数组中取最小值
int result = dp[0];
for (int j = 1; j < n; ++j) {
result = min(result, dp[j]);
}
return result;
}
};
(注:为了逻辑更清晰,我将边界和中间分开处理。也可以在循环中用更多变量处理,但思路是相同的)
总结:提炼思维模型
到目前为止,我们解决的这几道题,其实代表了网格/矩阵类DP的两种基础模型:
-
固定双向选择模型:如“不同路径”、“最小路径和”,每个点的来源固定来自上方和左方。状态转移方程非常规整。
-
多向动态选择模型:如“三角形”、“下降路径和”,每个点的来源有多个,且在边界处会发生变化。这类问题更能考验我们对边界条件的处理,也是空间优化的难点和亮点。
从简单的二维漫步,到考虑障碍,再到金字塔寻宝,直到今天的三向坠落,我们对动态规划的理解正在从“点”连成“线”,从“线”织成“面”。我们不再是死记硬背模板,而是真正理解了如何根据题目的约束,去定义状态、推导转移、处理边界,并最终寻求优化。
这,就是高质量学习的魅力!
如果这篇深度剖析对你有所启发,请不要吝啬你的点赞和收藏,这是对我最大的鼓励!