引言
本文将用一道经典的爬楼梯问题来详细解释如何使用递归和动态规划,有兴趣的掘友也可以自己在力扣上搜第70题。
什么时候用递归?
相信许多掘金大佬看到这道题目第一眼就能想到用递归来解决,但请先别急,请跟我一起来想一下什么样的题目我们会想到用递归?很显然,当一个问题可以被自然地分解成多个相似的子问题时,这个时候我们就可以考虑递归了。当想清楚用递归后,我们应该先把问题定位到终点,然后站在终点思考后退的各种可能性,最后再开始写代码。
爬楼梯的三种解法
递归解法
在这道题里,当楼梯只有1级时,显然只有1种方法,当楼梯有2级时,可以一次爬2级或两次各爬1级,共有2种方法。- 对于超过2级的楼梯,假设我们已经知道爬到第 n-1
级和第 n-2
级的方法数,那么爬到第 n
级的方法数就是这两者的和。因为从第 n-1
级再爬1级可以到达第 n
级,从第 n-2
级再爬2级也可以到达第 n
级。所以我们可以总结出一个递归公式: climbStairs(n) = climbStairs(n-1) + climbStairs(n-2)
const climbStairs = function(n){
if(n===1){
return 1
}
if(n===2){
return 2
}
return climbStairs(n-1)+climbStairs(n-2)
}
根据上面的讲解,应该是很轻松的能写出这段代码的,然而当我们去力扣提交时它却会显示超出时间限制,这是因为这种做法会导致大量的重复计算,因为每次调用都会重新计算相同的子问题,这可能导致性能上的瓶颈,尤其是在n较大时,很容易导致爆栈。
记忆化搜索(带缓存的递归)
为了优化递归方法,我们可以引入记忆化技术,即通过一个数组来存储已经计算过的值,从而避免重复计算。这是从自顶向下的递归转向带缓存的递归(也叫记忆化搜索)。代码如下:
const f = []
const climbStairs = function (n) {
if (n === 1) return 1
if (n === 2) return 2
if (f[n] === undefined)
f[n] = climbStairs(n - 1) + climbStairs(n - 2)
return f[n]
}
恭喜你掌握这种方法后已经能成功的在力扣上跑了,使用这种方法可以避免重复计算从而提高效率,我们是直接从缓存中获取结果,时间复杂度降低到O(n)
动态规划解法
上面那种方法还不是最优解,我们可以使用动态规划来解这道题目,与递归不同,动态规划通常采用自底向上的方式来解决问题,即先解决较小的子问题,然后逐步构建更大的问题的解。这种方法避免了递归带来的函数调用栈开销,同时也更容易理解和实现。
const climbStairs = function (n) {
const f = []
f[1] = 1
f[2] = 2
for (let i = 3; i <= n; i++) {
f[i] = f[i - 1] + f[i - 2]
}
return f[n]
}
这里我们构建了一个数组f
,其中f[i]
表示到达第i
阶的方法数。此方法不仅提高了效率,还减少了函数调用栈的开销,适用于更大的输入值。动态规划的核心在于状态转移方程:f[i] = f[i - 1] + f[i - 2]
,它描述了如何根据之前的结果来计算当前的结果。
小结
本文我们通过经典的爬楼梯问题,展示了如何从递归逐步优化到动态规划。最初,使用递归方法直接将问题分解为子问题求解,但会导致大量重复计算和性能瓶颈。为了优化,引入了记忆化搜索(带缓存的递归),通过存储中间结果避免重复计算,大大提高了效率。
最终,采用动态规划方法,自底向上解决问题,避免了递归带来的栈开销,并简化了代码逻辑。动态规划通过状态转移方程f[i] = f[i - 1] + f[i - 2]
清晰地表达了从已知状态推导未知状态的过程,适用于具有最优子结构和重叠子问题的问题。
总之,从递归到动态规划不仅优化了算法性能,还提升了代码的可读性和维护性。