三角形最小路径和是动态规划领域的经典入门题目,也是技术面试中的高频考点。我第一次遇到这个问题是在某次大厂面试中,当时虽然知道要用动态规划,但对状态转移的理解还不够透彻,导致解题过程磕磕绊绊。经过多次实践和总结,现在我已经能够清晰地拆解这类问题的解决思路。
这个问题给定一个三角形数组,要求找出自顶向下的最小路径和。每次只能移动到下一行相邻的节点上。比如对于三角形[[2],[3,4],[6,5,7],[4,1,8,3]],最小路径和是2 + 3 + 5 + 1 = 11。理解这个问题的关键在于认识到每个位置的最小路径和取决于其下方两个相邻位置的最小值,这正是动态规划的典型特征。
最直观的解法是尝试所有可能的路径,计算每条路径的和,然后取最小值。对于一个n行的三角形,这种暴力解法的时间复杂度是O(2^n),因为每一层都有两种选择(左下或右下),显然这样的解法在n较大时完全不可行。
我曾经在初学时就尝试过这种暴力方法,当n=15时程序就已经明显变慢。这让我意识到必须寻找更高效的算法,也促使我深入学习动态规划。
动态规划的核心思想是将大问题分解为小问题,通过解决小问题来构建大问题的解。对于三角形最小路径和问题,我们可以定义dp[i][j]表示从位置(i,j)到底部的最小路径和。这样,原问题的解就是dp[0][0]。
关键的状态转移方程是:
dp[i][j] = min(dp[i+1][j], dp[i+1][j+1]) + triangle[i][j]
这个方程的意思是:当前位置的最小路径和等于下方两个相邻位置中较小的那个,加上当前位置的值。这种自底向上的计算方式确保了每个子问题只计算一次,大大提高了效率。
初始的DP实现使用了一个二维数组来存储中间结果。以下是详细的Java实现:
java复制class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
// 初始化最后一行
for (int j = 0; j < n; j++) {
dp[n-1][j] = triangle.get(n-1).get(j);
}
// 从倒数第二行开始向上计算
for (int i = n-2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[i][j] = Math.min(dp[i+1][j], dp[i+1][j+1]) + triangle.get(i).get(j);
}
}
return dp[0][0];
}
}
这个实现的时间复杂度是O(n^2),空间复杂度也是O(n^2),其中n是三角形的行数。虽然已经比暴力解法高效很多,但空间复杂度还有优化空间。
在实现过程中,有几个关键细节需要注意:
我曾经在实现时犯过一个错误:试图从上往下计算。这导致需要处理更多的边界条件,代码变得复杂且容易出错。自底向上的方法更加简洁直观。
观察状态转移方程可以发现,计算第i行时只需要第i+1行的数据。因此,我们可以将二维DP表优化为一维数组,将空间复杂度从O(n^2)降低到O(n)。
优化后的实现如下:
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] = Math.min(dp[j], dp[j+1]) + triangle.get(i).get(j);
}
}
return dp[0];
}
}
在进行空间优化时,有几个关键点需要注意:
我曾经在面试中被要求解释为什么可以这样优化空间,当时虽然能写出代码,但解释得不够清晰。后来我总结了"滚动数组"的概念来帮助理解:我们只需要"滚动"地使用一维数组来存储当前需要的中间结果。
在实现过程中,常见的错误包括:
调试技巧:
在进行空间优化时,容易犯以下错误:
调试建议:
有时候面试官会要求不仅计算最小路径和,还要输出具体的路径。这需要在DP过程中记录路径信息:
java复制class Solution {
public List<Integer> getMinPath(List<List<Integer>> triangle) {
int n = triangle.size();
int[][] dp = new int[n][n];
int[][] path = new int[n][n]; // 记录选择
// 初始化最后一行
for (int j = 0; j < n; j++) {
dp[n-1][j] = triangle.get(n-1).get(j);
}
// 填充DP表和路径
for (int i = n-2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
if (dp[i+1][j] < dp[i+1][j+1]) {
dp[i][j] = dp[i+1][j] + triangle.get(i).get(j);
path[i][j] = j; // 选择左下方
} else {
dp[i][j] = dp[i+1][j+1] + triangle.get(i).get(j);
path[i][j] = j+1; // 选择右下方
}
}
}
// 回溯路径
List<Integer> result = new ArrayList<>();
int col = 0;
for (int i = 0; i < n; i++) {
result.add(triangle.get(i).get(col));
col = path[i][col];
}
return result;
}
}
虽然自底向上的解法更高效,但理解自顶向下的方法也有助于全面掌握动态规划。自顶向下通常需要递归加记忆化:
java复制class Solution {
private Integer[][] memo;
public int minimumTotal(List<List<Integer>> triangle) {
memo = new Integer[triangle.size()][triangle.size()];
return dfs(triangle, 0, 0);
}
private int dfs(List<List<Integer>> triangle, int i, int j) {
if (i == triangle.size() - 1) {
return triangle.get(i).get(j);
}
if (memo[i][j] != null) {
return memo[i][j];
}
int left = dfs(triangle, i+1, j);
int right = dfs(triangle, i+1, j+1);
memo[i][j] = Math.min(left, right) + triangle.get(i).get(j);
return memo[i][j];
}
}
这种方法虽然直观,但在面试中通常会被要求优化为自底向上的迭代版本,因为递归会有额外的函数调用开销,而且对于大规模输入可能导致栈溢出。
在面试中解决这个问题时,建议按照以下步骤进行:
面试官可能会围绕这个问题提出各种相关问题,例如:
准备这些问题可以帮助你在面试中更加从容应对。我建议在练习时不仅要写出代码,还要能够清晰地解释每个决策背后的思考过程。
让我们比较不同解法的时间复杂度:
在实际测试中,当n=100时,基础DP和优化DP都能在毫秒级完成,而暴力解法已经无法在合理时间内完成。
以下是在LeetCode上的测试结果(多次运行平均值):
| 解法类型 | 执行时间(ms) | 内存消耗(MB) |
|---|---|---|
| 基础DP | 3 | 43.8 |
| 优化DP | 2 | 40.1 |
| 记忆化递归 | 5 | 44.5 |
从数据可以看出,空间优化后的DP版本在时间和空间上都有所提升。虽然时间复杂度相同,但减少了内存访问和分配的开销。
在实现动态规划算法时,良好的变量命名可以大大提高代码的可读性:
java复制// 好的命名
int[][] minPathSum = new int[rows][rows];
int[] currentRowDP = new int[rows];
// 不好的命名
int[][] dp = new int[n][n];
int[] d = new int[n];
将逻辑分解到辅助方法中可以使主方法更清晰:
java复制class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int rows = triangle.size();
int[] dp = initializeLastRow(triangle, rows);
computeMinPathSum(triangle, rows, dp);
return dp[0];
}
private int[] initializeLastRow(List<List<Integer>> triangle, int rows) {
int[] dp = new int[rows];
List<Integer> lastRow = triangle.get(rows - 1);
for (int j = 0; j < rows; j++) {
dp[j] = lastRow.get(j);
}
return dp;
}
private void computeMinPathSum(List<List<Integer>> triangle, int rows, int[] dp) {
for (int i = rows - 2; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[j] = Math.min(dp[j], dp[j+1]) + triangle.get(i).get(j);
}
}
}
}
这种结构虽然稍微增加了代码量,但大大提高了可读性和可维护性,特别适合在面试中展示你的代码组织能力。
三角形最小路径和问题代表了一类具有以下特征的问题:
识别出这些特征可以帮助我们快速判断是否适用动态规划。类似的问题包括:
这个问题可以看作是特殊的有向无环图(DAG)的最短路径问题,其中:
理解了这种对应关系后,我们可以将三角形问题看作DAG最短路径问题的特例,这有助于将解法推广到更一般的图算法中。