从经典问题入手,吃透动态规划核心(DP五部曲实战)

从经典问题入手,吃透动态规划核心(DP五部曲实战)

动态规划(Dynamic Programming,简称DP)是算法面试中的高频考点,其核心思想是「将复杂问题拆解为重叠子问题,通过存储子问题的解避免重复计算」。想要掌握DP,最有效的方式是从经典问题出发,用「DP五部曲」的框架拆解问题------这是一套标准化的分析方法,能帮我们快速理清思路、写出正确代码。

本文将结合斐波那契数、爬楼梯、最小花费爬楼梯、机器人路径(含障碍物)、整数拆分、不同的BST、01背包等8个经典问题,手把手教你用「DP五部曲」分析和实现动态规划解法。

一、动态规划五部曲(核心框架)

无论什么DP问题,都可以按以下5个步骤拆解,这是解决DP问题的「万能钥匙」:

  1. 确定dp数组及下标的含义 :明确dp[i](或二维dp[i][j])代表什么物理意义(比如"第i阶台阶的爬法数");

  2. 确定递推公式 :找到dp[i]与子问题dp[i-1]/dp[i-2]等的依赖关系(核心);

  3. dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值;

  4. 确定遍历顺序 :保证计算dp[i]时,其依赖的子问题已经被计算完成;

  5. 打印dp数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)。

下面结合具体问题,逐一实战这套框架。

二、经典问题实战:从基础到进阶

问题1:斐波那契数(入门)

LeetCode 链接509. 斐波那契数

问题描述

斐波那契数通常用 F(n) 表示,形成的序列称为斐波那契数列 。该数列由 01 开始,后面的每一项数字都是前面两项数字的和。也就是:

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五部曲分析
  1. dp数组含义dp[i] 表示第i个斐波那契数,下标i对应斐波那契数的序号;

  2. 递推公式dp[i] = dp[i-1] + dp[i-2]

  3. 初始化dp[0] = 0dp[1] = 1

  4. 遍历顺序 :从左到右(i从2到n),保证计算dp[i]时,dp[i-1]dp[i-2]已计算;

  5. 打印验证:遍历过程中打印dp数组,验证每一步的和是否正确。

具体分析

为什么用动态规划?

斐波那契数列的定义本身就是递归的:F(n) = F(n-1) + F(n-2)。如果用递归直接计算,会有大量重复计算(如计算 F(5) 时会重复计算 F(3)、F(2) 等)。

思路推导:

  1. 识别重叠子问题:计算 F(n) 需要 F(n-1) 和 F(n-2),而计算 F(n-1) 又需要 F(n-2) 和 F(n-3),存在重叠。

  2. 状态定义dp[i] 表示第 i 个斐波那契数,这是最直观的定义。

  3. 状态转移 :题目已经给出了递推关系:F(n) = F(n-1) + F(n-2),直接套用即可。

  4. 边界条件:题目明确给出 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 阶你才能到达楼顶。

每次你可以爬 12 个台阶。你有多少种不同的方法可以爬到楼顶呢?

示例 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五部曲分析
  1. dp数组含义dp[i] 表示爬到第i阶台阶的不同方法数;

  2. 递推公式dp[i] = dp[i-1] + dp[i-2](到第i阶的方法=到i-1阶爬1步 + 到i-2阶爬2步);

  3. 初始化dp[1] = 1(1阶只有1种方法),dp[2] = 2(2阶有2种方法);

  4. 遍历顺序:从左到右(i从3到n);

  5. 打印验证 :遍历过程中打印dp[i],验证方法数是否符合预期。

具体分析

为什么用动态规划?

要到达第 n 阶,最后一步可能是从第 n-1 阶爬 1 步,或者从第 n-2 阶爬 2 步。这两种情况是互斥且完备的(覆盖所有可能),所以到达第 n 阶的方法数 = 到达第 n-1 阶的方法数 + 到达第 n-2 阶的方法数。

思路推导:

  1. 最后一步分析

    • 如果最后一步是爬 1 阶,那么之前必须到达第 n-1 阶
    • 如果最后一步是爬 2 阶,那么之前必须到达第 n-2 阶
    • 这两种情况互不重叠,且覆盖所有可能
  2. 状态定义dp[i] 表示到达第 i 阶的方法数。

  3. 状态转移dp[i] = dp[i-1] + dp[i-2]

    • dp[i-1]:从第 i-1 阶爬 1 步到达第 i 阶的方法数
    • dp[i-2]:从第 i-2 阶爬 2 步到达第 i 阶的方法数
  4. 边界条件

    • 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 <= 1000
  • 0 <= cost[i] <= 999
DP五部曲分析
  1. dp数组含义dp[i] 表示到达第i阶顶部的最低花费(i为顶部下标,对应超出最后一个台阶的位置);

  2. 递推公式dp[i] = Math.min(dp[i-2] + cost[i-2], dp[i-1] + cost[i-1])(从i-2阶爬2步 或 从i-1阶爬1步,取最小值);

  3. 初始化dp[0] = 0(初始位置,无花费),dp[1] = 0(站在1阶台阶免费);

  4. 遍历顺序 :从左到右(i从2到n,n为cost长度);

  5. 打印验证 :遍历过程中打印dp[i],验证最低花费是否正确。

具体分析

为什么用动态规划?

要到达第 i 阶顶部,最后一步可能是从第 i-2 阶爬 2 步(花费 cost[i-2]),或者从第 i-1 阶爬 1 步(花费 cost[i-1])。我们需要选择花费更少的那条路径。

思路推导:

  1. 最后一步分析

    • 如果最后一步是从第 i-2 阶爬 2 步,总花费 = 到达第 i-2 阶的花费 + cost[i-2]
    • 如果最后一步是从第 i-1 阶爬 1 步,总花费 = 到达第 i-1 阶的花费 + cost[i-1]
    • 取两者的最小值
  2. 状态定义dp[i] 表示到达第 i 阶顶部的最低花费。

    • 注意:i 是"顶部"的下标,如果 cost 数组长度为 n,那么顶部是第 n 阶(下标 n)
  3. 状态转移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 步到顶部
  4. 边界条件

    • 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五部曲分析
  1. dp数组含义dp[i][j] 表示从左上角到(i,j)的不同路径数(二维);优化后一维dp[x]表示当前行第x列的路径数;

  2. 递推公式dp[i][j] = dp[i-1][j] + dp[i][j-1](一维优化:dp[x] = dp[x] + dp[x-1]);

  3. 初始化:第一行/第一列路径数为1(只能沿一个方向走);

  4. 遍历顺序:从上到下、从左到右;

  5. 打印验证:打印每行dp数组,验证路径数是否正确。

具体分析

为什么用动态规划?

要到达位置 (i, j),最后一步可能是从上方 (i-1, j) 向下移动,或者从左方 (i, j-1) 向右移动。这两种情况互斥且完备,所以到达 (i, j) 的路径数 = 到达 (i-1, j) 的路径数 + 到达 (i, j-1) 的路径数。

思路推导:

  1. 最后一步分析

    • 如果最后一步是向下,那么之前必须在 (i-1, j)
    • 如果最后一步是向右,那么之前必须在 (i, j-1)
    • 这两种情况互不重叠,且覆盖所有可能
  2. 状态定义dp[i][j] 表示从 (0, 0) 到达 (i, j) 的不同路径数。

  3. 状态转移dp[i][j] = dp[i-1][j] + dp[i][j-1]

    • dp[i-1][j]:从上方到达的路径数
    • dp[i][j-1]:从左方到达的路径数
  4. 边界条件

    • 第一行 dp[0][j] = 1:只能一直向右走
    • 第一列 dp[i][0] = 1:只能一直向下走
  5. 空间优化

    • 二维数组可以优化为一维数组
    • 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")。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

网格中的障碍物和空位置分别用 10 来表示。

示例 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.length
  • n == obstacleGrid[i].length
  • 1 <= m, n <= 100
  • obstacleGrid[i][j]01
DP五部曲分析(核心差异)
  1. dp数组含义 :同无障碍物版本,但障碍物位置dp[x] = 0

  2. 递推公式:无障碍物时同原公式,有障碍物则置0;

  3. 初始化:第一行遇到障碍物后,后续位置全部置0(无法绕开);

  4. 遍历顺序:同上,但需先处理每一行的第一列(障碍物置0);

  5. 打印验证:打印每行dp数组,验证障碍物位置路径数是否为0。

具体分析

为什么用动态规划?

思路与无障碍物版本相同,但需要特殊处理障碍物:障碍物位置无法到达,路径数为 0。

思路推导:

  1. 障碍物的影响

    • 如果 (i, j) 是障碍物,则 dp[i][j] = 0(无法到达)
    • 如果 (i, j) 不是障碍物,则 dp[i][j] = dp[i-1][j] + dp[i][j-1]
  2. 初始化特殊处理

    • 第一行:遇到障碍物后,后续所有位置路径数都是 0(无法绕开)
    • 第一列:每行都要检查第一列是否有障碍物
  3. 状态转移

    javascript 复制代码
    if (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五部曲分析
  1. dp数组含义dp[i] 表示将正整数i拆分为至少两个数的和,能得到的最大乘积;

  2. 递推公式dp[i] = max(当前最大值, j*(i-j), j*dp[i-j])(j从1到i-1,拆分为j和i-j,i-j可拆或不拆);

  3. 初始化dp[2] = 1(2只能拆1+1,乘积1);

  4. 遍历顺序:从左到右(i从3到n);

  5. 打印验证 :打印每个dp[i],验证是否符合预期(如dp[10]=36)。

具体分析

为什么用动态规划?

要拆分 n,可以先将 n 拆成 j 和 (n-j),然后 (n-j) 可以继续拆分,也可以不拆分。我们需要找到所有拆分方式中乘积最大的。

思路推导:

  1. 拆分策略

    • 将 n 拆成 j 和 (n-j) 两部分(j 从 1 到 n-1)
    • 对于 (n-j),有两种选择:
      • 不继续拆分:乘积 = j × (n-j)
      • 继续拆分:乘积 = j × dp[n-j](dp[n-j] 是 n-j 拆分后的最大乘积)
  2. 状态定义dp[i] 表示将 i 拆分为至少两个正整数的和,能得到的最大乘积。

  3. 状态转移

    javascript 复制代码
    for (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 继续拆分
  4. 为什么 j 不拆分?

    • 实际上,j 也可以拆分,但如果我们遍历所有 j,j 的拆分情况会在计算 dp[j] 时已经考虑过了
    • 例如:计算 dp[5] 时,j=2 的情况会在计算 dp[3] 时考虑(3=2+1,2 可以拆分)
  5. 边界条件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 个节点组成且节点值从 1n 互不相同的二叉搜索树有多少种?返回满足题意的二叉搜索树的种数。

二叉搜索树(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五部曲分析
  1. dp数组含义dp[i] 表示由 i 个节点组成的二叉搜索树的不同形态数量;

  2. 递推公式dp[i] = Σ(dp[j-1] × dp[i-j])(j 从 1 到 i,j 作为根节点,左子树 j-1 个节点,右子树 i-j 个节点);

  3. 初始化dp[0] = 1(空树算1种形态),dp[1] = 1(1个节点只有1种形态);

  4. 遍历顺序:从左到右(i 从 2 到 n),内层循环枚举根节点位置 j(从 1 到 i);

  5. 打印验证 :打印每个 dp[i],验证是否符合卡特兰数规律(如 dp[3]=5,dp[4]=14)。

具体分析

为什么用动态规划?

要构造 n 个节点的 BST,我们需要选择一个节点作为根,然后将剩余的节点分配到左子树和右子树。由于 BST 的性质,一旦根节点确定,左右子树的节点集合也就确定了(比根小的在左,比根大的在右)。这是一个典型的重叠子问题:不同根节点选择下,左右子树的构造问题会重复出现。

思路推导:

  1. 根节点选择策略

    • 对于 n 个节点(值为 1 到 n),我们可以选择任意一个节点 i(1 ≤ i ≤ n)作为根
    • 选择 i 作为根后:
      • 左子树必须包含所有小于 i 的节点(1 到 i-1),共 i-1 个节点
      • 右子树必须包含所有大于 i 的节点(i+1 到 n),共 n-i 个节点
  2. 状态定义dp[i] 表示由 i 个节点组成的 BST 的不同形态数量。

  3. 状态转移

    • 对于 n 个节点,枚举根节点 j(1 ≤ j ≤ n)
    • 以 j 为根的 BST 数量 = 左子树形态数 × 右子树形态数 = dp[j-1] × dp[n-j]
    • 总数量 = 所有根节点对应的方案数之和:dp[n] = Σ(dp[j-1] × dp[n-j])(j 从 1 到 n)
  4. 为什么 dp[0] = 1?

    • 空树也是一种合法的 BST 形态
    • 当根节点的左子树或右子树为空时,需要用到 dp[0]
    • 例如:n=1 时,选 1 当根,左右子树都是空树,方案数 = dp[0] × dp[0] = 1 × 1 = 1
  5. 卡特兰数关系

    • 这个问题的结果恰好是第 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 <= 100
  • 1 <= w <= 1000
  • 1 <= weight[i] <= w
  • 0 <= value[i] <= 1000
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从前 i 件物品中选择,在容量为 j 的背包中能获得的最大价值;

  2. 递推公式

    • 不选第 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])
  3. 初始化

    • dp[0][j] = 0(0件物品,任何容量的价值都是0)
    • dp[i][0] = 0(容量为0,任何物品都无法装入)
  4. 遍历顺序:外层遍历物品(i 从 1 到 n),内层遍历容量(j 从 1 到 w);

  5. 打印验证:打印 dp 数组,验证每一步的计算是否正确(特别是边界情况和最大值的选择)。

具体分析

为什么用动态规划?

01背包问题具有重叠子问题最优子结构的特征:

  • 重叠子问题 :计算 dp[i][j] 时,需要用到 dp[i-1][j]dp[i-1][j-weight[i-1]],这些子问题会被重复计算
  • 最优子结构:前 i 件物品在容量 j 下的最优解,包含了前 i-1 件物品在更小容量下的最优解

思路推导:

  1. 状态定义

    • dp[i][j] 表示从前 i 件物品中选择,在容量为 j 的背包中能获得的最大价值
    • 这是一个二维DP问题,因为需要考虑两个维度:物品数量和背包容量
  2. 状态转移的核心思想

    • 对于第 i 件物品,我们有两种选择:
      • 不选dp[i][j] = dp[i-1][j](容量不变,价值不变)
      • dp[i][j] = dp[i-1][j-weight[i-1]] + value[i-1](需要先腾出 weight[i-1] 的容量,然后加上当前物品的价值)
    • 取两者的最大值
  3. 为什么是 dp[i-1][j-weight[i-1]]

    • 因为每个物品只能用一次,所以选择第 i 件物品时,必须基于"前 i-1 件物品"的状态
    • j-weight[i-1] 表示选择当前物品后,剩余的容量
    • dp[i-1][j-weight[i-1]] 表示在剩余容量下,前 i-1 件物品能获得的最大价值
  4. 边界条件处理

    • 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个步骤拆解:

  1. 确定dp数组及下标的含义 :明确 dp[i](或二维 dp[i][j])代表什么物理意义
  2. 确定递推公式 :找到 dp[i] 与子问题 dp[i-1]/dp[i-2] 等的依赖关系(核心)
  3. dp数组如何初始化:根据问题边界条件,初始化无法通过递推得到的基础值
  4. 确定遍历顺序 :保证计算 dp[i] 时,其依赖的子问题已经被计算完成
  5. 打印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 常见优化技巧

  1. 空间优化

    • 一维DP优化:斐波那契数、爬楼梯等,只需保存前两个状态
    • 二维DP → 一维DP:机器人路径、01背包等,用滚动数组优化
    • 关键点:注意遍历顺序(01背包必须倒序)
  2. 循环优化

    • 减少无效遍历:整数拆分中 j 只需遍历到 i/2
    • 提前终止 :01背包中跳过超重物品,内层循环从 curWeight 开始
  3. 边界条件处理

    • 初始化技巧dp[0] = 1(空树、空集等特殊情况)
    • 数组越界:注意下标转换(0-based vs 1-based)

3.5 调试技巧

  1. 打印dp数组:在关键位置打印中间结果,验证递推逻辑
  2. 小数据验证:先用小规模数据手动计算,验证代码正确性
  3. 边界测试:测试 n=0、n=1、空数组等边界情况
  4. 对比二维和一维:空间优化时,先用二维DP验证,再优化为一维

3.6 解题思路总结

如何快速识别DP问题?

  • 求最值、计数、可行性问题
  • 问题可以拆解为重叠子问题
  • 有明确的"状态"和"选择"

如何快速确定dp数组含义?

  • 看问题问什么,dp就存什么(最大价值、方案数等)
  • 看状态有几个维度(一维:位置/数量;二维:位置+容量/位置+位置)

如何推导递推公式?

  • 最后一步分析:考虑最后一步的选择(选/不选、走哪条路等)
  • 状态转移:当前状态 = 子状态 + 当前选择的影响
  • 取最值/求和:根据问题类型选择 max、min、sum 等操作
相关推荐
WBluuue21 小时前
AtCoder Beginner Contest 438(ABCDEF)
c++·算法
Murphy_3121 小时前
从根上了解一下复指数
算法
Run_Teenage21 小时前
Linux:理解IO,重定向
linux·运维·算法
深盾科技21 小时前
C++ 中 std::error_code 的应用与实践
java·前端·c++
你撅嘴真丑21 小时前
素数对 与 不吉利日期
算法
多米Domi01121 小时前
0x3f 第20天 三更24-32 hot100子串
java·python·算法·leetcode·动态规划
Jagger_21 小时前
我的AI驯服记:从7640px大屏的惨败,到总结出一套高效协作SOP
前端
hy352821 小时前
VUE 踩坑合集
前端·javascript·vue.js
wzfj1234521 小时前
Opaque Pointer / Incomplete Type
c++·算法·c