LeetCode 120. Triangle:从 0 分到 100 分的思考过程(含二维 DP 与空间优化)

题目描述与模型抽象

题目给了一个"自顶向下"的整数三角形 triangle,要求从顶到底,选择一条路径,使路径上的数字之和最小。leetcode

每一步只能走到下一行相邻的位置:如果当前在第 i 行的下标 j,下一步只能走到第 i+1 行的 j 或 j+1。leetcode

最终可以停在最后一行的任意位置,求最小路径和。leetcode

示例:leetcode

复制代码
输入:[[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
一条最优路径是 2 -> 3 -> 5 -> 1,和为 11。

约束:

  • 行数 1 <= triangle.length <= 200。leetcode
  • 每一行比上一行多一个元素,且元素值在 [-10^4, 10^4] 之间。leetcode
  • Follow up:能否在 O(n) 额外空间内解决,其中 n 是行数。leetcode

这个问题非常典型:从起点出发,每到一个位置都有"从上面某些位置走下来"的限制,而且要找最小路径和,自然会想到动态规划。

初始 DP 思路与常见坑

最直观的想法是:

用 dp[i][j] 表示"到达 triangle[i][j] 的最小路径和"。

最终答案是最后一行 dp[最后一行][0...last] 中的最小值。

这个定义是正确的,但直接写成:

状态:dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle[i][j]

会立刻踩到两个坑:

边界问题

  • 当 j == 0 时,dp[i - 1][j - 1] 越界。
  • 当 j == i(这一行最后一个元素)时,dp[i - 1][j] 越界。

遍历细节问题

  • i 从 0 还是从 1 开始?
  • j 的范围到底是 0...i 还是 0...triangleColSize[i] - 1?

这两个问题如果不想清楚,代码很容易写错或写得很乱。

自顶向下二维 DP 正确写法

状态定义

状态 :dp[i][j] 表示从顶点 triangle[0][0] 走到 triangle[i][j] 的最小路径和。leetcode

维度:dp 与 triangle 同形状,即第 i 行长度为 triangleColSize[i]。

初始化

顶点只有一种走法:

dp[0][0] = triangle[0][0]。leetcode

其他位置初始可以先设为一个很大的数(比如 INF_MAX),后面通过转移不断更新。

你在代码中是这样做的:

c 复制代码
dp[i][j] = INF_MAX;

这一步的好处是:即使一开始没被正确转移到,这个点也不会意外地变成最优路径的一部分。leetcode

转移方程:边界 + 中间

对第 i 行、第 j 列(当前行长度为 col_size):

第一列 j == 0

只能从上一行的 j 位置走下来:

dp[i][0] = dp[i - 1][0] + triangle[i][0]。leetcode

最后一列 j == col_size - 1

只能从上一行的 j - 1 位置走下来:

dp[i][col_size - 1] = dp[i - 1][col_size - 2] + triangle[i][col_size - 1]。leetcode

中间列 0 < j < col_size - 1

可以由上一行的 j - 1 或 j 走下来,取较小值:

dp[i][j] = min(dp[i - 1][j - 1], dp[i - 1][j]) + triangle[i][j]。leetcode

你的 C 代码实现正是这三种情况的拆分:

c 复制代码
for (i = 1; i < triangleSize; i++) {
    col_size = triangleColSize[i];
    for (j = 0; j < col_size; j++) {
        if (j == 0)
            dp[i][j] = dp[i - 1][j] + triangle[i][j];
        else if (j == col_size - 1)
            dp[i][j] = dp[i - 1][j - 1] + triangle[i][j];
        else
            dp[i][j] = MIN(dp[i - 1][j - 1] + triangle[i][j],
                           dp[i - 1][j]     + triangle[i][j]);
    }
}

可以看到,这一版已经是标准、完整的二维 DP 解法了。leetcode

遍历顺序与 j 的边界

很多人一开始会纠结:

为何有时写 for (j = 0; j <= i; j++),有时写 for (j = 0; j < triangleColSize[i]; j++)

根本原因是:

题目保证 triangle[i].length == i + 1leetcode

也就是说,第 i 行的长度是 i + 1,合法下标是 0...i。

因此:

写成 for (j = 0; j < triangleColSize[i]; j++),就是遍历 0...length-1。

写成 for (j = 0; j <= i; j++),就是遍历 0...i。

在这个特定题目里,triangleColSize[i] == i + 1,所以:

j < triangleColSize[i] 等价于 j <= i

两者本质相同,只是一个以"长度"为边界,一个以"最大下标"为边界:

  • 通用写法:0 <= j < length(用 <)。
  • 利用规律的写法:0 <= j <= i(用 <=)。

你的写法用 triangleColSize[i] 更通用、可迁移到其它不规则的"每行长度不一定为 i+1"的题目,这是很好的编码习惯。leetcode

最终答案

最后一行的任何一个位置都可以作为终点,因此在最后一行取最小值:

c 复制代码
result = INF_MAX;
col_size = triangleColSize[triangleSize - 1];
for (j = 0; j < col_size; j++)
    result = MIN(result, dp[triangleSize - 1][j]);

这一步同样是标准写法。leetcode

复杂度分析与空间优化思路

二维 DP 的复杂度:

  • 时间复杂度:每个元素只被计算一次,总共大约 n(n+1)/2 个元素,所以是 O(n^2)。
  • 空间复杂度:dp 与 triangle 同形状,约 O(n^2)。

Follow up 要求的是 O(n) 额外空间。核心观察是:

第 i 行的 dp[i][] 只依赖于第 i - 1 行的 dp[i - 1][],也就是说,只依赖于"上一行"。

因此可以把二维数组压缩成一维滚动数组。

一维滚动数组:O(n) 空间优化

状态定义与数组含义

使用一维数组 dp[j],表示"当前处理到的这一行,对应位置 j 的最小路径和"。

更新这一行时,要用到上一行的值,所以更新顺序就很关键。

关键点:从右向左更新

当从左向右更新时,dp[j] 在被更新之后,后面的 j+1 会用到更新后的 dp[j],相当于"当前行依赖当前行",就错了。

所以正确做法是:

对于第 i 行,令 j 从这一行的末尾向前更新(从大到小)。

假定当前行长度为 col_size:

  • 如果 j == col_size - 1(这一行最后一个元素):

    只能从上一行的 j - 1 来:

    对应一维写法:dp[j] = dp[j - 1] + triangle[i][j]。

  • 如果 j == 0:

    只能从上一行的 j 来:

    对应一维写法:dp[0] = dp[0] + triangle[i][0]。

  • 否则:

    dp[j] = min(dp[j - 1], dp[j]) + triangle[i][j]。

整体结构类似你二维 DP 的逻辑,只是把 dp[i][] 和 dp[i - 1][] 合并到同一个数组里,通过从右往左保证"dp[j] 还没被当前行覆盖时先用完"。

一维 DP 示意(伪代码)

伪代码大致如下(C 风格):

c 复制代码
int minimumTotal(int **triangle, int triangleSize, int *triangleColSize) {
    int n = triangleSize;
    int *dp = malloc(triangleColSize[n - 1] * sizeof(int));
    int i, j;

    // 初始化第一行
    dp[0] = triangle[0][0];

    // 逐行更新
    for (i = 1; i < n; i++) {
        int col_size = triangleColSize[i];
        // 从右往左更新
        for (j = col_size - 1; j >= 0; j--) {
            if (j == col_size - 1) {                // 最右边
                dp[j] = dp[j - 1] + triangle[i][j];
            } else if (j == 0) {                    // 最左边
                dp[j] = dp[j] + triangle[i][j];
            } else {                                // 中间
                dp[j] = MIN(dp[j - 1], dp[j]) + triangle[i][j];
            }
        }
    }

    // 最后一行取最小
    int result = dp[0];
    for (j = 1; j < triangleColSize[n - 1]; j++)
        result = MIN(result, dp[j]);

    free(dp);
    return result;
}

这样:

  • 时间复杂度仍然是 O(n^2)。
  • 空间复杂度降为 O(n)(只与行数/最后一行长度成线性关系)。leetcode

小结:从定义到实现的关键点回顾

dp[i][j] 的定义非常自然:到达该点的最小路径和,你最初的思路是正确的。

真正拉开"AC 与 WA"的差距的是:

  1. 边界处理:j == 0 和 j == col_size - 1 时,只能从一个方向来。
  2. 遍历范围:第 i 行只需要遍历 0...triangleColSize[i]-1(在本题中也就是 0...i)。
  3. 返回值:最后一行取最小,而不是固定某一列。

优化时要抓住 DP 的依赖关系:

  • 只依赖上一行 ⇒ 可以用一维滚动数组。
  • 右到左更新,避免覆盖"上一行"的信息。

你当前的二维 DP C 代码已经是一个非常标准、可读性强的答案;在此基础上,再实现一版一维 DP,会对"状态压缩"和"更新顺序"有更深刻的理解。leetcode


https://leetcode.com/problems/triangle/submissions/1870659130/?envType=study-plan-v2&envId=top-interview-150

https://leetcode.com/problems/triangle/?envType=study-plan-v2&envId=top-interview-150

相关推荐
微光闪现2 小时前
国际航班动态提醒与延误预测优选平台指南
大数据·人工智能·算法
gihigo19982 小时前
基于反步法的路径追踪控制
算法
Jim-2ha02 小时前
【JavaScript】常见排序算法实现
javascript·算法·排序算法
`林中水滴`2 小时前
Linux Shell 命令:nohup、&、>、bg、fg、jobs 总结
linux·服务器·microsoft
王老师青少年编程2 小时前
2025年12月GESP(C++二级): 黄金格
c++·算法·gesp·csp·信奥赛·二级·黄金格
最后一个bug3 小时前
当linux触发panic后进行自定义收尾回调处理
linux·服务器·系统架构·bug
Herbert_hwt3 小时前
C语言位操作符详解:从入门到实战应用
c语言·算法
一只旭宝3 小时前
Linux专题十二:mysql数据库以及redis数据库
linux·数据库·mysql
赵民勇3 小时前
paste命令用法详解
linux·shell