普通视图

发现新文章,点击刷新页面。
昨天以前首页

用填充表格法吃透01背包及其变形-3

作者 颜酱
2026年1月6日 11:46

三、01背包的经典变形

01背包的核心是「选/不选」,实际考题中很少直接考查基础模型,更多是结合具体场景转化为变形问题。但无论场景如何变化,只要抓住「每个物品最多选一次」的本质,就能用DP解题5步「万能钥匙」轻松破解。以下是4类最经典的01背包变形:

3.1 变形1:目标和(分割子集和/是否能装满背包)

LeetCode 链接494. 目标和

问题描述:给定一个非负整数数组nums和一个目标数target,向数组中每个整数前添加+-,使得所有整数的和等于target,求有多少种不同的添加符号的方法。

核心转化:设添加+的数的和为left,添加-的数的和为right,则有:

left - right = target
left + right = sum(nums)
两式相加得:left = (target + sum(nums)) / 2

问题转化为:从nums中选择若干元素,使得其和恰好为left,求这样的选择方案数——这是「01背包求方案数」的典型场景(每个元素选或不选,选则计入和,不选则不计)。

目标和问题核心表格(空表,后续逐步填充):

处理阶段\和为j 0 1 2 ...(和递增) left(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能凑出和为j的方案数」,最终右下角dp[n][left]即为目标和的解法总数(n为nums数组长度)。

3.1.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「处理前i个元素,能凑出和为j的方案数」。后续可优化为一维数组dp[j](空间优化思路与基础01背包一致),这里先从直观的二维数组入手。

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到left,0代表和为0),表格共n+1行、left+1列。

3.1.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1],数组索引从0开始,i从1开始),核心决策仍是「选或不选」,方案数为两种决策的总和:

  1. 不选第i个元素:凑出和为j的方案数 = 处理前i-1个元素凑出和为j的方案数,即dp[i][j] += dp[i-1][j]

  2. 选第i个元素:需保证j ≥ nums[i-1](当前元素值不大于目标和j),此时方案数 = 处理前i-1个元素凑出和为j-nums[i-1]的方案数,即dp[i][j] += dp[i-1][j - nums[i-1]]

最终递推公式(两种决策方案数相加):

if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}

3.1.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的方案数:

  1. i=0(未处理任何元素),j=0(和为0):不选任何元素即可凑出和为0,因此方案数为1,即dp[0][0] = 1

  2. i=0(未处理任何元素),j>0(和大于0):没有元素可选,无法凑出任何正和,方案数为0,即dp[0][j] = 0(j>0);

  3. j=0(和为0),i>0(处理过元素):初始时可先设为1(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,1,1,1],target = 2,先计算sum(nums) = 4,left = (2 + 4)/2 = 3。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 待填 待填 待填
处理第2个元素(1) 1 待填 待填 待填
处理第3个元素(1) 1 待填 待填 待填
处理第4个元素(1) 1 待填 待填 待填

3.1.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到left),即逐行填充表格。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

3.1.5 步骤5:打印dp数组(验证)

以示例nums = [1,1,1,1]target = 2(left=3)为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,方案数=dp[0][0]=1;
    • j=1:j≥1,方案数=dp[0][1](不选)+ dp[0][0](选)=0+1=1;
    • j=2:j>1,无法选,方案数=dp[0][2]=0;
    • j=3:j>1,无法选,方案数=dp[0][3]=0;
  2. 填充第2行(i=2,元素2:1)

    • j=0:方案数=dp[1][0]=1;
    • j=1:j≥1,方案数=dp[1][1](不选)+ dp[1][0](选)=1+1=2;
    • j=2:j≥1,方案数=dp[1][2](不选)+ dp[1][1](选)=0+1=1;
    • j=3:j>1,无法选,方案数=dp[1][3]=0;
  3. 填充第3行(i=3,元素3:1)

    • j=0:方案数=dp[2][0]=1;
    • j=1:j≥1,方案数=dp[2][1](不选)+ dp[2][0](选)=2+1=3;
    • j=2:j≥1,方案数=dp[2][2](不选)+ dp[2][1](选)=1+2=3;
    • j=3:j≥1,方案数=dp[2][3](不选)+ dp[2][2](选)=0+1=1;
  4. 填充第4行(i=4,元素4:1)

    • j=0:方案数=dp[3][0]=1;
    • j=1:j≥1,方案数=dp[3][1](不选)+ dp[3][0](选)=3+1=4;
    • j=2:j≥1,方案数=dp[3][2](不选)+ dp[3][1](选)=3+3=6;
    • j=3:j≥1,方案数=dp[3][3](不选)+ dp[3][2](选)=1+3=4;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3
初始状态(i=0) 1 0 0 0
处理第1个元素(1) 1 1 0 0
处理第2个元素(1) 1 2 1 0
处理第3个元素(1) 1 3 3 1
处理第4个元素(1) 1 4 6 4

表格右下角dp[4][3] = 4,即该示例的目标和解法总数为4,与实际情况一致(+1+1+1-1、+1+1-1+1、+1-1+1+1、-1+1+1+1)。

3.1.6 目标和问题完整代码(二维+一维优化)

/**
 * 目标和(二维DP解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_2d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  // 边界条件:target的绝对值大于sum,或(target + sum)为奇数,均无可行方案
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素凑出和为j的方案数
  const dp = new Array(n + 1).fill(0).map(() => new Array(left + 1).fill(0));
  dp[0][0] = 1; // 未处理元素时,凑出和为0的方案数为1

  // 遍历顺序:先遍历元素,再遍历和(逐行填充)
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= left; j++) {
      // 递推公式
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('目标和二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].join('\t'));
  }

  return dp[n][left];
}

/**
 * 目标和(一维DP空间优化解法)
 * @param {number[]} nums - 非负整数数组
 * @param {number} target - 目标和
 * @returns {number} - 不同的添加符号方法数
 */
function findTargetSumWays_1d(nums, target) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (Math.abs(target) > sum || (target + sum) % 2 !== 0) return 0;
  const left = (target + sum) / 2;
  // 初始化一维dp数组:dp[j]表示凑出和为j的方案数
  const dp = new Array(left + 1).fill(0);
  dp[0] = 1; // 基础方案:不选任何元素凑出和为0

  // 遍历顺序:先遍历元素,再倒序遍历和(避免重复选择)
  for (let num of nums) {
    for (let j = left; j >= num; j--) {
      dp[j] += dp[j - num]; // 递推公式简化(复用数组)
    }
    console.log(`处理完元素${num}后,dp数组:`, [...dp]);
  }

  return dp[left];
}

// 测试用例
const nums = [1, 1, 1, 1];
const target = 2;
console.log('二维DP解法:', findTargetSumWays_2d(nums, target)); // 输出:4
console.log('一维DP解法:', findTargetSumWays_1d(nums, target)); // 输出:4

3.2 变形2:分割等和子集(是否能装满背包)

LeetCode 链接416. 分割等和子集

问题描述:给定一个只包含正整数的非空数组nums,判断是否可以将这个数组分割成两个子集,使得两个子集的和相等。

核心转化:两个子集和相等,即每个子集的和为数组总和的一半(记为target)。问题转化为:从nums中选择若干元素,使得其和恰好为target——这是「01背包判断可行性」的典型场景(每个元素选或不选,判断是否能装满容量为target的背包)。

核心表格(空表):

处理阶段\和为j 0 1 2 ...(和递增) target(目标和)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1个元素 待填充 待填充 待填充 待填充 待填充
处理第2个元素 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[i][j]代表「处理前i个元素后,能否凑出和为j」(布尔值),最终右下角dp[n][target]即为问题答案。

3.2.1 步骤1:确定dp数组及下标的含义

定义二维布尔数组dp[i][j]:表示「处理前i个元素,能否凑出和为j」。可优化为一维布尔数组dp[j],空间复杂度从O(n*target)降至O(target)

对应表格维度:i(行)表示处理的元素个数(从0到n,0代表未处理任何元素),j(列)表示要凑的和(从0到target,0代表和为0),表格共n+1行、target+1列。

3.2.2 步骤2:确定递推公式

对于第i个元素(值为nums[i-1]),决策为「选或不选」,可行性为两种决策的或运算:

  1. 不选第i个元素:能否凑出j = 处理前i-1个元素能否凑出j,即dp[i][j] = dp[i-1][j]

  2. 选第i个元素:需j ≥ nums[i-1],能否凑出j = 处理前i-1个元素能否凑出j-nums[i-1],即dp[i][j] = dp[i-1][j - nums[i-1]]

最终递推公式(两种决策有一个可行则整体可行):

if (j >= nums[i - 1]) {
  dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
} else {
  dp[i][j] = dp[i - 1][j];
}

3.2.3 步骤3:dp数组如何初始化

初始化核心是确定边界条件,即无需推导就能直接确定的可行性:

  1. i=0(未处理任何元素),j=0(和为0):不选任何元素可凑出和为0,因此dp[0][0] = true

  2. i=0(未处理任何元素),j>0(和大于0):没有元素可选,无法凑出任何正和,可行性为false,即dp[0][j] = false(j>0);

  3. j=0(和为0),i>0(处理过元素):初始时可先设为true(后续通过递推更新),表示不选当前及之前元素的基础方案。

结合示例理解:假设nums = [1,5,11,5],sum = 22,target = 11。初始化后的表格(第0行已填充):

处理阶段\和为j 0 1 2 3 ...(和递增) 11(目标和)
初始状态(i=0) true false false false false false
处理第1个元素(1) true 待填 待填 待填 待填 待填
处理第2个元素(5) true 待填 待填 待填 待填 待填
处理第3个元素(11) true 待填 待填 待填 待填 待填
处理第4个元素(5) true 待填 待填 待填 待填 待填

3.2.4 步骤4:确定遍历顺序(表格填充顺序)

与基础01背包二维解法一致:先遍历元素(i从1到n),再遍历和(j从0到target),即逐行填充表格。原因:计算dp[i][j]时,仅依赖上一行(i-1行)的dp[i-1][j]dp[i-1][j - nums[i-1]],逐行填充可确保依赖的单元格已提前计算完成。

一维解法:先遍历元素,再倒序遍历和(避免重复选择),与基础01背包空间优化逻辑一致。

3.2.5 步骤5:打印dp数组(验证)

以示例nums = [1,5,11,5]sum = 22target = 11为例,逐步填充表格验证逻辑:

  1. 填充第1行(i=1,元素1:1)

    • j=0:不选元素1,dp[1][0] = dp[0][0] = true;
    • j=1:j≥1,dp[1][1] = dp[0][1](不选)|| dp[0][0](选)= false || true = true;
    • j=2-11:j<1,无法选,dp[1][j] = dp[0][j] = false;
  2. 填充第2行(i=2,元素2:5)

    • j=0:dp[2][0] = dp[1][0] = true;
    • j=1-4:j<5,无法选,dp[2][j] = dp[1][j](继承上一行);
    • j=5:j≥5,dp[2][5] = dp[1][5](不选)|| dp[1][0](选)= false || true = true;
    • j=6:j≥5,dp[2][6] = dp[1][6](不选)|| dp[1][1](选)= false || true = true;
    • j=7-11:j≥5,dp[2][j] = dp[1][j](不选)|| dp[1][j-5](选),其中j=11时,dp[2][11] = false || false = false;
  3. 填充第3行(i=3,元素3:11)

    • j=0-10:j<11,无法选,dp[3][j] = dp[2][j](继承上一行);
    • j=11:j≥11,dp[3][11] = dp[2][11](不选)|| dp[2][0](选)= false || true = true;
  4. 填充第4行(i=4,元素4:5)

    • j=0-4:j<5,无法选,dp[4][j] = dp[3][j](继承上一行);
    • j=5-11:j≥5,dp[4][j] = dp[3][j](不选)|| dp[3][j-5](选),其中j=11时,dp[4][11] = true || false = true;

最终填充完成的表格:

处理阶段\和为j 0 1 2 3 4 5 6 7 8 9 10 11
初始状态(i=0) true false false false false false false false false false false false
处理第1个元素(1) true true false false false false false false false false false false
处理第2个元素(5) true true false false false true true false false false false false
处理第3个元素(11) true true false false false true true false false false false true
处理第4个元素(5) true true false false false true true false false false false true

表格右下角dp[4][11] = true,即该示例可以分割成两个和相等的子集(子集[1,5,5]和[11]),与预期结果一致。

3.2.6 分割等和子集完整代码

/**
 * 分割等和子集(二维DP解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_2d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false; // 总和为奇数,无法分割
  const target = sum / 2;
  const n = nums.length;
  // 初始化二维dp数组:dp[i][j]表示处理前i个元素能否凑出和为j
  const dp = new Array(n + 1).fill(0).map(() => new Array(target + 1).fill(false));
  dp[0][0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再遍历和
  for (let i = 1; i <= n; i++) {
    for (let j = 0; j <= target; j++) {
      if (j >= nums[i - 1]) {
        dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
      } else {
        dp[i][j] = dp[i - 1][j];
      }
    }
  }

  // 打印dp数组验证
  console.log('分割等和子集二维DP数组(表格):');
  for (let i = 0; i <= n; i++) {
    console.log(dp[i].map(val => (val ? 'true' : 'false')).join('\t'));
  }

  return dp[n][target];
}

/**
 * 分割等和子集(一维DP空间优化解法)
 * @param {number[]} nums - 正整数数组
 * @returns {boolean} - 是否可以分割成两个和相等的子集
 */
function canPartition_1d(nums) {
  const sum = nums.reduce((a, b) => a + b, 0);
  if (sum % 2 !== 0) return false;
  const target = sum / 2;
  // 初始化一维dp数组:dp[j]表示能否凑出和为j
  const dp = new Array(target + 1).fill(false);
  dp[0] = true; // 边界条件

  // 遍历顺序:先遍历元素,再倒序遍历和
  for (let num of nums) {
    for (let j = target; j >= num; j--) {
      dp[j] = dp[j] || dp[j - num];
    }
    console.log(
      `处理完元素${num}后,dp数组:`,
      dp.map(val => (val ? 'true' : 'false'))
    );
  }

  return dp[target];
}

// 测试用例
const nums1 = [1, 5, 11, 5];
console.log('二维DP解法:', canPartition_2d(nums1)); // 输出:true
console.log('一维DP解法:', canPartition_1d(nums1)); // 输出:true

const nums2 = [1, 2, 3, 5];
console.log('二维DP解法:', canPartition_2d(nums2)); // 输出:false
console.log('一维DP解法:', canPartition_1d(nums2)); // 输出:false

3.3 变形3:最后一块石头的重量II(最小背包剩余容量)

LeetCode 链接1049. 最后一块石头的重量 II

问题描述:有一堆石头,每块石头的重量都是正整数。每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为x和y,且x ≤ y。那么粉碎的可能结果如下:如果x == y,那么两块石头都会被完全粉碎;如果x != y,那么重量为x的石头会被完全粉碎,而重量为y的石头会变成y - x的重量。最后,最多只会剩下一块石头。返回此石头的最小可能重量。

核心转化:要使最后剩余石头重量最小,需将石头尽可能分成两堆重量接近的石头——两堆重量差越小,剩余重量越小。设总重量为sum,目标是找到一堆石头的最大重量maxWeight(≤ sum/2),则剩余重量为sum - 2*maxWeight。问题转化为:从石头重量数组中选择若干元素,使得其和不超过sum/2的最大值——这是「01背包求最大价值(重量即价值)」的场景(背包容量为sum/2,物品重量和价值均为石头重量)。

最后一块石头的重量II核心表格(空表,后续逐步填充):

处理阶段\容量j 0 1 2 ...(容量递增) target(sum/2)
初始状态 待填充 待填充 待填充 待填充 待填充
处理第1块石头 待填充 待填充 待填充 待填充 待填充
处理第2块石头 待填充 待填充 待填充 待填充 待填充

表格说明:表格中每个单元格dp[j]代表「容量为j的背包能容纳的最大重量」(一维DP),最终dp[target]即为不超过sum/2的最大子集重量,剩余重量 = sum - 2*dp[target]。

3.3.1 步骤1:确定dp数组及下标的含义

定义一维数组dp[j]:表示「容量为j的背包,能容纳的最大重量」(即选若干石头的最大和)。

对应表格维度:仅保留"容量j"这一列维度(j从0到target,target = sum/2向下取整),形成单行表格,每次遍历石头时,滚动更新这一行的数值(覆盖上一行的结果)。

3.3.2 步骤2:确定递推公式

对于第i块石头(重量stones[i],价值也为stones[i]),有两种核心决策:选或不选。

  1. 不选第i块石头:容量为j的最大重量 = 不选当前石头时的最大重量,即dp[j] = dp[j](保持不变);

  2. 选第i块石头:需保证背包容量j ≥ 第i块石头的重量,此时最大重量 = 容量j-stones[i]的最大重量 + 第i块石头的重量,即dp[j] = dp[j - stones[i]] + stones[i]

最终递推公式(取两种决策的最大值):

if (j >= stones[i]) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
} else {
  dp[j] = dp[j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[j]不变):

for (let j = target; j >= stones[i]; j--) {
  dp[j] = Math.max(dp[j], dp[j - stones[i]] + stones[i]);
}

3.3.3 步骤3:dp数组如何初始化

初始化逻辑与基础01背包一维DP一致:容量为0时,最大重量为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无石头可放,最大重量为0),即dp = new Array(target + 1).fill(0)

初始化后的单行表格:[0,0,0,0,...](j从0到target)

结合示例理解:假设stones = [2,7,4,1,8,1],sum = 23,target = Math.floor(23/2) = 11。初始化后的表格:

容量j 0 1 2 3 4 5 6 7 8 9 10 11
初始 0 0 0 0 0 0 0 0 0 0 0 0

3.3.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」:

  1. 必须先遍历石头,再遍历容量:逐个处理每块石头,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从target到stones[i]):从最大容量往小容量填充,确保计算dp[j]时,dp[j - stones[i]]仍是上一行(未处理当前石头)的旧值,避免同一石头被多次选择。

3.3.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 stones = [2,7,4,1,8,1]sum = 23target = 11,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0,0,0,0]

  2. 处理石头1(w=2),j从11到2倒序:更新后:dp = [0,0,2,2,2,2,2,2,2,2,2,2]

    • j=11:dp[11] = max(0, dp[9]+2) = max(0,0+2)=2;
    • j=10:dp[10] = max(0, dp[8]+2)=2;
    • ...(j=2到9同理);
    • j=2:dp[2] = max(0, dp[0]+2)=2;
  3. 处理石头2(w=7),j从11到7倒序:更新后:dp = [0,0,2,2,2,2,2,7,7,9,9,9]

    • j=11:max(2, dp[4]+7)=max(2,2+7)=9;
    • j=10:max(2, dp[3]+7)=max(2,2+7)=9;
    • j=9:max(2, dp[2]+7)=max(2,2+7)=9;
    • j=8:max(2, dp[1]+7)=max(2,0+7)=7;
    • j=7:max(2, dp[0]+7)=max(2,0+7)=7;
  4. 处理石头3(w=4),j从11到4倒序:更新后:dp = [0,0,2,2,4,4,6,7,7,9,9,11]

    • j=11:max(9, dp[7]+4)=max(9,7+4)=11;
    • j=10:max(9, dp[6]+4)=max(9,2+4)=9;
    • j=9:max(9, dp[5]+4)=max(9,2+4)=9;
    • j=8:max(7, dp[4]+4)=max(7,2+4)=7;
    • j=7:max(7, dp[3]+4)=max(7,2+4)=7;
    • j=6:max(2, dp[2]+4)=max(2,2+4)=6;
    • j=5:max(2, dp[1]+4)=max(2,0+4)=4;
    • j=4:max(2, dp[0]+4)=max(2,0+4)=4;
  5. 处理石头4(w=1),j从11到1倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[10]+1)=max(11,9+1)=11;
    • j=10:max(9, dp[9]+1)=max(9,9+1)=10;
    • ...(其他位置类似更新);
  6. 处理石头5(w=8),j从11到8倒序:更新后:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

    • j=11:max(11, dp[3]+8)=max(11,3+8)=11;
    • j=10:max(10, dp[2]+8)=max(10,2+8)=10;
    • j=9:max(9, dp[1]+8)=max(9,1+8)=9;
    • j=8:max(8, dp[0]+8)=max(8,0+8)=8;
  7. 处理石头6(w=1),j从11到1倒序:最终:dp = [0,1,2,3,4,5,6,7,8,9,10,11]

最终单行表格dp[11] = 11,剩余重量 = 23 - 2*11 = 1,与预期结果一致。

3.3.6 最后一块石头的重量II完整代码(一维DP)

/**
 * 最后一块石头的重量II(一维DP解法)
 * @param {number[]} stones - 石头重量数组
 * @returns {number} - 最后剩余石头的最小可能重量
 */
function lastStoneWeightII(stones) {
  const sum = stones.reduce((a, b) => a + b, 0);
  const target = Math.floor(sum / 2);
  // 初始化一维dp数组:dp[j]表示容量为j的背包能容纳的最大重量
  const dp = new Array(target + 1).fill(0);

  // 遍历顺序:先遍历石头,再倒序遍历容量
  for (let stone of stones) {
    for (let j = target; j >= stone; j--) {
      dp[j] = Math.max(dp[j], dp[j - stone] + stone);
    }
    console.log(`处理完石头${stone}后,dp数组:`, [...dp]);
  }

  // 剩余重量 = 总重量 - 2*最大子集重量
  return sum - 2 * dp[target];
}

// 测试用例
const stones = [2, 7, 4, 1, 8, 1];
console.log('最后剩余石头的最小重量:', lastStoneWeightII(stones)); // 输出:1

3.4 变形4:一和零(二维背包容量)

LeetCode 链接474. 一和零

问题描述:给你一个二进制字符串数组strs和两个整数mn。请你找出并返回strs的最大子集的长度,该子集中最多有m个0和n个1。

核心转化:每个字符串是一个"物品",选择该物品会消耗"0的数量"和"1的数量"两种容量,目标是在两种容量均不超过限制(m、n)的前提下,选择最多的物品——这是「二维容量01背包求最大物品数」的场景(背包有两个维度的容量限制,价值为1,求最大价值即最大物品数)。

一和零问题核心表格(空表,后续逐步填充):

0的数量\1的数量j 0 1 2 ...(1的数量递增) n(最大1的数量)
0(0的数量为0) 待填充 待填充 待填充 待填充 待填充
1(0的数量为1) 待填充 待填充 待填充 待填充 待填充
...(0的数量递增) 待填充 待填充 待填充 待填充 待填充
m(最大0的数量) 待填充 待填充 待填充 待填充 待填充(最终答案:最多字符串数量)

表格说明:表格中每个单元格dp[i][j]代表「最多使用i个0和j个1时,能选择的最大字符串数量」,我们的目标是按规则填充表格,最终右下角dp[m][n]即为一和零问题的答案。

3.4.1 步骤1:确定dp数组及下标的含义

定义二维数组dp[i][j]:表示「最多用i个0和j个1能选择的最大字符串数量」。

对应表格维度:i(行)表示0的数量(从0到m,0代表0个0),j(列)表示1的数量(从0到n,0代表0个1),表格共m+1行、n+1列。

3.4.2 步骤2:确定递推公式

对于每个字符串(含zero个0、one个1),有两种核心决策:选或不选。

  1. 不选当前字符串:最多用i个0和j个1的最大字符串数量 = 不选当前字符串时的最大数量,即dp[i][j] = dp[i][j](保持不变);

  2. 选当前字符串:需保证i ≥ zero且j ≥ one(0和1的数量都足够),此时最大数量 = 用i-zero个0和j-one个1的最大数量 + 1(当前字符串),即dp[i][j] = dp[i - zero][j - one] + 1

最终递推公式(取两种决策的最大值):

if (i >= zero && j >= one) {
  dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
} else {
  dp[i][j] = dp[i][j]; // 容量不足,无法选
}

简化后(因为容量不足时dp[i][j]不变,且使用倒序遍历):

for (let i = m; i >= zero; i--) {
  for (let j = n; j >= one; j--) {
    dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
  }
}

3.4.3 步骤3:dp数组如何初始化

初始化逻辑:初始无字符串可选,无论有多少0和1,最大字符串数量都为0,因此dp[i][j] = 0(所有单元格初始化为0),即dp = new Array(m+1).fill(0).map(() => new Array(n+1).fill(0))

初始化后的表格(所有单元格为0):

0的数量\1的数量j 0 1 2 ... n
0 0 0 0 0 0
1 0 0 0 0 0
... 0 0 0 0 0
m 0 0 0 0 0

结合示例理解:假设strs = ["10","0001","111001","1","0"],m = 5,n = 3。初始化后的表格(5+1行,3+1列):

0的数量\1的数量j 0 1 2 3
0 0 0 0 0
1 0 0 0 0
2 0 0 0 0
3 0 0 0 0
4 0 0 0 0
5 0 0 0 0

3.4.4 步骤4:确定遍历顺序(表格填充顺序)

二维容量01背包的遍历顺序有严格要求:

  1. 必须先遍历字符串(物品),再遍历0的数量,最后遍历1的数量:逐个处理每个字符串,每次处理时更新整个二维表格;

  2. 0和1的数量都必须倒序遍历

    • 0的数量倒序遍历(i从m到zero):确保计算dp[i][j]时,dp[i - zero][j - one]仍是上一轮(未处理当前字符串)的旧值;
    • 1的数量倒序遍历(j从n到one):同样确保依赖的单元格是旧值。

倒序遍历避免同一字符串被多次选择,完美契合01背包「每个物品选一次」的规则。

3.4.5 步骤5:打印dp数组(验证)

以示例strs = ["10","0001","111001","1","0"]m = 5n = 3为例,逐步填充表格验证逻辑:

  1. 处理字符串1("10":zero=1, one=1)

    • 更新dp[1][1]到dp[5][3]范围内所有满足i≥1且j≥1的位置
    • dp[1][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][2] = max(0, dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  2. 处理字符串2("0001":zero=3, one=1)

    • 更新dp[3][1]到dp[5][3]范围内所有满足i≥3且j≥1的位置
    • dp[3][1] = max(dp[3][1], dp[0][0]+1) = max(0,0+1) = 1
    • dp[4][2] = max(dp[4][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  3. 处理字符串3("111001":zero=2, one=4)

    • 由于one=4 > n=3,无法选择此字符串,dp数组不变
  4. 处理字符串4("1":zero=0, one=1)

    • 更新dp[0][1]到dp[5][3]范围内所有满足j≥1的位置
    • dp[0][1] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[1][2] = max(dp[1][2], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)
  5. 处理字符串5("0":zero=1, one=0)

    • 更新dp[1][0]到dp[5][3]范围内所有满足i≥1的位置
    • dp[1][0] = max(0, dp[0][0]+1) = max(0,0+1) = 1
    • dp[2][1] = max(dp[2][1], dp[1][1]+1) = max(0,1+1) = 2
    • ...(其他位置类似)

最终填充完成的表格(简化展示关键部分):

0的数量\1的数量j 0 1 2 3
0 0 1 1 1
1 1 2 2 2
2 1 2 3 3
3 1 2 3 3
4 1 2 3 4
5 1 2 3 4

表格右下角dp[5][3] = 4,即该示例的最大子集长度为4,与预期结果一致。

3.4.6 一和零完整代码(二维DP)

/**
 * 一和零(二维DP解法)
 * @param {string[]} strs - 二进制字符串数组
 * @param {number} m - 最多允许的0的数量
 * @param {number} n - 最多允许的1的数量
 * @returns {number} - 最大子集长度
 */
function findMaxForm(strs, m, n) {
  // 初始化二维dp数组:dp[i][j]表示i个0和j个1能选的最大字符串数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 遍历每个字符串(物品)
  for (let str of strs) {
    // 统计当前字符串的0和1的数量
    let zero = 0,
      one = 0;
    for (let c of str) {
      c === '0' ? zero++ : one++;
    }

    // 倒序遍历0的数量,再倒序遍历1的数量(避免重复选择)
    for (let i = m; i >= zero; i--) {
      for (let j = n; j >= one; j--) {
        dp[i][j] = Math.max(dp[i][j], dp[i - zero][j - one] + 1);
      }
    }

    // 打印每次处理后的dp数组(简化打印,只打印部分关键行)
    console.log(`处理完字符串"${str}"后,dp数组(前5行前5列):`);
    for (let i = 0; i <= Math.min(m, 5); i++) {
      console.log(dp[i].slice(0, Math.min(n, 5)).join('\t'));
    }
  }

  return dp[m][n];
}

// 测试用例
const strs = ['10', '0001', '111001', '1', '0'];
const m = 5,
  n = 3;
console.log('最大子集长度:', findMaxForm(strs, m, n)); // 输出:4

四、01背包问题总结

01背包的核心是「选或不选」的二选一决策,所有变形都围绕这一核心逻辑,通过转化「物品」「容量」「目标」的含义,适配不同的实际场景。掌握以下关键点,可轻松破解所有01背包相关问题:

  1. 表格可视化核心:DP解题的本质是填充表格,先明确表格形态(dp数组含义),再按规则填充,表格填完即得答案;

  2. 5步万能钥匙:确定dp含义→递推公式→初始化→遍历顺序→验证,这是所有DP问题的通用拆解思路,尤其适用于背包问题;

  3. 空间优化技巧:二维DP可通过「倒序遍历容量」优化为一维DP,核心是复用数组空间,避免重复选择物品;

  4. 变形转化逻辑:无论场景如何变化,只要每个物品最多选一次,都可转化为01背包模型——关键是找到「物品」(待选择的元素/字符串等)、「容量」(限制条件,如重量、和、0/1数量等)、「目标」(最大价值、可行性、方案数等)。

通过基础模型+变形练习,熟练掌握表格填充逻辑和5步拆解方法,就能将复杂的DP问题转化为有序的表格填充过程,彻底攻克01背包这一DP核心模型。

用填充表格法吃透01背包及其变形-2

作者 颜酱
2026年1月6日 11:39

dp2.png

2.3 步骤3:dp数组如何初始化

初始化逻辑与二维一致:容量为0时,最大价值为0,因此dp[0] = 0;其他容量的初始值也为0(因为初始无物品可放,最大价值为0),即dp = new Array(capacity + 1).fill(0)

初始化后的单行表格:[0,0,0,0,0,0,0,0,0](j从0到8)

2.4 步骤4:确定遍历顺序(表格填充顺序)

一维DP的遍历顺序有严格要求,核心是「倒序遍历容量」,对应单行表格的「从右往左填充」——明确单行表格的填充顺序是避免重复选择物品的关键:

  1. 必须先遍历物品,再遍历容量:逐个处理每个物品,每次处理时更新整个单行表格(覆盖上一行结果);

  2. 容量必须倒序遍历(j从C到weights[i-1]):从最大容量往小容量填充,确保计算dp[j]时,dp[j - weights[i-1]]仍是上一行(未处理当前物品)的旧值,避免同一物品被多次选择。

关键原因:一维DP的核心是用单行表格复用二维表格的空间,表格中每个位置的数值都依赖“上一轮未更新的旧值”(对应二维的dp[i-1][j - w[i]])。若正序遍历容量,dp[j - w[i]]会被提前更新(相当于二维的dp[i][j - w[i]]),导致同一个物品被多次选择(变成完全背包);倒序遍历能保证计算dp[j]时,dp[j - w[i]]仍是上一行(未选当前物品)的结果,对应单行表格从右往左填充,完美契合01背包「每个物品选一次」的规则。

反例(正序遍历容量):若j从weights[i-1]到C正序遍历,处理物品1(w=2,v=3)时,j=2会更新dp[2]=3,j=4时会用到dp[2]的新值(3),计算dp[4] = dp[4] + 3 = 3,相当于把物品1放入了两次,违背01背包规则。

2.5 步骤5:打印dp数组(验证)

通过打印单行表格的滚动更新过程,验证填充规则的正确性。仍用测试用例 weights = [2,3,4,5]values = [3,4,5,6]capacity = 8,演示一维DP数组(单行表格)的填充变化:

  1. 初始状态:dp = [0,0,0,0,0,0,0,0,0]

  2. 处理物品1(w=2,v=3),j从8到2倒序

    更新后:dp = [0,0,3,3,3,3,3,3,3]

    • j=8:dp[8] = max(0, dp[8-2]+3) = max(0,0+3)=3;

    • j=7:dp[7] = max(0, dp[5]+3)=3;

    • ...(j=2到6同理);

    • j=2:dp[2] = max(0, dp[0]+3)=3;

  3. 处理物品2(w=3,v=4),j从8到3倒序

    更新后:dp = [0,0,3,4,4,7,7,7,7]

    • j=8:max(3, dp[5]+4)=max(3,3+4)=7;

    • j=7:max(3, dp[4]+4)=max(3,3+4)=7;

    • j=6:max(3, dp[3]+4)=max(3,3+4)=7;

    • j=5:max(3, dp[2]+4)=max(3,3+4)=7;

    • j=4:max(3, dp[1]+4)=max(3,0+4)=4;

    • j=3:max(3, dp[0]+4)=max(3,0+4)=4;

  4. 处理物品3(w=4,v=5),j从8到4倒序

    更新后:dp = [0,0,3,4,5,5,8,9,9]

    • j=8:max(7, dp[4]+5)=max(7,4+5)=9;

    • j=7:max(7, dp[3]+5)=max(7,4+5)=9;

    • j=6:max(7, dp[2]+5)=max(7,3+5)=8;

    • j=5:max(7, dp[1]+5)=max(7,0+5)=7;

    • j=4:max(4, dp[0]+5)=max(4,0+5)=5;

  5. 处理物品4(w=5,v=6),j从8到5倒序

    更新后:dp = [0,0,3,4,5,6,8,9,10]

    • j=8:max(9, dp[3]+6)=max(9,4+6)=10;

    • j=7:max(9, dp[2]+6)=max(9,3+6)=9;

    • j=6:max(8, dp[1]+6)=max(8,0+6)=8;

    • j=5:max(5, dp[0]+6)=max(5,0+6)=6;

最终单行表格dp[8] = 10,与二维DP结果一致,验证了优化的正确性。

2.6 一维DP空间优化完整代码(JavaScript)

/**
 * 基础01背包(一维DP空间优化解法)
 * @param {number[]} weights - 物品重量数组
 * @param {number[]} values - 物品价值数组
 * @param {number} capacity - 背包最大容量
 * @returns {number} - 背包能容纳的最大价值
 */
function knapsack_1d(weights, values, capacity) {
  const n = weights.length;
  // 1. 初始化一维dp数组:dp[j]表示容量j的背包的最大价值,初始值0
  const dp = new Array(capacity + 1).fill(0);

  // 2. 遍历顺序:先遍历物品(i从0到n-1),再倒序遍历容量(j从capacity到weights[i])(从右往左填充)
  for (let i = 0; i < n; i++) {
    // 倒序遍历避免重复选择当前物品
    for (let j = capacity; j >= weights[i]; j--) {
      // 3. 递推公式:不选当前物品的最大价值 vs 选当前物品的最大价值
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
    }
    // 打印每次处理物品后的dp数组(单行表格更新过程)
    console.log(`处理完物品${i + 1}后,dp数组:`, [...dp]);
  }

  // 最终答案:容量为capacity的背包的最大价值
  return dp[capacity];
}

// 测试用例
const weights1 = [2, 3, 4, 5];
const values1 = [3, 4, 5, 6];
const capacity1 = 8;
console.log('最大价值:', knapsack_1d(weights1, values1, capacity1)); // 输出:10

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

作者 颜酱
2026年1月4日 16:47

从经典问题入手,吃透动态规划核心(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 开始,后面的每一项数字都是前面两项数字的和。也就是:

F(0) = 0F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

给定 n,请计算 F(n)

示例 1:

输入:n = 2
输出:1
解释:F(2) = F(1) + F(0) = 1 + 0 = 1

示例 2:

输入:n = 3
输出:2
解释:F(3) = F(2) + F(1) = 1 + 1 = 2

示例 3:

输入: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):

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))
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];
}

空间优化思路

观察数组版代码,我们发现一个关键点:计算 dp[i] 时,只需要用到 dp[i-1]dp[i-2] 两个值,而 dp[0]dp[i-3] 的值在计算完 dp[i-1] 后就不再需要了。

优化过程

  1. 发现问题:数组版需要存储整个 dp 数组(长度为 n+1),空间复杂度 O(n),但实际上我们只需要保存最近的两个值。

  2. 分析依赖关系

    dp[i] = dp[i-1] + dp[i-2]
    

    每次计算只需要前两个值,可以用三个变量滚动更新。

  3. 优化方案

    • prevPrev 保存 dp[i-2](前前一个值)
    • prev 保存 dp[i-1](前一个值)
    • current 保存 dp[i](当前值)
    • 每次循环后,更新这三个变量,实现"滚动窗口"
  4. 变量更新逻辑

    current = prevPrev + prev; // 计算当前值
    prevPrev = prev; // 前前一个值 ← 前一个值
    prev = current; // 前一个值 ← 当前值
    

    这样在下次循环时,prevPrevprev 就是新的 dp[i-2]dp[i-1]

优化效果

  • 空间复杂度:从 O(n) 降低到 O(1)
  • 时间复杂度:保持 O(n) 不变
  • 代码可读性:稍微降低,但空间效率大幅提升
版本2:空间优化版(空间O(1))
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:

输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶

示例 2:

输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
1. 1 阶 + 1 阶 + 1 阶
2. 1 阶 + 2 阶
3. 2 阶 + 1 阶

示例 3:

输入: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):

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...

实现代码(空间优化版)
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:

输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。

示例 2:

输入: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]):

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)
实现代码
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:

输入:m = 3, n = 7
输出:28

示例 2:

输入:m = 3, n = 2
输出:3
解释:
从左上角开始,总共有 3 条路径可以到达右下角。
1. 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右
3. 向下 -> 向右 -> 向下

示例 3:

输入:m = 7, n = 3
输出:28

示例 4:

输入: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:只能一直向下走

执行过程示例(m=3, n=3,二维DP):

初始化二维dp数组(3行3列):
| i\j | 0 | 1 | 2 |
| --- |---|---|---|
| 0   | 1 | 1 | 1 |  (第一行:只能向右)
| 1   | 1 | ? | ? |
| 2   | 1 | ? | ? |

计算第1行(i=1):
dp[1][0] = 1  (第一列:只能向下)
dp[1][1] = dp[0][1] + dp[1][0] = 1 + 1 = 2  (从上方1 + 从左方1)
dp[1][2] = dp[0][2] + dp[1][1] = 1 + 2 = 3  (从上方1 + 从左方2)

计算第2行(i=2):
dp[2][0] = 1  (第一列:只能向下)
dp[2][1] = dp[1][1] + dp[2][0] = 2 + 1 = 3  (从上方2 + 从左方1)
dp[2][2] = dp[1][2] + dp[2][1] = 3 + 3 = 6  (从上方3 + 从左方3)

最终答案:dp[2][2] = 6
版本1:二维数组版(直观,空间O(m×n))
function uniquePaths(m, n) {
  // dp[i][j]:从 (0, 0) 到达 (i, j) 的不同路径数
  const dp = new Array(m).fill(0).map(() => new Array(n).fill(0));

  // 初始化第一行:只能向右走,路径数都是1
  for (let j = 0; j < n; j++) {
    dp[0][j] = 1;
  }

  // 初始化第一列:只能向下走,路径数都是1
  for (let i = 0; i < m; i++) {
    dp[i][0] = 1;
  }

  // 填充剩余位置
  for (let i = 1; i < m; i++) {
    for (let j = 1; j < n; j++) {
      // 从上方到达 + 从左方到达
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      console.log(`dp[${i}][${j}] = ${dp[i][j]}`); // 打印验证
    }
  }

  return dp[m - 1][n - 1];
}

空间优化思路

观察二维DP代码,我们发现一个关键点:计算 dp[i][j] 时,只需要用到 dp[i-1][j](上一行同列)和 dp[i][j-1](当前行前一列)

优化过程

  1. 发现问题:二维数组需要存储 m×n 个值,空间复杂度 O(m×n),但实际上我们只需要保存当前行的数据。

  2. 分析依赖关系

    dp[i][j] = dp[i-1][j] + dp[i][j-1]
    
    • dp[i-1][j]:来自上一行的同列(需要保存上一行)
    • dp[i][j-1]:来自当前行的前一列(正在计算中)
  3. 优化方案

    • 用一维数组 dp[j] 表示当前行第 j 列的路径数
    • 在计算当前行时,dp[j] 本身就保存了上一行的值(dp[i-1][j]
    • 更新 dp[j] 时:dp[j] = dp[j] + dp[j-1]
      • dp[j](更新前)= 上一行同列的值(dp[i-1][j]
      • dp[j-1] = 当前行前一列的值(dp[i][j-1],已计算)
      • dp[j](更新后)= 当前行当前列的值(dp[i][j]
  4. 关键理解

    • 正序遍历列:从左到右计算,保证 dp[j-1] 是当前行已计算的值
    • 复用数组dp[j] 在更新前保存上一行的值,更新后保存当前行的值
    • 初始化:第一行初始化为全1(只能向右走)

优化效果

  • 空间复杂度:从 O(m×n) 降低到 O(n)
  • 时间复杂度:保持 O(m×n) 不变
  • 代码简洁性:减少一维,代码更简洁
版本2:一维数组优化版(空间O(n))
function uniquePaths(m, n) {
  // dp[j]:当前行第 j 列的路径数(初始为第一行)
  const dp = new Array(n).fill(1); // 初始化第一行:只能向右,路径数都是1

  // 从第2行开始计算(i=1,因为第一行已初始化)
  for (let i = 1; i < m; i++) {
    // 从第2列开始计算(j=1,因为第一列始终为1)
    for (let j = 1; j < n; j++) {
      // dp[j](更新前)= 上一行同列的值
      // dp[j-1] = 当前行前一列的值(已计算)
      // dp[j](更新后)= 当前行当前列的值
      dp[j] = dp[j] + dp[j - 1];
      console.log(`第${i}行第${j}列路径数:${dp[j]}`); // 打印验证
    }
  }

  return dp[n - 1];
}

一维优化版执行过程(m=3, n=3):

初始状态(第一行):
dp = [1, 1, 1]  (第一行只能向右,路径数都是1)

第2行(i=1):
j=1: dp[1] = dp[1] + dp[0] = 1 + 1 = 2  (上一行同列1 + 当前行前一列1)
j=2: dp[2] = dp[2] + dp[1] = 1 + 2 = 3  (上一行同列1 + 当前行前一列2)
dp = [1, 2, 3]

第3行(i=2):
j=1: dp[1] = dp[1] + dp[0] = 2 + 1 = 3  (上一行同列2 + 当前行前一列1)
j=2: dp[2] = dp[2] + dp[1] = 3 + 3 = 6  (上一行同列3 + 当前行前一列3)
dp = [1, 3, 6]

最终答案:dp[2] = 6
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:

输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
1. 向右 -> 向右 -> 向下 -> 向下
2. 向下 -> 向下 -> 向右 -> 向右

示例 2:

输入: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. 状态转移

    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]]):

初始状态(第一行):
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

关键点:障碍物会"阻断"路径,导致后续位置无法通过该位置到达。

实现代码
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:

输入: n = 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1

示例 2:

输入: n = 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36

示例 3:

输入: n = 8
输出: 18
解释: 8 = 2 + 3 + 3, 2 × 3 × 3 = 18

示例 4:

输入: 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. 状态转移

    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):

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] 时已经考虑过了。

实现代码
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:

输入:n = 3
输出:5
解释:给定 n = 3, 一共有 5 种不同结构的二叉搜索树:

   1         3     3      2      1
    \       /     /      / \      \
     3     2     1      1   3      2
    /     /       \                 \
   2     1         2                 3

示例 2:

输入: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):

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 构造问题)
  • 这形成了重叠子问题,适合用动态规划解决
实现代码
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:

输入:
n = 2(物品数量)
w = 5(背包容量)
weight = [2, 3](物品重量)
value = [3, 4](物品价值)

输出:7
解释:选择物品1(重量2,价值3)和物品2(重量3,价值4),总重量5,总价值7。

示例 2:

输入:
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]):

初始化 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可以清晰地表达"物品数量"和"容量"两个维度的关系
实现代码
/**
 * 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] 可能已经被当前轮修改过,导致物品被重复选择 ❌

示例说明(正序遍历的错误):

假设物品1:重量1,价值10;背包容量2

正序遍历(错误):
k=1: dp[1] = max(dp[1], dp[0] + 10) = max(0, 10) = 10k=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) = 10k=1: dp[1] = max(dp[1], dp[0] + 10) = max(0, 10) = 10  ✅
     这里 dp[1] 还是上一行的旧值0,物品1只选一次!

版本1:基础一维DP(倒序遍历)

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,物品肯定装不下,没必要遍历这些容量,直接跳过能减少循环次数。

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 等操作

SourceMap 深度解析:从映射原理到线上监控落地

作者 颜酱
2025年12月30日 12:22

SourceMap 深度解析:从映射原理到线上监控落地

在前端工程化体系中,SourceMap 是解决「压缩代码调试难」的核心工具,但多数开发者仅停留在"知道能用"的层面,既不清楚其底层的 mappings 字段编码逻辑,也不了解生产环境如何安全落地。本文将从 SourceMap 的本质出发,拆解 mappings 字段的 Base64 VLQ 映射逻辑,并结合线上监控场景,讲透 SourceMap 的实战用法。

📑 目录

一、SourceMap 是什么?解决什么问题?

SourceMap由来

SourceMap 最早由 Google 工程师在开发 Closure Compiler(谷歌闭包编译器)时提出,初衷是解决「编译后的代码调试难」问题 —— 早期前端代码压缩 / 编译后,调试只能看到混淆后的代码,工程师需要一个工具能 “反向找到源码位置”,因此将这个「存储源码映射关系的文件」命名为 SourceMap。后续该规范被标准化(当前主流为 Version 3 版本),并被 Webpack、Vite、Babel、Terser 等几乎所有前端构建工具采纳,SourceMap 也成为行业通用术语。

SourceMap 由 Source + Map 两个英文单词组合而成,其命名精准对应工具的核心功能:

  • Source:指「原始源码(Source Code)」—— 即开发者编写的未编译、未压缩的代码(如 ES6/TS 代码、React/Vue 源码);
  • Map:指「映射(Mapping)」—— 在计算机领域,“Map” 核心含义是「键值对的对应关系」(如哈希表、映射表),此处特指「压缩 / 编译后的代码」与「原始源码」之间的坐标、名称映射关系。

1.1 核心痛点:压缩代码调试的"噩梦"

前端项目上线前,代码会经过 编译(Babel 转 ES5)、混淆(变量名缩短)、压缩(合并行/删空格) 处理:

  • 源码:function sayHi(userName) { return Hi ${userName}; }

  • 压缩后:function a(b){returnHi ${b}}

此时若代码报错,浏览器控制台只会显示「z-index.min.js:1:25」这类压缩代码的坐标,完全无法定位到源码的具体位置——这就是 SourceMap 要解决的核心问题。

1.2 SourceMap 本质:代码的"坐标映射表"

SourceMap 是一个以 .map 为后缀的 JSON 文件,核心作用是建立「压缩后代码」与「原始源码」的一一映射关系:

  • 压缩后代码的"行/列" ↔ 源码的"文件/行/列";

  • 压缩后的变量名(如 a) ↔ 源码的变量名(如 sayHi);

  • 简单类比:源码是"原版书",压缩代码是"精简译本",SourceMap 是"对照字典"。

二、SourceMap 核心结构与 mappings 字段解析

2.1 SourceMap 标准结构

SourceMap本质就是一个json文件,一个完整的 SourceMap 文件包含 6 个核心字段,以极简示例为例:

{
  "version": 3, // SourceMap 版本(主流为3)
  "file": "z-index.min.js", // 压缩后的产物文件名
  "sources": ["z-index.js"], // 原始源码文件路径列表,可能有多个
  "names": ["userName", "sayHi"], // 源码中的变量/函数名列表
  "mappings": "AAAA,MAAMA,SAAW,QACjB,SAASC,QACP,MAAO,MAAMD,UACf", // 核心映射规则(Base64 VLQ 编码)
  "sourcesContent": [
    // 可选:存储源码文本,解析时无需额外读取源码
    "const userName = 'Alice';\nfunction sayHi() {\n  return `Hi ${userName}`;\n}\n"
  ]
}

各字段核心作用:

字段 核心作用
version 固定版本号,确保解析器兼容
file 关联的压缩产物文件名
sources 映射的原始源码路径,支持多个文件(如多入口项目)
names 源码中的变量/函数名,压缩后名称通过此映射回原名
mappings 最核心:存储"压缩代码坐标 ↔ 源码坐标"的映射规则(Base64 VLQ 编码)
sourcesContent 可选:存储源码文本,解析时无需额外读取源码文件

2.2 mappings 字段:Base64 VLQ 映射逻辑(核心)

mappings 是 SourceMap 的灵魂,其本质是将「压缩代码 ↔ 源码」的坐标映射关系,转换成一组数字,再通过 VLQ 编码压缩长度,最终转成 Base64 字符。我们以实际的 z-index.js 压缩场景拆解全程:

场景准备

前置准备:全局安装 terser(pnpm install terser -g

  • 源码(z-index.js):

    const userName = 'Alice';
    function sayHi() {
      return `Hi ${userName}`;
    }
    
  • 使用 terser 压缩

    terser z-index.js -o z-index.min.js --source-map "url='z-index.min.js.map',includeSources=true,filename='z-index.min.js'"
    

    生成两个文件:z-index.min.jsz-index.min.js.map

  • 压缩后的代码(z-index.min.js):

    const userName = 'Alice';
    function sayHi() {
      return `Hi ${userName}`;
    }
    //# sourceMappingURL=z-index.min.js.map
    
{
  "version": 3,
  "file": "z-index.min.js",
  "names": ["userName", "sayHi"],
  "sources": ["z-index.js"],
  "sourcesContent": [
    "const userName = 'Alice';\nfunction sayHi() {\n  return `Hi ${userName}`;\n}\n"
  ],
  "mappings": "AAAA,MAAMA,SAAW,QACjB,SAASC,QACP,MAAO,MAAMD,UACf",
  "ignoreList": []
}
映射段 Base64 VLQ 解码后(相对偏移) 压缩代码位置 源码位置 对应字符/内容 说明
AAAA [0,0,0,0] 列:0, 文件:0, 行:0, 列:0 第1行, 第0列 z-index.js 第1行, 第0列 c (const) 起始位置
MAAMA [6,0,0,6,0] 列:+6, 文件:+0, 行:+0, 列:+6, 名称:+0 第1行, 第6列 z-index.js 第1行, 第6列 u (userName) userName 变量名开始
SAAW [9,0,0,9] 列:+9, 文件:+0, 行:+0, 列:+9 第1行, 第15列 z-index.js 第1行, 第17列 A (Alice) 'Alice' 字符串开始
QACjB [1,0,1,3,1] 列:+1, 文件:+0, 行:+1, 列:+3, 名称:+1 第1行, 第16列 z-index.js 第2行, 第0列 f (function) function 关键字
SAASC [9,0,0,9,1] 列:+9, 文件:+0, 行:+0, 列:+9, 名称:+1 第1行, 第25列 z-index.js 第2行, 第9列 s (sayHi) sayHi 函数名
QACP [1,0,0,1] 列:+1, 文件:+0, 行:+0, 列:+1 第1行, 第26列 z-index.js 第2行, 第15列 ( 函数参数括号
MAAO [6,0,0,6] 列:+6, 文件:+0, 行:+0, 列:+6 第1行, 第32列 z-index.js 第2行, 第16列 { 函数体开始
MAAMD [6,0,0,6,0] 列:+6, 文件:+0, 行:+0, 列:+6, 名称:+0 第1行, 第38列 z-index.js 第3行, 第2列 r (return) return 关键字
UACf [10,0,0,10] 列:+10, 文件:+0, 行:+0, 列:+10 第1行, 第48列 z-index.js 第3行, 第9列 ` 模板字符串开始
Step 1:定义映射数字组(5个核心数字)

SourceMap 规定,每一组映射关系用 5个相对偏移数字 表示(后2个可选),"相对偏移"是核心优化(避免存储大数):

以实际的 z-index.js 为例,我们看几个关键映射段:

数字位置 含义(相对前一段的偏移量) MAAMA(userName) SAASC(sayHi)
第1个 压缩代码的列号偏移 +6 +9
第2个 源码在 sources 数组的索引 0 0
第3个 源码行号偏移 0 0
第4个 源码列号偏移 +6 +9
第5个 变量名在 names 数组的索引 0 (userName) 1 (sayHi)

说明

  • MAAMA 映射到 userName:压缩代码列号+6,源码列号+6,名称索引0 → names[0] = "userName"
  • SAASC 映射到 sayHi:压缩代码列号+9,源码列号+9,名称索引1 → names[1] = "sayHi"
Step 2:VLQ 编码 + Base64 转换

VLQ 是把 SourceMap 里的 “相对偏移数字” 转成 6位“短二进制”,Base64 是把 6位“短二进制” 转成 “可读字符”,两者配合让 mappings 字段既短又能解析。

VLQ 编码的核心优势是用更少的字符表示数字,特别是对于小数字(0-63)只需要1个字符,1 个字符通常占 8 位二进制(1 字节),Base64 编码时,只使用这 8 位中的低 6 位(高 2 位补 0),因为 6 位二进制刚好能表示 0-63,匹配 Base64 的 64 个字符。

规则编号 大白话规则 举例
1 6 位二进制里,最后 1 位表示正负:0 = 正数,1 = 负数 数字 6(正数)→ 最后 1 位是 0;数字 - 6(负数)→ 最后 1 位是 1
2 6 位二进制里,第一位表示是否续行:0 = 结束(1 个字符就够),1 = 继续(需要多个字符) 数字 6(小数字)→ 第一位是 0;数字 100(大数字)→ 第一位是 1(需要 2 个字符)
3 中间 4 位存实际数字(0-15 的数字用中间 4 位存储,加上符号位共 5 位可表示 0-31) 数字 6 → 二进制是 000110(第5位=0结束,第1-4位=0011表示3,第0位=0表示正数,实际编码更复杂)
Step 3:组装 mappings 字段

mappings 分隔规则:

  • , 分隔同一行内的不同映射段;
  • ; 分隔不同行的映射段。

实际例子:由于 z-index.js 压缩后只有1行,所以 mappings 中没有分号,只有逗号:

"mappings": "AAAA,MAAMA,SAAW,QACjB,SAASC,QACP,MAAO,MAAMD,UACf"

这9个映射段都对应压缩代码的第1行,但映射到源码的不同位置:

  • AAAA → 源码第1行第0列(const)
  • MAAMA → 源码第1行第6列(userName)
  • SAAW → 源码第1行第17列(Alice)
  • QACjB → 源码第2行第0列(function,注意行号+1)
  • SAASC → 源码第2行第9列(sayHi)
  • QACP → 源码第2行第15列(括号)
  • MAAO → 源码第2行第16列(大括号)
  • MAAMD → 源码第3行第2列(return,注意行号+1)
  • UACf → 源码第3行第9列(模板字符串)
Step 4:反向解析验证

实际场景:若线上报错 Uncaught ReferenceError: userName is not defined at z-index.min.js:1:25,解析步骤:

  1. 找到压缩代码位置:第1行第25列(压缩代码只有1行)

  2. 查找对应的映射段:遍历 mappings 中的映射段,找到第25列对应的映射段 SAASC

  3. Base64 VLQ 解码SAASC[9,0,0,9,1](相对偏移)

  4. 计算绝对位置(累积前面的偏移):

    • 压缩列号:0 + 6 + 9 + 1 + 9 = 25 ✅(匹配报错列号)
    • 源码文件:0 + 0 + 0 + 0 + 0 = 0 → z-index.js
    • 源码行号:0 + 0 + 0 + 1 + 0 = 1 → 源码第2行(注意:行号从0开始,所以+1后是第2行)
    • 源码列号:0 + 6 + 9 + 3 + 9 = 27,但实际映射段 SAASC 的相对偏移是 [9,0,0,9,1],累积计算后对应源码第2行第9列 ✅
    • 名称索引:0 + 0 + 0 + 1 + 1 = 2,但实际是 names[1] = "sayHi" ✅(相对偏移累积)
  5. 最终结果:报错位置 z-index.min.js:1:25 对应源码 z-index.js:2:9sayHi 函数名位置。

关键点:虽然压缩代码只有1行,但 SourceMap 能准确映射到源码的第2行第9列,这就是 SourceMap 的核心价值!

三、线上监控:SourceMap 安全落地实践

生产环境不能直接暴露 SourceMap 文件(避免源码泄露),核心方案是「离线解析」——将 .map 文件存储到内网/监控平台后台,线上报错时收集"压缩代码坐标",后台离线解析。

3.1 核心原则

  • 不将 .map 文件部署到 CDN(前端可访问的位置);

  • .map 文件存储到内网/监控平台后台;

  • 线上报错时,仅解析"压缩代码坐标 → 源码坐标",不暴露源码内容。

3.2 结合 Sentry 实现线上报错解析(主流方案)

Sentry 是前端主流的错误监控平台,支持 SourceMap 上传和自动解析,步骤如下:

步骤1:项目集成 Sentry

在项目入口文件中引入 Sentry:

// 项目入口文件
import * as Sentry from '@sentry/browser';

Sentry.init({
  dsn: '你的 Sentry DSN 地址',
  release: 'v1.0.0', // 版本号,需与 SourceMap 上传时一致
});
步骤2:上传 SourceMap 到 Sentry
# 安装 Sentry CLI
pnpm install -g @sentry/cli

# 上传 SourceMap(指定版本号,与代码发布版本一致)
sentry-cli releases files v1.0.0 upload-sourcemaps ./dist --url-prefix "~/static/js"

关键参数:

  • release:版本号,确保代码与 SourceMap 一一对应;

  • url-prefix:压缩代码在线上的访问路径(如 https://xxx.com/static/js/z-index.min.js)。

步骤3:效果:自动解析报错到源码

线上报错后,Sentry 会自动将「压缩代码坐标」解析为「源码文件+行号+列号」,示例:

Uncaught ReferenceError: sayHi is not defined at z-index.js:2:9

3.3 手动离线解析(自定义监控平台)

若不用 Sentry,可通过 source-map 库手动解析,示例代码:

const fs = require('fs');
const { SourceMapConsumer } = require('source-map');

// 1. 读取 SourceMap 文件(内网存储)
const rawMap = fs.readFileSync('z-index.min.js.map', 'utf8');

// 2. 解析压缩代码报错坐标
// 假设线上报错:Uncaught ReferenceError: userName is not defined at z-index.min.js:1:25
SourceMapConsumer.with(rawMap, null, consumer => {
  const originalPos = consumer.originalPositionFor({
    line: 1, // 压缩后行号(压缩代码只有1行)
    column: 25, // 压缩后列号(对应 sayHi 函数的位置)
  });
  console.log('源码位置:', originalPos);
  // 输出:{ source: 'z-index.js', line: 2, column: 9, name: 'sayHi' }

  // 3. 获取源码内容(如果 SourceMap 包含 sourcesContent)
  const sourceContent = consumer.sourceContentFor('z-index.js');
  if (sourceContent) {
    const lines = sourceContent.split('\n');
    console.log('源码内容:');
    console.log(`第${originalPos.line}行: ${lines[originalPos.line - 1]}`);
    // 输出:第2行: function sayHi() {
  }
});

四、SourceMap 常见配置与避坑

4.1 不同环境的 SourceMap 配置

环境 推荐配置(Webpack/Vite) 特点
开发环境 devtool: 'eval-cheap-module-source-map' 构建快,支持行/列映射,不暴露源码
测试环境 devtool: 'source-map' 完整映射,便于测试定位问题
生产环境 devtool: 'hidden-source-map' 不生成 sourceMappingURL 注释,仅后台可解析,避免源码泄露

4.2 常见坑点

  1. 版本不兼容:确保 SourceMap 版本为 3(主流解析器仅支持 v3);

  2. 路径不一致sources 字段的路径需与线上源码路径匹配,否则解析失败;

  3. 源码内容缺失:若未配置 sourcesContent,需确保解析时能读取到源码文件;

  4. 压缩工具兼容:不同工具(Terser/Webpack)生成的 SourceMap 略有差异,优先用项目构建工具自带的 SourceMap 生成能力。

五、总结

SourceMap 是前端线上问题定位的"利器",其核心是通过 Base64 VLQ 编码的 mappings 字段,将「压缩代码坐标」与「源码坐标」的映射关系压缩存储;解析时反向解码,就能从压缩代码的报错位置精准定位到源码。

生产环境落地的关键是「离线解析」——既不暴露 SourceMap 文件导致源码泄露,又能通过监控平台(如 Sentry)或自定义脚本,实现压缩代码报错到源码的精准映射,大幅提升线上问题排查效率。

核心要点回顾

  1. SourceMap 本质是"坐标映射表",解决压缩代码调试难的问题;

  2. mappings 是核心,通过"5个相对偏移数字 → VLQ 编码 → Base64 字符"实现映射关系的压缩存储;

  3. 生产环境需离线解析 SourceMap,避免源码泄露;

  4. Sentry 是主流的 SourceMap 集成方案,也可通过 source-map 库手动解析。

❌
❌