从经典问题入手,吃透动态规划核心(DP五部曲实战)
动态规划(Dynamic Programming,简称DP)是算法面试中的高频考点,其核心思想是「将复杂问题拆解为重叠子问题,通过存储子问题的解避免重复计算」。想要掌握DP,最有效的方式是从经典问题出发,用「DP五部曲」的框架拆解问题------这是一套标准化的分析方法,能帮我们快速理清思路、写出正确代码。
本文将结合斐波那契数、爬楼梯、最小花费爬楼梯、机器人路径(含障碍物)、整数拆分、不同的BST、01背包等8个经典问题,手把手教你用「DP五部曲」分析和实现动态规划解法。
一、动态规划五部曲(核心框架)
无论什么DP问题,都可以按以下5个步骤拆解,这是解决DP问题的「万能钥匙」:
-
确定dp数组及下标的含义 :明确
dp[i](或二维dp[i][j])代表什么物理意义(比如"第i阶台阶的爬法数"); -
确定递推公式 :找到
dp[i]与子问题dp[i-1]/dp[i-2]等的依赖关系(核心); -
dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值;
-
确定遍历顺序 :保证计算
dp[i]时,其依赖的子问题已经被计算完成; -
打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)。
下面结合具体问题,逐一实战这套框架。
二、经典问题实战:从基础到进阶
问题1:斐波那契数(入门)
LeetCode 链接 :509. 斐波那契数
问题描述
斐波那契数通常用 F(n) 表示,形成的序列称为斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是:
scss
F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1
给定 n,请计算 F(n)。
示例 1:
scss
输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
scss
输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
scss
输入:n = 4
输出:3
解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
0 <= n <= 30
DP五部曲分析
-
dp数组含义 :
dp[i]表示第i个斐波那契数,下标i对应斐波那契数的序号; -
递推公式 :
dp[i] = dp[i-1] + dp[i-2]; -
初始化 :
dp[0] = 0,dp[1] = 1; -
遍历顺序 :从左到右(i从2到n),保证计算
dp[i]时,dp[i-1]和dp[i-2]已计算; -
打印验证:遍历过程中打印dp数组,验证每一步的和是否正确。
具体分析
为什么用动态规划?
斐波那契数列的定义本身就是递归的:F(n) = F(n-1) + F(n-2)。如果用递归直接计算,会有大量重复计算(如计算 F(5) 时会重复计算 F(3)、F(2) 等)。
思路推导:
-
识别重叠子问题:计算 F(n) 需要 F(n-1) 和 F(n-2),而计算 F(n-1) 又需要 F(n-2) 和 F(n-3),存在重叠。
-
状态定义 :
dp[i]表示第 i 个斐波那契数,这是最直观的定义。 -
状态转移 :题目已经给出了递推关系:
F(n) = F(n-1) + F(n-2),直接套用即可。 -
边界条件:题目明确给出 F(0) = 0,F(1) = 1,这就是初始化。
执行过程示例(n=5):
ini
dp[0] = 0 (初始化)
dp[1] = 1 (初始化)
dp[2] = dp[1] + dp[0] = 1 + 0 = 1
dp[3] = dp[2] + dp[1] = 1 + 1 = 2
dp[4] = dp[3] + dp[2] = 2 + 1 = 3
dp[5] = dp[4] + dp[3] = 3 + 2 = 5
实现代码(两种版本)
版本1:数组版(直观,空间O(n))
javascript
function fibo(n) {
if (n === 0) return 0;
if (n === 1) return 1;
const dp = [0, 1];
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
console.log(`dp数组更新:${dp}`); // 打印验证
}
return dp[n];
}
版本2:空间优化版(空间O(1))
javascript
function fibo(n) {
if (n === 0) return 0;
if (n === 1) return 1;
let prevPrev = 0; // 对应dp[i-2]
let prev = 1; // 对应dp[i-1]
let current = 0;
for (let i = 2; i <= n; i++) {
current = prevPrev + prev;
prevPrev = prev;
prev = current;
console.log(`第${i}个斐波那契数:${current}`); // 打印验证
}
return current;
}
问题2:爬楼梯(基础)
LeetCode 链接 :70. 爬楼梯
问题描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
ini
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
示例 2:
markdown
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶
示例 3:
markdown
输入:n = 4
输出:5
解释:有五种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 1 阶 + 2 阶
3. 1 阶 + 2 阶 + 1 阶
4. 2 阶 + 1 阶 + 1 阶
5. 2 阶 + 2 阶
提示:
1 <= n <= 45
DP五部曲分析
-
dp数组含义 :
dp[i]表示爬到第i阶台阶的不同方法数; -
递推公式 :
dp[i] = dp[i-1] + dp[i-2](到第i阶的方法=到i-1阶爬1步 + 到i-2阶爬2步); -
初始化 :
dp[1] = 1(1阶只有1种方法),dp[2] = 2(2阶有2种方法); -
遍历顺序:从左到右(i从3到n);
-
打印验证 :遍历过程中打印
dp[i],验证方法数是否符合预期。
具体分析
为什么用动态规划?
要到达第 n 阶,最后一步可能是从第 n-1 阶爬 1 步,或者从第 n-2 阶爬 2 步。这两种情况是互斥且完备的(覆盖所有可能),所以到达第 n 阶的方法数 = 到达第 n-1 阶的方法数 + 到达第 n-2 阶的方法数。
思路推导:
-
最后一步分析:
- 如果最后一步是爬 1 阶,那么之前必须到达第 n-1 阶
- 如果最后一步是爬 2 阶,那么之前必须到达第 n-2 阶
- 这两种情况互不重叠,且覆盖所有可能
-
状态定义 :
dp[i]表示到达第 i 阶的方法数。 -
状态转移 :
dp[i] = dp[i-1] + dp[i-2]dp[i-1]:从第 i-1 阶爬 1 步到达第 i 阶的方法数dp[i-2]:从第 i-2 阶爬 2 步到达第 i 阶的方法数
-
边界条件:
dp[1] = 1:只有 1 种方法(直接爬 1 阶)dp[2] = 2:有 2 种方法(1+1 或 2)
执行过程示例(n=5):
ini
dp[1] = 1 (初始化:1阶只有1种方法)
dp[2] = 2 (初始化:2阶有2种方法:1+1 或 2)
dp[3] = dp[2] + dp[1] = 2 + 1 = 3 (从2阶爬1步 + 从1阶爬2步)
dp[4] = dp[3] + dp[2] = 3 + 2 = 5 (从3阶爬1步 + 从2阶爬2步)
dp[5] = dp[4] + dp[3] = 5 + 3 = 8 (从4阶爬1步 + 从3阶爬2步)
注意 :这个问题本质上就是斐波那契数列!dp[1]=1, dp[2]=2, dp[3]=3, dp[4]=5, dp[5]=8...
实现代码(空间优化版)
javascript
function climbStairs(n) {
if (n === 1) return 1;
if (n === 2) return 2;
let prevPrev = 1; // dp[i-2]
let prev = 2; // dp[i-1]
let cur;
for (let i = 3; i <= n; i++) {
cur = prevPrev + prev;
prevPrev = prev;
prev = cur;
console.log(`爬到第${i}阶的方法数:${cur}`); // 打印验证
}
return cur;
}
问题3:最小花费爬楼梯(进阶)
LeetCode 链接 :746. 使用最小花费爬楼梯
问题描述
给你一个整数数组 cost,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
ini
输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。
示例 2:
diff
输入:cost = [1,100,1,1,1,100,1,1,100,1]
输出:6
解释:你将从下标为 0 的台阶开始。
- 支付 1 ,向上爬两个台阶,到达下标为 2 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 4 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 6 的台阶。
- 支付 1 ,向上爬一个台阶,到达下标为 7 的台阶。
- 支付 1 ,向上爬两个台阶,到达下标为 9 的台阶。
- 支付 1 ,向上爬一个台阶,到达楼梯顶部。
总花费为 6 。
提示:
2 <= cost.length <= 10000 <= cost[i] <= 999
DP五部曲分析
-
dp数组含义 :
dp[i]表示到达第i阶顶部的最低花费(i为顶部下标,对应超出最后一个台阶的位置); -
递推公式 :
dp[i] = Math.min(dp[i-2] + cost[i-2], dp[i-1] + cost[i-1])(从i-2阶爬2步 或 从i-1阶爬1步,取最小值); -
初始化 :
dp[0] = 0(初始位置,无花费),dp[1] = 0(站在1阶台阶免费); -
遍历顺序 :从左到右(i从2到n,n为
cost长度); -
打印验证 :遍历过程中打印
dp[i],验证最低花费是否正确。
具体分析
为什么用动态规划?
要到达第 i 阶顶部,最后一步可能是从第 i-2 阶爬 2 步(花费 cost[i-2]),或者从第 i-1 阶爬 1 步(花费 cost[i-1])。我们需要选择花费更少的那条路径。
思路推导:
-
最后一步分析:
- 如果最后一步是从第 i-2 阶爬 2 步,总花费 = 到达第 i-2 阶的花费 + cost[i-2]
- 如果最后一步是从第 i-1 阶爬 1 步,总花费 = 到达第 i-1 阶的花费 + cost[i-1]
- 取两者的最小值
-
状态定义 :
dp[i]表示到达第 i 阶顶部的最低花费。- 注意:i 是"顶部"的下标,如果 cost 数组长度为 n,那么顶部是第 n 阶(下标 n)
-
状态转移 :
dp[i] = Math.min(dp[i-2] + cost[i-2], dp[i-1] + cost[i-1])dp[i-2] + cost[i-2]:从第 i-2 阶爬 2 步到顶部dp[i-1] + cost[i-1]:从第 i-1 阶爬 1 步到顶部
-
边界条件:
dp[0] = 0:站在第 0 阶(起始位置),免费dp[1] = 0:站在第 1 阶,免费(题目说可以从下标 0 或 1 开始)
执行过程示例(cost = [10, 15, 20]):
scss
cost = [10, 15, 20],长度为 3,顶部是第 3 阶
dp[0] = 0 (初始化:站在0阶免费)
dp[1] = 0 (初始化:站在1阶免费)
dp[2] = min(dp[0] + cost[0], dp[1] + cost[1])
= min(0 + 10, 0 + 15) = min(10, 15) = 10
(从0阶爬2步花费10,从1阶爬1步花费15,选10)
dp[3] = min(dp[1] + cost[1], dp[2] + cost[2])
= min(0 + 15, 10 + 20) = min(15, 30) = 15
(从1阶爬2步花费15,从2阶爬1步花费30,选15)
实现代码
javascript
function minCost(cost) {
const n = cost.length;
// 边界:只有1阶台阶,必须支付cost[0]才能到顶部
if (n === 1) return cost[0];
// 2阶台阶:取从0阶或1阶爬的最小花费
if (n === 2) return Math.min(cost[0], cost[1]);
let prevPrev = 0; // 到达i-2阶的花费
let prev = 0; // 到达i-1阶的花费
let cur;
for (let i = 2; i <= n; i++) {
cur = Math.min(prevPrev + cost[i - 2], prev + cost[i - 1]);
prevPrev = prev;
prev = cur;
console.log(`到达第${i}阶顶部的最低花费:${cur}`); // 打印验证
}
return cur;
}
问题4:机器人走网格(基础→进阶:含障碍物)
子问题4.1:无障碍物的不同路径
LeetCode 链接 :62. 不同路径
问题描述
一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start")。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。
问总共有多少条不同的路径?
示例 1:
ini
输入:m = 3, n = 7
输出:28
示例 2:
rust
输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下
示例 3:
ini
输入:m = 7, n = 3
输出:28
示例 4:
ini
输入:m = 3, n = 3
输出:6
提示:
1 <= m, n <= 100- 题目数据保证答案小于等于
2 * 10^9
DP五部曲分析
-
dp数组含义 :
dp[i][j]表示从左上角到(i,j)的不同路径数(二维);优化后一维dp[x]表示当前行第x列的路径数; -
递推公式 :
dp[i][j] = dp[i-1][j] + dp[i][j-1](一维优化:dp[x] = dp[x] + dp[x-1]); -
初始化:第一行/第一列路径数为1(只能沿一个方向走);
-
遍历顺序:从上到下、从左到右;
-
打印验证:打印每行dp数组,验证路径数是否正确。
具体分析
为什么用动态规划?
要到达位置 (i, j),最后一步可能是从上方 (i-1, j) 向下移动,或者从左方 (i, j-1) 向右移动。这两种情况互斥且完备,所以到达 (i, j) 的路径数 = 到达 (i-1, j) 的路径数 + 到达 (i, j-1) 的路径数。
思路推导:
-
最后一步分析:
- 如果最后一步是向下,那么之前必须在 (i-1, j)
- 如果最后一步是向右,那么之前必须在 (i, j-1)
- 这两种情况互不重叠,且覆盖所有可能
-
状态定义 :
dp[i][j]表示从 (0, 0) 到达 (i, j) 的不同路径数。 -
状态转移 :
dp[i][j] = dp[i-1][j] + dp[i][j-1]dp[i-1][j]:从上方到达的路径数dp[i][j-1]:从左方到达的路径数
-
边界条件:
- 第一行
dp[0][j] = 1:只能一直向右走 - 第一列
dp[i][0] = 1:只能一直向下走
- 第一行
-
空间优化:
- 二维数组可以优化为一维数组
dp[x]表示当前行第 x 列的路径数- 更新时:
dp[x] = dp[x] + dp[x-1](上一行同列 + 当前行前一列)
执行过程示例(m=3, n=3):
ini
初始状态(第一行):
dp = [1, 1, 1] (第一行只能向右,路径数都是1)
第2行:
dp[0] = 1 (第一列只能向下,保持1)
dp[1] = dp[1] + dp[0] = 1 + 1 = 2 (从上方1 + 从左方1)
dp[2] = dp[2] + dp[1] = 1 + 2 = 3 (从上方1 + 从左方2)
dp = [1, 2, 3]
第3行:
dp[0] = 1 (第一列只能向下,保持1)
dp[1] = dp[1] + dp[0] = 2 + 1 = 3 (从上方2 + 从左方1)
dp[2] = dp[2] + dp[1] = 3 + 3 = 6 (从上方3 + 从左方3)
dp = [1, 3, 6]
最终答案:dp[2] = 6
实现代码(一维优化版,0-based)
javascript
function ways(countX, countY) {
const dp = new Array(countX).fill(1); // 初始化第一行
for (let y = 1; y <= countY - 1; y++) {
for (let x = 1; x <= countX - 1; x++) {
dp[x] = dp[x] + dp[x - 1]; // 上一行同列 + 当前行前一列
console.log(`第${y}行第${x}列路径数:${dp[x]}`); // 打印验证
}
}
return dp[countX - 1];
}
子问题4.2:有障碍物的不同路径
LeetCode 链接 :63. 不同路径 II
问题描述
一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start")。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
示例 1:
rust
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右
示例 2:
lua
输入:obstacleGrid = [[0,1],[0,0]]
输出:1
提示:
m == obstacleGrid.lengthn == obstacleGrid[i].length1 <= m, n <= 100obstacleGrid[i][j]为0或1
DP五部曲分析(核心差异)
-
dp数组含义 :同无障碍物版本,但障碍物位置
dp[x] = 0; -
递推公式:无障碍物时同原公式,有障碍物则置0;
-
初始化:第一行遇到障碍物后,后续位置全部置0(无法绕开);
-
遍历顺序:同上,但需先处理每一行的第一列(障碍物置0);
-
打印验证:打印每行dp数组,验证障碍物位置路径数是否为0。
具体分析
为什么用动态规划?
思路与无障碍物版本相同,但需要特殊处理障碍物:障碍物位置无法到达,路径数为 0。
思路推导:
-
障碍物的影响:
- 如果 (i, j) 是障碍物,则
dp[i][j] = 0(无法到达) - 如果 (i, j) 不是障碍物,则
dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 如果 (i, j) 是障碍物,则
-
初始化特殊处理:
- 第一行:遇到障碍物后,后续所有位置路径数都是 0(无法绕开)
- 第一列:每行都要检查第一列是否有障碍物
-
状态转移:
javascriptif (gridArr[y][x] === 1) { dp[x] = 0; // 障碍物,无法到达 } else { dp[x] = dp[x] + dp[x - 1]; // 正常路径计算 }
执行过程示例(gridArr = [[0,0,0],[0,1,0],[0,0,0]]):
ini
初始状态(第一行):
dp = [1, 1, 1] (无障碍物,正常初始化)
第2行(y=1):
- gridArr[1][0] = 0,无障碍物,dp[0] = 1(保持)
- gridArr[1][1] = 1,有障碍物!dp[1] = 0
- gridArr[1][2] = 0,无障碍物,dp[2] = dp[2] + dp[1] = 1 + 0 = 1
dp = [1, 0, 1]
第3行(y=2):
- gridArr[2][0] = 0,无障碍物,dp[0] = 1(保持)
- gridArr[2][1] = 0,无障碍物,dp[1] = dp[1] + dp[0] = 0 + 1 = 1
- gridArr[2][2] = 0,无障碍物,dp[2] = dp[2] + dp[1] = 1 + 1 = 2
dp = [1, 1, 2]
最终答案:dp[2] = 2
关键点:障碍物会"阻断"路径,导致后续位置无法通过该位置到达。
实现代码
javascript
function getWays(gridArr) {
const m = gridArr.length; // 行数
const n = gridArr[0].length; // 列数
const dp = new Array(n).fill(0);
// 初始化第一行:遇到障碍物则后续全为0
for (let x = 0; x < n; x++) {
if (gridArr[0][x] === 1) break;
dp[x] = 1;
}
// 遍历剩余行
for (let y = 1; y < m; y++) {
// 处理当前行第一列
if (gridArr[y][0] === 1) dp[0] = 0;
// 处理剩余列
for (let x = 1; x < n; x++) {
if (gridArr[y][x] === 1) {
dp[x] = 0;
} else {
dp[x] = dp[x] + dp[x - 1];
}
}
console.log(`第${y}行dp数组:${dp}`); // 打印验证
}
return dp[n - 1];
}
问题5:整数拆分(进阶)
LeetCode 链接 :343. 整数拆分
问题描述
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。返回你可以获得的最大乘积。
示例 1:
ini
输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
ini
输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
示例 3:
ini
输入: n = 8
输出: 18
解释: 8 = 2 + 3 + 3, 2 × 3 × 3 = 18。
示例 4:
ini
输入: n = 7
输出: 12
解释: 7 = 3 + 4, 3 × 4 = 12。
提示:
2 <= n <= 58
DP五部曲分析
-
dp数组含义 :
dp[i]表示将正整数i拆分为至少两个数的和,能得到的最大乘积; -
递推公式 :
dp[i] = max(当前最大值, j*(i-j), j*dp[i-j])(j从1到i-1,拆分为j和i-j,i-j可拆或不拆); -
初始化 :
dp[2] = 1(2只能拆1+1,乘积1); -
遍历顺序:从左到右(i从3到n);
-
打印验证 :打印每个
dp[i],验证是否符合预期(如dp[10]=36)。
具体分析
为什么用动态规划?
要拆分 n,可以先将 n 拆成 j 和 (n-j),然后 (n-j) 可以继续拆分,也可以不拆分。我们需要找到所有拆分方式中乘积最大的。
思路推导:
-
拆分策略:
- 将 n 拆成 j 和 (n-j) 两部分(j 从 1 到 n-1)
- 对于 (n-j),有两种选择:
- 不继续拆分:乘积 = j × (n-j)
- 继续拆分:乘积 = j × dp[n-j](dp[n-j] 是 n-j 拆分后的最大乘积)
-
状态定义 :
dp[i]表示将 i 拆分为至少两个正整数的和,能得到的最大乘积。 -
状态转移:
javascriptfor (let j = 1; j < i; j++) { curMax = Math.max(curMax, j * (i - j), j * dp[i - j]); }j * (i - j):只拆成两部分,不继续拆分j * dp[i - j]:j 不拆分,i-j 继续拆分
-
为什么 j 不拆分?
- 实际上,j 也可以拆分,但如果我们遍历所有 j,j 的拆分情况会在计算
dp[j]时已经考虑过了 - 例如:计算 dp[5] 时,j=2 的情况会在计算 dp[3] 时考虑(3=2+1,2 可以拆分)
- 实际上,j 也可以拆分,但如果我们遍历所有 j,j 的拆分情况会在计算
-
边界条件 :
dp[2] = 1(2 只能拆成 1+1)
执行过程示例(n=10):
ini
dp[2] = 1 (初始化:2 = 1+1,乘积1)
dp[3]:
j=1: max(1*2, 1*dp[2]) = max(2, 1) = 2
dp[3] = 2
dp[4]:
j=1: max(1*3, 1*dp[3]) = max(3, 2) = 3
j=2: max(2*2, 2*dp[2]) = max(4, 2) = 4
dp[4] = 4
dp[5]:
j=1: max(1*4, 1*dp[4]) = max(4, 4) = 4
j=2: max(2*3, 2*dp[3]) = max(6, 4) = 6
j=3: max(3*2, 3*dp[2]) = max(6, 3) = 6
j=4: max(4*1, 4*dp[1]) = 4 (dp[1]无意义,不考虑)
dp[5] = 6
...继续计算到 dp[10] = 36
关键洞察 :对于每个拆分 j 和 (i-j),我们只需要考虑 (i-j) 是否继续拆分,因为 j 的所有拆分情况在之前计算 dp[j] 时已经考虑过了。
实现代码
javascript
function integerBreak(n) {
const dp = new Array(n + 1).fill(0);
dp[2] = 1; // 初始化边界
for (let curN = 3; curN <= n; curN++) {
let curMax = 0;
for (let i = 1; i < curN; i++) {
const j = curN - i;
curMax = Math.max(curMax, i * j, i * dp[j]);
}
dp[curN] = curMax;
console.log(`dp[${curN}] = ${dp[curN]}`); // 打印验证
}
return dp[n];
}
问题6:不同的二叉搜索树(进阶)
LeetCode 链接 :96. 不同的二叉搜索树
问题描述
给你一个整数 n,求恰由 n 个节点组成且节点值从 1 到 n 互不相同的二叉搜索树有多少种?返回满足题意的二叉搜索树的种数。
二叉搜索树(BST)的核心规则:
对任意节点,满足:
- 左子树的所有节点值 < 该节点值;
- 右子树的所有节点值 > 该节点值;
- 左右子树也必须是BST。
示例 1:
ini
输入:n = 3
输出:5
解释:给定 n = 3, 一共有 5 种不同结构的二叉搜索树:
1 3 3 2 1
\ / / / \ \
3 2 1 1 3 2
/ / \ \
2 1 2 3
示例 2:
ini
输入:n = 1
输出:1
提示:
1 <= n <= 19
DP五部曲分析
-
dp数组含义 :
dp[i]表示由 i 个节点组成的二叉搜索树的不同形态数量; -
递推公式 :
dp[i] = Σ(dp[j-1] × dp[i-j])(j 从 1 到 i,j 作为根节点,左子树 j-1 个节点,右子树 i-j 个节点); -
初始化 :
dp[0] = 1(空树算1种形态),dp[1] = 1(1个节点只有1种形态); -
遍历顺序:从左到右(i 从 2 到 n),内层循环枚举根节点位置 j(从 1 到 i);
-
打印验证 :打印每个
dp[i],验证是否符合卡特兰数规律(如 dp[3]=5,dp[4]=14)。
具体分析
为什么用动态规划?
要构造 n 个节点的 BST,我们需要选择一个节点作为根,然后将剩余的节点分配到左子树和右子树。由于 BST 的性质,一旦根节点确定,左右子树的节点集合也就确定了(比根小的在左,比根大的在右)。这是一个典型的重叠子问题:不同根节点选择下,左右子树的构造问题会重复出现。
思路推导:
-
根节点选择策略:
- 对于 n 个节点(值为 1 到 n),我们可以选择任意一个节点 i(1 ≤ i ≤ n)作为根
- 选择 i 作为根后:
- 左子树必须包含所有小于 i 的节点(1 到 i-1),共 i-1 个节点
- 右子树必须包含所有大于 i 的节点(i+1 到 n),共 n-i 个节点
-
状态定义 :
dp[i]表示由 i 个节点组成的 BST 的不同形态数量。 -
状态转移:
- 对于 n 个节点,枚举根节点 j(1 ≤ j ≤ n)
- 以 j 为根的 BST 数量 = 左子树形态数 × 右子树形态数 =
dp[j-1] × dp[n-j] - 总数量 = 所有根节点对应的方案数之和:
dp[n] = Σ(dp[j-1] × dp[n-j])(j 从 1 到 n)
-
为什么 dp[0] = 1?
- 空树也是一种合法的 BST 形态
- 当根节点的左子树或右子树为空时,需要用到
dp[0] - 例如:n=1 时,选 1 当根,左右子树都是空树,方案数 =
dp[0] × dp[0] = 1 × 1 = 1
-
卡特兰数关系:
- 这个问题的结果恰好是第 n 个卡特兰数
- 卡特兰数的递推公式:
C(n) = Σ(C(i-1) × C(n-i))(i 从 1 到 n) - 这与我们的 DP 递推公式完全一致
执行过程示例(n=4):
ini
dp[0] = 1 (初始化:空树算1种)
dp[1] = 1 (初始化:1个节点只有1种形态)
dp[2]:
j=1: dp[0] × dp[1] = 1 × 1 = 1 (1为根,左空右1个节点)
j=2: dp[1] × dp[0] = 1 × 1 = 1 (2为根,左1个节点右空)
dp[2] = 1 + 1 = 2
dp[3]:
j=1: dp[0] × dp[2] = 1 × 2 = 2 (1为根,左空右2个节点)
j=2: dp[1] × dp[1] = 1 × 1 = 1 (2为根,左1个节点右1个节点)
j=3: dp[2] × dp[0] = 2 × 1 = 2 (3为根,左2个节点右空)
dp[3] = 2 + 1 + 2 = 5
dp[4]:
j=1: dp[0] × dp[3] = 1 × 5 = 5
j=2: dp[1] × dp[2] = 1 × 2 = 2
j=3: dp[2] × dp[1] = 2 × 1 = 2
j=4: dp[3] × dp[0] = 5 × 1 = 5
dp[4] = 5 + 2 + 2 + 5 = 14
关键洞察:
- BST 的性质决定了"根节点选择"后,左右子树的节点集合是唯一确定的
- 不同根节点对应的左右子树大小不同,但构造方式相同(都是 BST 构造问题)
- 这形成了重叠子问题,适合用动态规划解决
实现代码
javascript
function numTrees(n) {
// dp[i]:由 i 个节点组成的二叉搜索树的不同形态数量
const dp = new Array(n + 1).fill(0);
// 初始化:空树算1种,1个节点算1种
dp[0] = 1;
dp[1] = 1;
// 遍历计算 2~n 个节点的 BST 数量
for (let i = 2; i <= n; i++) {
let sum = 0;
// 枚举所有可能的根节点位置 j
for (let j = 1; j <= i; j++) {
const leftCount = j - 1; // 左子树节点数
const rightCount = i - j; // 右子树节点数
sum += dp[leftCount] * dp[rightCount];
}
dp[i] = sum;
console.log(`dp[${i}] = ${dp[i]}`); // 打印验证
}
return dp[n];
}
复杂度分析:
- 时间复杂度:O(n²),外层循环 n 次,内层循环最多 n 次
- 空间复杂度:O(n),dp 数组存储 n+1 个值
问题7:01背包问题(经典)
LeetCode 相关题目 :416. 分割等和子集(01背包变种)
问题描述
有 n 件物品和一个最多能背重量为 w 的背包。第 i 件物品的重量是 weight[i],得到的价值是 value[i]。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
01背包的核心特点:
- 0:不选当前物品
- 1:选当前物品(只能用一次)
- 每个物品只有"选"或"不选"两种状态
示例 1:
ini
输入:
n = 2(物品数量)
w = 5(背包容量)
weight = [2, 3](物品重量)
value = [3, 4](物品价值)
输出:7
解释:选择物品1(重量2,价值3)和物品2(重量3,价值4),总重量5,总价值7。
示例 2:
ini
输入:
n = 3
w = 4
weight = [1, 3, 4]
value = [15, 20, 30]
输出:35
解释:选择物品1(重量1,价值15)和物品2(重量3,价值20),总重量4,总价值35。
提示:
1 <= n <= 1001 <= w <= 10001 <= weight[i] <= w0 <= value[i] <= 1000
DP五部曲分析
-
dp数组含义 :
dp[i][j]表示从前i件物品中选择,在容量为j的背包中能获得的最大价值; -
递推公式:
- 不选第 i 件物品:
dp[i][j] = dp[i-1][j](继承上一行的结果) - 选第 i 件物品:
dp[i][j] = dp[i-1][j-weight[i-1]] + value[i-1](需要容量足够) - 取最大值:
dp[i][j] = Math.max(dp[i-1][j], dp[i-1][j-weight[i-1]] + value[i-1])
- 不选第 i 件物品:
-
初始化:
dp[0][j] = 0(0件物品,任何容量的价值都是0)dp[i][0] = 0(容量为0,任何物品都无法装入)
-
遍历顺序:外层遍历物品(i 从 1 到 n),内层遍历容量(j 从 1 到 w);
-
打印验证:打印 dp 数组,验证每一步的计算是否正确(特别是边界情况和最大值的选择)。
具体分析
为什么用动态规划?
01背包问题具有重叠子问题 和最优子结构的特征:
- 重叠子问题 :计算
dp[i][j]时,需要用到dp[i-1][j]和dp[i-1][j-weight[i-1]],这些子问题会被重复计算 - 最优子结构:前 i 件物品在容量 j 下的最优解,包含了前 i-1 件物品在更小容量下的最优解
思路推导:
-
状态定义:
dp[i][j]表示从前 i 件物品中选择,在容量为 j 的背包中能获得的最大价值- 这是一个二维DP问题,因为需要考虑两个维度:物品数量和背包容量
-
状态转移的核心思想:
- 对于第 i 件物品,我们有两种选择:
- 不选 :
dp[i][j] = dp[i-1][j](容量不变,价值不变) - 选 :
dp[i][j] = dp[i-1][j-weight[i-1]] + value[i-1](需要先腾出 weight[i-1] 的容量,然后加上当前物品的价值)
- 不选 :
- 取两者的最大值
- 对于第 i 件物品,我们有两种选择:
-
为什么是
dp[i-1][j-weight[i-1]]?- 因为每个物品只能用一次,所以选择第 i 件物品时,必须基于"前 i-1 件物品"的状态
j-weight[i-1]表示选择当前物品后,剩余的容量dp[i-1][j-weight[i-1]]表示在剩余容量下,前 i-1 件物品能获得的最大价值
-
边界条件处理:
- 当
j < weight[i-1]时,当前物品装不下,只能不选:dp[i][j] = dp[i-1][j]
- 当
执行过程示例(n=2, w=5, weight=[2,3], value=[3,4]):
ini
初始化 dp 数组(3行6列,全0):
| i\j | 0 | 1 | 2 | 3 | 4 | 5 |
| --- |---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| 2 | 0 | 0 | 0 | 0 | 0 | 0 |
计算 i=1(考虑物品1,重量2,价值3):
j=1: 容量1 < 重量2,装不下 → dp[1][1] = dp[0][1] = 0
j=2: 容量2 ≥ 重量2,可以装
- 不选:dp[0][2] = 0
- 选:dp[0][2-2] + 3 = dp[0][0] + 3 = 0 + 3 = 3
- 取max:dp[1][2] = 3
j=3: 容量3 ≥ 重量2,可以装
- 不选:dp[0][3] = 0
- 选:dp[0][3-2] + 3 = dp[0][1] + 3 = 0 + 3 = 3
- 取max:dp[1][3] = 3
j=4: 同理,dp[1][4] = 3
j=5: 同理,dp[1][5] = 3
此时 dp 数组:
| i\j | 0 | 1 | 2 | 3 | 4 | 5 |
| --- |---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 3 | 3 | 3 | 3 |
| 2 | 0 | 0 | 0 | 0 | 0 | 0 |
计算 i=2(考虑物品2,重量3,价值4):
j=1: 容量1 < 重量3,装不下 → dp[2][1] = dp[1][1] = 0
j=2: 容量2 < 重量3,装不下 → dp[2][2] = dp[1][2] = 3
j=3: 容量3 ≥ 重量3,可以装
- 不选:dp[1][3] = 3
- 选:dp[1][3-3] + 4 = dp[1][0] + 4 = 0 + 4 = 4
- 取max:dp[2][3] = 4
j=4: 容量4 ≥ 重量3,可以装
- 不选:dp[1][4] = 3
- 选:dp[1][4-3] + 4 = dp[1][1] + 4 = 0 + 4 = 4
- 取max:dp[2][4] = 4
j=5: 容量5 ≥ 重量3,可以装
- 不选:dp[1][5] = 3
- 选:dp[1][5-3] + 4 = dp[1][2] + 4 = 3 + 4 = 7
- 取max:dp[2][5] = 7
最终 dp 数组:
| i\j | 0 | 1 | 2 | 3 | 4 | 5 |
| --- |---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 3 | 3 | 3 | 3 |
| 2 | 0 | 0 | 3 | 4 | 4 | 7 |
最终答案:dp[2][5] = 7(选择物品1和物品2,总价值3+4=7)
关键洞察:
- 01背包的核心是"选或不选"的决策,每个物品只有一次机会
- 状态转移时,必须基于"前 i-1 件物品"的状态,体现"只能用一次"的约束
- 二维DP可以清晰地表达"物品数量"和"容量"两个维度的关系
实现代码
javascript
/**
* 01背包最大价值计算(二维DP版本)
* @param {number} n - 物品总数
* @param {number} w - 背包最大容量
* @param {number[]} weightArr - 物品重量数组(0-based)
* @param {number[]} valueArr - 物品价值数组(0-based)
* @returns {number} - 最大价值
*/
function maxValue(n, w, weightArr, valueArr) {
// dp[i][k]:从前 i 件物品中选择,容量为 k 时的最大价值
const dp = new Array(n + 1).fill(0).map(() => new Array(w + 1).fill(0));
// 外层循环:遍历物品(1~n)
for (let i = 1; i <= n; i++) {
// 内层循环:遍历容量(1~w)
for (let k = 1; k <= w; k++) {
const curWeight = weightArr[i - 1]; // 当前物品重量
const curValue = valueArr[i - 1]; // 当前物品价值
// 不选当前物品:继承上一行的结果
const valueNotChoose = dp[i - 1][k];
if (curWeight > k) {
// 当前物品超重,只能不选
dp[i][k] = valueNotChoose;
} else {
// 选当前物品:当前价值 + 剩余容量的最大价值
const valueChoose = curValue + dp[i - 1][k - curWeight];
// 取选/不选的最大值
dp[i][k] = Math.max(valueNotChoose, valueChoose);
}
}
}
// 打印dp数组,方便验证(可选)
console.log('dp数组:', dp);
// 最终结果:前 n 件物品,容量 w 时的最大价值
return dp[n][w];
}
// 测试用例
const n = 2;
const w = 5;
const weightArr = [2, 3];
const valueArr = [3, 4];
console.log('最大价值:', maxValue(n, w, weightArr, valueArr)); // 输出:7
复杂度分析:
- 时间复杂度:O(n × w),需要填充 n×w 的二维数组
- 空间复杂度:O(n × w),二维DP数组的空间
空间优化:二维DP → 一维DP
二维 DP 中,计算 dp[i][k](前 i 件、容量 k)只依赖「上一行」的两个值:
dp[i-1][k](不选当前物品,上一行同列)dp[i-1][k-weight](选当前物品,上一行左侧列)
也就是说,当前行的值只和上一行有关,不需要保存所有行的历史数据 ------ 只用一个一维数组 dp[k] 记录「上一行」的结果,就能推导出当前行。
关键:为什么必须倒序遍历容量?
一维数组 dp[k] 的本质是复用同一个数组,既存「上一行(前 i-1 件物品)」的结果,又存「当前行(前 i 件物品)」的结果。
- 倒序遍历(k 从 w 到 1) :计算
dp[k]时,dp[k-weight]还是上一行的旧值,保证每个物品只选一次 ✅ - 正序遍历(k 从 1 到 w) :计算
dp[k]时,dp[k-weight]可能已经被当前轮修改过,导致物品被重复选择 ❌
示例说明(正序遍历的错误):
ini
假设物品1:重量1,价值10;背包容量2
正序遍历(错误):
k=1: dp[1] = max(dp[1], dp[0] + 10) = max(0, 10) = 10 ✅
k=2: dp[2] = max(dp[2], dp[1] + 10) = max(0, 10+10) = 20 ❌
这里 dp[1] 已经被当前轮修改为10,导致物品1被选了2次!
倒序遍历(正确):
k=2: dp[2] = max(dp[2], dp[1] + 10) = max(0, 0+10) = 10 ✅
k=1: dp[1] = max(dp[1], dp[0] + 10) = max(0, 10) = 10 ✅
这里 dp[1] 还是上一行的旧值0,物品1只选一次!
版本1:基础一维DP(倒序遍历)
javascript
function maxValue(n, w, weightArr, valueArr) {
// dp[k]:容量为 k 时的最大价值
const dp = new Array(w + 1).fill(0);
// 外层循环:遍历物品(1~n)
for (let i = 1; i <= n; i++) {
const curWeight = weightArr[i - 1];
const curValue = valueArr[i - 1];
// 内层循环:倒序遍历容量(w~1)
// 倒序保证 dp[k-weight] 是上一行的旧值
for (let k = w; k >= 1; k--) {
if (curWeight <= k) {
// 选当前物品:当前价值 + 剩余容量的最大价值
const valueChoose = curValue + dp[k - curWeight];
// 不选当前物品:dp[k](上一行的旧值)
// 取两者最大值
dp[k] = Math.max(dp[k], valueChoose);
}
// 如果 curWeight > k,dp[k] 保持不变(已经是上一行的值)
}
}
return dp[w];
}
版本2:进一步优化(跳过无效容量)
内层循环的下限可以从 1 改成 curWeight(当前物品的重量)------ 因为如果 k < curWeight,物品肯定装不下,没必要遍历这些容量,直接跳过能减少循环次数。
javascript
function maxValue(n, w, weightArr, valueArr) {
// dp[k]:容量为 k 时的最大价值
const dp = new Array(w + 1).fill(0);
// 外层循环:遍历物品(1~n)
for (let i = 1; i <= n; i++) {
const curWeight = weightArr[i - 1];
const curValue = valueArr[i - 1];
// 跳过超重物品(重量超过背包最大容量,不可能被选)
if (curWeight > w) continue;
// 内层循环:倒序遍历容量(从 w 到 curWeight)
// 优化:只遍历 k >= curWeight 的容量,跳过装不下的情况
for (let k = w; k >= curWeight; k--) {
// 选当前物品:当前价值 + 剩余容量的最大价值
const valueChoose = curValue + dp[k - curWeight];
// 不选当前物品:dp[k](上一行的旧值)
// 取两者最大值
dp[k] = Math.max(dp[k], valueChoose);
}
}
return dp[w];
}
两个版本的对比:
| 特性 | 版本1(基础) | 版本2(优化) |
|---|---|---|
| 内层循环范围 | k = w; k >= 1 |
k = w; k >= curWeight |
| 超重判断 | 在循环内判断 if (curWeight <= k) |
循环前判断 if (curWeight > w) continue |
| 循环次数 | 每次遍历所有容量 | 跳过 k < curWeight 的容量 |
| 性能 | 基础版本 | 更优(减少无效循环) |
| 推荐使用 | 理解原理 | 实际应用 ✅ |
三、动态规划核心总结
3.1 核心思想
动态规划的本质是用空间换时间,通过存储子问题的解避免重复计算,将时间复杂度从指数级降低到多项式级。
两个核心特征:
- 重叠子问题:递归过程中会重复计算相同的子问题
- 最优子结构:问题的最优解包含子问题的最优解
3.2 DP五部曲框架(万能钥匙)
无论什么DP问题,都可以按以下5个步骤拆解:
- 确定dp数组及下标的含义 :明确
dp[i](或二维dp[i][j])代表什么物理意义 - 确定递推公式 :找到
dp[i]与子问题dp[i-1]/dp[i-2]等的依赖关系(核心) - dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值
- 确定遍历顺序 :保证计算
dp[i]时,其依赖的子问题已经被计算完成 - 打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)
3.3 已掌握的经典问题
本文通过DP五部曲框架,详细讲解了以下7个经典DP问题:
| 问题 | 类型 | 核心特点 | LeetCode |
|---|---|---|---|
| 1. 斐波那契数 | 一维DP | 基础递推,空间可优化 | 509 |
| 2. 爬楼梯 | 一维DP | 与斐波那契数本质相同 | 70 |
| 3. 最小花费爬楼梯 | 一维DP | 带权重的爬楼梯问题 | 746 |
| 4. 机器人路径(无障碍) | 二维DP | 二维状态转移,可空间优化 | 62 |
| 5. 机器人路径(有障碍) | 二维DP | 障碍物处理,边界条件复杂 | 63 |
| 6. 整数拆分 | 一维DP | 双重循环,枚举拆分点 | 343 |
| 7. 不同的BST | 一维DP | 卡特兰数,乘法原理 | 96 |
| 8. 01背包问题 | 二维DP | 选或不选,可空间优化(倒序) | 经典问题 |
3.4 常见优化技巧
-
空间优化:
- 一维DP优化:斐波那契数、爬楼梯等,只需保存前两个状态
- 二维DP → 一维DP:机器人路径、01背包等,用滚动数组优化
- 关键点:注意遍历顺序(01背包必须倒序)
-
循环优化:
- 减少无效遍历:整数拆分中 j 只需遍历到 i/2
- 提前终止 :01背包中跳过超重物品,内层循环从
curWeight开始
-
边界条件处理:
- 初始化技巧 :
dp[0] = 1(空树、空集等特殊情况) - 数组越界:注意下标转换(0-based vs 1-based)
- 初始化技巧 :
3.5 调试技巧
- 打印dp数组:在关键位置打印中间结果,验证递推逻辑
- 小数据验证:先用小规模数据手动计算,验证代码正确性
- 边界测试:测试 n=0、n=1、空数组等边界情况
- 对比二维和一维:空间优化时,先用二维DP验证,再优化为一维
3.6 解题思路总结
如何快速识别DP问题?
- 求最值、计数、可行性问题
- 问题可以拆解为重叠子问题
- 有明确的"状态"和"选择"
如何快速确定dp数组含义?
- 看问题问什么,dp就存什么(最大价值、方案数等)
- 看状态有几个维度(一维:位置/数量;二维:位置+容量/位置+位置)
如何推导递推公式?
- 最后一步分析:考虑最后一步的选择(选/不选、走哪条路等)
- 状态转移:当前状态 = 子状态 + 当前选择的影响
- 取最值/求和:根据问题类型选择 max、min、sum 等操作