动态规划入门:状态与转移

本节目标

DP 三要素:① 定义状态 dp[i] 表示什么;② 写转移方程 dp[i] = ...;③ 定边界(初始值)。本质是"把大问题拆成重叠子问题,记下来避免重复算"。

// 运行环境:Node.js 14+
// 保存为 dsa-l20.js,执行:node dsa-l20.js

// 1) 爬楼梯:dp[i] = 到第 i 阶的方法数 = dp[i-1]+dp[i-2]
function climbStairs(n) {
  if (n <= 2) return n
  let a = 1, b = 2 // dp[i-2], dp[i-1]
  for (let i = 3; i <= n; i++) [a, b] = [b, a + b]
  return b
}

// 2) 打家劫舍:dp[i] = max(不偷 i: dp[i-1], 偷 i: dp[i-2]+nums[i])
function rob(nums) {
  let prev = 0, cur = 0
  for (const x of nums) [prev, cur] = [cur, Math.max(cur, prev + x)]
  return cur
}

// 3) 0-1 背包:dp[j] = 容量 j 下最大价值;逆序更新避免重复装
function knapsack(weights, values, W) {
  const dp = new Array(W + 1).fill(0)
  for (let i = 0; i < weights.length; i++) {
    for (let j = W; j >= weights[i]; j--) {        // 必须逆序!
      dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i])
    }
  }
  return dp[W]
}

console.log(climbStairs(5))                    // 8
console.log(rob([2, 7, 9, 3, 1]))              // 12
console.log(knapsack([1, 3, 4], [15, 20, 30], 4)) // 35(选 1+3 重量 4 价值 35)

为什么背包要逆序:正序会让同一件物品被多次装入(变成完全背包);逆序保证每件只用一次。

名词解释

课后练习

  1. 0-1 背包为什么内层循环必须逆序?
    • 答案:正序更新时 dp[j-weights[i]] 已是"本层(已装过第 i 件)"的值,会重复装;逆序保证引用的是"上一层"状态,每件仅用一次。
  2. 爬楼梯为什么空间能降到 O(1)?
    • 答案:dp[i] 只依赖 dp[i-1] 和 dp[i-2],用 a,b 两个滚动变量即可递推,无需数组。

总结

动态规划是算法学习的一道分水岭——很多人能写递归,却在 DP 面前卡死,根因是没建立起"状态思维"。DP 不是某种固定套路,而是一种视角:把问题重新表述成"dp[i] 表示什么",然后问"dp[i] 能从哪些更小的 dp 推导出来"。一旦状态定义和转移方程立住,代码往往就几行。我特别想强调"最优子结构"这个前提:如果子问题的最优解不能拼成全局最优解(比如带路径约束时),DP 就失效,得换思路。另一个容易踩的坑是"更新顺序"——0-1 背包逆序、完全背包正序,顺序错了答案天差地别。初学建议从一维 DP(爬楼梯、打家劫舍)入手,体会"定义状态→写转移→定边界→优化空间"这条主线,再进阶二维。