LeetCode 120. 三角形最小路径和:动态规划详解

0 阅读6分钟

在LeetCode的动态规划题目中,「三角形最小路径和」是一道经典的入门级题目,它既考察了对动态规划核心思想的理解,也需要我们结合题目特性设计合理的状态转移方程。今天就来一步步拆解这道题,从题目分析到代码实现,再到思路优化,帮你彻底搞懂这道题的解题逻辑。

一、题目回顾:明确需求与约束

题目给出一个三角形 triangle,要求找出自顶向下的最小路径和,核心约束如下:

  • 每一步只能移动到下一行中「相邻的结点」;

  • 相邻结点定义:当前行下标为 i,下一步可移动到下一行的 ii+1 下标;

  • 三角形的行数不固定,每一行的元素个数等于当前行的行号(从0开始计数的话,第0行1个元素,第1行2个元素,以此类推)。

举个简单示例:若三角形为 [[2],[3,4],[6,5,7],[4,1,8,3]],自顶向下的最小路径和为 2→3→5→1 = 11。

二、解题思路:为什么用动态规划?

这道题的核心是「求最小路径和」,而路径的选择具有「重叠子问题」和「最优子结构」,这正是动态规划的适用场景:

  1. 重叠子问题:到达第 i 行第 j 个元素的最小路径和,依赖于到达第 i-1 行第 j 个和第 j-1 个元素的最小路径和(除了边界情况);

  2. 最优子结构:到达第 i 行第 j 个元素的最小路径和,是其上方两个可能路径的最小路径和加上当前元素的值,即「局部最优推导全局最优」。

因此,我们可以用动态规划的思路,通过构建一个DP数组,逐步计算每一步的最小路径和,最终得到整个三角形的最小路径和。

三、代码逐行解析:读懂每一步的意义

先给出完整的解题代码(题目要求的TypeScript版本),再逐行拆解其逻辑:

function minimumTotal(triangle: number[][]): number {
  const Len = triangle.length;
  const dp: number[][] = new Array(Len);
  for (let i = 0; i < Len; i++) {
    dp[i] = new Array(i + 1).fill(0);
  }
  dp[0][0] = triangle[0][0];
  for (let i = 1; i < Len; i++) {
    for (let j = 0; j <= i; j++) {
      if (j === 0) {
        dp[i][j] = dp[i - 1][j] + triangle[i][j];
      } else if (j === i) {
        dp[i][j] = dp[i - 1][j - 1] + triangle[i][j];
      } else {
        dp[i][j] = Math.min(dp[i - 1][j], dp[i - 1][j - 1]) + triangle[i][j];
      }
    }
  }
  return Math.min(...dp[Len - 1]);
};

1. 初始化DP数组

首先获取三角形的行数 Len,然后创建一个二维DP数组 dp,其中 dp[i][j] 表示「到达第 i 行第 j 个元素的最小路径和」。

由于第 i 行有 i+1 个元素(行号从0开始),所以我们通过循环给每一行的DP数组初始化,长度为 i+1,初始值填充为0。

2. 边界条件:三角形顶部

三角形的顶部只有一个元素(第0行第0列),到达这个元素的路径只有一条,所以 dp[0][0] = triangle[0][0],这是整个DP数组的起始值。

3. 状态转移:填充DP数组

从第1行开始(因为第0行已经初始化),逐行逐列填充DP数组,分三种情况讨论:

  • j === 0(当前列是当前行的第一列):只能从上层的第一列(j=0)移动过来,所以 dp[i][j] = dp[i-1][j] + triangle[i][j]

  • j === i(当前列是当前行的最后一列):只能从上层的最后一列(j-1)移动过来,所以 dp[i][j] = dp[i-1][j-1] + triangle[i][j]

  • 其他情况(中间列):可以从上层的 j 列或 j-1 列移动过来,取两者的最小值,再加上当前元素的值,即 dp[i][j] = Math.min(dp[i-1][j], dp[i-1][j-1]) + triangle[i][j]

4. 最终结果:取最后一行的最小值

DP数组填充完成后,最后一行的每个元素 dp[Len-1][j],分别表示到达三角形底部每一个元素的最小路径和。我们只需要取这一行的最小值,就是整个三角形的最小路径和,用 Math.min(...dp[Len-1]) 即可实现。

四、代码测试:验证逻辑正确性

用前面提到的示例 triangle = [[2],[3,4],[6,5,7],[4,1,8,3]] 测试代码:

  1. 初始化DP数组为 [[0], [0,0], [0,0,0], [0,0,0,0]]

  2. 设置 dp[0][0] = 2

  3. 第1行(i=1):j=0 → dp[1][0] = 2+3=5;j=1 → dp[1][1] = 2+4=6;

  4. 第2行(i=2):j=0 → 5+6=11;j=1 → min(5,6)+5=10;j=2 → 6+7=13;

  5. 第3行(i=3):j=0 → 11+4=15;j=1 → min(11,10)+1=11;j=2 → min(10,13)+8=18;j=3 →13+3=16;

  6. 最后一行最小值为 11,与预期结果一致,代码正确。

五、优化方向:空间复杂度优化

上面的代码空间复杂度是 O(n²)(n为三角形行数),因为我们创建了一个和三角形大小一致的DP数组。但实际上,我们可以优化到 O(n),核心思路是「滚动数组」:

观察状态转移方程,我们发现第 i行的DP值,只依赖于第 i-1 行的DP值,因此不需要保存整个二维数组,只需要一个一维数组,不断覆盖更新即可。

优化后的代码(TypeScript):

function minimumTotal(triangle: number[][]): number {
  const Len = triangle.length;
  let dp: number[] = new Array(Len).fill(0);
  dp[0] = triangle[0][0];
  for (let i = 1; i < Len; i++) {
    // 从后往前更新,避免覆盖上一行未使用的值
    for (let j = i; j >= 0; j--) {
      if (j === 0) {
        dp[j] = dp[j] + triangle[i][j];
      } else if (j === i) {
        dp[j] = dp[j - 1] + triangle[i][j];
      } else {
        dp[j] = Math.min(dp[j], dp[j - 1]) + triangle[i][j];
      }
    }
  }
  return Math.min(...dp);
};

注意:优化后需要「从后往前」更新一维数组,因为如果从前往后更新,会覆盖上一行的 dp[j-1],导致后续计算出错。

六、总结:解题关键与易错点

解题关键

  • 明确DP数组的定义:dp[i][j] 表示到达第 i 行第 j 个元素的最小路径和;

  • 区分边界情况(第一列和最后一列)和中间情况,正确写出状态转移方程;

  • 最终结果是最后一行的最小值。

易错点

  • 忽略边界条件:忘记第一列只能从上层第一列过来,最后一列只能从上层最后一列过来;

  • DP数组初始化错误:第 i 行的长度应该是 i+1,而非固定长度;

  • 空间优化时,从前往后更新一维数组,导致数据覆盖出错。

这道题虽然简单,但却是动态规划思想的典型应用,掌握它的解题逻辑,能为后续解决更复杂的DP问题打下基础。