Skip to content

动态规划专题

动态规划核心:定义状态 + 状态转移方程 + 边界条件

📊 题目总览

题目难度梯队关键技巧
爬楼梯⭐⭐第一梯队基础 DP
零钱兑换⭐⭐第二梯队完全背包
最长递增子序列⭐⭐⭐第二梯队LIS
最长公共子序列⭐⭐⭐第三梯队LCS
0-1 背包问题⭐⭐⭐第三梯队经典背包
打家劫舍⭐⭐第二梯队状态选择

DP 核心思想

适用条件

  1. 最优子结构: 问题的最优解包含子问题的最优解
  2. 重叠子问题: 相同子问题被反复计算

解题步骤

1. 定义状态:dp[i] 的含义是什么
2. 状态转移方程:dp[i] 如何由之前的状态推导
3. 初始化边界:dp[0] 或 dp[0][0] 等初始值
4. 遍历顺序:从小到大还是从大到小
5. 空间优化:滚动数组降低空间复杂度

1. 爬楼梯

LeetCode: 70. 爬楼梯 | 难度: 简单 | 梯队: 第一梯队 ✅

题目描述

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?

输入: n = 3
输出: 3
解释: 有三种方法: 1+1+1, 1+2, 2+1

核心思路

  1. 状态定义: dp[i] = 爬到第 i 阶的方法数
  2. 状态转移: dp[i] = dp[i-1] + dp[i-2](从 i-1 爬 1 阶,或从 i-2 爬 2 阶)
  3. 边界条件: dp[1] = 1, dp[2] = 2

代码实现

javascript
/**
 * 爬楼梯 - 空间优化版
 * @param {number} n
 * @return {number}
 */
function climbStairs(n) {
  if (n <= 2) return n;

  let prev1 = 1; // dp[i-2]
  let prev2 = 2; // dp[i-1]

  for (let i = 3; i <= n; i++) {
    const curr = prev1 + prev2;
    prev1 = prev2;
    prev2 = curr;
  }

  return prev2;
}

/**
 * 爬楼梯 - 标准 DP 版(易理解)
 * @param {number} n
 * @return {number}
 */
function climbStairsDP(n) {
  if (n <= 2) return n;

  const dp = new Array(n + 1);
  dp[1] = 1;
  dp[2] = 2;

  for (let i = 3; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }

  return dp[n];
}

示例调用

javascript
console.log(climbStairs(3));  // 3
console.log(climbStairs(5));  // 8
console.log(climbStairs(10)); // 89

复杂度分析

  • 时间: O(n)
  • 空间: O(1)(优化版)/ O(n)(标准版)

2. 零钱兑换

LeetCode: 322. 零钱兑换 | 难度: 中等 | 梯队: 第二梯队

题目描述

给定不同面额的硬币 coins 和一个总金额 amount。计算可以凑成总金额所需的最少硬币个数。如果无法凑成,返回 -1。

输入: coins = [1, 2, 5], amount = 11
输出: 3
解释: 11 = 5 + 5 + 1

核心思路

  1. 状态定义: dp[i] = 凑成金额 i 所需的最少硬币数
  2. 状态转移: dp[i] = min(dp[i], dp[i - coin] + 1) 对每个硬币
  3. 边界条件: dp[0] = 0(凑成 0 元需要 0 个硬币)

代码实现

javascript
/**
 * 零钱兑换
 * 完全背包问题:先遍历金额再遍历硬币(两种遍历顺序结果相同)
 * @param {number[]} coins
 * @param {number} amount
 * @return {number}
 */
function coinChange(coins, amount) {
  // 初始化为 amount + 1(不可能的值,相当于 Infinity)
  const dp = new Array(amount + 1).fill(amount + 1);
  dp[0] = 0;

  for (let i = 1; i <= amount; i++) {
    for (const coin of coins) {
      if (i >= coin) {
        dp[i] = Math.min(dp[i], dp[i - coin] + 1);
      }
    }
  }

  return dp[amount] > amount ? -1 : dp[amount];
}

示例调用

javascript
console.log(coinChange([1, 2, 5], 11)); // 3 (5+5+1)
console.log(coinChange([2], 3));        // -1 (无法凑成)
console.log(coinChange([1, 3, 5], 11)); // 3 (5+3+3)

复杂度分析

  • 时间: O(n × amount),n 为硬币种类数
  • 空间: O(amount)

3. 最长递增子序列

LeetCode: 300. 最长递增子序列 | 难度: 中等 | 梯队: 第二梯队

题目描述

给定一个整数数组 nums,找到其中最长严格递增子序列的长度。

输入: nums = [10, 9, 2, 5, 3, 7, 101, 18]
输出: 4
解释: 最长递增子序列是 [2, 3, 7, 101]

核心思路

方法一:动态规划 O(n²)

  • dp[i] = 以 nums[i] 结尾的 LIS 长度
  • 对于每个 j < i,若 nums[j] < nums[i],则 dp[i] = max(dp[i], dp[j] + 1)

方法二:二分查找 O(n log n)

  • 维护一个递增数组 tails,tails[i] 表示长度为 i+1 的 LIS 的最小结尾元素
  • 对于每个数,二分查找其位置并更新

代码实现

javascript
/**
 * 最长递增子序列 - DP 解法
 * @param {number[]} nums
 * @return {number}
 */
function lengthOfLIS(nums) {
  if (!nums.length) return 0;

  const dp = new Array(nums.length).fill(1);

  for (let i = 1; i < nums.length; i++) {
    for (let j = 0; j < i; j++) {
      if (nums[i] > nums[j]) {
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }
  }

  return Math.max(...dp);
}

/**
 * 最长递增子序列 - 二分优化(推荐)
 * @param {number[]} nums
 * @return {number}
 */
function lengthOfLISBinary(nums) {
  if (!nums.length) return 0;

  const tails = [nums[0]];

  for (let i = 1; i < nums.length; i++) {
    if (nums[i] > tails[tails.length - 1]) {
      // 比所有元素都大,直接追加
      tails.push(nums[i]);
    } else {
      // 二分查找第一个 >= nums[i] 的位置并替换
      let left = 0, right = tails.length - 1;
      while (left < right) {
        const mid = left + Math.floor((right - left) / 2);
        if (tails[mid] < nums[i]) {
          left = mid + 1;
        } else {
          right = mid;
        }
      }
      tails[left] = nums[i];
    }
  }

  return tails.length;
}

示例调用

javascript
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLIS([1, 2, 3, 4, 5]));               // 5
console.log(lengthOfLIS([5, 4, 3, 2, 1]));               // 1

复杂度分析

方法时间复杂度空间复杂度
DPO(n²)O(n)
二分优化O(n log n)O(n)

4. 最长公共子序列

LeetCode: 1143. 最长公共子序列 | 难度: 中等 | 梯队: 第三梯队

题目描述

给定两个字符串 text1text2,返回这两个字符串的最长公共子序列的长度。

输入: text1 = "abcde", text2 = "ace"
输出: 3
解释: 最长公共子序列是 "ace"

核心思路

  1. 状态定义: dp[i][j] = text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度
  2. 状态转移:
    • text1[i-1] === text2[j-1]: dp[i][j] = dp[i-1][j-1] + 1
    • 否则: dp[i][j] = max(dp[i-1][j], dp[i][j-1])
  3. 边界条件: dp[0][j] = dp[i][0] = 0

代码实现

javascript
/**
 * 最长公共子序列
 * @param {string} text1
 * @param {string} text2
 * @return {number}
 */
function longestCommonSubsequence(text1, text2) {
  const m = text1.length;
  const n = text2.length;

  // dp[i][j] 表示 text1[0..i-1] 和 text2[0..j-1] 的 LCS 长度
  const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));

  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (text1[i - 1] === text2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }

  return dp[m][n];
}

// 空间优化版(滚动数组):将二维 dp 压缩为两行,空间 O(n),逻辑相同。

示例调用

javascript
console.log(longestCommonSubsequence("abcde", "ace")); // 3 ("ace")
console.log(longestCommonSubsequence("abc", "abc"));   // 3
console.log(longestCommonSubsequence("abc", "def"));   // 0

复杂度分析

  • 时间: O(m × n)
  • 空间: O(m × n) / O(n) 优化后

5. 0-1 背包问题

经典问题 | 难度: 中等 | 梯队: 第三梯队

题目描述

有 n 个物品,每个物品有重量 weights[i] 和价值 values[i]。背包最大承重为 capacity。每个物品只能选一次,求能装入背包的最大价值。

输入: weights = [1, 2, 3], values = [6, 10, 12], capacity = 5
输出: 22
解释: 选择物品 0 和物品 2 (重量 1+3=4, 价值 6+12=18)
      或选择物品 1 和物品 2 (重量 2+3=5, 价值 10+12=22)

核心思路

  1. 状态定义: dp[i][w] = 前 i 个物品,容量为 w 时的最大价值
  2. 状态转移:
    • 不选第 i 个物品: dp[i][w] = dp[i-1][w]
    • 选第 i 个物品: dp[i][w] = dp[i-1][w-weights[i]] + values[i]
    • dp[i][w] = max(不选, 选)
  3. 空间优化: 一维数组,倒序遍历容量

代码实现

javascript
/**
 * 0-1 背包 - 二维 DP
 * @param {number[]} weights - 物品重量
 * @param {number[]} values - 物品价值
 * @param {number} capacity - 背包容量
 * @return {number}
 */
function knapsack01(weights, values, capacity) {
  const n = weights.length;
  // dp[i][w] = 前 i 个物品,容量 w 的最大价值
  const dp = Array.from({ length: n + 1 }, () => new Array(capacity + 1).fill(0));

  for (let i = 1; i <= n; i++) {
    for (let w = 0; w <= capacity; w++) {
      // 不选第 i 个物品
      dp[i][w] = dp[i - 1][w];
      // 选第 i 个物品(如果装得下)
      if (w >= weights[i - 1]) {
        dp[i][w] = Math.max(
          dp[i][w],
          dp[i - 1][w - weights[i - 1]] + values[i - 1]
        );
      }
    }
  }

  return dp[n][capacity];
}

/**
 * 0-1 背包 - 空间优化(一维)
 * 关键:必须倒序遍历容量,防止重复选取
 */
function knapsack01Optimized(weights, values, capacity) {
  const n = weights.length;
  const dp = new Array(capacity + 1).fill(0);

  for (let i = 0; i < n; i++) {
    // 必须倒序遍历!
    for (let w = capacity; w >= weights[i]; w--) {
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }

  return dp[capacity];
}

示例调用

javascript
console.log(knapsack01([1, 2, 3], [6, 10, 12], 5));  // 22
console.log(knapsack01([1, 2, 3], [6, 10, 12], 0));  // 0
console.log(knapsack01([10, 20, 30], [1, 2, 3], 5)); // 0 (全装不下)

完全背包对比

javascript
/**
 * 完全背包 - 每个物品可以无限选
 * 区别:正序遍历容量
 */
function knapsackComplete(weights, values, capacity) {
  const n = weights.length;
  const dp = new Array(capacity + 1).fill(0);

  for (let i = 0; i < n; i++) {
    // 正序遍历,允许重复选取
    for (let w = weights[i]; w <= capacity; w++) {
      dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
    }
  }

  return dp[capacity];
}

// 对比示例
console.log(knapsack01([1, 2, 3], [6, 10, 12], 5));       // 22 (0-1背包)
console.log(knapsackComplete([1, 2, 3], [6, 10, 12], 5)); // 30 (完全背包: 5个物品0)

复杂度分析

  • 时间: O(n × capacity)
  • 空间: O(capacity) 优化后

6. 打家劫舍

LeetCode: 198. 打家劫舍 | 难度: 中等 | 梯队: 第二梯队

题目描述

你是一个小偷,沿街房屋存有现金。相邻房屋装有报警器,如果两间相邻房屋在同一晚被闯入,系统会报警。求在不触动警报的情况下,能偷窃到的最高金额。

输入: [1, 2, 3, 1]
输出: 4
解释: 偷窃 1 号房 (金额 = 1) 和 3 号房 (金额 = 3),总金额 = 4

核心思路

  1. 状态定义: dp[i] = 偷到第 i 个房屋时的最大金额
  2. 状态转移:
    • 偷第 i 个: dp[i-2] + nums[i]
    • 不偷第 i 个: dp[i-1]
    • dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  3. 空间优化: 只需保留前两个状态

代码实现

javascript
/**
 * 打家劫舍
 * @param {number[]} nums
 * @return {number}
 */
function rob(nums) {
  if (!nums.length) return 0;
  if (nums.length === 1) return nums[0];

  let prev1 = 0;        // dp[i-2]
  let prev2 = nums[0];  // dp[i-1]

  for (let i = 1; i < nums.length; i++) {
    const curr = Math.max(prev2, prev1 + nums[i]);
    prev1 = prev2;
    prev2 = curr;
  }

  return prev2;
}

/**
 * 打家劫舍 II - 环形版(房屋围成一圈)
 * LeetCode 213
 */
function robCircular(nums) {
  if (!nums.length) return 0;
  if (nums.length === 1) return nums[0];

  // 偷第一个,不偷最后一个
  const rob1 = robRange(nums, 0, nums.length - 2);
  // 不偷第一个,偷最后一个
  const rob2 = robRange(nums, 1, nums.length - 1);

  return Math.max(rob1, rob2);
}

function robRange(nums, start, end) {
  let prev1 = 0;
  let prev2 = 0;

  for (let i = start; i <= end; i++) {
    const curr = Math.max(prev2, prev1 + nums[i]);
    prev1 = prev2;
    prev2 = curr;
  }

  return prev2;
}

/**
 * 打家劫舍 III - 树形版
 * LeetCode 337
 */
function robTree(root) {
  function dfs(node) {
    if (!node) return [0, 0]; // [不选当前, 选当前]

    const left = dfs(node.left);
    const right = dfs(node.right);

    // 不选当前节点:子节点可选可不选,取最大
    const notRob = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
    // 选当前节点:子节点必须不选
    const doRob = node.val + left[0] + right[0];

    return [notRob, doRob];
  }

  const [notRob, doRob] = dfs(root);
  return Math.max(notRob, doRob);
}

示例调用

javascript
console.log(rob([1, 2, 3, 1]));    // 4
console.log(rob([2, 7, 9, 3, 1])); // 12 (2+9+1)

// 环形版本
console.log(robCircular([2, 3, 2]));    // 3
console.log(robCircular([1, 2, 3, 1])); // 4

复杂度分析

版本时间复杂度空间复杂度
基础版O(n)O(1)
环形版O(n)O(1)
树形版O(n)O(h) 递归栈

💡 动态规划技巧总结

DP 问题分类

类型典型题目特点
线性 DP爬楼梯、打家劫舍单数组,一维状态
背包 DP0-1 背包、零钱兑换选择问题
双序列 DPLCS、编辑距离两个字符串
区间 DP戳气球、矩阵链乘法区间划分
状态压缩 DPTSP、棋盘覆盖状态用二进制表示
树形 DP打家劫舍 III在树上做 DP

解题模板

javascript
// 通用 DP 模板
function dpSolution(input) {
  const n = input.length;

  // 1. 定义状态
  const dp = new Array(n + 1).fill(初始值);

  // 2. 边界条件
  dp[0] = 边界值;

  // 3. 状态转移
  for (let i = 1; i <= n; i++) {
    dp[i] = 状态转移方程(dp[i-1], input[i-1], ...);
  }

  // 4. 返回结果
  return dp[n]; // 或 Math.max(...dp)
}

空间优化技巧

javascript
// 优化前:二维数组
const dp = Array.from({ length: m }, () => new Array(n).fill(0));

// 优化后:滚动数组(两行)
let prev = new Array(n).fill(0);
let curr = new Array(n).fill(0);
// 循环后交换 [prev, curr] = [curr, prev]

// 优化后:一维数组(注意遍历顺序)
const dp = new Array(n).fill(0);
// 0-1 背包:倒序遍历
// 完全背包:正序遍历

前端面试知识库