给定一个三角形 triangle
,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。 相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 +
1 的两个结点。也就是说,如果正位于当前行的下标 i
,那么下一步可以移动到下一行的下标 i
或 i + 1
。
示例 1:
**输入:** triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
**输出:** 11
**解释:** 如下面简图所示:
**2**
**3** 4
6 **5** 7
4 **1** 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
示例 2:
**输入:** triangle = [[-10]]
**输出:** -10
提示:
1 <= triangle.length <= 200
triangle[0].length == 1
triangle[i].length == triangle[i - 1].length + 1
-104 <= triangle[i][j] <= 104
进阶:
- 你可以只使用
O(n)
的额外空间(n
为三角形的总行数)来解决这个问题吗?
前言
本题是一道非常经典且历史悠久的动态规划题,其作为算法题出现,最早可以追溯到 1994 年的 IOI(国际信息学奥林匹克竞赛)的 The Triangle 。时光飞逝,经过 20 多年的沉淀,往日的国际竞赛题如今已经变成了动态规划的入门必做题,不断督促着我们学习和巩固算法。
在本题中,给定的三角形的行数为 $n$,并且第 $i$ 行(从 $0$ 开始编号)包含了 $i+1$ 个数。如果将每一行的左端对齐,那么会形成一个等腰直角三角形,如下所示:
1 2 3 4
| [2] [3,4] [6,5,7] [4,1,8,3]
|
方法一:动态规划
思路与算法
我们用 $f[i][j]$ 表示从三角形顶部走到位置 $(i, j)$ 的最小路径和。这里的位置 $(i, j)$ 指的是三角形中第 $i$ 行第 $j$ 列(均从 $0$ 开始编号)的位置。
由于每一步只能移动到下一行「相邻的节点」上,因此要想走到位置 $(i, j)$,上一步就只能在位置 $(i - 1, j - 1)$ 或者位置 $(i - 1, j)$。我们在这两个位置中选择一个路径和较小的来进行转移,状态转移方程为:
$$
f[i][j] = \min(f[i-1][j-1], f[i-1][j]) + c[i][j]
$$
其中 $c[i][j]$ 表示位置 $(i, j)$ 对应的元素值。
注意第 $i$ 行有 $i+1$ 个元素,它们对应的 $j$ 的范围为 $[0, i]$。当 $j=0$ 或 $j=i$ 时,上述状态转移方程中有一些项是没有意义的。例如当 $j=0$ 时,$f[i-1][j-1]$ 没有意义,因此状态转移方程为:
$$
f[i][0] = f[i-1][0] + c[i][0]
$$
即当我们在第 $i$ 行的最左侧时,我们只能从第 $i-1$ 行的最左侧移动过来。当 $j=i$ 时,$f[i-1][j]$ 没有意义,因此状态转移方程为:
$$
f[i][i] = f[i-1][i-1] + c[i][i]
$$
即当我们在第 $i$ 行的最右侧时,我们只能从第 $i-1$ 行的最右侧移动过来。
最终的答案即为 $f[n-1][0]$ 到 $f[n-1][n-1]$ 中的最小值,其中 $n$ 是三角形的行数。
细节
状态转移方程的边界条件是什么?由于我们已经去除了所有「没有意义」的状态,因此边界条件可以定为:
$$
f[0][0] = c[0][0]
$$
即在三角形的顶部时,最小路径和就等于对应位置的元素值。这样一来,我们从 $1$ 开始递增地枚举 $i$,并在 $[0, i]$ 的范围内递增地枚举 $j$,就可以完成所有状态的计算。
[sol1-C++]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Solution { public: int minimumTotal(vector<vector<int>>& triangle) { int n = triangle.size(); vector<vector<int>> f(n, vector<int>(n)); f[0][0] = triangle[0][0]; for (int i = 1; i < n; ++i) { f[i][0] = f[i - 1][0] + triangle[i][0]; for (int j = 1; j < i; ++j) { f[i][j] = min(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j]; } f[i][i] = f[i - 1][i - 1] + triangle[i][i]; } return *min_element(f[n - 1].begin(), f[n - 1].end()); } };
|
[sol1-Java]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Solution { public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int[][] f = new int[n][n]; f[0][0] = triangle.get(0).get(0); for (int i = 1; i < n; ++i) { f[i][0] = f[i - 1][0] + triangle.get(i).get(0); for (int j = 1; j < i; ++j) { f[i][j] = Math.min(f[i - 1][j - 1], f[i - 1][j]) + triangle.get(i).get(j); } f[i][i] = f[i - 1][i - 1] + triangle.get(i).get(i); } int minTotal = f[n - 1][0]; for (int i = 1; i < n; ++i) { minTotal = Math.min(minTotal, f[n - 1][i]); } return minTotal; } }
|
[sol1-Python3]1 2 3 4 5 6 7 8 9 10 11 12 13
| class Solution: def minimumTotal(self, triangle: List[List[int]]) -> int: n = len(triangle) f = [[0] * n for _ in range(n)] f[0][0] = triangle[0][0]
for i in range(1, n): f[i][0] = f[i - 1][0] + triangle[i][0] for j in range(1, i): f[i][j] = min(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j] f[i][i] = f[i - 1][i - 1] + triangle[i][i] return min(f[n - 1])
|
[sol1-Golang]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| func minimumTotal(triangle [][]int) int { n := len(triangle) f := make([][]int, n) for i := 0; i < n; i++ { f[i] = make([]int, n) } f[0][0] = triangle[0][0] for i := 1; i < n; i++ { f[i][0] = f[i - 1][0] + triangle[i][0] for j := 1; j < i; j++ { f[i][j] = min(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j] } f[i][i] = f[i - 1][i - 1] + triangle[i][i] } ans := math.MaxInt32 for i := 0; i < n; i++ { ans = min(ans, f[n-1][i]) } return ans }
func min(x, y int) int { if x < y { return x } return y }
|
[sol1-C]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| int minimumTotal(int** triangle, int triangleSize, int* triangleColSize) { int f[triangleSize][triangleSize]; memset(f, 0, sizeof(f)); f[0][0] = triangle[0][0]; for (int i = 1; i < triangleSize; ++i) { f[i][0] = f[i - 1][0] + triangle[i][0]; for (int j = 1; j < i; ++j) { f[i][j] = fmin(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j]; } f[i][i] = f[i - 1][i - 1] + triangle[i][i]; } int ret = f[triangleSize - 1][0]; for (int i = 1; i < triangleSize; i++) ret = fmin(ret, f[triangleSize - 1][i]); return ret; }
|
复杂度分析
方法二:动态规划 + 空间优化
思路与算法
在题目描述中的「进阶」部分,提到了可以将空间复杂度优化至 $O(n)$。
我们回顾方法一中的状态转移方程:
$$
\begin{aligned}
f[i][j] = \begin{cases}
f[i-1][0] + c[i][0], & j=0\
f[i-1][i-1] + c[i][i], & j=i \
\min(f[i-1][j-1], f[i-1][j]) + c[i][j], & \text{otherwise}
\end{cases}
\end{aligned}
$$
可以发现,$f[i][j]$ 只与 $f[i-1][..]$ 有关,而与 $f[i-2][..]$ 及之前的状态无关,因此我们不必存储这些无关的状态。具体地,我们使用两个长度为 $n$ 的一维数组进行转移,将 $i$ 根据奇偶性映射到其中一个一维数组,那么 $i-1$ 就映射到了另一个一维数组。这样我们使用这两个一维数组,交替地进行状态转移。
[sol2-C++]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| class Solution { public: int minimumTotal(vector<vector<int>>& triangle) { int n = triangle.size(); vector<vector<int>> f(2, vector<int>(n)); f[0][0] = triangle[0][0]; for (int i = 1; i < n; ++i) { int curr = i % 2; int prev = 1 - curr; f[curr][0] = f[prev][0] + triangle[i][0]; for (int j = 1; j < i; ++j) { f[curr][j] = min(f[prev][j - 1], f[prev][j]) + triangle[i][j]; } f[curr][i] = f[prev][i - 1] + triangle[i][i]; } return *min_element(f[(n - 1) % 2].begin(), f[(n - 1) % 2].end()); } };
|
[sol2-Java]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Solution { public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int[][] f = new int[2][n]; f[0][0] = triangle.get(0).get(0); for (int i = 1; i < n; ++i) { int curr = i % 2; int prev = 1 - curr; f[curr][0] = f[prev][0] + triangle.get(i).get(0); for (int j = 1; j < i; ++j) { f[curr][j] = Math.min(f[prev][j - 1], f[prev][j]) + triangle.get(i).get(j); } f[curr][i] = f[prev][i - 1] + triangle.get(i).get(i); } int minTotal = f[(n - 1) % 2][0]; for (int i = 1; i < n; ++i) { minTotal = Math.min(minTotal, f[(n - 1) % 2][i]); } return minTotal; } }
|
[sol2-Python3]1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class Solution: def minimumTotal(self, triangle: List[List[int]]) -> int: n = len(triangle) f = [[0] * n for _ in range(2)] f[0][0] = triangle[0][0]
for i in range(1, n): curr, prev = i % 2, 1 - i % 2 f[curr][0] = f[prev][0] + triangle[i][0] for j in range(1, i): f[curr][j] = min(f[prev][j - 1], f[prev][j]) + triangle[i][j] f[curr][i] = f[prev][i - 1] + triangle[i][i] return min(f[(n - 1) % 2])
|
[sol2-Golang]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| func minimumTotal(triangle [][]int) int { n := len(triangle) f := [2][]int{} for i := 0; i < 2; i++ { f[i] = make([]int, n) } f[0][0] = triangle[0][0] for i := 1; i < n; i++ { curr := i % 2 prev := 1 - curr f[curr][0] = f[prev][0] + triangle[i][0] for j := 1; j < i; j++ { f[curr][j] = min(f[prev][j - 1], f[prev][j]) + triangle[i][j] } f[curr][i] = f[prev][i - 1] + triangle[i][i] } ans := math.MaxInt32 for i := 0; i < n; i++ { ans = min(ans, f[(n-1)%2][i]) } return ans }
func min(x, y int) int { if x < y { return x } return y }
|
[sol2-C]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| int minimumTotal(int** triangle, int triangleSize, int* triangleColSize) { int f[2][triangleSize]; memset(f, 0, sizeof(f)); f[0][0] = triangle[0][0]; for (int i = 1; i < triangleSize; ++i) { int curr = i % 2; int prev = 1 - curr; f[curr][0] = f[prev][0] + triangle[i][0]; for (int j = 1; j < i; ++j) { f[curr][j] = fmin(f[prev][j - 1], f[prev][j]) + triangle[i][j]; } f[curr][i] = f[prev][i - 1] + triangle[i][i]; } int ret = f[(triangleSize - 1) % 2][0]; for (int i = 1; i < triangleSize; i++) ret = fmin(ret, f[(triangleSize - 1) % 2][i]); return ret; }
|
上述方法的空间复杂度为 $O(n)$,使用了 $2n$ 的空间存储状态。我们还可以继续进行优化吗?
答案是可以的。我们从 $i$ 到 $0$ 递减地枚举 $j$,这样我们只需要一个长度为 $n$ 的一维数组 $f$,就可以完成状态转移。
为什么只有在递减地枚举 $j$ 时,才能省去一个一维数组?当我们在计算位置 $(i, j)$ 时,$f[j+1]$ 到 $f[i]$ 已经是第 $i$ 行的值,而 $f[0]$ 到 $f[j]$ 仍然是第 $i-1$ 行的值。此时我们直接通过
$$
f[j] = \min(f[j-1], f[j]) + c[i][j]
$$
进行转移,恰好就是在 $(i-1, j-1)$ 和 $(i-1, j)$ 中进行选择。但如果我们递增地枚举 $j$,那么在计算位置 $(i, j)$ 时,$f[0]$ 到 $f[j-1]$ 已经是第 $i$ 行的值。如果我们仍然使用上述状态转移方程,那么是在 $(i, j-1)$ 和 $(i-1, j)$ 中进行选择,就产生了错误。
这样虽然空间复杂度仍然为 $O(n)$,但我们只使用了 $n$ 的空间存储状态,减少了一半的空间消耗。
[sol3-C++]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class Solution { public: int minimumTotal(vector<vector<int>>& triangle) { int n = triangle.size(); vector<int> f(n); f[0] = triangle[0][0]; for (int i = 1; i < n; ++i) { f[i] = f[i - 1] + triangle[i][i]; for (int j = i - 1; j > 0; --j) { f[j] = min(f[j - 1], f[j]) + triangle[i][j]; } f[0] += triangle[i][0]; } return *min_element(f.begin(), f.end()); } };
|
[sol3-Java]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class Solution { public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int[] f = new int[n]; f[0] = triangle.get(0).get(0); for (int i = 1; i < n; ++i) { f[i] = f[i - 1] + triangle.get(i).get(i); for (int j = i - 1; j > 0; --j) { f[j] = Math.min(f[j - 1], f[j]) + triangle.get(i).get(j); } f[0] += triangle.get(i).get(0); } int minTotal = f[0]; for (int i = 1; i < n; ++i) { minTotal = Math.min(minTotal, f[i]); } return minTotal; } }
|
[sol3-Python3]1 2 3 4 5 6 7 8 9 10 11 12 13
| class Solution: def minimumTotal(self, triangle: List[List[int]]) -> int: n = len(triangle) f = [0] * n f[0] = triangle[0][0]
for i in range(1, n): f[i] = f[i - 1] + triangle[i][i] for j in range(i - 1, 0, -1): f[j] = min(f[j - 1], f[j]) + triangle[i][j] f[0] += triangle[i][0] return min(f)
|
[sol3-Golang]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| func minimumTotal(triangle [][]int) int { n := len(triangle) f := make([]int, n) f[0] = triangle[0][0] for i := 1; i < n; i++ { f[i] = f[i - 1] + triangle[i][i] for j := i - 1; j > 0; j-- { f[j] = min(f[j - 1], f[j]) + triangle[i][j] } f[0] += triangle[i][0] } ans := math.MaxInt32 for i := 0; i < n; i++ { ans = min(ans, f[i]) } return ans }
func min(x, y int) int { if x < y { return x } return y }
|
[sol3-C]1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| int minimumTotal(int** triangle, int triangleSize, int* triangleColSize) { int f[triangleSize]; memset(f, 0, sizeof(f)); f[0] = triangle[0][0]; for (int i = 1; i < triangleSize; ++i) { f[i] = f[i - 1] + triangle[i][i]; for (int j = i - 1; j > 0; --j) { f[j] = fmin(f[j - 1], f[j]) + triangle[i][j]; } f[0] += triangle[i][0]; } int ret = f[0]; for (int i = 1; i < triangleSize; i++) ret = fmin(ret, f[i]); return ret; }
|
复杂度分析
结语
本题还有一些其它的动态规划方法,例如:
读者可以自行尝试。如果在面试中遇到类似的题目,需要和面试官进行沟通,可以询问「是否有空间复杂度限制」「是否可以修改原数组」等问题,给出符合条件的算法。