持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第28天,点击查看活动详情🚀🚀
前言
终于来到了动态规划,这也意味着这次算法训练营快进入尾声了~
这里我就只拿爬楼梯问题来做一个典型的例子,然后再结合修言老师小册的内容进行一个对动态规划的大致梳理吧~
70. 爬楼梯 - 力扣(LeetCode)
思路分析
Step1:递归思想分析问题
基于动态规划的思想来做题,我们首先要想到的思维工具就是“倒着分析问题”。“倒着分析问题”分两步走:
- 定位到问题的终点
- 站在终点这个视角,思考后退的可能性
在这道题里,“问题的终点”指的就是走到第 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)
这个关系用树形结构表示会更加形象
以此类推,我们可以再使我们的树更加饱满一些
随着拆分的进行,一定会有一个时刻,求解到了 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 会直接超时。我们一起来看看原因,回到我们上面这张树形结构图上来:
这次我把 f(n-2) 和f(n-3)给标红了。大家不难看出,我们在图中对 f(n-2)和f(n-3) 进行了重复的计算。事实上,随着我们递归层级的加深,这个重复的问题会越来越严重:
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:记忆化搜索转化为动态规划
要想完成记忆化搜索与动态规划之间的转化,首先要清楚两者间的区别。
先说记忆化搜索,记忆化搜索可以理解为优化过后的递归。递归往往可以基于树形思维模型来做,以这道题为例:
我们基于树形思维模型来解题时,实际上是站在了一个比较大的未知数量级(也就是最终的那个
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];
};
以上便是这道题的动态规划解法。
小结
总结一下,对于动态规划,建议大家优先选择这样的分析路径:
- 递归思想明确树形思维模型:找到问题终点,思考倒退的姿势,往往可以帮助你更快速地明确状态间的关系
- 结合记忆化搜索,明确状态转移方程
- 递归代码转化为迭代表达