算法入门必看:递归、记忆化搜索与动态规划的完整拆解
前言
在刚接触算法时,我对递归、记忆化、动态规划这几个词一直有点混乱:
它们看起来很像,却又总是被分开讲;有时候觉得“我好像懂了”,一到做题却又不知道该用哪一个。
后来我才慢慢意识到:它们并不是三套割裂的技巧,而是同一类问题在不同阶段的解决方式。
这篇文章就按照我自己的理解过程,用最常见的例子,把这条演进路径完整走一遍。
一、为什么要掌握递归与动态规划
可以把解题过程想成拼一幅复杂的拼图:
- 递归:先把整幅图拆成很多小块,一次只专注解决一小块;
- 记忆化:发现有些小块会反复出现,于是给已经拼好的部分贴上标签,下次直接用;
- 动态规划(DP) :干脆把这些小块按顺序摆好,从最简单的开始,一块一块往上拼。
当一个问题同时出现:
- 答案可以由更小规模的问题推导出来
- 中间结果会被反复计算
那它基本就在向你暗示:这题值得往动态规划方向去想。
二、递归入门:阶乘——递归的第一课
阶乘是我第一次真正“理解递归”的例子。
阶乘天然具有自相似结构:
n! = n × (n - 1)!
只要不断把问题规模缩小,最终一定会落到一个可以直接返回结果的情况。
function mul(n) {
if (n === 1) return 1
return n * mul(n - 1)
}
console.log(mul(6)) // 720
这里发生了什么?
- 递推关系:
mul(n) = n * mul(n - 1) - 终止条件:
n === 1 - 特点:每一层递归只计算一次,不存在重复子问题
所以在这个例子里,递归既直观又高效,非常合适。
三、递归的代价:斐波那契(Fibonacci)与重复计算的陷阱
递归真正的问题,往往出现在有重叠子问题的时候。
斐波那契数列定义如下:
f(n) = f(n - 1) + f(n - 2)
朴素递归实现
function fib(n) {
if (n === 1 || n === 2) return 1
return fib(n - 1) + fib(n - 2)
}
console.log(fib(10)) // 55
为什么它低效?
随着 n 变大,递归会不断向下分叉,
同一个 fib(k) 会在不同分支中被反复计算。
结果是:看起来在拆问题,实际上在不停地重复劳动
时间复杂度接近指数级,n 稍微大一点就完全不可用。
四、记忆化与动态规划:爬楼梯(LeetCode 70)的三种思路
爬楼梯问题是我理解这三种思想关系的关键。
题意:
每次可以爬 1 或 2 阶,问到第 n 阶有多少种走法。
递推关系:
f(n) = f(n - 1) + f(n - 2)
本质上就是斐波那契的变体。
4.1朴素递归(不推荐)
代码很直观,但和斐波那契一样,会产生大量重复计算。
适合用来想清楚问题,不适合最终提交。
4.2记忆化递归(Top-down + cache)
在递归的基础上加一个缓存,把算过的结果存起来,下次直接取。
优点:
- 思路接近递归,容易验证正确性
- 解决了重复计算的问题
这一步,往往是**从“能跑”到“跑得动”**的关键过渡。
4.3动态规划(Bottom-up,推荐)
直接从最小子问题开始,一步步迭代构建答案。
var climbStairs = function(n) {
if (n === 1) return 1
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]
}
console.log(climbStairs(5)) // 8
空间优化(滚动变量,O(1) 空间) :
var climbStairs = function(n) {
if (n === 1) return 1
let a = 1, b = 2 // f(1), f(2)
for (let i = 3; i <= n; i++) {
const c = a + b
a = b
b = c
}
return b
}
这里本质上是在说:
当前状态只依赖前两个状态,那就没必要存整个数组。

总结
回过头看,递归、记忆化搜索与动态规划并不是彼此竞争的解法,而是一条逐步演进的学习路径:
递归让我们看清问题的结构;
记忆化让递归变得高效;
动态规划则把这种高效方式系统化、工程化。
在做题时,与其一开始就强行“套 DP 模板”,不如先写出递归,明确状态和边界,再判断是否存在重复子问题,最后自然过渡到动态规划。
一旦掌握这条思考路径,你会发现:无论是爬楼梯,还是更复杂的 DP 题目,真正难的从来不是代码,而是如何拆问题。