题目描述与模型抽象
题目给了一个"自顶向下"的整数三角形 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 + 1。leetcode
也就是说,第 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"的差距的是:
- 边界处理:j == 0 和 j == col_size - 1 时,只能从一个方向来。
- 遍历范围:第 i 行只需要遍历 0...triangleColSize[i]-1(在本题中也就是 0...i)。
- 返回值:最后一行取最小,而不是固定某一列。
优化时要抓住 DP 的依赖关系:
- 只依赖上一行 ⇒ 可以用一维滚动数组。
- 右到左更新,避免覆盖"上一行"的信息。
你当前的二维 DP C 代码已经是一个非常标准、可读性强的答案;在此基础上,再实现一版一维 DP,会对"状态压缩"和"更新顺序"有更深刻的理解。leetcode
https://leetcode.com/problems/triangle/?envType=study-plan-v2&envId=top-interview-150