大家好,我是你们的老朋友FogLetter。今天我们要一起探索算法世界中两个非常重要的概念——递归和动态规划。这两个概念听起来可能有点吓人,但相信我,通过斐波那契数列和爬楼梯这两个经典问题,你会发现它们其实既有趣又实用!
一、斐波那契数列:递归的经典案例
1.1 什么是斐波那契数列?
斐波那契数列是一个非常有趣的数列,它的定义如下:
- F(0) = 0
- F(1) = 1
- F(n) = F(n-1) + F(n-2) (当n≥2时)
也就是说,每个数字都是前两个数字的和。数列看起来像这样:0, 1, 1, 2, 3, 5, 8, 13, 21, 34...
1.2 最直观的递归实现
当我们第一次学习递归时,斐波那契数列往往是最先接触的例子之一。用JavaScript实现起来非常简单:
function fib(n) {
if(n <= 1) return n;
return fib(n-1) + fib(n-2);
}
console.log(fib(10)); // 输出55
这段代码看起来简洁优雅,完美体现了数学定义。但是,当我们尝试计算较大的斐波那契数时,比如fib(50),就会发现程序运行得非常慢,这是为什么呢?
1.3 递归的缺陷:重复计算
让我们画出fib(5)的递归调用树:
fib(5)
├── fib(4)
│ ├── fib(3)
│ │ ├── fib(2)
│ │ │ ├── fib(1)
│ │ │ └── fib(0)
│ │ └── fib(1)
│ └── fib(2)
│ ├── fib(1)
│ └── fib(0)
└── fib(3)
├── fib(2)
│ ├── fib(1)
│ └── fib(0)
└── fib(1)
可以看到,fib(3)被计算了2次,fib(2)被计算了3次,fib(1)和fib(0)被计算的次数更多。这种重复计算导致了指数级的时间复杂度O(2^n),效率极低。
二、闭包与记忆化:优化递归的利器
2.1 什么是记忆化(Memoization)?
记忆化是一种优化技术,它通过存储已经计算过的结果来避免重复计算。对于斐波那契数列来说,我们可以创建一个缓存对象,在每次计算前先检查缓存中是否已有结果。
2.2 使用闭包实现记忆化
闭包是JavaScript中一个强大的特性,它允许函数访问并记住其词法作用域中的变量,即使函数在其词法作用域之外执行。我们可以利用闭包来实现记忆化:
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 fib = memoizedFib();
console.log(fib(100)); // 354224848179262000000
这个版本的斐波那契函数可以轻松计算出fib(100),而原始递归版本可能永远无法完成这个任务。
2.3 为什么记忆化如此有效?
记忆化将时间复杂度从指数级的O(2^n)降低到了线性的O(n),因为我们只需要计算每个斐波那契数一次。空间复杂度也是O(n),因为我们需要存储n个结果。
三、爬楼梯问题:从递归到动态规划
3.1 问题描述
爬楼梯是另一个经典问题:假设你正在爬楼梯,每次你可以爬1个或2个台阶。问爬到第n个台阶有多少种不同的方法?
例如:
- n=1:1种方法(1)
- n=2:2种方法(1+1或2)
- n=3:3种方法(1+1+1,1+2,2+1)
3.2 递归解法
仔细观察,你会发现爬楼梯问题与斐波那契数列非常相似。要到达第n个台阶,你可以从n-1台阶跨1步上来,或者从n-2台阶跨2步上来。因此:
const climbStairs = function(n) {
if(n == 1) return 1;
if(n == 2) return 2;
return climbStairs(n-1) + climbStairs(n-2);
}
同样地,这个递归解法也存在重复计算的问题。
3.3 记忆化递归解法
我们可以使用与斐波那契数列相同的记忆化技术来优化:
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];
}
3.4 动态规划解法
动态规划(Dynamic Programming, DP)是一种更系统化的优化方法。它通常用于解决具有重叠子问题和最优子结构性质的问题。对于爬楼梯问题,我们可以使用动态规划:
const climbStairs = function(n) {
const dp = [];
dp[1] = 1;
dp[2] = 2;
for(let i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
}
return dp[n];
}
3.5 动态规划的优势
动态规划解法有以下几个优点:
- 时间复杂度O(n):只需一次遍历即可得到结果
- 空间复杂度O(n):使用数组存储中间结果
- 更直观:明确展示了状态转移过程
我们还可以进一步优化空间复杂度到O(1),因为实际上我们只需要前两个状态:
const climbStairs = function(n) {
const dp = [];
dp[1]=1;
dp[2]=2;
for(let i=3;i<=n;i++) {
dp[i] = dp[i-1] + dp[i-2]; // 状态转移方程
}
return dp[n];
}
四、递归与动态规划的思维升华
4.1 闭包思想的延伸
在解决这些问题时,我们运用了闭包思想:
- 大问题拆解为小问题:将fib(n)拆解为fib(n-1)和fib(n-2)
- 退出条件:n <= 1时直接返回
- 避免重复计算:通过缓存存储已计算结果
- 防止爆栈:虽然记忆化优化了时间复杂度,但递归深度仍然可能导致栈溢出
4.2 自顶向下 vs 自底向上
- 递归+记忆化是自顶向下的方法:从目标问题开始,逐步分解
- 动态规划是自底向上的方法:从基础情况开始,逐步构建
4.3 树形结构的思维
递归问题通常可以表示为树形结构:
- 每个节点代表一个子问题
- 子节点的合并得到父节点的解
- 记忆化避免了子树重复计算
五、实际应用与扩展
5.1 斐波那契数列的应用
斐波那契数列在自然界中广泛存在,如:
- 花瓣的数量
- 菠萝的螺旋
- 向日葵的种子排列
- 股票市场分析(斐波那契回调)
5.2 动态规划的更多应用
动态规划可以解决许多经典问题:
- 背包问题
- 最长公共子序列
- 最短路径问题
- 编辑距离
- 股票买卖问题
5.3 面试中的考察点
在面试中,面试官通常会考察:
- 能否识别问题可以使用递归/DP解决
- 能否正确写出状态转移方程
- 能否优化空间复杂度
- 边界条件的处理能力
六、总结
通过斐波那契数列和爬楼梯问题,我们深入探讨了递归和动态规划这两个强大的算法范式。关键点总结:
- 递归是解决问题的自然思路,但需要注意重复计算和栈溢出问题
- 记忆化通过缓存结果大幅提升递归效率
- 动态规划提供了更系统化的解决方案,通常更高效
- 闭包是实现记忆化的有力工具
- 理解状态转移方程是解决DP问题的核心
记住,掌握这些算法思想不仅可以帮助你通过技术面试,更能提升你解决实际问题的能力。希望这篇笔记对你有所帮助,如果有任何问题,欢迎在评论区留言讨论!
最后留个思考题:如果每次可以爬1、2或3个台阶,爬n个台阶有多少种方法?如何用动态规划解决?