摘要 :动态规划(DP)是算法面试与竞赛的高频考点,核心在于把大问题拆分为重叠子问题,通过保存子问题解避免重复计算。本文从「思路」出发,给出可复用的解题五步法:明确状态定义 → 推导状态转移方程 → 确定初始化与边界 → 确定遍历顺序 → 确定返回值。随后用爬楼梯、最长递增子序列、0-1 背包三道经典题逐步拆解,并总结常见题型套路、误区与空间优化技巧,帮助读者建立系统的 DP 解题方法论。
1. 引言
动态规划(Dynamic Programming,简称 DP)是算法面试和竞赛中的高频考点,也是很多初学者觉得最难啃的硬骨头。它看似抽象,其实有一套固定的思考框架。本文不堆砌公式,而是从「思路」出发,带你建立一套可复用的解题方法论,配合经典例题逐步拆解,帮你真正掌握动态规划。
2. 什么是动态规划
动态规划的核心思想是:把一个大问题拆分成若干重叠的子问题,通过求解并保存子问题的解,避免重复计算,从而高效地得到原问题的解。
它通常适用于具有以下两个特征的问题:
- 最优子结构:原问题的最优解可以由子问题的最优解推导而来。
- 重叠子问题:在递归求解过程中,同一个子问题会被反复计算多次。
与之相对的是分治算法------分治的子问题通常是相互独立的,而动态规划的子问题之间是重叠的,这正是它能用「记忆化」或「填表」来加速的根本原因。
3. 动态规划解题五步法
面对一道动态规划题,不要急着写代码,先按下面五步走:
3.1 明确状态定义
状态是动态规划的灵魂。问自己:我需要记录哪些信息,才能描述当前所处的子问题?
通常用一个或多个维度来表示,例如:
dp[i]表示前i个元素的最优解;dp[i][j]表示在二维场景下(如网格、两个字符串)的状态。
状态定义的好坏,直接决定转移方程是否好写。定义得太粗会丢失信息,定义得太细又会造成维度爆炸。
3.2 推导状态转移方程
这是最核心的一步。思考:当前状态是如何从更小的状态转移过来的?
可以这样问自己:
- 最后一步做了什么决策?
- 这个决策对应哪几个前驱状态?
- 取最大值、最小值,还是求和、求布尔值?
例如斐波那契数列的转移方程就是:
text
dp[i] = dp[i-1] + dp[i-2]
3.3 确定初始化和边界条件
边界条件决定了递推的起点。通常需要手动设置:
dp[0]、dp[1]等初始值;- 数组越界时的处理;
- 空串、空数组等特殊输入。
初始化错误是动态规划最常见的 bug 来源,务必仔细。
3.4 确定遍历顺序
遍历顺序要保证:计算当前状态时,它所依赖的前驱状态已经被计算出来。
常见的有:
- 一维问题通常从左到右遍历;
- 二维问题通常按行从上到下、从左到右遍历;
- 背包类问题要注意物品循环和容量循环的先后顺序。
3.5 确定返回值
最后想清楚:题目要的答案存在哪个状态里?是 dp[n]、dp[n][m],还是 dp 数组中的最大值?
4. 经典例题拆解
理论讲完,我们用三道经典题把五步法串起来。
4.1 爬楼梯
题目 :每次可以爬 1 或 2 个台阶,爬到第 n 级台阶有多少种不同方法?
思路拆解:
- 状态定义 :
dp[i]表示爬到第i级台阶的方法数。 - 转移方程 :最后一步要么从第
i-1级跨 1 步,要么从第i-2级跨 2 步,所以dp[i] = dp[i-1] + dp[i-2]。 - 初始化 :
dp[1] = 1,dp[2] = 2。 - 遍历顺序 :从
3到n递增。 - 返回值 :
dp[n]。
python
def climb_stairs(n: int) -> int:
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1], dp[2] = 1, 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
4.2 最长递增子序列
题目:给定一个整数数组,求其中最长的严格递增子序列的长度。
思路拆解:
- 状态定义 :
dp[i]表示以nums[i]结尾的最长递增子序列长度。 - 转移方程 :遍历
j < i,若nums[j] < nums[i],则dp[i] = max(dp[i], dp[j] + 1)。 - 初始化:每个位置至少为 1(自身构成子序列)。
- 遍历顺序 :外层
i从左到右,内层j遍历i之前的所有位置。 - 返回值 :
dp数组的最大值。
python
def length_of_lis(nums: list[int]) -> int:
n = len(nums)
if n == 0:
return 0
dp = [1] * n
for i in range(n):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
4.3 0-1 背包问题
题目 :有 n 个物品,每个物品有重量 w[i] 和价值 v[i],背包容量为 C,求能装入的最大价值。
思路拆解:
- 状态定义 :
dp[i][j]表示前i个物品放入容量为j的背包能获得的最大价值。 - 转移方程 :第
i个物品不选或选,取较大者:
text
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
- 初始化 :
dp[0][j] = 0。 - 遍历顺序:外层遍历物品,内层遍历容量。
- 返回值 :
dp[n][C]。
python
def knapsack(weights: list[int], values: list[int], capacity: int) -> int:
n = len(weights)
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(capacity + 1):
if j >= weights[i - 1]:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])
else:
dp[i][j] = dp[i - 1][j]
return dp[n][capacity]
5. 常见题型与套路总结
掌握基础后,可以按题型归类记忆套路:
| 题型 | 典型特征 | 常用状态 |
|---|---|---|
| 线性 DP | 一维数组、序列问题 | dp[i] |
| 区间 DP | 合并、切分区间 | dp[i][j] 表示区间 [i, j] |
| 背包 DP | 容量限制下的选择问题 | dp[i][j] 前 i 个物品容量 j |
| 状态压缩 DP | 集合、子集枚举 | 用二进制位表示状态 |
| 树形 DP | 树结构上的决策 | dp[node] 以节点为根的子树 |
6. 常见误区与优化技巧
6.1 常见误区
- 状态定义不清晰就急着写转移方程,导致越写越乱;
- 忽略边界条件,数组越界或初始值错误;
- 遍历顺序错误,依赖的状态还没算出来;
- 把动态规划和贪心混淆,贪心只保证局部最优,动态规划才保证全局最优。
6.2 空间优化
很多一维 DP 只依赖前一个或前两个状态,可以用滚动数组把空间从 O(n) 降到 O(1)。例如爬楼梯问题:
python
def climb_stairs_optimized(n: int) -> int:
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
prev2, prev1 = prev1, prev2 + prev1
return prev1
7. 总结
动态规划并不神秘,它只是一套「拆解问题、记录中间结果、避免重复计算」的思维工具。拿到题目后,按五步法走:定义状态 → 推导转移方程 → 初始化边界 → 确定遍历顺序 → 确定返回值,再配合大量练习,就能逐渐形成肌肉记忆。
建议从爬楼梯、打家劫舍、最长公共子序列等经典题入手,每道题都刻意用五步法写一遍思路,而不是直接抄代码。坚持一段时间,你会发现动态规划其实是最有规律可循的题型之一。