前端必备动态规划的10道经典题目

前端必备动态规划:10道经典题目详解(DP五部曲实战)

动态规划是前端算法面试的高频考点。本文通过「DP五部曲」框架,手把手带你掌握10道前端必备的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数组(验证):通过打印中间结果,验证递推逻辑是否正确(调试必备)

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


二、入门级(3道,理解DP核心三步法,必刷)

1. LeetCode70. 爬楼梯 ★

题目链接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 阶
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],验证方法数是否符合预期
完整版代码(二维DP思想,但实际用一维数组)
js 复制代码
/**
 * LeetCode70. 爬楼梯
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function climbStairs(n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示爬到第i阶台阶的不同方法数
  const dp = new Array(n + 1);

  // 【步骤3】dp数组如何初始化
  // 边界条件:1阶只有1种方法,2阶有2种方法
  if (n === 1) return 1;
  if (n === 2) return 2;

  dp[1] = 1; // 1阶:只有1种方法(直接爬1阶)
  dp[2] = 2; // 2阶:有2种方法(1+1 或 2)

  // 【步骤4】确定遍历顺序:从左到右,保证计算dp[i]时dp[i-1]和dp[i-2]已计算
  for (let i = 3; i <= n; i++) {
    // 【步骤2】确定递推公式
    // 到达第i阶只有两种方式:
    // 1. 从第i-1阶爬1步到达 → 方法数 = dp[i-1]
    // 2. 从第i-2阶爬2步到达 → 方法数 = dp[i-2]
    // 总方法数 = 两种方式的方法数之和(加法原理)
    dp[i] = dp[i - 1] + dp[i - 2];

    // 【步骤5】打印dp数组(验证) - 调试时可以取消注释
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  return dp[n];
}

// 测试用例
console.log(climbStairs(2)); // 2
console.log(climbStairs(3)); // 3
console.log(climbStairs(4)); // 5
console.log(climbStairs(5)); // 8
空间优化版(滚动数组)
js 复制代码
/**
 * 空间优化版:滚动数组
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i] = dp[i-1] + dp[i-2]
 * 发现dp[i]只依赖前两个状态,不需要保存整个数组
 * 可以用三个变量滚动更新:prevPrev(dp[i-2]), prev(dp[i-1]), cur(dp[i])
 */
function climbStairs(n) {
  // 【易错点1】边界处理:n=1或n=2时需要提前返回
  // 如果n=1时进入循环,prevPrev=1, prev=2会计算出错误结果
  if (n === 1) return 1;
  if (n === 2) return 2;

  // 初始化:对应dp[1]=1, dp[2]=2
  let prevPrev = 1; // dp[i-2],初始表示dp[1]=1
  let prev = 2; // dp[i-1],初始表示dp[2]=2
  let cur;

  // 从第3阶开始计算
  for (let i = 3; i <= n; i++) {
    // 计算当前阶的方法数
    cur = prevPrev + prev;

    // 【易错点2】滚动更新顺序很重要:先更新prevPrev,再更新prev
    // 如果顺序错误(如先更新prev),会导致prevPrev获取到错误的值
    prevPrev = prev; // 下一轮的dp[i-2] = 当前的dp[i-1]
    prev = cur; // 下一轮的dp[i-1] = 当前的dp[i]
  }

  return cur;
}

前端应用场景

  • 分页器组件:计算从第1页跳转到第n页的不同路径数(每次可以跳1页或2页)
  • 游戏开发:角色在台阶上移动,每次可以走1步或2步,计算到达目标位置的方案数
  • 动画路径计算:计算元素从位置A到位置B的不同动画路径数量

2. LeetCode53. 最大子数组和 ★

题目链接53. 最大子数组和

难度:简单

核心:贪心 + DP 结合,理解「状态转移的条件选择」

前端场景:数据趋势统计、收益/数值最值分析、股票K线图最大收益区间、用户行为数据峰值分析

题目描述

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组是数组中的一个连续部分。

示例 1

ini 复制代码
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6。

示例 2

ini 复制代码
输入:nums = [1]
输出:1

示例 3

ini 复制代码
输入:nums = [5,4,-1,7,8]
输出:23
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为结尾的最大子数组和(注意:必须以nums[i]结尾)
  2. 递推公式dp[i] = Math.max(nums[i], dp[i-1] + nums[i])(要么重新开始,要么延续前面的和)
  3. 初始化dp[0] = nums[0](第一个元素的最大子数组和就是它自己)
  4. 遍历顺序:从左到右(i从1到n-1)
  5. 打印验证:打印dp数组,找到最大值
完整版代码
js 复制代码
/**
 * LeetCode53. 最大子数组和
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxSubArray(nums) {
  const len = nums.length;

  // 【易错点1】边界处理:空数组返回0
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] 表示以nums[i]为结尾的最大子数组和(注意:必须以nums[i]结尾)
  // 这个定义很关键:保证子数组是连续的
  const dp = new Array(len);

  // 【步骤3】dp数组如何初始化
  // 第一个元素的最大子数组和就是它自己(没有前面的元素可以延续)
  dp[0] = nums[0];

  // 用于记录全局最大值(因为dp[i]只表示以i结尾的最大和,不一定全局最大)
  let maxSum = dp[0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < len; i++) {
    // 【步骤2】确定递推公式
    // 核心思想:如果前面的和是负数,不如重新开始(贪心思想)
    // 两种选择:
    // 1. 重新开始:只选当前元素nums[i](前面的和是负数,拖累总和)
    // 2. 延续前面的:dp[i-1] + nums[i](前面的和是正数,可以继续累加)
    dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);

    // 【易错点2】必须实时更新全局最大值
    // 因为dp[i]只是以i结尾的最大和,最终答案不一定是dp[len-1]
    maxSum = Math.max(maxSum, dp[i]);

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}, maxSum = ${maxSum}`);
  }

  return maxSum;
}

// 测试用例
console.log(maxSubArray([-2, 1, -3, 4, -1, 2, 1, -5, 4])); // 6
console.log(maxSubArray([1])); // 1
console.log(maxSubArray([5, 4, -1, 7, 8])); // 23
console.log(maxSubArray([-1])); // -1
空间优化版(只需一个变量)
js 复制代码
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * dp[i]只依赖dp[i-1],不需要保存整个数组
 * 用一个变量prev保存上一个状态即可
 */
function maxSubArray(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 用prev代替dp[i-1],初始值为dp[0]
  let prev = nums[0];
  let maxSum = prev;

  for (let i = 1; i < len; i++) {
    // 计算当前状态:要么重新开始,要么延续前面的
    prev = Math.max(nums[i], prev + nums[i]);

    // 更新全局最大值
    maxSum = Math.max(maxSum, prev);
  }

  return maxSum;
}

前端应用场景

  • 股票K线图:计算某段时间内买入卖出的最大收益(价格差的最大连续子数组和)
  • 用户行为分析:分析用户在某段时间内的活跃度峰值(数据流的最大连续区间和)
  • 性能监控:找到服务器响应时间最差的连续时间段(负值转换为响应时间)
  • 数据可视化:在折线图中高亮显示数据增长最快的连续区间

3. LeetCode198. 打家劫舍 ★

题目链接198. 打家劫舍

难度:简单

核心:状态转移的「条件限制」(相邻不选),基础空间优化

前端场景:资源筛选、最优选择问题、权限分配优化、任务调度(不能同时执行相邻任务)

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。

示例 1

ini 复制代码
输入:nums = [1,2,3,1]
输出:4
解释:偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。总金额 = 1 + 3 = 4。

示例 2

ini 复制代码
输入:nums = [2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 9),接着偷窃 5 号房屋(金额 = 1)。总金额 = 2 + 9 + 1 = 12。
DP五部曲分析
  1. dp数组含义dp[i] 表示前i间房屋能偷到的最高金额
    • 可以用二维状态:dp[i][0] 表示第i间不偷,dp[i][1] 表示第i间偷
  2. 递推公式
    • dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1])(不偷当前,前一间可偷可不偷)
    • dp[i][1] = dp[i-1][0] + nums[i-1](偷当前,前一间必须不偷)
  3. 初始化dp[0] = [0, 0](前0间,偷或不偷都是0)
  4. 遍历顺序:从左到右(i从1到n)
  5. 打印验证:打印dp数组验证
完整版代码(二维状态)
js 复制代码
/**
 * LeetCode198. 打家劫舍
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][0] = 前i间房屋,第i间不偷能获取的最高金额
  // dp[i][1] = 前i间房屋,第i间偷能获取的最高金额
  // 使用二维状态可以清晰地表达"相邻不能同时偷"的约束
  const dp = new Array(len + 1);

  // 【步骤3】dp数组如何初始化
  // 前0间房屋:不偷和偷的金额都是0(没有房屋可偷)
  dp[0] = [0, 0];

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i <= len; i++) {
    // 【易错点2】数组索引转换:dp[i]对应nums[i-1]
    // dp[1]对应nums[0](第1间房屋),dp[2]对应nums[1](第2间房屋)
    const curVal = nums[i - 1];

    // 【步骤2】确定递推公式
    // 状态1:第i间不偷 → 前i-1间可以偷也可以不偷,取最大值
    const valNotThief = Math.max(...dp[i - 1]);

    // 状态2:第i间偷 → 前i-1间必须不偷(相邻不能同时偷),加上当前金额
    // 【易错点3】必须是dp[i-1][0],不能是dp[i-1][1](违反相邻规则)
    const valThief = curVal + dp[i - 1][0];

    // 更新当前状态
    dp[i] = [valNotThief, valThief];

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = [不偷:${valNotThief}, 偷:${valThief}]`);
  }

  // 最终结果:前len间房屋偷或不偷的最大值
  return Math.max(...dp[len]);
}

// 测试用例
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([2, 7, 9, 3, 1])); // 12
console.log(rob([2, 1, 1, 2])); // 4
空间优化版(两个变量)
js 复制代码
/**
 * 空间优化版:滚动变量
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察递推公式:dp[i]只依赖dp[i-1]的两个值
 * 可以用两个变量vNot和vYes滚动更新
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];

  // 初始化:对应dp[0] = [0, 0]
  let vNot = 0; // 前i间不偷的最大值
  let vYes = 0; // 前i间偷的最大值

  for (let i = 1; i <= len; i++) {
    const curVal = nums[i - 1];

    // 【易错点4】关键:提前保存上一轮的所有状态,避免更新时覆盖
    // 如果直接使用vNot和vYes,更新vNot时可能会用到已经更新的vYes值
    const prevNot = vNot;
    const prevYes = vYes;

    // 不偷当前间:上一轮偷或不偷的最大值
    vNot = Math.max(prevNot, prevYes);

    // 偷当前间:上一轮不偷的最大值 + 当前金额
    // 【易错点5】必须用prevNot,不能用vNot(因为vNot已经更新了)
    vYes = curVal + prevNot;
  }

  return Math.max(vNot, vYes);
}

前端应用场景

  • 任务调度:在任务列表中,某些任务不能同时执行(有依赖关系),求最大收益
  • 权限分配:某些权限不能同时授予(互斥权限),求最大权限价值组合
  • 资源选择:在资源列表中,相邻资源有冲突,求最优选择方案
  • 广告投放优化:相邻时段的广告不能同时投放,求最大收益的投放方案

三、经典应用级(4道,DP核心考点,高频考)

4. LeetCode62. 不同路径 ★

题目链接62. 不同路径

难度:中等

核心:二维DP基础(可优化为一维),理解「路径型DP」

前端场景:可视化布局路径计算、网格类问题、Canvas/SVG路径绘制、游戏地图路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

问总共有多少条不同的路径?

示例 1

ini 复制代码
输入:m = 3, n = 7
输出:28

示例 2

markdown 复制代码
输入:m = 3, n = 2
输出:3
解释:从左上角开始,总共有 3 条路径可以到达右下角:
1. 向右 → 向下 → 向下
2. 向下 → 向下 → 向右
3. 向下 → 向右 → 向下
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点(0,0)走到位置(i,j)的不同路径数
  2. 递推公式dp[i][j] = dp[i-1][j] + dp[i][j-1](只能从上方或左方来)
  3. 初始化
    • 第一行所有位置:dp[0][j] = 1(只能从左边来)
    • 第一列所有位置:dp[i][0] = 1(只能从上边来)
  4. 遍历顺序:从上到下、从左到右(双重循环)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
js 复制代码
/**
 * LeetCode62. 不同路径
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePaths(m, n) {
  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从左上角(0,0)走到位置(i,j)的不同路径数
  // 为了方便处理边界,使用dp[i+1][j+1]表示网格(i,j)的路径数
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 第一行(i=1):所有位置只能从左边来,路径数都是1
  for (let j = 1; j <= n; j++) {
    dp[1][j] = 1;
  }

  // 第一列(j=1):所有位置只能从上边来,路径数都是1
  for (let i = 1; i <= m; i++) {
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从上到下、从左到右
  // 从(2,2)开始,因为第一行和第一列已经初始化
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 【步骤2】确定递推公式
      // 走到(i,j)只有两种方式:
      // 1. 从上方(i-1,j)向下走一步 → 路径数 = dp[i-1][j]
      // 2. 从左方(i,j-1)向右走一步 → 路径数 = dp[i][j-1]
      // 总路径数 = 两种方式的路径数之和(加法原理)
      dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    }
  }

  // 【步骤5】打印验证(调试时取消注释)
  // console.log('DP数组:');
  // for (let i = 1; i <= m; i++) {
  //   console.log(dp[i].slice(1).join(' '));
  // }

  return dp[m][n];
}

// 测试用例
console.log(uniquePaths(3, 7)); // 28
console.log(uniquePaths(3, 2)); // 3
console.log(uniquePaths(7, 3)); // 28
空间优化版(一维DP)
js 复制代码
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n) ← 从O(m*n)优化到O(n)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]
 * 计算第i行时,只需要用到:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-1列的值(dp[i][j-1])→ 对应更新后的dp[j-1]
 * 可以用一维数组dp[j]滚动更新
 */
function uniquePaths(m, n) {
  // 【步骤1】一维dp数组:dp[j]表示当前行第j列的路径数
  // 初始化为第一行的值:所有位置路径数都是1(只能从左边来)
  const dp = new Array(n + 1).fill(1);

  // 【步骤4】确定遍历顺序:从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    // 【易错点1】j从2开始,因为第1列(j=1)的值永远是1(只能从上边来)
    for (let j = 2; j <= n; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 上一行第j列的路径数(dp[i-1][j])
      // dp[j-1](更新后)= 当前行第j-1列的路径数(dp[i][j-1])
      // 更新:dp[j] = dp[j](旧值,来自上方)+ dp[j-1](新值,来自左方)
      dp[j] = dp[j] + dp[j - 1];

      // 【易错点2】注意:这里dp[j-1]是已经更新的值(当前行),
      // 而dp[j]是旧值(上一行),正好符合递推公式的需求
    }
  }

  return dp[n];
}

前端应用场景

  • Canvas/SVG路径绘制:计算从起点到终点的不同绘制路径数量
  • 游戏地图:计算角色从起点到终点的移动方案数(只能向右或向下)
  • 网格布局计算:在CSS Grid或Flex布局中,计算元素排列的不同路径数
  • 路由规划:在地图应用中,计算从A点到B点的不同路线数量

5. LeetCode63. 不同路径 II

题目链接63. 不同路径 II

难度:中等

核心:带障碍的路径DP,学会「状态转移的边界判断」

前端场景:网格布局中的障碍物处理、表单验证路径计算、游戏地图障碍物路径规划

题目描述

一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为 "Start" )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish")。

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

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

示例 1

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

示例 2

lua 复制代码
输入:obstacleGrid = [[0,1],[0,0]]
输出:1
DP五部曲分析
  1. dp数组含义dp[i][j] 表示从起点到达位置(i,j)的路径数
  2. 递推公式
    • 如果(i,j)是障碍物:dp[i][j] = 0
    • 否则:dp[i][j] = dp[i-1][j] + dp[i][j-1]
  3. 初始化
    • 第一行:遇到障碍物前都是1,遇到障碍物后都是0
    • 第一列:遇到障碍物前都是1,遇到障碍物后都是0
  4. 遍历顺序:从上到下、从左到右
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
js 复制代码
/**
 * LeetCode63. 不同路径 II(带障碍物)
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(m * n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  // 【易错点1】边界处理:起点或终点是障碍物,直接返回0
  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] 表示从起点到达位置(i-1,j-1)的路径数(索引从1开始便于处理边界)
  const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 初始化第一行:只能从左边来,遇到障碍物则后续位置无法到达
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1]; // 网格索引转换
    if (curGrid === 1) {
      // 【易错点2】遇到障碍物,后续位置都无法到达,直接跳出
      break;
    }
    dp[1][j] = 1;
  }

  // 初始化第一列:只能从上边来,遇到障碍物则后续位置无法到达
  for (let i = 2; i <= m; i++) {
    // 【易错点3】从i=2开始,因为dp[1][1]已在第一行初始化
    const curGrid = obstacleGrid[i - 1][0];
    if (curGrid === 1) {
      break;
    }
    dp[i][1] = 1;
  }

  // 【步骤4】确定遍历顺序:从第2行第2列开始
  for (let i = 2; i <= m; i++) {
    for (let j = 2; j <= n; j++) {
      // 网格索引转换:dp[i][j]对应网格(i-1, j-1)
      const curGrid = obstacleGrid[i - 1][j - 1];

      // 【步骤2】确定递推公式
      if (curGrid === 1) {
        // 【易错点4】当前位置是障碍物,无法到达,路径数为0
        dp[i][j] = 0;
      } else {
        // 当前位置不是障碍物,可以从上方或左方到达
        dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
      }
    }
  }

  return dp[m][n];
}
空间优化版(一维DP)
js 复制代码
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(m * n)
 * 空间复杂度:O(n)
 */
function uniquePathsWithObstacles(obstacleGrid) {
  const m = obstacleGrid.length;
  const n = obstacleGrid[0].length;

  if (obstacleGrid[0][0] === 1 || obstacleGrid[m - 1][n - 1] === 1) {
    return 0;
  }

  // 一维dp数组:dp[j]表示当前行第j列的路径数
  const dp = new Array(n + 1).fill(0);

  // 初始化第一行
  for (let j = 1; j <= n; j++) {
    const curGrid = obstacleGrid[0][j - 1];
    if (curGrid === 1) break;
    dp[j] = 1;
  }

  // 从第2行开始遍历
  for (let i = 2; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      const curGrid = obstacleGrid[i - 1][j - 1];

      if (curGrid === 1) {
        // 【易错点5】障碍物位置路径数置0
        dp[j] = 0;
      } else if (j === 1) {
        // 第一列:只能从上边来,保持dp[j]不变(如果上边是障碍物,dp[j]已经是0)
        // 不需要更新,因为第一列的路径数在初始化时已经确定
      } else {
        // 非第一列:可以从上方或左方到达
        dp[j] = dp[j] + dp[j - 1];
      }
    }
  }

  return dp[n];
}

前端应用场景

  • 表单验证:在复杂的多步骤表单中,某些步骤可能被禁用(障碍物),计算完成表单的不同路径
  • 游戏地图:在游戏中,某些格子是障碍物,计算从起点到终点的路径数
  • 权限路由:在权限系统中,某些路由节点被禁用,计算用户可访问的路由路径数
  • 工作流设计:在工作流中,某些节点可能被跳过,计算完成流程的不同路径

6. LeetCode213. 打家劫舍 II

题目链接213. 打家劫舍 II

难度:中等

核心:环形DP,拆分为两个基础DP问题(分治思想)

前端场景:环形资源分配、循环任务调度、权限系统中的循环依赖处理

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算你在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

示例 1

ini 复制代码
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2),因为他们是相邻的。

示例 2

ini 复制代码
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4。

示例 3

ini 复制代码
输入:nums = [1,2,3]
输出:3
DP五部曲分析(分治思想)

核心思路:环形问题转化为两个线性问题

  • 情况1:不偷第一间(可以偷最后一间)→ 转换为线性问题:偷 [1, len-1]
  • 情况2:不偷最后一间(可以偷第一间)→ 转换为线性问题:偷 [0, len-2]
  • 取两种情况的最大值
完整版代码
js 复制代码
/**
 * LeetCode213. 打家劫舍 II(环形)
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function rob(nums) {
  const len = nums.length;

  // 【易错点1】边界处理
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 【核心思路】环形问题拆分为两个线性问题
  // 情况1:不偷第一间(可以偷最后一间)→ 范围 [1, len-1]
  // 情况2:不偷最后一间(可以偷第一间)→ 范围 [0, len-2]
  // 取两种情况的最大值

  /**
   * 辅助函数:线性数组的打家劫舍(LeetCode198的解法)
   * @param {number[]} arr - 线性房屋数组
   * @returns {number} - 能偷到的最大金额
   */
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    // 二维状态DP
    const dp = new Array(n + 1);
    dp[0] = [0, 0]; // 前0间:不偷和偷都是0

    for (let i = 1; i <= n; i++) {
      const curVal = arr[i - 1];
      // 不偷当前间:前i-1间偷或不偷的最大值
      const valNotThief = Math.max(...dp[i - 1]);
      // 偷当前间:前i-1间必须不偷
      const valThief = curVal + dp[i - 1][0];
      dp[i] = [valNotThief, valThief];
    }

    return Math.max(...dp[n]);
  }

  // 情况1:不偷第一间,范围是nums[1]到nums[len-1]
  const case1 = robLinear(nums.slice(1));

  // 情况2:不偷最后一间,范围是nums[0]到nums[len-2]
  const case2 = robLinear(nums.slice(0, len - 1));

  // 【易错点2】返回两种情况的最大值
  return Math.max(case1, case2);
}

// 测试用例
console.log(rob([2, 3, 2])); // 3
console.log(rob([1, 2, 3, 1])); // 4
console.log(rob([1, 2, 3])); // 3
空间优化版(使用滚动变量)
js 复制代码
/**
 * 空间优化版:robLinear函数使用滚动变量
 */
function rob(nums) {
  const len = nums.length;
  if (len === 0) return 0;
  if (len === 1) return nums[0];
  if (len === 2) return Math.max(nums[0], nums[1]);

  // 辅助函数:线性数组打家劫舍(空间优化版)
  function robLinear(arr) {
    const n = arr.length;
    if (n === 0) return 0;
    if (n === 1) return arr[0];

    let vNot = 0; // 不偷的最大值
    let vYes = 0; // 偷的最大值

    for (let i = 0; i < n; i++) {
      const curVal = arr[i];
      const prevNot = vNot;
      const prevYes = vYes;

      vNot = Math.max(prevNot, prevYes);
      vYes = curVal + prevNot;
    }

    return Math.max(vNot, vYes);
  }

  const case1 = robLinear(nums.slice(1));
  const case2 = robLinear(nums.slice(0, len - 1));

  return Math.max(case1, case2);
}

前端应用场景

  • 循环任务调度:在循环列表中,某些任务不能同时执行,求最大收益的调度方案
  • 环形权限分配:在权限环中,相邻权限互斥,求最大权限价值组合
  • 资源循环利用:在循环资源池中,某些资源不能同时使用,求最优资源分配
  • 时间轮调度:在时间轮算法中,计算最优的任务执行方案

7. LeetCode322. 零钱兑换 ★

题目链接322. 零钱兑换

难度:中等

核心:完全背包基础版,理解「最值型DP」的状态转移

前端场景:金额/资源最优分配、最少步骤问题、支付找零算法、资源最小化配置

题目描述

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

示例 1

ini 复制代码
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1

示例 2

ini 复制代码
输入:coins = [2], amount = 3
输出:-1
解释:无法凑成总金额 3

示例 3

ini 复制代码
输入:coins = [1], amount = 0
输出:0
DP五部曲分析
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j所需的最少硬币个数
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]] + 1(注意是dp[i]不是dp[i-1],因为可以重复选)
    • 取最小值:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
  3. 初始化
    • dp[0][0] = 0(0种硬币凑0元需要0个)
    • dp[0][j>0] = Infinity(0种硬币无法凑正数金额)
    • dp[i][0] = 0(凑0元永远需要0个)
  4. 遍历顺序:外层遍历硬币种类,内层遍历金额(正序,因为是完全背包)
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
js 复制代码
/**
 * LeetCode322. 零钱兑换(完全背包-最值型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  // 【易错点1】边界处理:凑0元直接返回0
  if (target === 0) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j所需的最少硬币个数
  // 初始化:所有值先填Infinity(表示初始无法凑出)
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(target + 1).fill(Infinity));

  // 【步骤3】dp数组如何初始化
  dp[0][0] = 0; // 0种硬币凑0元,需要0个
  // 0种硬币凑正数金额,无法凑出(保持Infinity)
  for (let j = 1; j <= target; j++) {
    dp[0][j] = Infinity;
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币种类,内层遍历金额(正序)
  // 正序遍历是因为完全背包:每种硬币可以使用无限次
  for (let i = 1; i <= coinCount; i++) {
    dp[i][0] = 0; // 凑0元永远需要0个硬币
    const curCoin = coins[i - 1]; // 【易错点2】数组索引转换:第i种硬币对应coins[i-1]

    for (let j = 1; j <= target; j++) {
      if (j < curCoin) {
        // 金额不足,无法使用当前硬币,继承前i-1种硬币的结果
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式
        // 完全背包核心:用当前硬币时是dp[i][j-curCoin]+1(而非dp[i-1])
        // 因为硬币可以重复使用,所以用dp[i](已经考虑了当前硬币)
        dp[i][j] = Math.min(
          dp[i - 1][j], // 不用第i种硬币
          dp[i][j - curCoin] + 1 // 用第i种硬币(注意是dp[i],可以重复选)
        );
      }
    }
  }

  // 【易错点3】无法凑出时返回-1,而非Infinity
  return dp[coinCount][target] === Infinity ? -1 : dp[coinCount][target];
}

// 测试用例
console.log(coinChange([1, 2, 5], 11)); // 3
console.log(coinChange([2], 3)); // -1
console.log(coinChange([1], 0)); // 0
空间优化版(一维DP)
js 复制代码
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount) ← 从O(coins.length * amount)优化到O(amount)
 *
 * 【优化思路】
 * 观察递推公式:dp[i][j] = Math.min(dp[i-1][j], dp[i][j-coins[i-1]] + 1)
 * 计算dp[i][j]时只需要:
 * 1. 上一行第j列的值(dp[i-1][j])→ 对应更新前的dp[j]
 * 2. 当前行第j-coins[i-1]列的值(dp[i][j-coins[i-1]])→ 对应更新后的dp[j-coins[i-1]]
 * 可以用一维数组dp[j]正序遍历(完全背包特征:正序)
 */
function coinChange(coins, amount) {
  const coinCount = coins.length;
  const target = amount;

  if (target === 0) return 0;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j所需的最少硬币个数
  const dp = new Array(target + 1).fill(Infinity);

  // 【步骤3】初始化
  dp[0] = 0; // 凑0元需要0个硬币

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【易错点4】完全背包必须正序遍历:保证每种硬币可以使用无限次
  // 如果倒序遍历,就变成了01背包(每种硬币只能用一次)
  for (let i = 1; i <= coinCount; i++) {
    const curCoin = coins[i - 1];

    // 【易错点5】从curCoin开始遍历,避免j<curCoin的无效判断
    for (let j = curCoin; j <= target; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的最少个数(上一轮的结果)
      // dp[j - curCoin] + 1 = 用当前硬币的最少个数(当前轮已更新的结果)
      dp[j] = Math.min(dp[j], dp[j - curCoin] + 1);
    }
  }

  // 【易错点6】返回前检查是否为Infinity
  return dp[target] === Infinity ? -1 : dp[target];
}

前端应用场景

  • 支付找零:在支付系统中,计算用最少硬币数找零给用户
  • 资源最小化配置:在资源分配中,用最少的资源组合达到目标值
  • API调用优化:计算用最少的API调用次数完成某个任务
  • 组件懒加载:计算用最少的组件加载次数完成页面渲染

8. LeetCode518. 零钱兑换 II

题目链接518. 零钱兑换 II

难度:中等

核心:完全背包的「组合数型DP」,与322(最值型)做区分

前端场景:组合方案统计、支付方式组合数计算、资源配置方案数统计

题目描述

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。

示例 1

ini 复制代码
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1

示例 2

ini 复制代码
输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币无法凑成总金额 3。

示例 3

ini 复制代码
输入:amount = 10, coins = [10]
输出:1
DP五部曲分析(与322的区别)
  1. dp数组含义dp[i][j] 表示用前i种硬币凑出金额j的组合数(注意:是组合数,不是最少个数)
  2. 递推公式
    • 不选第i种硬币:dp[i][j] = dp[i-1][j]
    • 选第i种硬币:dp[i][j] = dp[i][j-coins[i-1]](注意是加法,不是取最小值)
    • 总组合数:dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]]
  3. 初始化
    • dp[0][0] = 1(0种硬币凑0元,有1种组合:不选任何硬币)
    • dp[i][0] = 1(凑0元永远只有1种组合)
    • dp[0][j>0] = 0(0种硬币无法凑正数金额)
  4. 遍历顺序:外层遍历硬币(保证组合不重复),内层正序遍历金额
  5. 打印验证:打印dp数组验证
完整版代码(二维DP)
js 复制代码
/**
 * LeetCode518. 零钱兑换 II(完全背包-组合数型)
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(coins.length * amount)
 *
 * 【与322的区别】
 * - 322求:最少的硬币个数(最值型)→ Math.min
 * - 518求:组合数(计数型)→ 加法
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  // 【易错点1】边界处理:凑0元返回1(唯一组合:不选任何硬币)
  if (targetAmount === 0) return 1;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i][j] = 用前i种硬币凑出金额j的组合数
  const dp = new Array(coinCount + 1).fill(0).map(() => new Array(targetAmount + 1).fill(0));

  // 【步骤3】dp数组如何初始化
  // 【易错点2】凑0元的组合数是1(不选任何硬币),不是0
  for (let i = 0; i <= coinCount; i++) {
    dp[i][0] = 1; // 凑0元永远只有1种组合
  }

  // 【步骤4】确定遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键】外层遍历硬币保证了组合不重复:如[1,2]和[2,1]被视为同一种组合
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1]; // 【易错点3】数组索引转换

    for (let j = 1; j <= targetAmount; j++) {
      if (j < currentCoin) {
        // 金额不足,无法用当前硬币,继承前i-1种的组合数
        dp[i][j] = dp[i - 1][j];
      } else {
        // 【步骤2】确定递推公式(组合数 = 不用 + 用)
        // dp[i-1][j]:不用第i种硬币的组合数
        // dp[i][j-currentCoin]:用第i种硬币的组合数(注意是dp[i],可重复选)
        dp[i][j] = dp[i - 1][j] + dp[i][j - currentCoin];
      }
    }
  }

  // 无法凑出时自然返回0(符合题目要求)
  return dp[coinCount][targetAmount];
}

// 测试用例
console.log(change(5, [1, 2, 5])); // 4
console.log(change(3, [2])); // 0
console.log(change(10, [10])); // 1
console.log(change(0, [1, 2])); // 1
空间优化版(一维DP)
js 复制代码
/**
 * 空间优化版:一维DP
 * 时间复杂度:O(coins.length * amount)
 * 空间复杂度:O(amount)
 */
function change(amount, coins) {
  const coinCount = coins.length;
  const targetAmount = amount;

  if (targetAmount === 0) return 1;

  // 【步骤1】一维dp数组:dp[j] = 凑出金额j的组合数
  const dp = new Array(targetAmount + 1).fill(0);
  dp[0] = 1; // 【核心初始化】凑0元的组合数为1

  // 【步骤4】遍历顺序:外层遍历硬币,内层正序遍历金额
  // 【关键理解】外层遍历硬币 → 保证组合数不重复
  // 如果外层遍历金额,内层遍历硬币,会得到排列数(顺序有关)
  for (let i = 1; i <= coinCount; i++) {
    const currentCoin = coins[i - 1];

    // 【易错点4】完全背包:金额正序遍历(从currentCoin开始)
    for (let j = currentCoin; j <= targetAmount; j++) {
      // 【步骤2】递推公式(一维版)
      // dp[j](更新前)= 不用当前硬币的组合数(上一轮的结果)
      // dp[j - currentCoin](更新后)= 用当前硬币的组合数(当前轮已更新的结果)
      dp[j] = dp[j] + dp[j - currentCoin];
    }
  }

  return dp[targetAmount];
}

前端应用场景

  • 支付方式组合:计算用户可以用多少种不同的支付方式组合完成支付
  • 资源配置方案:计算有多少种不同的资源配置方案可以达到目标
  • 功能组合统计:计算有多少种不同的功能组合可以满足用户需求
  • 优惠券组合:计算有多少种不同的优惠券组合可以使用

四、进阶拓展级(3道,中大厂加分,理解即可)

9. LeetCode300. 最长递增子序列

题目链接300. 最长递增子序列

难度:中等

核心:单维度DP的经典拓展,理解「非连续状态转移」

前端场景:数据趋势分析、序列统计、时间线组件、用户行为序列分析

题目描述

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

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

示例 1

ini 复制代码
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4。

示例 2

ini 复制代码
输入:nums = [0,1,0,3,2,3]
输出:4
解释:最长递增子序列是 [0,1,2,3],长度为 4

示例 3

ini 复制代码
输入:nums = [7,7,7,7,7,7,7]
输出:1
解释:最长递增子序列是 [7],长度为 1
DP五部曲分析
  1. dp数组含义dp[i] 表示以 nums[i] 为最后一个元素的最长严格递增子序列的长度
  2. 递推公式
    • 对于每个 nums[i],遍历前面所有元素 nums[j] (j < i)
    • 如果 nums[i] > nums[j],则 nums[i] 可以接在 nums[j] 的子序列后面
    • dp[i] = Math.max(dp[i], dp[j] + 1) (在所有满足条件的j中取最大值)
  3. 初始化dp[i] = 1(每个元素自身构成长度为1的子序列)
  4. 遍历顺序:外层遍历i(从1到n-1),内层遍历j(从0到i-1)
  5. 打印验证:打印dp数组,返回最大值(注意:最长子序列不一定以最后一个元素结尾)
完整版代码
js 复制代码
/**
 * LeetCode300. 最长递增子序列
 * 时间复杂度:O(n²)
 * 空间复杂度:O(n)
 */
function lengthOfLIS(nums) {
  const count = nums.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return count;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 以nums[i]为最后一个元素的最长严格递增子序列的长度
  // 【易错点2】初始化错误:必须初始化为1,不能初始化为0
  // 因为每个元素自身至少构成长度为1的子序列
  const dp = new Array(count).fill(1);

  // 【步骤4】确定遍历顺序:外层遍历i,内层遍历j
  // 【易错点3】i从1开始:i=0时前面没有元素,无法计算
  for (let i = 1; i < count; i++) {
    const curNum = nums[i]; // 当前元素

    // 内层遍历:检查i前面所有元素j(而非仅j=i-1)
    // 【易错点4】必须遍历所有j<i,不能只遍历j=i-1
    // 因为子序列可以非连续,nums[i]可以接在任意满足条件的nums[j]后面
    for (let j = 0; j < i; j++) {
      // 【步骤2】确定递推公式
      // 【易错点5】递增条件:必须是严格递增(>),不能是>=
      if (curNum > nums[j]) {
        // 如果nums[i] > nums[j],则nums[i]可以接在nums[j]的子序列后面
        // 取所有满足条件的dp[j]+1的最大值
        dp[i] = Math.max(dp[i], dp[j] + 1);
      }
    }

    // 【步骤5】打印验证
    // console.log(`dp[${i}] = ${dp[i]}`);
  }

  // 【易错点6】返回错误:不能返回dp[count-1]
  // 因为最长递增子序列不一定以最后一个元素结尾
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])); // 4
console.log(lengthOfLIS([0, 1, 0, 3, 2, 3])); // 4
console.log(lengthOfLIS([7, 7, 7, 7, 7, 7, 7])); // 1
console.log(lengthOfLIS([1, 3, 6, 7, 9, 4, 10, 5, 6])); // 6

前端应用场景

  • 时间线组件:在时间线中,找到最长连续增长的时间段
  • 用户行为分析:分析用户行为序列中最长的正向发展趋势
  • 数据可视化:在图表中高亮显示数据的最长递增区间
  • 版本号比较:找到版本号序列中最长的递增子序列

10. LeetCode121. 买卖股票的最佳时机 ★

题目链接121. 买卖股票的最佳时机

难度:简单

核心:DP + 贪心结合,也可纯DP实现,理解「状态定义的简化」

前端场景:数据趋势、收益计算、股票K线图分析、价格波动分析

题目描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。

如果你不能获取任何利润,返回 0 。

示例 1

ini 复制代码
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5。
     注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。

示例 2

ini 复制代码
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 交易无法完成, 所以返回 0。
DP五部曲分析
  1. dp数组含义dp[i] 表示第i天卖出股票能获得的最大利润
  2. 递推公式dp[i] = prices[i] - minPrice(当天价格减去之前的最小价格)
  3. 初始化dp[0] = 0(第0天无法卖出),minPrice = prices[0]
  4. 遍历顺序:从左到右(i从1到n-1),同时维护最小价格
  5. 打印验证:打印dp数组,返回最大值(注意:最大利润不一定在最后一天卖出)
完整版代码
js 复制代码
/**
 * LeetCode121. 买卖股票的最佳时机
 * 时间复杂度:O(n)
 * 空间复杂度:O(n)
 */
function maxProfit(prices) {
  const count = prices.length;

  // 【易错点1】边界处理:空数组/单元素数组
  if (count <= 1) return 0;

  // 【步骤1】确定dp数组及下标的含义
  // dp[i] = 第i天卖出股票能获得的最大利润
  const dp = new Array(count).fill(0);

  // 【步骤3】dp数组如何初始化
  // 第0天无法卖出(必须先买入),利润为0
  dp[0] = 0;

  // 维护遍历到当前的最小价格(用于计算利润)
  let minPrice = prices[0]; // 初始买入价格是第0天的价格

  // 【步骤4】确定遍历顺序:从左到右
  for (let i = 1; i < count; i++) {
    const curPrice = prices[i]; // 当天价格

    // 【核心逻辑1】更新最小价格(必须先更新,再计算利润)
    // 【易错点2】顺序错误:如果先计算利润再更新minPrice,会导致"当天买当天卖"的逻辑错误
    // 正确的顺序:先更新minPrice(基于之前的价格),再计算当天卖出的利润
    minPrice = Math.min(minPrice, curPrice);

    // 【步骤2】确定递推公式
    // 第i天卖出的最大利润 = 当天价格 - 之前的最小价格(最佳买入点)
    // 如果结果为负,dp[i]保持0(等价于不交易)
    dp[i] = Math.max(0, curPrice - minPrice);

    // 【步骤5】打印验证
    // console.log(`第${i}天:价格=${curPrice}, 最小价格=${minPrice}, 利润=${dp[i]}`);
  }

  // 【易错点3】返回错误:不能返回dp[count-1]
  // 因为最大利润不一定在最后一天卖出(如示例1中最大利润在第5天,不是最后一天)
  // 必须返回dp数组中的最大值
  return Math.max(...dp);
}

// 测试用例
console.log(maxProfit([7, 1, 5, 3, 6, 4])); // 5
console.log(maxProfit([7, 6, 4, 3, 1])); // 0
console.log(maxProfit([2, 4, 1])); // 2
console.log(maxProfit([3, 2, 6, 5, 0, 3])); // 4
空间优化版(只需一个变量)
js 复制代码
/**
 * 空间优化版:贪心思想
 * 时间复杂度:O(n)
 * 空间复杂度:O(1) ← 从O(n)优化到O(1)
 *
 * 【优化思路】
 * 观察:dp[i]只依赖dp[i-1]和minPrice
 * 而且我们只需要最大值,不需要保存整个dp数组
 * 用一个变量maxProfit实时更新最大值即可
 */
function maxProfit(prices) {
  const count = prices.length;
  if (count <= 1) return 0;

  let minPrice = prices[0]; // 最小买入价格
  let maxProfit = 0; // 最大利润

  for (let i = 1; i < count; i++) {
    const curPrice = prices[i];

    // 更新最小价格
    minPrice = Math.min(minPrice, curPrice);

    // 计算当天卖出的利润,并更新最大利润
    maxProfit = Math.max(maxProfit, curPrice - minPrice);
  }

  return maxProfit;
}

前端应用场景

  • 股票K线图:在股票图表中,计算买入卖出的最佳时机和最大收益
  • 价格趋势分析:分析商品价格变化,找到最佳买卖点
  • 收益计算器:在投资应用中,计算投资组合的最大收益
  • 数据波动分析:分析数据序列中的最大正向波动(类似股票收益)

五、总结

通过这10道动态规划经典题目,我们掌握了:

核心框架:DP五部曲

  1. 确定dp数组及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 打印dp数组(验证)

题目分类

类别 题目 核心特点 空间优化
基础递推 爬楼梯、最大子数组和、打家劫舍 一维DP,状态转移简单 滚动变量 O(1)
路径型DP 不同路径、不同路径II 二维DP,网格问题 一维数组 O(n)
背包问题 零钱兑换、零钱兑换II 完全背包,最值/计数 一维数组 O(amount)
序列问题 最长递增子序列 非连续状态转移 难优化
状态机DP 买卖股票、打家劫舍II 状态转换,环形问题 滚动变量 O(1)

易错点总结

  1. 边界处理:空数组、单元素、索引转换
  2. 初始化:根据问题特点正确初始化(0、1、Infinity等)
  3. 遍历顺序:完全背包正序,01背包倒序
  4. 返回值 :注意是返回dp[n]还是Math.max(...dp)
  5. 空间优化:注意更新顺序,避免覆盖未使用的值

前端应用价值

动态规划在前端开发中广泛应用于:

  • 性能优化:资源分配、组件懒加载优化
  • 业务逻辑:支付找零、权限分配、任务调度
  • 数据可视化:趋势分析、K线图、时间线组件
  • 算法优化:路径规划、组合统计、最值计算

掌握这10道题目,足以应对前端算法面试中的大部分DP问题。记住:先理解DP五部曲框架,再套用到具体问题,最后优化空间复杂度


相关资源

相关推荐
Dfreedom.1 分钟前
计算机视觉全景图
人工智能·算法·计算机视觉·图像算法
我是大猴子5 分钟前
Spring代理类为何依赖注入失效?
java·后端·spring
码事漫谈23 分钟前
手把手带你部署本地模型,让你Token自由(小白专属)
前端·后端
ZC跨境爬虫27 分钟前
【爬虫实战对比】Requests vs Scrapy 笔趣阁小说爬虫,从单线程到高效并发的全方位升级
前端·爬虫·scrapy·html
爱上好庆祝28 分钟前
svg图片
前端·css·学习·html·css3
Morwit35 分钟前
【力扣hot100】 1. 两数之和
数据结构·c++·算法·leetcode·职场和发展
王夏奇1 小时前
python中的__all__ 具体用法
java·前端·python
无小道1 小时前
算法——暴力+优化
算法·优化·暴力
Free Tester1 小时前
如何判断 LeakCanary 报告的严重程度
java·jvm·算法
大家的林语冰1 小时前
《前端周刊》尤大开源 Vite+ 全家桶,前端工业革命启动;尤大爆料 Void 云服务新产品,Vite 进军全栈开发;ECMA 源码映射规范......
前端·javascript·vue.js