今天,我的大脑和JavaScript引擎进行了一场深度对话,主题是:如何优雅地让计算机学会"数楼梯"和"养兔子"。
第一章:递归的美丽与哀愁
想象一下,你站在一栋高楼的楼梯前,每次只能迈1阶或2阶。爬到楼顶有多少种走法?或者换个场景:一对兔子每月生一对小兔,小兔两个月后又能生育,一年后会有多少兔子?这两个看似不相关的问题,在代码世界里竟有着相同的基因序列!
// 斐波那契的递归舞步
function fib(n) {
if (n <= 1) return n; // 终点站:0阶或1阶
return fib(n - 1) + fib(n - 2); // 分身为两个自己
}
console.log(fib(10)); // 输出:55
这段优雅的代码背后藏着一个秘密:树状分身术!当我们计算fib(10)时,它会分裂成fib(9)和fib(8),每个子问题继续分裂,直到触达基础条件。就像细胞分裂:
f(10)
f(9) f(8)
f(8) f(7) f(7) f(6) // 可以很清楚地看到重复的计算
···
f(0) f(1)
美中不足的是——当n=40时,函数调用次数会超过2亿次!这就像让蚂蚁数清撒哈拉沙粒,可怜的JavaScript引擎会举白旗投降:"RangeError: Maximum call stack size exceeded"。这就出现了我们的爆栈,也就是栈溢出的情况,我们知道,函数在执行栈里面执行,先入后出,只有当最后进入栈的函数执行完毕才会出栈,之后陆续出栈,递归可能就会造成栈满,导致栈溢出的情况
第二章:闭包的记忆魔法
如何拯救濒临崩溃的引擎?答案藏在闭包这个时间管理大师的口袋里:
function memoizedFib() {
const cache = {}; // 记忆宝盒
return function fib(n) {
if (n <= 1) return n;
if (cache[n]) return cache[n]; // 查历史记录
cache[n] = fib(n - 1) + fib(n - 2); // 存档新结果
return cache[n];
}
}
const smartFib = memoizedFib();
console.log(smartFib(100)); // 秒算354224848179262000000
闭包的精妙之处在于:
- 函数嵌套函数:外层是保险箱(cache),内层是金库管理员(fib)
- 自由变量永生:cache在函数执行后依然存活,就像永远在线的云备忘录
- 空间换时间:用O(n)空间把时间复杂度从O(2^n)降到O(n)
试试调用smartFib(10)时的记忆轨迹:
cache = {
2: 1,
3: 2,
4: 3,
5: 5,
... // 像滚雪球般积累
}
第三章:全局变量的双刃剑
闭包虽好,但有些场景需要更直接的记忆方式。(当然这里我们依旧可以用闭包的方式)比如爬楼梯问题:
// 原始递归版:优雅但脆弱
const climbStairs = function (n) {
if (n === 1) return 1; // 1阶:1种走法
if (n === 2) return 2; // 2阶:2种走法(1+1或2)
return climbStairs(n-1) + climbStairs(n-2);
}
这里我们同样存在栈溢出的问题
给这个递归战士配上全局记忆铠甲:
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]; // 返回记忆中的答案
}
console.log(climbStairs(100)); // 573147844013817200000
但全局变量是带刺的玫瑰:
- ✅ 优点:简单粗暴,跨函数共享
- ❌ 致命伤:可能被意外修改(想象队友写了句
f = [])这种感觉就像你打王者碰到🐖队友,把你打野红蓝buff抢了
第四章:动态规划的降维打击
真正的终极大招来了——动态规划(DP) !它把递归倒过来思考,从地基开始建楼:
const climbStairs = function (n) {
const dp = []; // DP数组:dp[i]表示i阶楼梯的走法
dp[1] = 1; // 1阶:1种
dp[2] = 2; // 2阶:2种
// 自底向上建造
for (let i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
}
return dp[n]; // 返回顶层设计
}
从底层开始,知道每层阶梯的情况
dp[3]=3
dp[4]=dp[3]+dp[2]=5
dp[5]=dp[4]+dp[3]=8
·····
dp[n]=dp[n-1]+dp[n-2]
我们是利用循环来解决,并没有栈溢出的情况,不会消耗过多的内存
DP的智慧体现在:
- 最优子结构:当前解=子问题解的组合
- 无后效性:未来决策不影响历史状态
- 空间换时间:O(n)时间+O(n)空间
更妙的是空间压缩技巧(滚动数组):
const climbStairs = (n) => {
if (n <= 2) return n;
let a = 1, b = 2;
for (let i = 3; i <= n; i++) {
[a, b] = [b, a + b]; // 数组解构,双指针交替前进
}
return b;
}
// 空间复杂度降至O(1)!
✅ 运行过程示例(n = 5):
| 步骤 | i | a (前前项) | b (前一项) | 新 b = a + b |
|---|---|---|---|---|
| 初始 | - | 1 | 2 | 3 |
| i=3 | 3 | 2 | 1+2=3 | 5 |
| i=4 | 4 | 3 | 2+3=5 | 8 |
| i=5 | 5 | 5 | 3+5=8 | - |
最终返回 b = 8,即 f(5)
✅ 优点总结
| 优点 | 描述 |
|---|---|
| 空间最优 | 只使用常量级空间 O(1),适用于内存受限场景 |
| 无递归栈溢出风险 | 是迭代而非递归,不会爆栈 |
| 高效简洁 | 没有多余的条件判断或数据结构操作 |
| 可扩展性强 | 若题目变为每次能爬 1、2、3 阶,也能轻松改成三变量滚动 |
第五章:面试官的思维解码器
"大问题拆分小问题(相似)→ 退出条件 → 避免重复计算 → 防止爆栈"
这四步法就像编程世界的黄金分割:
-
自顶向下设计(人类思考方式)
- 爬n阶楼梯 = 先走1阶 + 爬(n-1)阶,或先走2阶 + 爬(n-2)阶
-
自底向上实现(机器执行方式)
- 从dp[1]和dp[2]开始迭代
递归与DP的本质区别:
| 特性 | 递归 | 动态规划 |
|---|---|---|
| 思考方向 | 自顶向下 | 自底向上 |
| 计算方式 | 可能重复计算 | 无重复 |
| 空间复杂度 | 调用栈O(n) | 通常O(n) |
| 时间复杂度 | 指数级(O(2^n)) | 线性(O(n)) |
| 适用场景 | 问题规模小 | 大规模问题 |
结语:编程之道的三重境界
- 见山是山:暴力递归直抒胸臆
- 见山不是山:闭包/全局变量优化避免重复
- 见山还是山:动态规划直击本质
下次当你看到斐波那契数列时,请记得:
- 兔子繁殖问题是13世纪斐波那契的数学游戏
- 爬楼梯解法出现在1995年《算法导论》经典案例
- 闭包记忆法则是JavaScript的函数式魔法
最后送上代码哲学三连:
// 初学者
const fib1 = n => n <= 1 ? n : fib1(n-1) + fib1(n-2);
// 进阶者
const fib2 = (() => {
const cache = [0,1];
return n => cache[n] ?? (cache[n] = fib2(n-1) + fib2(n-2));
})();
// 悟道者
const climbStairs = function (n) {
const dp = []; // DP数组:dp[i]表示i阶楼梯的走法
dp[1] = 1; // 1阶:1种
dp[2] = 2; // 2阶:2种
// 自底向上建造
for (let i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
}
return dp[n]; // 返回顶层设计
}
// 成道者
const fib3 = n => {
let [a, b] = [0, 1];
for (let i = 0; i < n; i++) [a, b] = [b, a + b];
return a;
};
记住:好的代码像诗歌,既要优雅简洁,也要经得起大规模数据的考验!