从超时到秒杀:动态规划(Dynamic Programming)的极致优化之路
你是否也曾在 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 动态规划的四步解题框架
所有动态规划问题都可以按照以下步骤解决:
-
定义状态(State):
- dp[i] 或 dp[i][j] 代表什么?(通常是问题的子问题最优解)
- 例:最长递增子序列中 dp[i] 表示以 nums[i] 结尾的最长子序列长度
-
确定转移方程(Transition):
- 大问题如何由小问题推导而来?
- 例:dp[i] = max(dp[j] + 1) for j < i and nums[j] < nums[i]
-
初始化边界条件(Initialization):
- 最小子问题的解是什么?
- 例:所有 dp[i] 初始化为 1(每个元素自身是长度为 1 的子序列)
-
计算顺序与空间优化(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 为起点的最大路径和(只能选择左右子树中的一条路径)
计算逻辑:
- 递归计算左右子树的最大贡献值(负数贡献舍弃)
- 更新包含当前节点的最大路径和(node.val + left_gain + right_gain)
- 返回当前节点对父节点的贡献值(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 空间优化三板斧
-
滚动数组:当 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] -
状态合并:多个状态变量合并为一个,减少维度
# 股票问题的三维 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 个状态变量,降低维度 -
维度消除:通过数学变形消去冗余维度
# 斐波那契数列的 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 动态规划常见陷阱与避坑指南
-
状态定义模糊:
- 错误:定义 dp[i] 为前 i 个元素的最大和(未明确是否包含第 i 个元素)
- 正确:定义 dp[i] 为以第 i 个元素结尾的最大子数组和
-
转移方程遗漏情况:
- 最长公共子序列中遗漏 s[i] != s[j] 的情况
- 背包问题中忘记处理物品不能放入的情况
-
边界条件错误:
- 初始化时未考虑 dp[0] 的正确值
- 循环起始条件错误导致越界
-
计算顺序错误:
- 区间 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,我们经历了从直观理解到抽象建模的思维跃迁。掌握动态规划不仅能解决算法问题,更能培养我们将复杂问题分解为子问题的结构化思维能力。
进阶学习路径:
- 多维度 DP:学习三维及以上状态的动态规划问题
- 动态规划优化:掌握单调队列优化、斜率优化等高级技巧
- 相关问题 DP:学习相关问题的 DP 解法
- 实际应用:在工程问题中应用 DP(如资源调度、路径规划)
要真正掌握动态规划,需要完成至少 50 道以上的相关题目,并对每道题进行深入复盘:尝试不同的状态定义、比较多种实现方式、思考空间优化的可能性。记住,动态规划的难点不在于记住公式,而在于建立状态建模的思维方式。
最后,推荐使用本文开头提到的四步解题框架分析每个问题,坚持 15 天的训练计划,你将彻底攻克动态规划这一算法难关!
本文配套代码和更多例题解析已开源在:https://gitcode.com/datawhalechina/leetcode-notes 欢迎加入学习社群,与学习者共同进步!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



