✅✅代码随想录算法训练营Day38 || 70. 爬楼梯 (动态规划)

176 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第28天,点击查看活动详情🚀🚀

前言

终于来到了动态规划,这也意味着这次算法训练营快进入尾声了~

这里我就只拿爬楼梯问题来做一个典型的例子,然后再结合修言老师小册的内容进行一个对动态规划的大致梳理吧~

70. 爬楼梯 - 力扣(LeetCode)

image.png

思路分析

Step1:递归思想分析问题

基于动态规划的思想来做题,我们首先要想到的思维工具就是“倒着分析问题”。“倒着分析问题”分两步走:

  1. 定位到问题的终点
  2. 站在终点这个视角,思考后退的可能性

在这道题里,“问题的终点”指的就是走到第 n 阶楼梯这个目标对应的路径数,我们把它记为 f(n)

那么站在第 n 阶楼梯这个视角, 有哪些后退的可能性呢?按照题目中的要求,一次只能后退 1 步或者 2 步。因此可以定位到从第 n 阶楼梯只能后退到第 n-1 或者第 n-2 阶。我们把抵达第 n-1 阶楼梯对应的路径数记为f(n-1),把抵达第 n-2 阶楼梯对应的路径数记为 f(n-2),不难得出以下关系:

f(n) = f(n-1) + f(n-2)

这个关系用树形结构表示会更加形象

image.png

以此类推,我们可以再使我们的树更加饱满一些

image.png

随着拆分的进行,一定会有一个时刻,求解到了 f(1) 或 f(2)。按照题设规则,第 1 阶楼梯只能走 1 步抵达,第 2 阶楼梯可以走 1 步或者走 2 步抵达,因此我们不难得出 f(1) 和 f(2) 的值:

f(1) = 1   
f(2) = 2

因此我们不难写出其对应的递归解法代码:

/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
    // 处理递归边界
    if(n === 1) {
        return 1
    }
    if(n === 2){
        return 2
    }
    // 递归计算
    return climbStairs(n-1) + climbStairs(n-2)
};

但是这个解法问题比较大,丢进 OJ 会直接超时。我们一起来看看原因,回到我们上面这张树形结构图上来:

image.png

这次我把 f(n-2) 和f(n-3)给标红了。大家不难看出,我们在图中对 f(n-2)f(n-3) 进行了重复的计算。事实上,随着我们递归层级的加深,这个重复的问题会越来越严重:

image.png

Step2:记忆化搜索来提效

重复计算带来了时间效率上的问题,要想解决这类问题,最直接的思路就是用空间换时间,也就是想办法记住之前已经求解过的结果。这里我们只需要定义一个数组:

const f = []

每计算出一个 f(n) 的值,都把它塞进 f 数组里。下次要用到这个值的时候,直接取出来就行了:

/**
* @param {number} n
* @return {number}
*/
// 定义记忆数组 f
const f = []
const climbStairs = function(n) {
  if(n==1) {
      return 1
  }
  if(n==2) {
      return 2
  }
  // 若f[n]不存在,则进行计算
  if(f[n]===undefined)  f[n] = climbStairs(n-1) + climbStairs(n-2)
  // 若f[n]已经求解过,直接返回
  return f[n]
};

以上这种在递归的过程中,不断保存已经计算出的结果,从而避免重复计算的手法,叫做记忆化搜索

Step3:记忆化搜索转化为动态规划

要想完成记忆化搜索与动态规划之间的转化,首先要清楚两者间的区别。
先说记忆化搜索,记忆化搜索可以理解为优化过后的递归。递归往往可以基于树形思维模型来做,以这道题为例:

image.png 我们基于树形思维模型来解题时,实际上是站在了一个比较大的未知数量级(也就是最终的那个n),来不断进行拆分,最终拆回较小的已知数量级(f(1)f(2))。这个过程是一个明显的自顶向下的过程。

动态规划则恰恰相反,是一个自底向上的过程。它要求我们站在已知的角度,通过定位已知未知之间的关系,一步一步向前推导,进而求解出未知的值。
在这道题中,已知 f(1) 和 f(2) 的值,要求解未知的 f(n),我们唯一的抓手就是这个等价关系:

f(n) = f(n-1) + f(n-2)

以 f(1) 和 f(2) 为起点,不断求和,循环递增 n 的值,我们就能够求出f(n)了:

/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
    // 初始化状态数组
    const f = [];
    // 初始化已知值
    f[1] = 1;
    f[2] = 2;
    // 动态更新每一层楼梯对应的结果
    for(let i = 3;i <= n;i++){
        f[i] = f[i-2] + f[i-1];
    }
    // 返回目标值
    return f[n];
};

以上便是这道题的动态规划解法。

小结

总结一下,对于动态规划,建议大家优先选择这样的分析路径:

  1. 递归思想明确树形思维模型:找到问题终点,思考倒退的姿势,往往可以帮助你更快速地明确状态间的关系
  2. 结合记忆化搜索,明确状态转移方程
  3. 递归代码转化为迭代表达