动态规划基础讲解
动态规划(Dynamic Programming,简称 DP)是一种通过把大问题拆成小问题,并保存小问题答案来避免重复计算的算法思想。它常用于求最值、计数、可行性判断等问题。
一、什么时候可以用动态规划?
一个问题通常需要满足:
-
最优子结构
大问题的最优解可以由小问题的最优解推出来。
例如:爬到第
n阶的方法数,可以由第n-1阶和第n-2阶推出。 -
重叠子问题
不同的大问题会反复用到相同的小问题。
例如斐波那契数列中,
f(5)会反复用到f(3)、f(2)。 -
无后效性
当前状态确定后,后面的决策只依赖当前状态,不依赖"怎么走到这里"的历史路径。
二、动态规划解题五步法
1. 定义状态
明确 dp[i] 或 dp[i][j] 表示什么。
例如爬楼梯:
dp[i]表示爬到第i阶的方法数。
状态定义是动态规划最核心的一步。
2. 推导状态转移方程
找 dp[i] 和之前状态的关系。
例如爬楼梯,每次可以走 1 阶或 2 阶:
dp[i] = dp[i-1] + dp[i-2]
3. 初始化
确定最小状态的初始值。
例如:
dp[0] = 1,dp[1] = 1
4. 确定遍历顺序
保证计算 dp[i] 时,它依赖的状态已经算出来。
例如爬楼梯从小到大遍历:
python
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
5. 返回答案
根据题意返回 dp[n]、dp[n][m] 或所有状态中的最值等。
三、经典例子:爬楼梯
题目:每次可以爬 1 阶或 2 阶,问爬到第 n 阶有多少种方法。
状态定义
dp[i]:爬到第 i 阶的方法数。
转移方程
最后一步可能从 i-1 阶走 1 阶上来,也可能从 i-2 阶走 2 阶上来:
text
dp[i] = dp[i-1] + dp[i-2]
初始化
text
dp[0] = 1
dp[1] = 1
代码
python
def climbStairs(n):
if n <= 1:
return 1
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
空间优化
因为 dp[i] 只依赖前两个状态,所以可以只用两个变量:
python
def climbStairs(n):
if n <= 1:
return 1
a, b = 1, 1
for i in range(2, n + 1):
a, b = b, a + b
return b
四、经典例子:0-1 背包
题目:有 n 个物品,第 i 个物品重量为 w[i],价值为 v[i],背包容量为 C。每个物品最多选一次,求最大价值。
状态定义
dp[j]:容量为 j 时能获得的最大价值。
转移方程
对于每个物品:
text
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
含义:
- 不选当前物品:价值是
dp[j] - 选当前物品:价值是
dp[j - w[i]] + v[i]
关键点:倒序遍历容量
0-1 背包中每个物品只能用一次,所以容量要倒序遍历:
python
def knapsack(C, weights, values):
dp = [0] * (C + 1)
for i in range(len(weights)):
for j in range(C, weights[i] - 1, -1):
dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
return dp[C]
如果正序遍历,就变成完全背包,表示每个物品可以选无限次。
五、动态规划和递归、贪心的区别
| 方法 | 特点 |
|---|---|
| 递归 | 直接拆问题,但可能大量重复计算 |
| 记忆化搜索 | 递归 + 缓存,是动态规划的一种写法 |
| 动态规划 | 保存子问题结果,通常用表格或数组递推 |
| 贪心 | 每步选局部最优,不保证全局最优;DP 会综合考虑多个状态 |
很多题可以写成:
- 自顶向下:记忆化搜索
- 自底向上:递推 DP
两者本质相同。
六、常见动态规划类型
- 线性 DP:爬楼梯、打家劫舍、最长递增子序列
- 背包 DP:0-1 背包、完全背包、多重背包
- 区间 DP:石子合并、最长回文子序列
- 树形 DP:树上的最大独立集、树形背包
- 状态压缩 DP:用二进制表示状态,如旅行商问题
- 数位 DP:统计满足数位条件的数字个数
七、动态规划常见误区
-
状态定义不清楚
先想清楚
dp[i]到底表示什么。 -
转移方程漏情况
要枚举"最后一步"或"当前决策"的所有可能。
-
初始化错误
初始值往往决定结果,尤其是计数题和背包题。
-
遍历顺序错误
要保证依赖状态先被计算。背包问题中正序和倒序含义不同。
-
空间优化后逻辑混乱
一维滚动数组优化前,最好先写二维版本验证。
八、动态规划做题模板
text
1. 明确 dp 数组含义
2. 写出状态转移方程
3. 初始化 dp
4. 确定遍历顺序
5. 返回目标答案
6. 考虑是否能压缩空间
简单记:
定义状态 → 找转移 → 初始化 → 按序计算 → 返回答案
动态规划的核心不是背代码,而是练习"如何定义状态"和"如何从子状态推出当前状态"。建议从爬楼梯、打家劫舍、0-1 背包、最长递增子序列这几类题开始练习。
学习动态规划前,建议补充的知识
动态规划本身并不要求你先学很多高深内容,但下面这些前置知识会显著降低学习难度。可以分成三类:必须会 、强烈建议会 、按题型补充。
一、必须会的基础
1. 编程语言基础
至少熟练掌握一种语言中的:
- 变量、条件判断、循环
- 函数定义与调用
- 数组 / 列表 / 二维数组
- 字符串基本操作
- 索引和边界处理
例如 Python 中要熟练写:
python
dp = [0] * (n + 1)
dp = [[0] * (m + 1) for _ in range(n + 1)]
因为动态规划大量使用数组保存状态。
2. 数组与二维表
要理解:
- 一维数组
dp[i]表示某个状态 - 二维数组
dp[i][j]表示两个维度下的状态 - 如何初始化数组
- 如何遍历数组
- 如何避免下标越界
很多 DP 题本质就是"填一张表"。
例如 0-1 背包的一维数组:
python
dp = [0] * (C + 1)
区间 DP 的二维数组:
python
dp = [[0] * n for _ in range(n)]
3. 循环与遍历顺序
动态规划非常依赖遍历顺序。
你需要理解:
- 正序遍历和倒序遍历的区别
- 外层循环和内层循环分别代表什么
- 为什么计算
dp[i]时,它依赖的状态必须已经算好
例如 0-1 背包容量必须倒序:
python
for j in range(C, w - 1, -1):
dp[j] = max(dp[j], dp[j - w] + v)
如果这个不熟,后面很容易把 0-1 背包和完全背包写混。
4. 递归与函数调用
动态规划经常从递归演化而来,所以要会:
- 递归函数怎么写
- 递归终止条件
- 递归树大概长什么样
- 什么是重复子问题
例如斐波那契递归:
python
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
要能看出它为什么慢:fib(3) 被重复计算很多次。
5. 递推思想
递推是动态规划最直接的前置知识。
要理解:
已知前面的结果,如何推出后面的结果。
例如:
text
f(0) = 0
f(1) = 1
f(i) = f(i-1) + f(i-2)
这其实就是最简单的动态规划。
建议先练:
- 斐波那契数列
- 爬楼梯
- 杨辉三角
- 简单数列求和
二、强烈建议补充的知识
1. 复杂度分析
至少会估算:
- 时间复杂度:
O(n)、O(n^2)、O(nm) - 空间复杂度
- 为什么二维 DP 可以优化成一维
例如:
python
for i in range(n):
for j in range(m):
...
这是 O(nm)。
动态规划题通常要判断状态数 × 每个状态的转移代价。
2. 记忆化搜索
记忆化搜索是"递归 + 缓存",是理解 DP 的绝佳桥梁。
例如:
python
memo = {}
def dfs(n):
if n <= 1:
return n
if n in memo:
return memo[n]
memo[n] = dfs(n - 1) + dfs(n - 2)
return memo[n]
学会它之后,你会更容易理解:
- 什么是状态
- 什么是重复子问题
- 为什么 DP 能优化递归
3. 暴力枚举与搜索
动态规划很多时候是在优化暴力搜索。
建议了解:
- 枚举所有选择
- 子集、排列、组合的基本概念
- DFS 搜索的基本写法
- 搜索树的概念
例如 0-1 背包的暴力思路:
每个物品选或不选,枚举所有组合,再取最大价值。
理解暴力后,才能理解 DP 在优化什么。
4. 数学归纳思想
动态规划的状态转移和数学归纳很像:
- 验证初始状态成立
- 假设前面的状态成立
- 推出当前状态成立
例如:
如果知道爬到第
i-1阶和第i-2阶的方法数,就能推出第i阶。
不需要形式化证明,但要有这种"由小推大"的思维。
5. 边界与初始化
很多 DP 错误不是转移方程错,而是初始化错。
要习惯思考:
dp[0]应该是什么?- 空集、0 容量、0 个物品怎么处理?
- 不可能状态用什么值表示?
- 计数题初始值是 0 还是 1?
- 求最小值时初始值用多大?
例如:
python
dp = [0] * (n + 1)
dp[0] = 1
计数题里 dp[0] = 1 通常表示"空方案也是一种方案"。
三、按题型补充的知识
不是一开始都要学,学到对应 DP 类型前再补即可。
1. 学背包 DP 前
补充:
- 二维数组
- 正序 / 倒序遍历
- 最大最小值更新
- 容量、重量、价值这类建模
2. 学线性 DP 前
补充:
- 一维数组递推
- 前缀思想
- 序列下标处理
3. 学区间 DP 前
补充:
- 二维数组
- 区间
[l, r] - 按区间长度遍历
- 子区间划分
4. 学树形 DP 前
补充:
- 树的基本概念
- 邻接表
- DFS 遍历树
- 后序遍历思想
5. 学状态压缩 DP 前
补充:
- 二进制
- 位运算:
&、|、^、<<、>> - 用二进制表示集合
6. 学数位 DP 前
补充:
- 数字按位拆分
- 前缀统计思想
- 递归枚举数位
四、建议的学习顺序
比较平滑的路线是:
text
编程基础
→ 数组与循环
→ 递归
→ 递推
→ 记忆化搜索
→ 复杂度分析
→ 简单线性 DP
→ 0-1 背包
→ 完全背包
→ 区间 DP / 树形 DP / 状压 DP
具体练习可以按这个顺序:
- 斐波那契数列
- 爬楼梯
- 杨辉三角
- 打家劫舍
- 最长递增子序列
- 0-1 背包
- 完全背包
五、最小前置清单
如果只想尽快开始学动态规划,至少先掌握:
- 数组 / 列表
- 二维数组
- for 循环与倒序遍历
- 函数和递归
- 递归终止条件
- 斐波那契式递推
- 基本复杂度
O(n)、O(n^2) - 边界和初始化意识
一句话总结:
学 DP 前最该补的是:数组、循环、递归、递推、记忆化和复杂度分析。
其他如树、位运算、区间思想,可以等到对应题型再补。