动态规划(Dynamic Programming,DP)是算法设计中最重要也最容易卡住的思想之一。它的核心不是"递归",而是用空间换时间,避免重复计算。下面从原理、识别方法、经典模型到代码实例完整梳理。
一、核心思想
动态规划解决的是多阶段决策问题:一个问题可以拆成若干重叠的子问题,每个子问题的解会被多次用到。DP 的做法是:
定义状态:用一个或多个变量描述"当前处于什么局面"。
找状态转移方程:当前状态如何由更小状态的解推导出来。
确定边界:最小规模子问题的解(递推起点)。
按顺序求解:自底向上填表,或自顶向下递归+记忆化。
一句话概括:DP = 递归 + 记忆化,或 = 状态 + 转移 + 边界 + 顺序。
二、两个前提条件(判断能不能用 DP)
条件 含义 反例
最优子结构 大问题的最优解由子问题的最优解构成 最长简单路径问题(子问题最优不保证全局最优)
重叠子问题 不同分支会反复求解同一个子问题 归并排序(子问题独立,用分治而非 DP)
分治 vs DP 的关键区别:分治的子问题相互独立(归并排序左右两半互不影响);DP 的子问题相互重叠(斐波那契、背包问题会反复算同一个值)。如果子问题重叠却用朴素递归,复杂度会指数爆炸。
三、两种实现方式
以斐波那契数列 f(n)=f(n-1)+f(n-2) 为例:
- 自顶向下(记忆化递归 / Memoization)
python
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n-1) + fib(n-2)
思路自然,但递归深度大时可能栈溢出。
- 自底向上(递推填表 / Tabulation)
python
def fib(n):
if n <= 1:
return n
dp = 0 * (n + 1)
dp0, dp1 = 0, 1
for i in range(2, n + 1):
dpi = dpi-1 + dpi-2
return dpn
无递归开销,还可以进一步把空间压到 O(1)(只保留前两个值)。面试和工程中优先自底向上。
四、DP 解题四步法
这是刷题时最实用的流程:
确定 dp 数组的含义:dpi 或 dpij 代表什么?(这一步最关键,定义错了后面全错)
推导状态转移方程:dpi = ?,即当前状态由哪些前驱状态得来。
初始化边界:dp0、dp1 或第一行第一列的值。
确定遍历顺序:保证计算 dpi 时它依赖的状态已经算好(一维通常从前向后,二维注意行列方向)。
五、经典模型与代码实例
模型 1:线性 DP------爬楼梯(斐波那契变体)
问题:每次可爬 1 或 2 阶,爬 n 阶有多少种方法?
状态:dpi = 爬到第 i 阶的方法数
转移:dpi = dpi-1 + dpi-2(最后一步要么跨 1 阶,要么跨 2 阶)
边界:dp0=1, dp1=1
python
def climb_stairs(n):
if n <= 1:
return 1
dp = 0 * (n + 1)
dp0, dp1 = 1, 1
for i in range(2, n + 1):
dpi = dpi-1 + dpi-2
return dpn
空间优化版:只需保留前两个状态
def climb_stairs_opt(n):
if n <= 1:
return 1
prev, cur = 1, 1
for _ in range(2, n + 1):
prev, cur = cur, prev + cur
return cur
复杂度:时间 O(n),空间 O(n) → 优化后 O(1)。
模型 2:0-1 背包------最经典的 DP
问题:有 n 件物品,第 i 件重量 wi、价值 vi,背包容量 C。每件物品只能选 0 或 1 次,求最大价值。
状态:dpij = 前 i 件物品、容量 j 时的最大价值
转移:
不选第 i 件:dpij = dpi-1j
选第 i 件(需 j >= wi):dpij = dpi-1j-w\[i] + vi
取两者较大值
边界:dp0j = 0(没有物品时价值为 0)
python
def knapsack_01(weights, values, capacity):
n = len(weights)
dpij: 前 i 件物品、容量 j 的最大价值
dp = \[0 * (capacity + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(capacity + 1):
# 不选第 i 件
dp[i][j] = dp[i-1][j]
# 选第 i 件(容量够的话)
if j >= weights[i-1]:
dp[i][j] = max(dp[i][j],
dp[i-1][j - weights[i-1]] + values[i-1])
return dp[n][capacity]
测试
weights = 2, 3, 4, 5
values = 3, 4, 5, 6
print(knapsack_01(weights, values, 8)) # 10
空间优化(一维滚动数组):注意到 dpi 只依赖 dpi-1,可以压成一维。关键点是容量必须倒序遍历,否则同一件物品会被重复选(变成完全背包)。
python
def knapsack_01_opt(weights, values, capacity):
dp = 0 * (capacity + 1)
for i in range(len(weights)):
for j in range(capacity, weightsi - 1, -1): # 倒序!
dpj = max(dpj, dpj - weights\[i] + valuesi)
return dpcapacity
记忆口诀:0-1 背包倒序(每件最多一次),完全背包正序(每件无限次)。
模型 3:最长递增子序列(LIS)
问题:求数组中最长严格递增子序列的长度(子序列不要求连续)。
状态:dpi = 以 numsi 结尾的最长递增子序列长度
转移:dpi = max(dpj) + 1,对所有 j < i 且 numsj < numsi
边界:每个 dpi 初始为 1(自己单独成序列)
python
def length_of_lis(nums):
if not nums:
return 0
n = len(nums)
dp = 1 * n
for i in range(n):
for j in range(i):
if numsj < numsi:
dpi = max(dpi, dpj + 1)
return max(dp)
测试
print(length_of_lis(10, 9, 2, 5, 3, 7, 101, 18)) # 4 (2,3,7,101)
复杂度 O(n²)。进阶有 O(n log n) 的贪心+二分解法(维护一个"最小结尾数组"),面试常考。
模型 4:最长公共子序列(LCS)
问题:求两个字符串的最长公共子序列长度。
状态:dpij = s1 前 i 个字符与 s2 前 j 个字符的 LCS 长度
转移:
若 s1i-1 == s2j-1:dpij = dpi-1j-1 + 1
否则:dpij = max(dpi-1j, dpij-1)
边界:dp0* = dp* 0 = 0
python
def lcs(s1, s2):
m, n = len(s1), len(s2)
dp = \[0 * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1i-1 == s2j-1:
dpij = dpi-1j-1 + 1
else:
dpij = max(dpi-1j, dpij-1)
return dpmn
测试
print(lcs("abcde", "ace")) # 3 ("ace")
模型 5:编辑距离(Levenshtein Distance)
问题:将 word1 变成 word2 最少需要多少次插入/删除/替换操作。
状态:dpij = word1 前 i 个字符转成 word2 前 j 个字符的最小操作数
转移:
字符相同:dpij = dpi-1j-1
不同:dpij = 1 + min(dpi-1j(删除), dpij-1(插入), dpi-1j-1(替换))
边界:dpi0 = i(删光),dp0j = j(插入 j 个)
python
def min_distance(word1, word2):
m, n = len(word1), len(word2)
dp = \[0 * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dpi0 = i
for j in range(n + 1):
dp0j = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1i-1 == word2j-1:
dpij = dpi-1j-1
else:
dpij = 1 + min(dpi-1j, # 删除
dpij-1, # 插入
dpi-1j-1) # 替换
return dpmn
测试
print(min_distance("horse", "ros")) # 3
模型 6:最大子数组和(Kadane 算法)
问题:求连续子数组的最大和。
状态:dpi = 以 numsi 结尾的最大子数组和
转移:dpi = max(numsi, dpi-1 + numsi)(要么接上前面,要么从自己重新开始)
边界:dp0 = nums0
python
def max_subarray(nums):
dp = nums0
ans = nums0
for x in nums1::
dp = max(x, dp + x) # 空间已优化为 O(1)
ans = max(ans, dp)
return ans
测试
print(max_subarray(-2, 1, -3, 4, -1, 2, 1, -5, 4)) # 6 (4,-1,2,1)
六、DP 题型识别速查表
题目特征 对应模型 状态维度
求方案数、路径数 计数型 DP 一维/二维
求最大/最小值 最优化 DP(背包类) 一维/二维
两个字符串比较 LCS / 编辑距离 二维 dpij
连续子数组/子串 Kadane / 滑动窗口 DP 一维(以 i 结尾)
子序列(不连续) LIS / LCS 一维/二维
选或不选、容量限制 背包问题 二维→一维
区间合并、矩阵链乘 区间 DP dpij 区间长度遍历
七、常见易错点
dp 数组含义定义模糊:是"以 i 结尾"还是"前 i 个"?两者转移方程完全不同(LIS 与 LCS 就是典型对比)。
遍历顺序错误:二维 DP 要看依赖方向;一维背包要区分正序/倒序。
边界初始化遗漏:dp0 或第一行第一列没赋初值,导致后续全错。
空间优化后忘记倒序:0-1 背包压成一维后必须倒序遍历容量。
把 DP 和贪心混淆:贪心每步取局部最优不回头,DP 会保留所有可能状态取全局最优(如零钱兑换,贪心可能无解)。