动态规划入门:状态与转移
本节目标
- 理解 DP 的"状态定义 + 转移方程 + 边界"
- 手写爬楼梯、打家劫舍、0-1 背包
- 掌握"一维 DP"与"滚动变量"优化
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)为什么背包要逆序:正序会让同一件物品被多次装入(变成完全背包);逆序保证每件只用一次。
名词解释
- 动态规划(Dynamic Programming, DP):把原问题拆成"重叠子问题",用状态表记录子问题答案,避免重复计算。适合有"最优子结构 + 无后效性"的问题。
- 状态(State)与转移(Transition):
dp[i]定义"到第 i 步的答案",转移方程描述"如何从已算出的状态推出新状态"。 - 滚动数组 / 滚动变量优化:当
dp[i]只依赖前一两项,不必保留整张表,用两三个变量滚动即可,把空间从 O(n) 降到 O(1)。
课后练习
- 0-1 背包为什么内层循环必须逆序?
- 答案:正序更新时
dp[j-weights[i]]已是"本层(已装过第 i 件)"的值,会重复装;逆序保证引用的是"上一层"状态,每件仅用一次。
- 答案:正序更新时
- 爬楼梯为什么空间能降到 O(1)?
- 答案:
dp[i]只依赖dp[i-1]和dp[i-2],用a,b两个滚动变量即可递推,无需数组。
- 答案:
总结
动态规划是算法学习的一道分水岭——很多人能写递归,却在 DP 面前卡死,根因是没建立起"状态思维"。DP 不是某种固定套路,而是一种视角:把问题重新表述成"dp[i] 表示什么",然后问"dp[i] 能从哪些更小的 dp 推导出来"。一旦状态定义和转移方程立住,代码往往就几行。我特别想强调"最优子结构"这个前提:如果子问题的最优解不能拼成全局最优解(比如带路径约束时),DP 就失效,得换思路。另一个容易踩的坑是"更新顺序"——0-1 背包逆序、完全背包正序,顺序错了答案天差地别。初学建议从一维 DP(爬楼梯、打家劫舍)入手,体会"定义状态→写转移→定边界→优化空间"这条主线,再进阶二维。