12.动态规划

引入:斐波拉契数列

// 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 的路径都不会改变。

解决动态规划问题的通用步骤

自底向上的的步骤:

  1. 确定 dp 数组以及下标的含义:
    dp[i] 代表什么?如:dp[i] 通常表示以第 i 个元素结尾的某种状态。

  2. 确定状态转移方程
    确定了 dp 数组的含义后,要找出 dp[i] 与之前的状态(如 dp[i-1], dp[i-2])之间的关系。这个关系就是状态转移方程。
    技巧:思考这个问题:要想得到 dp[i],需要知道哪些子问题的解?如何把它们组合起来?

  3. 初始化 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];
}

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值