终极打工技巧:自顶向下拆快递 VS 自底向上拼乐高 | 乐高狂热算法

81 阅读8分钟

一、从 “算斐波那契数列” 说起:为啥要学三种解题法?

斐波那契数列大家肯定听过:第 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”

题目:给一棵二叉树和一个目标和,找出所有从根节点到叶子节点的路径,要求路径上所有节点的和等于目标和。

递归思路

  1. 大任务:找 “从当前节点出发,剩下的和要凑够(目标和 - 当前节点值)” 的路径;
  1. 小任务:分别找左子树、右子树里,能凑够剩下的和的路径;
  1. 终止条件:如果当前节点是叶子(没有左、右孩子),而且剩下的和刚好等于当前节点值,说明这条路径有效。

代码实现

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 元” 这些小任务,算得越久越慢。

闭包优化思路

  1. 外层函数建一个 “小本本(memo)”,key 是 “还需要凑的金额”,value 是 “凑这个金额最少需要的硬币数”;
  1. 内层递归函数先翻本子:有结果就直接用,没有就计算,算完再记到本子里。

代码实现

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 数组版)

思路

  1. 定义dp[i]为 “凑出 i 元最少需要的硬币数”;
  1. 最小任务:dp[0] = 0(凑 0 元不用硬币),其他dp[i]先设为 “无穷大”(表示暂时不知道怎么凑);
  1. 推导过程:从 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)
容易出啥问题反复算、栈溢出任务太深还是会栈溢出得提前想清楚小任务的顺序

六、实战选哪种?看场景!

  1. 优先用自底向上 DP:如果小任务的顺序很清楚(比如从 1 元算到总金额、从左到右算序列),用 DP 数组又简单又快,还不会栈溢出;
  1. 次选用闭包优化递归:如果任务适合递归(比如树、回溯),但小任务重复多,装个 “小本本” 就能提效;
  1. 尽量不用普通递归:只有任务简单、小任务重复少的时候用(比如找单分支树的路径),不然算得太慢。

比如力扣第 70 题 “爬楼梯”(每次爬 1 或 2 阶,求到 n 阶有几种方法):普通递归会反复算,闭包和 DP 都能快算,但 DP 数组(dp[i] = dp[i-1] + dp[i-2])写起来更简单 —— 这就是 “看场景选工具” 的道理。