嘿,各位未来的算法大神们!👋 你们有没有遇到过这样的情况:明明只是个简单的“爬楼梯”问题,却能让你抓耳挠腮,甚至怀疑人生?别担心,你不是一个人在战斗!今天,咱们就来一起征服这个算法界的“小珠峰”——爬楼梯问题,保证让你看完直呼“原来如此!”🤩
一、 引言:爬楼梯,你真的会爬吗?🤔
想象一下,你面前有一段长长的楼梯,你正准备爬上去。每次你可以选择爬1个台阶,或者2个台阶。那么问题来了,爬到楼顶,总共有多少种不同的方法呢?是不是听起来很简单?但当你真正拿起笔来画一画、算一算的时候,就会发现,这小小的楼梯,竟然藏着大大的学问!🧐
这个看似简单的“爬楼梯”问题,其实是动态规划(Dynamic Programming,简称DP)领域的经典入门题。它就像一个温柔的“引路人”,带你走进DP的奇妙世界。别看它名字朴实无华,它可是能让你在面试中脱颖而出,甚至在日常编程中提升效率的“秘密武器”哦!🚀
今天,我将带你从最基础的爬楼梯问题开始,一步步揭开它的神秘面纱。我们会从“暴力”解法一路“进化”到优雅的动态规划,还会探讨一些有趣的变种问题。准备好了吗?系好安全带,咱们这就出发!🎢
二、 基础篇:经典爬楼梯问题(每次1或2步)🚶♀️🚶♂️
咱们先从最经典的“爬楼梯”问题说起。假设楼梯有 n 阶,你每次可以爬1个台阶或者2个台阶。请问,爬到楼顶总共有多少种不同的方法?
问题描述与分析
这个问题,初看之下,你可能会想:“这不就是排列组合吗?” 但仔细一想,好像又不是那么回事。我们来举个栗子:
-
如果
n = 1(只有1阶楼梯),你只有1种方法:爬1步。👣 -
如果
n = 2(有2阶楼梯),你有2种方法:- 爬1步,再爬1步 (1 + 1)
- 直接爬2步 (2)
-
如果
n = 3(有3阶楼梯),你有3种方法:- 1 + 1 + 1
- 1 + 2
- 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步、2步或3步,那么爬到第
n阶楼梯的方法数是多少?请尝试写出递推公式和代码。 - 斐波那契数列在自然界中无处不在,你还能想到哪些算法问题可以用斐波那契数列来解决?
三、 进阶篇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+2和2+1算同一种方法),那么
for循环的顺序应该如何调整? - 你能举出生活中哪些场景,可以用这种“每次可以爬至多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-1或i-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步、2步或3步,并且每一步都有不同的花费,如何计算到达楼顶的最小花费?
- 除了爬楼梯,你还能想到哪些问题可以用“最小花费”的思路来解决?比如,最短路径问题?🗺️
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] 的值。这就像你玩游戏,当前这一步的得分只取决于你之前的操作,而不会因为你后面玩得好不好而改变。🎮
如何识别动态规划问题
当你遇到一个问题,感觉它可能可以用动态规划解决时,可以从以下几个方面来判断:
- 求最值或计数: 比如“最长公共子序列”、“最小路径和”、“多少种方法”等等。爬楼梯问题就是典型的计数问题。🔢
- 问题可以分解为子问题: 并且这些子问题是相互重叠的。你可以尝试画出递归树,看看有没有重复的节点。🌳
- 存在最优子结构和无后效性: 确保子问题的最优解可以推导出原问题的最优解,并且当前状态不受未来影响。
动态规划解题步骤回顾
总结一下,解决动态规划问题,通常可以遵循“五部曲”:
- 确定dp数组及下标含义:
dp[i]到底代表什么?这是最关键的一步! - 确定递推公式:
dp[i]和dp[i-1]、dp[i-2]等之间的关系是什么? - dp数组如何初始化: 确定边界条件,比如
dp[0]、dp[1]的值。 - 确定遍历顺序: 是从前往后,还是从后往前?这取决于递推公式的依赖关系。
- 打印dp数组(可选): 帮助理解和调试。
掌握了这五部曲,你就能在动态规划的海洋里畅游无阻啦!🌊
6. 总结与展望:爬楼梯的尽头是星辰大海✨
恭喜你!🎉 经过一番“攀爬”,你已经成功征服了“爬楼梯”这个算法界的“小珠峰”,并且深入了解了动态规划的奥秘。从最基础的每次1或2步,到每次至多m步,再到最小花费,我们一步步揭示了动态规划的强大和优雅。你现在应该对动态规划有了更深刻的理解,并且掌握了解决这类问题的基本套路。
动态规划不仅仅是解决算法题的利器,它更是一种解决问题的思维方式。在现实生活中,很多复杂的问题都可以通过动态规划的思想来简化和优化,比如项目管理中的资源分配、金融投资中的策略选择、生物信息学中的基因序列比对等等。它的应用场景远比你想象的要广阔!🌌
学习建议与路径
- 多刷题: 理论知识固然重要,但实践才是检验真理的唯一标准。多做一些动态规划的题目,从简单到复杂,逐步提升。
- 多思考: 遇到问题不要急着看答案,先自己思考,尝试用动态规划的“五部曲”去分析问题,推导递推公式。
- 多总结: 每次做完一道题,都要总结一下这道题的特点,以及它和之前做过的题目有什么异同。形成自己的知识体系。
- 不要怕: 动态规划是很多人的“噩梦”,但只要你掌握了它的核心思想和解题套路,它就会变成你的“好朋友”!🤝
拓展思考:其他动态规划问题
除了爬楼梯,动态规划还有很多经典的题目,比如:
- 背包问题(0-1背包、完全背包、多重背包)
- 最长公共子序列/子串
- 编辑距离
- 打家劫舍
- 股票买卖
这些问题都等待着你去探索和征服!每一次成功解决动态规划问题,都会让你对算法的理解更上一层楼。🚀
希望这篇博客能为你打开动态规划的大门,让你在算法学习的道路上越走越远,最终成为算法界的“珠穆朗玛峰”!祝你学习愉快,代码无bug!🐛➡️✨