从超时到秒杀:动态规划(Dynamic Programming)的极致优化之路

从超时到秒杀:动态规划(Dynamic Programming)的极致优化之路

【免费下载链接】leetcode-notes 🐳 LeetCode 算法笔记:面试、刷题、学算法。在线阅读地址:https://datawhalechina.github.io/leetcode-notes/ 【免费下载链接】leetcode-notes 项目地址: https://gitcode.com/datawhalechina/leetcode-notes

你是否也曾在 LeetCode 刷题时遇到这样的困境:明明用递归思路解出了题目,却在大数据测试用例上遭遇超时(Time Limit Exceeded)?当递归深度超过 1000 时,栈溢出(Stack Overflow)的错误提示是否让你束手无策?本文将带你掌握动态规划(Dynamic Programming, DP)这一算法设计中的关键方法,从暴力递归到状态压缩,从基础模型到实战优化,彻底攻克 LeetCode 中 30% 以上的中等/困难题。

读完本文你将获得:

  • 动态规划问题的四步解题框架(状态定义→转移方程→边界条件→空间优化)
  • 五大经典 DP 模型的通用解法(线性 DP/背包问题/区间 DP/树形 DP/状压 DP)
  • 从 O(n²) 到 O(n) 再到 O(1) 的空间优化技巧(滚动数组/状态压缩/维度消除)
  • 15 个高频考点的 DP 问题详解(含 LeetCode 0053/0322/0123 等原题代码)
  • 动态规划 vs 贪心算法 vs 回溯算法的适用边界分析

一、动态规划本质:用空间换时间的智慧

1.1 从斐波那契数列看 DP 的诞生

让我们从最经典的斐波那契数列(Fibonacci sequence)开始。问题描述:求第 n 个斐波那契数,定义为 F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)。

暴力递归实现(超时版)

def fib(n):
    if n <= 1:
        return n
    return fib(n-1) + fib(n-2)

这段代码的时间复杂度是 O(2ⁿ),当 n=40 时就需要计算 102334155 次。通过绘制递归树我们发现,F(5) 被计算了 3 次,F(4) 被计算了 2 次,大量重复计算导致效率低下。

记忆化搜索优化(自顶向下)

def fib(n):
    memo = [0] * (n + 1) if n >= 0 else [0, 0]
    def dfs(i):
        if i <= 1:
            return i
        if memo[i] != 0:
            return memo[i]
        memo[i] = dfs(i-1) + dfs(i-2)
        return memo[i]
    return dfs(n)

通过缓存(Memoization)已经计算过的子问题结果,时间复杂度降至 O(n),空间复杂度 O(n)。这就是动态规划的第一种实现形式——记忆化搜索(递归版 DP)。

动态规划实现(自底向上)

def fib(n):
    if n <= 1:
        return n
    dp = [0] * (n + 1)
    dp[0], dp[1] = 0, 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

自底向上的迭代方式避免了递归栈开销,空间复杂度仍为 O(n)。进一步观察发现,计算 dp[i] 只需要前两个状态,因此可以进行空间优化:

空间优化版(O(1) 空间)

def fib(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        c = a + b
        a = b
        b = c
    return b

通过滚动变量将空间复杂度压缩至 O(1),这就是动态规划的核心思想:通过存储中间结果避免重复计算,通过状态转移构建最优解

1.2 动态规划的四步解题框架

所有动态规划问题都可以按照以下步骤解决:

mermaid

  1. 定义状态(State)

    • dp[i] 或 dp[i][j] 代表什么?(通常是问题的子问题最优解)
    • 例:最长递增子序列中 dp[i] 表示以 nums[i] 结尾的最长子序列长度
  2. 确定转移方程(Transition)

    • 大问题如何由小问题推导而来?
    • 例:dp[i] = max(dp[j] + 1) for j < i and nums[j] < nums[i]
  3. 初始化边界条件(Initialization)

    • 最小子问题的解是什么?
    • 例:所有 dp[i] 初始化为 1(每个元素自身是长度为 1 的子序列)
  4. 计算顺序与空间优化(Optimization)

    • 确定计算方向(从前到后/从后到前/对角线方向)
    • 判断是否可压缩空间(滚动数组/状态合并)

二、五大经典 DP 模型全解析

2.1 线性 DP:一维/二维状态的线性递推

典型问题:最长递增子序列(LIS)、最长公共子序列(LCS)、编辑距离

LeetCode 0053. 最大子数组和

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

def maxSubArray(nums):
    n = len(nums)
    dp = [0] * n
    dp[0] = nums[0]
    max_sum = dp[0]
    
    for i in range(1, n):
        # 状态转移:要么加入之前的子数组,要么重新开始
        dp[i] = max(nums[i], dp[i-1] + nums[i])
        max_sum = max(max_sum, dp[i])
    
    return max_sum

# 空间优化版(O(1) 空间)
def maxSubArray(nums):
    max_sum = current = nums[0]
    for num in nums[1:]:
        current = max(num, current + num)
        max_sum = max(max_sum, current)
    return max_sum

状态定义:dp[i] 表示以 nums[i] 结尾的连续子数组的最大和
转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i])
边界条件:dp[0] = nums[0]
时间复杂度:O(n),空间复杂度:O(1)(优化后)

2.2 背包问题:从 0-1 到多维优化

背包问题是动态规划的经典应用,主要分为以下几类:

背包类型特点状态定义转移方程时间复杂度
0-1背包物品只能选一次dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]]+v[i])逆序遍历容量O(n*C)
完全背包物品可选无限次dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]]+v[i])顺序遍历容量O(n*C)
多重背包物品可选有限次二进制优化/单调队列优化-O(nClog s)
分组背包物品分组成组选择dp[j] = max(dp[j], dp[j-w[k]]+v[k]) for k in group组内逆序遍历O(n*C)

LeetCode 0322. 零钱兑换(完全背包变种)

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

def coinChange(coins, amount):
    # 初始化 dp 数组为 amount+1(代表无穷大)
    dp = [amount + 1] * (amount + 1)
    dp[0] = 0  #  base case:0元需要0个硬币
    
    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i:
                dp[i] = min(dp[i], dp[i - coin] + 1)
    
    return dp[amount] if dp[amount] <= amount else -1

状态定义:dp[i] 表示凑成金额 i 所需的最少硬币数
转移方程:dp[i] = min(dp[i - coin] + 1) for coin in coins if coin <= i
边界条件:dp[0] = 0,其他初始化为无穷大
时间复杂度:O(amount * len(coins)),空间复杂度:O(amount)

2.3 区间 DP:区间长度递增的状态转移

区间 DP 通常用于解决区间上的最优问题,其特点是 dp[i][j] 表示区间 [i,j] 上的最优解。

LeetCode 0516. 最长回文子序列

给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。

def longestPalindromeSubseq(s):
    n = len(s)
    # dp[i][j] 表示 s[i..j] 的最长回文子序列长度
    dp = [[0] * n for _ in range(n)]
    
    # 初始化:单个字符的回文长度为1
    for i in range(n-1, -1, -1):
        dp[i][i] = 1
        for j in range(i+1, n):
            if s[i] == s[j]:
                # 首尾字符相同,长度+2
                dp[i][j] = dp[i+1][j-1] + 2
            else:
                # 首尾字符不同,取子区间最大值
                dp[i][j] = max(dp[i+1][j], dp[i][j-1])
    
    return dp[0][n-1]

状态定义:dp[i][j] 表示 s[i..j] 的最长回文子序列长度
转移方程

  • s[i] == s[j] → dp[i][j] = dp[i+1][j-1] + 2
  • s[i] != s[j] → dp[i][j] = max(dp[i+1][j], dp[i][j-1]) 计算顺序:从长度为 1 的区间开始,逐步计算更长的区间
    时间复杂度:O(n²),空间复杂度:O(n²)(可优化为 O(n))

2.4 树形 DP:树上路径的状态传递

树形 DP 是将树结构与动态规划结合的产物,通常使用后序遍历实现。

LeetCode 0124. 二叉树中的最大路径和

路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。路径和 是路径中各节点值的总和。给你一个二叉树的根节点 root ,返回其 最大路径和 。

class TreeNode:
    def __init__(self, val=0, left=None, right=None):
        self.val = val
        self.left = left
        self.right = right

def maxPathSum(root):
    max_sum = float('-inf')
    
    def dfs(node):
        nonlocal max_sum
        if not node:
            return 0
        
        # 递归计算左右子树的最大贡献值(若为负则不取)
        left_gain = max(dfs(node.left), 0)
        right_gain = max(dfs(node.right), 0)
        
        # 当前节点作为路径顶点的最大路径和
        current_max = node.val + left_gain + right_gain
        max_sum = max(max_sum, current_max)
        
        # 返回当前节点对父节点的最大贡献值
        return node.val + max(left_gain, right_gain)
    
    dfs(root)
    return max_sum

状态定义:dfs(node) 返回以 node 为起点的最大路径和(只能选择左右子树中的一条路径)
计算逻辑

  1. 递归计算左右子树的最大贡献值(负数贡献舍弃)
  2. 更新包含当前节点的最大路径和(node.val + left_gain + right_gain)
  3. 返回当前节点对父节点的贡献值(node.val + max(left_gain, right_gain)) 时间复杂度:O(n),空间复杂度:O(h)(h 为树的高度)

2.5 状压 DP:用二进制表示状态的动态规划

当问题的状态可以用一个二进制数表示时,可以使用状态压缩 DP。

LeetCode 0698. 划分为k个相等的子集

给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。

def canPartitionKSubsets(nums, k):
    total = sum(nums)
    if total % k != 0:
        return False
    target = total // k
    nums.sort(reverse=True)  # 降序排列优化剪枝
    n = len(nums)
    used = 0  # 用位掩码表示使用状态
    
    # 记忆化搜索:当前已使用状态为 used,当前子集和为 current_sum
    from functools import lru_cache
    @lru_cache(maxsize=None)
    def backtrack(used, current_sum, count):
        if count == k:  # 已成功划分 k 个子集
            return True
        if current_sum == target:  # 当前子集已满,开始下一个子集
            return backtrack(used, 0, count + 1)
        
        for i in range(n):
            if not (used & (1 << i)):  # 第 i 个元素未使用
                if current_sum + nums[i] > target:
                    continue  # 剪枝:当前元素过大
                # 尝试使用第 i 个元素
                if backtrack(used | (1 << i), current_sum + nums[i], count):
                    return True
        return False
    
    return backtrack(0, 0, 0)

状态定义:used 是一个位掩码(bitmask),第 i 位为 1 表示 nums[i] 已被使用
核心思想:通过回溯 + 记忆化,尝试将每个元素放入当前子集或下一个子集
时间复杂度:O(n * 2ⁿ),空间复杂度:O(2ⁿ)

三、动态规划的优化技巧与实战经验

3.1 空间优化三板斧

  1. 滚动数组:当 dp[i] 只依赖 dp[i-1] 时,可压缩为一维数组

    # 二维 DP
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1, m+1):
        for j in range(1, n+1):
            dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + grid[i-1][j-1]
    
    # 滚动数组优化(一维)
    dp = [0]*(n+1)
    for i in range(1, m+1):
        for j in range(1, n+1):
            dp[j] = max(dp[j], dp[j-1]) + grid[i-1][j-1]
    
  2. 状态合并:多个状态变量合并为一个,减少维度

    # 股票问题的三维 DP
    dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
    dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
    
    # 当 k=2 时可展开为 5 个状态变量,降低维度
    
  3. 维度消除:通过数学变形消去冗余维度

    # 斐波那契数列的 O(n) 空间
    dp = [0]*(n+1)
    dp[0], dp[1] = 0, 1
    for i in range(2, n+1):
        dp[i] = dp[i-1] + dp[i-2]
    
    # 优化为 O(1) 空间
    a, b = 0, 1
    for _ in range(2, n+1):
        c = a + b
        a, b = b, c
    

3.2 动态规划常见陷阱与避坑指南

  1. 状态定义模糊

    • 错误:定义 dp[i] 为前 i 个元素的最大和(未明确是否包含第 i 个元素)
    • 正确:定义 dp[i] 为以第 i 个元素结尾的最大子数组和
  2. 转移方程遗漏情况

    • 最长公共子序列中遗漏 s[i] != s[j] 的情况
    • 背包问题中忘记处理物品不能放入的情况
  3. 边界条件错误

    • 初始化时未考虑 dp[0] 的正确值
    • 循环起始条件错误导致越界
  4. 计算顺序错误

    • 区间 DP 未按区间长度递增顺序计算
    • 完全背包问题使用逆序遍历导致错误

3.3 动态规划 vs 其他算法

算法类型核心思想适用场景典型问题时空复杂度
动态规划存储子问题解,自底向上构建最优子结构+重叠子问题最长路径/背包问题中低(多项式级)
贪心算法局部最优推全局最优贪心选择性质+最优子结构区间调度/哈夫曼编码低(线性/对数级)
回溯算法尝试所有可能,剪枝去重排列组合/子集问题N皇后/子集和高(指数级)
分治算法分解为独立子问题子问题独立无重叠归并排序/快速排序中(n log n)

四、15 天动态规划训练计划

基于学习项目的课程安排,我们设计了以下 15 天学习路径:

第 1-2 天:动态规划基础

  • 掌握递归转 DP 的基本方法
  • 实现斐波那契数列的三种解法
  • 完成 LeetCode 0070(爬楼梯)、0338(比特位计数)

第 3-6 天:线性 DP

  • 学习一维/二维线性 DP 模型
  • 掌握 LIS/LCS 问题的 O(n²) 和 O(n log n) 解法
  • 完成 LeetCode 0053(最大子数组和)、01143(最长公共子序列)

第 7-9 天:背包问题

  • 实现 0-1 背包/完全背包/多重背包的标准解法
  • 掌握空间优化技巧和二维费用背包
  • 完成 LeetCode 0416(分割等和子集)、0474(一和零)

第 10-11 天:区间 DP

  • 学习区间 DP 的状态定义和计算顺序
  • 掌握回文问题和矩阵链乘法
  • 完成 LeetCode 0516(最长回文子序列)、0312(戳气球)

第 12 天:树形 DP

  • 学习树上路径问题的后序遍历解法
  • 掌握二叉树直径和最大路径和问题
  • 完成 LeetCode 0124(二叉树最大路径和)、0687(最长同值路径)

第 13-15 天:高级 DP 技巧

  • 学习状压 DP/计数 DP/数位 DP
  • 掌握状态压缩和优化技巧
  • 完成 LeetCode 0698(划分为k个相等子集)、0902(最大为 N 的数字组合)

五、总结与进阶

动态规划作为算法设计中的核心思想,其本质是通过存储中间结果避免重复计算。从基础的线性 DP 到复杂的状压 DP,我们经历了从直观理解到抽象建模的思维跃迁。掌握动态规划不仅能解决算法问题,更能培养我们将复杂问题分解为子问题的结构化思维能力。

进阶学习路径:

  1. 多维度 DP:学习三维及以上状态的动态规划问题
  2. 动态规划优化:掌握单调队列优化、斜率优化等高级技巧
  3. 相关问题 DP:学习相关问题的 DP 解法
  4. 实际应用:在工程问题中应用 DP(如资源调度、路径规划)

要真正掌握动态规划,需要完成至少 50 道以上的相关题目,并对每道题进行深入复盘:尝试不同的状态定义、比较多种实现方式、思考空间优化的可能性。记住,动态规划的难点不在于记住公式,而在于建立状态建模的思维方式。

最后,推荐使用本文开头提到的四步解题框架分析每个问题,坚持 15 天的训练计划,你将彻底攻克动态规划这一算法难关!

本文配套代码和更多例题解析已开源在:https://gitcode.com/datawhalechina/leetcode-notes 欢迎加入学习社群,与学习者共同进步!

【免费下载链接】leetcode-notes 🐳 LeetCode 算法笔记:面试、刷题、学算法。在线阅读地址:https://datawhalechina.github.io/leetcode-notes/ 【免费下载链接】leetcode-notes 项目地址: https://gitcode.com/datawhalechina/leetcode-notes

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值