bigywater.tech
算法与数据结构 · LeetCode 120

LeetCode 120. 三角形最小路径和

给定一个三角形数组,求从顶部到底部的最小路径和,每一步只能移动到下一行相邻的两个数字之一。一个很直觉但错误的思路是每一步都贪心地选数值较小的那个,这忽略了"眼前更小"的选择可能会把你带进后面全是大数字的死路。本文构造一个真实反例,其中贪心算出 101、正确答案却只有 2,并用 2 万组随机三角形验证:近三分之一的随机三角形会让贪心策略给出错误答案。再给出正确的自底向上 DP 写法、5 种语言实现,以及逐层取最小子问题的分步演示。

101 vs 2
构造的反例中,贪心策略算出的路径和是 101,而真正的最小路径和只有 2
31.6%
2 万组随机三角形里,有 31.6% 会让"每步选眼前更小的"贪心策略给出错误答案

题意与示例

本文只用自己的话概括题意,完整约束以官方页面为准。给定一个三角形数组,求从顶部到底部的最小路径和:每一步只能移动到下一行相邻的两个数字之一(下标 j 或 j+1)。一共 5 组用例,覆盖:官方示例、单行、构造的反例、简单两行三角形、含负数:

真实实测 · 本机执行输出
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2

陷阱:每步贪心选数值较小的相邻结点,不保证全局最优

一个很直觉但错误的思路:既然每一步都要在两个数字之间选一个,那就每步贪心地选数值较小的那个,最后把选过的数字加起来。这个想法忽略了一件事——这道题要的是全局最优的路径和,而"眼前更小"的选择可能会把你带进后面全是大数字的死路,错过后面能连上一串小数字的另一条路。构造一个反例:

真实实测 · 本机执行输出
triangle = [[0], [1, 2], [100, 100, 0]]\n正确写法(自底向上 DP)  →  2\n陷阱写法(每步贪心选较小的)  →  101

贪心写法在第二行会选择数值较小的 1(而不是 2),但选了 1 之后,它在第三行能碰到的两个数字都是 100;如果第二行选了看起来更大的 2,第三行却能碰到一个 0,最终路径和反而小得多。这个反例里贪心比最优解差了 99。为了确认这不是个例,做了一次随机测试:

真实实测 · 本机执行输出
20,000 组随机三角形(深度 2-8,值 0-20):\n贪心写法与 DP 正确写法结果不同的有 6,323 组,占 31.6%

接近三分之一的随机三角形会让"每步选眼前更小的"这个贪心策略给出错误答案——因为局部最优和全局最优在这类问题里并不等价,只有通过动态规划把"从这一点往下走的最小代价"逐层算清楚,才能保证最终答案是真正的全局最小值。

自底向上 DP:五种语言的实现

自底向上:用最后一行直接初始化一个 dp 数组(此时每个位置到底部的最小代价就是它自己),然后从倒数第二行开始往上,每个位置的答案是 自己的值 + min(正下方, 右下方) 这两个已经算好的子问题。滚动更新到 dp[0] 就是从顶部出发的全局最小路径和。时间 O(n²),空间 O(n)(把 dp 数组原地复用,不需要存整个三角形的副本)。Python、Java、Go、JavaScript、C++ 都是真实编译运行的,用同一组 5 个测试用例。

Python · 解法代码def minimum_total(triangle): n = len(triangle) dp = triangle[-1][:] # 从最后一行开始,自底向上 for i in range(n - 2, -1, -1): for j in range(i + 1): dp[j] = triangle[i][j] + min(dp[j], dp[j + 1]) return dp[0] if dp else 0
真实实测 · Python · 前几组用例的输出
$ python3 solution.py
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2
Java · 解法代码class Solution { public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int[] dp = new int[n]; for (int j = 0; j < n; j++) dp[j] = triangle.get(n - 1).get(j); for (int i = n - 2; i >= 0; i--) { for (int j = 0; j <= i; j++) { dp[j] = triangle.get(i).get(j) + Math.min(dp[j], dp[j + 1]); } } return n == 0 ? 0 : dp[0]; } }
真实实测 · Java · 前几组用例的输出
$ javac -d build Main.java && java -cp build Main
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2
Go · 解法代码func minimumTotal(triangle [][]int) int { n := len(triangle) if n == 0 { return 0 } dp := make([]int, n) copy(dp, triangle[n-1]) for i := n - 2; i >= 0; i-- { for j := 0; j <= i; j++ { m := dp[j] if dp[j+1] < m { m = dp[j+1] } dp[j] = triangle[i][j] + m } } return dp[0] }
真实实测 · Go · 前几组用例的输出
$ go run solution.go
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2
JavaScript · 解法代码function minimumTotal(triangle) { const n = triangle.length; if (n === 0) return 0; const dp = [...triangle[n - 1]]; for (let i = n - 2; i >= 0; i--) { for (let j = 0; j <= i; j++) { dp[j] = triangle[i][j] + Math.min(dp[j], dp[j + 1]); } } return dp[0]; }
真实实测 · JavaScript · 前几组用例的输出
$ node run.js
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2
C++ · 解法代码int minimumTotal(vector<vector<int>>& triangle) { int n = triangle.size(); if (n == 0) return 0; vector<int> dp = triangle[n-1]; for (int i = n-2; i >= 0; i--) { for (int j = 0; j <= i; j++) { dp[j] = triangle[i][j] + min(dp[j], dp[j+1]); } } return dp[0]; }
真实实测 · C++ · 前几组用例的输出
$ clang++ -std=c++17 -O2 -o bin sol.cpp && ./bin
[[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]]  →  11
[[-10]]  →  -10
[[0], [1, 2], [100, 100, 0]]  →  2
[[1], [2, 3]]  →  3
[[1], [-2, -3]]  →  -2
真实实测 · 本机执行输出
共 5 组用例(含边界),以 Python 结果为基准逐组比对:\n  Python      通过 5/5\n  Java        通过 5/5\n  Go          通过 5/5\n  JavaScript  通过 5/5\n  C++         通过 5/5

随机对拍

Python 版本的自底向上实现另外和一个自顶向下、带记忆化的递归版本做了随机对拍:dfs(i, j) 表示从第 i 行第 j 个位置出发到底部的最小路径和,递归到最后一行直接返回自身的值。

真实实测 · 本机执行输出
随机对拍 5,000 组(深度 1 到 10 的随机三角形,值取 -20 到 20),自底向上版与自顶向下记忆化版的结果全部一致,0 处不一致。

另外四种语言只在上面 5 组固定用例上验证过,没有做大规模随机对拍。

分步演示:自底向上,每步取下方两个子问题的较小值

下面的演示用本文构造的反例 triangle=[[0],[1,2],[100,100,0]]:dp 数组从最后一行开始,每往上一行就用"自己的值 + 下方两个已经算好的最小代价中较小的一个"来更新。蓝色是本轮正在更新的位置,绿色是已经算好、代表"从这里往下走的最优子结构"的位置。每一帧都是把上面的解法在这组输入上真实执行记录下来的——对照本文"陷阱"一节,能清楚看到 DP 在第1行同时算出了 dp[0]=101 和 dp[1]=2 两个选项,而贪心只会盯着眼前的 triangle[1][0]=1 更小就一头扎进去,根本不会去比较"往下走到底"的真实代价。

三角形最小路径和:自底向上,每步取下方两个子问题的较小值
本轮正在更新的位置已经算好、代表最优子结构的位置

    参考与说明

    • 官方题目页:LeetCode 120. 三角形最小路径和。本文只用自己的话概括了题意,完整约束以官方页面为准。
    • 数据来自本机真实运行,五种语言的版本写在页脚。
    • 没有覆盖:自顶向下、带记忆化的递归写法作为主解法展示(本文用它做了随机对拍验证)。
    京ICP备2026067502号-1京公网安备11010502063487号