引入:斐波拉契数列
// f = [1, 1, 2, 3, 5, 8]
f(1) = 1; f(2) = 1; // 初始值
f(n) = f(n-1) + f(n -2); // 状态转移方程
根据该公式可以获取求取斐波拉契数列的递归求解代码:
function fib(n) {
if(n === 1 || n === 2) {
return 1;
}
return fib(n - 1) + fib(n - 2);
}
fib(5);
问题:计算 fib(5) 时,计算过程会像一棵巨大的树,fib(2)、fib(3) 等值会被重复计算无数次。当 n 很大时,效率极低,时间复杂度为 O(2^n)。
即存在大量的“重叠子问题”, 而动态规划的第一个思想就是:解决重叠子问题,通过“记住”已经求解过的答案,避免重复计算。
动态规划的核心思想
动态规划通过一种“空间换时间”的策略,高效解决具有重叠子问题和最优子结构性质的问题。
它的核心思想是:
- 记住已经解决过的子问题的答案(避免重复计算)
- 通过组合子问题的解来得到原问题的解
这听起来很像“分治法”(如归并排序),但关键区别在于 DP 适用于子问题有重叠的情况。如果没有重叠,分治法就够了;如果有大量重叠,DP 通过“记忆化”可以极大地节省计算时间。
动态规划的实现
动态规划有两种实现方法:自顶向下和自底向上
自顶向下(记忆化搜索)—— 递归
- 思路:从原问题出发,递归地分解成子问题。在计算过程中,用一个数组或哈希表(通常叫 dp 表)来记录已经计算过的子问题的结果。下次遇到同样的子问题时,直接查表返回结果,避免重复计算。
- 优点:思维过程自然,更接近我们对问题的递归思考方式。
- 缺点:如果递归深度太大可能导致栈溢出
eg:斐波那契数列且求解:
let dp = {}
function fib(n){
if(dp[n]) return dp[n]
// 基础情况
if(n<=0) return 0
if(n<=2) return 1
// 计算并记录结果
dp[n] = fib(n-1) + fib(n-2)
return dp[n]
}
自底向上—— 迭代
- 思路:
从最小的子问题开始,逐步向上构建,直到得到原问题的解。我们通常会定义一个 dp 数组,dp[i] 表示问题规模为 i 时的解。然后通过循环,从小到大地填充这个 dp 数组。 - 优点:效率通常更高,避免了递归的开销。
- 缺点:有时不容易想到状态的定义和转移方程。
eg:斐波那契数列且求解:
function fib(n) {
// 定义dp数组:dp[i] 表示第i个斐波那契数
const dp = {}
// 初始化
dp[0] = 0
dp[1] = dp[2] = 1
// 通过状态转移方程构造dp
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]
}
return dp[n]
}
无需递归
使用动态规划解决的问题的特点
最优子结构:一个问题的最优解,包含了其子问题的最优解。
如:北京到广州的最短路径 = 北京到武汉的最短路径 + 武汉到广州的最短路径。如果北京到武汉的路径不是最短的,那么总路径必然也不是最短的重叠子问题:在递归地求解问题时,会反复计算相同的子问题,而不是生成新的子问题。无后效性:“未来与过去无关”。一旦一个子问题的状态确定了,它就不会再改变。后续的决策只能基于当前的状态,而不会影响之前已经做出的决策和状态。
如:在最短路径问题中,一旦确定了从起点到某点 A 的最短路径是 S->A,那么无论后续如何走到终点,这个 S->A 的路径都不会改变。
解决动态规划问题的通用步骤
自底向上的的步骤:
-
确定 dp 数组以及下标的含义:
dp[i] 代表什么?如:dp[i] 通常表示以第 i 个元素结尾的某种状态。 -
确定状态转移方程
确定了 dp 数组的含义后,要找出 dp[i] 与之前的状态(如 dp[i-1], dp[i-2])之间的关系。这个关系就是状态转移方程。
技巧:思考这个问题:要想得到 dp[i],需要知道哪些子问题的解?如何把它们组合起来? -
初始化 dp 数组
状态转移方程需要依靠已知的基础情况才能运行。我们需要初始化最初的几个值,比如 dp[0], dp[1] 等。
经典问题
爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?
解:
- 定义
dp[i] 表示爬到i阶梯有dp[i]中方法 - 状态转移方程:前一步可能是爬一个台阶也可能是爬两个台阶
dp[i] = dp[i-1] + dp[i-2] - 初始值
dp[1] = 1; dp[2] = 2
var climbStairs = function(n) {
let dp = new Array(n+1).fill(0)
dp[1] = 1
dp[2] = 2
for(let i = 3; i <= n; i++){
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
};
0-1背包问题
- 给定:
背包容量:W
n件物品,每件物品有重量w[i]和价值v[i]
每件物品要么完整放入(0),要么不放入(1) - 目标:在不超过背包容量的前提下,最大化物品总价值。
解:
- 状态定义
定义 dp[i][j] 表示考虑前i件物品,在背包容量为j时能获得的最大价值。 - 状态转移方程
对于第i件物品(索引从1开始),有两种选择:
不选第i件物品:dp[i][j] = dp[i-1][j]
选第i件物品: dp[i][j] = dp[i-1][j-w[i]] + v[i]
if j ≥ w[i-1] dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
if j < w[i-1] dp[i][j] = dp[i-1][j]
- 初始条件
dp[0][j] = 0(没有物品时,价值为0)
dp[i][0] = 0(容量为0时,价值为0)
代码:
function knapsack01(W, weights, values) {
const n = weights.length;
// 创建DP表 + 初始化条件
const dp = Array(n + 1).fill().map(() => Array(W + 1).fill(0));
// 填充DP表
// 物品从第一件遍历到第n件,但是取值的时候记得-1操作
for (let i = 1; i <= n; i++) {
// 容量从1到W进行遍历
for (let j = 1; j <= W; j++) {
if (j >= weights[i - 1]) { // 可以装入
// dp也从[1, 1]开始填入,所以最后返回dp[n][W]
dp[i][j] = Math.max(
dp[i - 1][j], // 不选当前物品
dp[i - 1][j - weights[i-1]] + values[i-1] // 选当前物品(i这里记得-1才能取到值)
);
} else {
dp[i][j] = dp[i - 1][j]; // 容量不足,只能不选
}
}
}
return dp[n][W];
}

被折叠的 条评论
为什么被折叠?



