一、从 “算斐波那契数列” 说起:为啥要学三种解题法?
斐波那契数列大家肯定听过:第 0 项是 0,第 1 项是 1,后面每一项都是前两项相加(比如第 2 项 = 第 1 项 + 第 0 项 = 1,第 3 项 = 第 2 项 + 第 1 项 = 2)。
但想算第 n 项,方法可不一样。比如力扣第 509 题 “斐波那契数”,要是直接写递归:
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
算到第 30 项就明显变慢了 —— 因为它会反复算同一个数(比如算第 5 项时,第 3 项要算两次,第 2 项要算三次)。这就是普通递归(自顶向下) 的毛病:光顾着把大问题拆成小问题,却没发现很多小问题其实已经算过了。
要是换个思路,从第 0 项开始一步步往上算:先算第 2 项,再算第 3 项,直到第 n 项,这就是自底向上规划,不仅好懂,还不会重复计算。
还有一种方法叫闭包优化递归,相当于给递归装了个 “小本本”—— 把算过的结果记下来,下次再要算的时候直接翻本子,不用重新算,效率一下就提上来了。
下面结合力扣真题,用大白话把这三种方法讲透。
二、普通递归(自顶向下):先拆问题,再拼答案
核心逻辑
就像剥洋葱,先把 “大任务” 拆成一个个 “小任务”,直到小任务简单到能直接答出来(比如斐波那契里的第 0 项 = 0、第 1 项 = 1),再把小任务的答案拼起来,得到大任务的结果。
力扣实战:第 113 题 “路径总和 II”
题目:给一棵二叉树和一个目标和,找出所有从根节点到叶子节点的路径,要求路径上所有节点的和等于目标和。
递归思路:
- 大任务:找 “从当前节点出发,剩下的和要凑够(目标和 - 当前节点值)” 的路径;
- 小任务:分别找左子树、右子树里,能凑够剩下的和的路径;
- 终止条件:如果当前节点是叶子(没有左、右孩子),而且剩下的和刚好等于当前节点值,说明这条路径有效。
代码实现:
class Solution:
def pathSum(self, root: Optional[TreeNode], targetSum: int) -> List[List[int]]:
res = [] # 存所有有效路径
def dfs(node, remain, path):
if not node: # 节点为空,直接返回
return
path.append(node.val) # 把当前节点加入路径
# 终止条件:叶子节点且剩下的和刚好等于当前节点值
if not node.left and not node.right and remain == node.val:
res.append(path.copy()) # 深拷贝,避免后续修改影响结果
# 拆成小任务:找左、右子树的路径
dfs(node.left, remain - node.val, path)
dfs(node.right, remain - node.val, path)
path.pop() # 回溯:把当前节点去掉,找其他路径
dfs(root, targetSum, [])
return res
优缺点:
- 优点:和题目描述的逻辑完全对应,能清楚看到 “从根到叶” 的过程,调试时容易找到问题;
- 缺点:如果树特别深(比如像一根链条),可能会超出递归栈的限制;而且没记笔记的功能,遇到重复的子树会反复算。
三、闭包优化递归:给递归装个 “记事本”
核心逻辑
闭包其实就是 “函数里套函数,内层函数能用到外层函数的变量”。我们利用这个特点,在外层函数里建一个 “小本本(缓存字典)”,把算过的小任务结果记下来,下次再遇到相同的小任务,直接翻本子拿结果,不用重新算 —— 这也叫 “记笔记搜索(记忆化搜索)”。
力扣实战:第 322 题 “零钱兑换”(优化版)
题目:给几种面额的硬币和一个总金额,求最少用几枚硬币能凑出总金额(硬币可以重复用)。
普通递归的毛病:比如凑 11 元,会反复算 “凑 10 元”“凑 9 元” 这些小任务,算得越久越慢。
闭包优化思路:
- 外层函数建一个 “小本本(memo)”,key 是 “还需要凑的金额”,value 是 “凑这个金额最少需要的硬币数”;
- 内层递归函数先翻本子:有结果就直接用,没有就计算,算完再记到本子里。
代码实现:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
def create_coin_calculator():
memo = {} # 小本本:记“剩余金额→最少硬币数”
def calculate(remain):
# 终止条件1:要凑的金额是0,不用硬币
if remain == 0:
return 0
# 终止条件2:要凑的金额是负数,凑不出来(用无穷大表示无效)
if remain < 0:
return float('inf')
# 先翻本子:有结果直接返回
if remain in memo:
return memo[remain]
min_coins = float('inf') # 初始化最少硬币数为无穷大
# 尝试每种硬币,找最少的数量
for coin in coins:
sub_result = calculate(remain - coin) # 算“凑剩余金额-硬币面额”需要的硬币数
if sub_result != float('inf'): # 如果能凑出来
min_coins = min(min_coins, sub_result + 1) # 更新最少硬币数(加1是因为用了当前这枚硬币)
memo[remain] = min_coins # 记到小本本里
return min_coins
calculator = create_coin_calculator() # 创建带小本本的计算器
result = calculator(amount)
# 要是结果还是无穷大,说明凑不出来,返回-1
return result if result != float('inf') else -1
优化效果:原本算 100 元可能要几十万次,现在最多算 300 次(总金额 × 硬币种类数),快了很多。
四、自底向上规划:从最小的任务开始 “堆” 答案
核心逻辑
和普通递归 “拆大任务” 相反,自底向上是先解决 “最小的小任务”,再用小任务的答案推 “大一点的任务”,直到推到最终要解决的任务。通常会用一个数组(叫 “DP 数组”)记小任务的答案,比如斐波那契里的dp[i]就是第 i 项的值,从dp[0]算到dp[n]。
力扣实战:第 322 题 “零钱兑换”(DP 数组版)
思路:
- 定义dp[i]为 “凑出 i 元最少需要的硬币数”;
- 最小任务:dp[0] = 0(凑 0 元不用硬币),其他dp[i]先设为 “无穷大”(表示暂时不知道怎么凑);
- 推导过程:从 1 元开始算到总金额,对每种金额 i,遍历所有硬币 —— 如果 i 比硬币面额大,就用 “凑 i - 硬币面额需要的硬币数 + 1”(加 1 是因为用了当前这枚硬币),更新dp[i]的最小值。
代码实现:
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# DP数组:dp[i]是凑i元最少需要的硬币数
dp = [float('inf')] * (amount + 1)
dp[0] = 0 # 最小任务:凑0元用0枚硬币
# 从1元算到总金额,一步步推
for i in range(1, amount + 1):
# 遍历每种硬币,尝试用它凑i元
for coin in coins:
if i >= coin: # 只有i比硬币面额大,才能用这枚硬币
# 用“凑i-coin元的硬币数+1”更新dp[i]
dp[i] = min(dp[i], dp[i - coin] + 1)
# 要是dp[amount]还是无穷大,说明凑不出来,返回-1
return dp[amount] if dp[amount] != float('inf') else -1
优缺点:
- 优点:和闭包优化一样快,但用数组记答案比 “小本本” 更直观,还不会有递归栈溢出的问题;
- 缺点:得提前想清楚 “小任务的顺序”(比如必须从 1 元算到总金额),遇到像树这样复杂的结构,不如递归灵活。
五、三种思路对比表(大白话版)
| 对比维度 | 普通递归(自顶向下) | 闭包优化递归(带小本本) | 自底向上规划(DP 数组) |
|---|---|---|---|
| 核心逻辑 | 大任务拆小任务,递归算 | 递归 + 小本本记结果,不重复算 | 先算最小任务,再推大任务 |
| 算得快不快 | 慢(反复算相同小任务) | 快(算过的记下来) | 快(和闭包优化一样) |
| 占内存多不多 | 递归栈占内存(看任务深度) | 递归栈 + 小本本占内存 | DP 数组占内存 |
| 适合啥场景 | 任务结构清楚(比如找树的路径) | 小任务重复多、任务不深的场景 | 小任务顺序明确(比如算金额、序列) |
| 力扣例题 | 路径总和 II(113) | 零钱兑换(322)、斐波那契数(509) | 零钱兑换(322)、爬楼梯(70) |
| 容易出啥问题 | 反复算、栈溢出 | 任务太深还是会栈溢出 | 得提前想清楚小任务的顺序 |
六、实战选哪种?看场景!
- 优先用自底向上 DP:如果小任务的顺序很清楚(比如从 1 元算到总金额、从左到右算序列),用 DP 数组又简单又快,还不会栈溢出;
- 次选用闭包优化递归:如果任务适合递归(比如树、回溯),但小任务重复多,装个 “小本本” 就能提效;
- 尽量不用普通递归:只有任务简单、小任务重复少的时候用(比如找单分支树的路径),不然算得太慢。
比如力扣第 70 题 “爬楼梯”(每次爬 1 或 2 阶,求到 n 阶有几种方法):普通递归会反复算,闭包和 DP 都能快算,但 DP 数组(dp[i] = dp[i-1] + dp[i-2])写起来更简单 —— 这就是 “看场景选工具” 的道理。