爬楼梯:你以为你懂?不,你只是刚入门!

314 阅读18分钟

嘿,各位未来的算法大神们!👋 你们有没有遇到过这样的情况:明明只是个简单的“爬楼梯”问题,却能让你抓耳挠腮,甚至怀疑人生?别担心,你不是一个人在战斗!今天,咱们就来一起征服这个算法界的“小珠峰”——爬楼梯问题,保证让你看完直呼“原来如此!”🤩

一、 引言:爬楼梯,你真的会爬吗?🤔

想象一下,你面前有一段长长的楼梯,你正准备爬上去。每次你可以选择爬1个台阶,或者2个台阶。那么问题来了,爬到楼顶,总共有多少种不同的方法呢?是不是听起来很简单?但当你真正拿起笔来画一画、算一算的时候,就会发现,这小小的楼梯,竟然藏着大大的学问!🧐

这个看似简单的“爬楼梯”问题,其实是动态规划(Dynamic Programming,简称DP)领域的经典入门题。它就像一个温柔的“引路人”,带你走进DP的奇妙世界。别看它名字朴实无华,它可是能让你在面试中脱颖而出,甚至在日常编程中提升效率的“秘密武器”哦!🚀

今天,我将带你从最基础的爬楼梯问题开始,一步步揭开它的神秘面纱。我们会从“暴力”解法一路“进化”到优雅的动态规划,还会探讨一些有趣的变种问题。准备好了吗?系好安全带,咱们这就出发!🎢

二、 基础篇:经典爬楼梯问题(每次1或2步)🚶‍♀️🚶‍♂️

咱们先从最经典的“爬楼梯”问题说起。假设楼梯有 n 阶,你每次可以爬1个台阶或者2个台阶。请问,爬到楼顶总共有多少种不同的方法?

问题描述与分析

这个问题,初看之下,你可能会想:“这不就是排列组合吗?” 但仔细一想,好像又不是那么回事。我们来举个栗子:

  • 如果 n = 1(只有1阶楼梯),你只有1种方法:爬1步。👣

  • 如果 n = 2(有2阶楼梯),你有2种方法:

    1. 爬1步,再爬1步 (1 + 1)
    2. 直接爬2步 (2)
  • 如果 n = 3(有3阶楼梯),你有3种方法:

    1. 1 + 1 + 1
    2. 1 + 2
    3. 2 + 1

有没有发现什么规律?🤔 当我们要爬到第 n 阶楼梯时,我们最后一步可能从第 n-1 阶爬1步上来,也可能从第 n-2 阶爬2步上来。所以,爬到第 n 阶的方法数,就等于爬到第 n-1 阶的方法数加上爬到第 n-2 阶的方法数!

这不就是斐波那契数列吗?!😲 没错,你发现了盲点!这就是动态规划的魅力所在,它能把复杂问题分解成更小的、相互关联的子问题。

暴力递归解法(为什么不行?)

既然发现了规律,最直观的想法就是用递归来解决:

 var climbStairs = function(n) {
     if (n === 1) return 1;
     if (n === 2) return 2;
     return climbStairs(n - 1) + climbStairs(n - 2);
 };

这段代码看起来简洁优雅,但如果你尝试用它来计算 n 比较大的情况,比如 n = 40,你会发现它会“卡壳”很久,甚至直接崩溃!😱 这是因为递归过程中存在大量的重复计算,比如 climbStairs(5) 会计算 climbStairs(4)climbStairs(3),而 climbStairs(4) 又会计算 climbStairs(3)climbStairs(2)climbStairs(3) 被重复计算了多次。这就像你在爬楼梯的时候,每爬一步都要重新计算一遍之前所有可能的路径,效率自然就低了。🐢

记忆化搜索优化

为了避免重复计算,我们可以用一个数组(或者哈希表)来存储已经计算过的结果。当我们需要计算某个 n 的方法数时,先去查一下这个数组,如果已经计算过了,就直接拿来用,否则再计算并存储起来。这就是“记忆化搜索”!🧠

 var climbStairs = function(n) {
     const memo = []; // 用于存储已经计算过的结果
     function dp(i) {
         if (i === 1) return 1;
         if (i === 2) return 2;
         if (memo[i]) return memo[i]; // 如果已经计算过,直接返回
         memo[i] = dp(i - 1) + dp(i - 2); // 计算并存储
         return memo[i];
     }
     return dp(n);
 };

记忆化搜索大大提高了效率,因为它避免了重复计算。但它本质上还是递归,只是加了个“缓存”。那么,有没有一种更“自底向上”的解法呢?当然有!那就是动态规划!💪

动态规划解法

动态规划的核心思想是:将问题分解为相互重叠的子问题,并按顺序解决这些子问题,将结果存储起来,以便后续使用。对于爬楼梯问题,我们可以定义一个 dp 数组:

  • 1. 确定dp数组及下标含义: dp[i] 表示爬到第 i 阶楼梯的方法总数。

  • 2. 确定递推公式: dp[i] = dp[i - 1] + dp[i - 2]。这和我们前面发现的规律一模一样!

  • 3. dp数组如何初始化:

    • dp[0] = 1:爬到第0阶楼梯,可以理解为只有1种方法(就是不爬,站在原地)。这是为了让 dp[2] 的计算 dp[1] + dp[0] 成立。
    • dp[1] = 1:爬到第1阶楼梯,只有1种方法(1步)。
  • 4. 确定遍历顺序: 从前往后遍历,因为 dp[i] 的计算依赖于 dp[i-1]dp[i-2]

  • 5. 打印dp数组(可选,用于debug): 这一步在实际编码中可以省略,但在理解和调试时非常有用。

让我们看看 70.js 中的经典动态规划解法:

 // 70. 爬楼梯
 /**
  * @param {number} n
  * @return {number}
  */
 var climbStairs = function(n) {
     // 动规五部曲
     // 1. 确定dp数组及下标含义 dp[i]表示爬到第 i 层楼梯的方法
     // 2. 确定递推公式 dp[i] = dp[i - 1] + dp[i - 2]
     // 3. dp数组如何初始化 dp[0] = 1, dp[1] = 1
     // 4. 确定遍历顺序 从前往后
     // 5. 打印dp数组 找出debug
     dp = []
     dp[0] = 1, dp[1] = 1
     for(let i = 2; i <= n; i++) {
         dp[i] = dp[i - 1] + dp[i - 2]
     }
     return dp[n]
 };

这段代码完美地诠释了动态规划的“五部曲”。它从 dp[0]dp[1] 开始,一步步计算出 dp[n],避免了递归的重复计算,效率杠杠的!🚀

空间优化解法

你可能注意到了,在计算 dp[i] 的时候,我们只用到了 dp[i-1]dp[i-2]。这意味着我们不需要存储整个 dp 数组,只需要存储前两个值就可以了!这就像你在爬楼梯的时候,只需要记住前两步是怎么爬的,就能决定下一步怎么爬,而不需要记住从头到尾的每一步。这大大节省了空间!💰

 var climbStairs = function(n) { // 动规压缩
     let pre1 = 1 // 相当于dp[i-1]
     let pre2 = 1 // 相当于dp[i-2]
     let temp
     for(let i = 2; i <= n; i++) {
         temp = pre1
         pre1 = pre1 + pre2
         pre2 = temp
     }
     return pre1
 };

这个空间优化后的版本,只用了常数级别的额外空间,效率和空间利用率都达到了极致!是不是很酷?😎

思考题 🤔

  1. 如果每次可以爬1步、2步或3步,那么爬到第 n 阶楼梯的方法数是多少?请尝试写出递推公式和代码。
  2. 斐波那契数列在自然界中无处不在,你还能想到哪些算法问题可以用斐波那契数列来解决?

三、 进阶篇1:每次可以爬至多m步的爬楼梯问题⬆️

好了,经典问题搞定了,咱们来点刺激的!🔥 如果现在你每次可以爬1步、2步、... 直到 m 步,那么爬到 n 阶楼梯总共有多少种不同的方法呢?

问题描述与分析

这个问题是经典爬楼梯问题的泛化。当 m = 2 时,它就退化成了我们前面讨论的经典问题。思路其实是类似的,当我们爬到第 j 阶楼梯时,最后一步可能从 j-1 阶爬1步上来,也可能从 j-2 阶爬2步上来,...,直到从 j-m 阶爬 m 步上来。所以,递推公式自然就是:

dp[j] = dp[j-1] + dp[j-2] + ... + dp[j-m]

是不是感觉有点像背包问题?没错,这其实就是完全背包问题的一种变种!每个台阶数(1到m)都可以看作是一个“物品”,而楼梯的总阶数 n 就是“背包容量”。我们要做的就是找出填满“背包”的所有“物品”组合。🎒

动态规划解法

我们来看 57k.js 中的解法,它完美地诠释了这种思路:

 // 57. 爬楼梯
 // 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 
 // 每次你可以爬至多m (1 <= m < n)个台阶。你有多少种不同的方法可以爬到楼顶呢? 
 // 注意:给定 n 是一个正整数
 var climbStairs = function (m, n) {
     // 确定 dp 数组及其下标含义 dp[j] 表示爬到 j 层楼梯的方法数
     let dp = new Array(n + 1).fill(0)
     // 确定递推公式 dp[j] = dp[j] + dp[j - 1]
     
     // 确定初始化 dp[0] = 1
     dp[0] = 1
     // 确定遍历顺序 
     // 求的是排列数, 先遍历背包容量, 再遍历物品
     for (let j = 0; j < n + 1; j++) { // 遍历背包容量(楼梯阶数)
         for (let i = 1; i < m + 1; i++) { // 遍历物品(每次爬的台阶数)
             if (j >= i) {
                 dp[j] = dp[j] + dp[j - i]
             }
         }
     }
     return dp[n]
 }

这里 dp[j] 依然表示爬到第 j 阶楼梯的方法数。初始化 dp[0] = 1 的原因和之前一样,表示到达第0阶有一种方法(原地不动)。

关键在于两层循环的顺序:

  • 外层循环 j 遍历的是楼梯的阶数(背包容量),从0到 n
  • 内层循环 i 遍历的是每次可以爬的台阶数(物品),从1到 m

dp[j] = dp[j] + dp[j - i] 这行代码的含义是:当前 dp[j] 的值,是之前所有能到达 j 阶的方法数之和。每次加上 dp[j-i],就相当于考虑了从 j-i 阶一步跳 i 阶到达 j 阶的情况。这种“先遍历背包,再遍历物品”的顺序,确保了我们计算的是“排列数”,也就是考虑了爬楼梯的顺序。比如先爬1再爬2,和先爬2再爬1,是两种不同的方法。🔄

思考题 🤔

  1. 如果这个问题要求的是“组合数”(不考虑爬楼梯的顺序,比如1+2和2+1算同一种方法),那么 for 循环的顺序应该如何调整?
  2. 你能举出生活中哪些场景,可以用这种“每次可以爬至多m步”的思路来解决?比如,凑钱买东西,每次可以拿出1元、5元、10元,凑够100元有多少种方法?💰

四、 进阶篇2:最小花费爬楼梯问题💰

爬楼梯除了要考虑有多少种方法,有时候还得考虑“成本”!比如,有些台阶可能特别滑,有些台阶可能特别高,每一步都需要付出不同的“代价”。这就是“最小花费爬楼梯”问题!💸

问题描述与分析

给你一个整数数组 cost,其中 cost[i] 是你从第 i 个台阶向上爬的费用。一旦你支付了费用,你可以选择爬1个台阶或者2个台阶。你可以从下标为0或1的台阶开始。请找出达到楼层顶部的最低花费。

这个问题和前面两个有点不一样,它不是求“方法数”,而是求“最小花费”。但核心思想依然是动态规划!我们要求的是到达楼顶的最小花费,那么到达某个台阶的最小花费,就取决于从前一个台阶或者前两个台阶跳过来的最小花费。这就像你在超市购物,想买最便宜的商品,你肯定会比较不同渠道的价格。🛒

动态规划解法

我们来看 746.js 中的解法:

 // 746. 使用最小花费爬楼梯
 /**
  * @param {number[]} cost
  * @return {number}
  */
 var minCostClimbingStairs = function(cost) {
     // 动规五部曲
     // 1. 确定dp数组及下标含义 dp[i]表示爬到第 i 层楼梯的最小花费
     // 2. 确定递推公式 dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
     // 3. dp数组如何初始化 dp[0] = 0, dp[1] = 0
     // 4. 确定遍历顺序 从前往后
     // 5. 打印dp数组 找出debug
     let dp = []
     dp[0] = 0
     dp[1] = 0
     for (let i = 2; i < cost.length; i++) {
         dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
     }
     return Math.min(dp[cost.length - 1] + cost[cost.length - 1], dp[cost.length - 2] + cost[cost.length - 2])
 };

这里 dp[i] 表示到达第 i 阶楼梯的最小花费。注意,这里的 dp[i] 并不是指爬到第 i 阶台阶的费用,而是指从起点到第 i 阶台阶的累计最小费用。而 cost[i] 是从第 i 阶台阶向上爬的费用。

  • 1. 确定dp数组及下标含义: dp[i] 表示爬到第 i 阶楼梯的最小花费。
  • 2. 确定递推公式: dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])。这里 cost[i-1]cost[i-2] 是指从 i-1i-2 向上爬的费用。
  • 3. dp数组如何初始化: dp[0] = 0, dp[1] = 0。因为你可以从下标为0或1的台阶开始,所以到达这两阶的花费为0。
  • 4. 确定遍历顺序: 从前往后遍历。

最终的结果是 Math.min(dp[cost.length - 1] + cost[cost.length - 1], dp[cost.length - 2] + cost[cost.length - 2]),因为你可以从倒数第一阶或倒数第二阶跳到楼顶(楼顶没有费用)。

另一种更直观的 dp 数组定义方式是 dp[i] 表示到达第 i 阶台阶所花费的最小费用(包含 cost[i])。

 var minCostClimbingStairs = function(cost) {
     let dp = []
     dp[0] = cost[0]
     dp[1] = cost[1]
     for (let i = 2; i < cost.length; i++) {
         dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i]
     }
     return Math.min(dp[cost.length - 1], dp[cost.length - 2])
 };

这种定义下,dp[i] 表示的是“站在”第 i 阶台阶上,所付出的最小费用。那么递推公式就变成了 dp[i] = Math.min(dp[i-1], dp[i-2]) + cost[i]。最后返回 dp 数组的最后两个元素的最小值,因为你可以从倒数第一阶或倒数第二阶跳到楼顶。

空间优化解法

和经典爬楼梯问题一样,最小花费爬楼梯问题也可以进行空间优化。我们只需要记住前两个状态即可:

 var minCostClimbingStairs = function(cost) { // 动规 状态压缩
     let pre1 = cost[1] // 相当于dp[i-1]
     let pre2 = cost[0] // 相当于dp[i-2]
     let temp
     for (let i = 2; i < cost.length; i++) {
         temp = pre1
         pre1 = Math.min(pre1, pre2) + cost[i]
         pre2 = temp
     }
     return Math.min(pre2, pre1)
 };

这个优化后的版本,同样只用了常数级别的额外空间,非常高效!👍

思考题 🤔

  1. 如果每次可以爬1步、2步或3步,并且每一步都有不同的花费,如何计算到达楼顶的最小花费?
  2. 除了爬楼梯,你还能想到哪些问题可以用“最小花费”的思路来解决?比如,最短路径问题?🗺️

5. 动态规划的本质:殊途同归的智慧💡

一路爬下来,你有没有发现,无论是经典爬楼梯、多步爬楼梯,还是最小花费爬楼梯,它们背后都隐藏着一个共同的“大Boss”——动态规划!😎

动态规划,听起来高大上,但它的核心思想其实非常朴素:大事化小,小事化了,然后把“小事”的结果存起来,避免重复劳动。 具体来说,它有三个非常重要的特征:

重叠子问题

就像我们前面看到的,climbStairs(n) 的计算需要 climbStairs(n-1)climbStairs(n-2) 的结果,而 climbStairs(n-1) 又需要 climbStairs(n-2)climbStairs(n-3) 的结果。你会发现,climbStairs(n-2) 被计算了多次。这些被重复计算的子问题,就是“重叠子问题”。动态规划通过存储子问题的解,避免了这种重复计算,大大提高了效率。这就像你做数学题,如果一道题的某个步骤之前已经算过了,你直接拿来用就行,不用再算一遍。省时省力!⏳

最优子结构

“最优子结构”指的是一个问题的最优解可以通过其子问题的最优解来构造。在爬楼梯问题中,爬到第 n 阶楼梯的最优方法(无论是方法数最多还是花费最少),一定是由爬到第 n-1 阶或第 n-2 阶的最优方法推导出来的。这就像你要去一个地方,最快的路线一定是由到达中间某个点的最快路线组成的。🛣️

无后效性

“无后效性”是指当前状态的决策,只依赖于之前的状态,而与未来的决策无关。换句话说,一旦我们确定了某个状态的值,这个值就不会再受到后续决策的影响。在爬楼梯问题中,我们计算 dp[i] 的时候,只需要知道 dp[i-1]dp[i-2] 的值,而不需要关心 dp[i+1]dp[i+2] 的值。这就像你玩游戏,当前这一步的得分只取决于你之前的操作,而不会因为你后面玩得好不好而改变。🎮

如何识别动态规划问题

当你遇到一个问题,感觉它可能可以用动态规划解决时,可以从以下几个方面来判断:

  1. 求最值或计数: 比如“最长公共子序列”、“最小路径和”、“多少种方法”等等。爬楼梯问题就是典型的计数问题。🔢
  2. 问题可以分解为子问题: 并且这些子问题是相互重叠的。你可以尝试画出递归树,看看有没有重复的节点。🌳
  3. 存在最优子结构和无后效性: 确保子问题的最优解可以推导出原问题的最优解,并且当前状态不受未来影响。

动态规划解题步骤回顾

总结一下,解决动态规划问题,通常可以遵循“五部曲”:

  1. 确定dp数组及下标含义: dp[i] 到底代表什么?这是最关键的一步!
  2. 确定递推公式: dp[i]dp[i-1]dp[i-2] 等之间的关系是什么?
  3. dp数组如何初始化: 确定边界条件,比如 dp[0]dp[1] 的值。
  4. 确定遍历顺序: 是从前往后,还是从后往前?这取决于递推公式的依赖关系。
  5. 打印dp数组(可选): 帮助理解和调试。

掌握了这五部曲,你就能在动态规划的海洋里畅游无阻啦!🌊

6. 总结与展望:爬楼梯的尽头是星辰大海✨

恭喜你!🎉 经过一番“攀爬”,你已经成功征服了“爬楼梯”这个算法界的“小珠峰”,并且深入了解了动态规划的奥秘。从最基础的每次1或2步,到每次至多m步,再到最小花费,我们一步步揭示了动态规划的强大和优雅。你现在应该对动态规划有了更深刻的理解,并且掌握了解决这类问题的基本套路。

动态规划不仅仅是解决算法题的利器,它更是一种解决问题的思维方式。在现实生活中,很多复杂的问题都可以通过动态规划的思想来简化和优化,比如项目管理中的资源分配、金融投资中的策略选择、生物信息学中的基因序列比对等等。它的应用场景远比你想象的要广阔!🌌

学习建议与路径

  1. 多刷题: 理论知识固然重要,但实践才是检验真理的唯一标准。多做一些动态规划的题目,从简单到复杂,逐步提升。
  2. 多思考: 遇到问题不要急着看答案,先自己思考,尝试用动态规划的“五部曲”去分析问题,推导递推公式。
  3. 多总结: 每次做完一道题,都要总结一下这道题的特点,以及它和之前做过的题目有什么异同。形成自己的知识体系。
  4. 不要怕: 动态规划是很多人的“噩梦”,但只要你掌握了它的核心思想和解题套路,它就会变成你的“好朋友”!🤝

拓展思考:其他动态规划问题

除了爬楼梯,动态规划还有很多经典的题目,比如:

  • 背包问题(0-1背包、完全背包、多重背包)
  • 最长公共子序列/子串
  • 编辑距离
  • 打家劫舍
  • 股票买卖

这些问题都等待着你去探索和征服!每一次成功解决动态规划问题,都会让你对算法的理解更上一层楼。🚀

希望这篇博客能为你打开动态规划的大门,让你在算法学习的道路上越走越远,最终成为算法界的“珠穆朗玛峰”!祝你学习愉快,代码无bug!🐛➡️✨