动态规划与回溯
DP 是"有重叠子问题的递归 + 记答案",回溯是"暴力枚举 + 撤销"。前者难在想通状态与转移,后者难在剪枝与去重。
一、动态规划思想
1.1 什么题目用 DP(识别特征)
- 求最优值/方案数/可行性(最大、最小、多少种、能否达成)而不是求具体路径。
- 问题能分解成子问题,且子问题被重复计算(重叠子问题)。
- 当前决策只依赖若干"已算好的子状态"(最优子结构)。
1.2 重叠子问题与最优子结构
- 重叠子问题:朴素递归会重复算同一子问题 → 用表记答案避免重复(斐波那契最典型)。
- 最优子结构:全局最优能由子问题最优拼出来(最短路径是;"最长简单路径"不是)。
1.3 自顶向下 vs 自底向上
| 方向 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 自顶向下 | 递归 + 备忘录(记忆化搜索) | 只算用到的状态,代码贴近递推式 | 递归深、有栈溢出风险 |
| 自底向上 | 迭代填表(递推) | 无递归开销、可滚动数组 | 可能算无用状态 |
二、DP 五步法
2.1 第一步:定义状态
- 用几个维度刻画"我们正在解决哪个子问题"。例:
dp[i]表示爬到第 i 级台阶的方案数。 - 状态维度 = 影响答案的独立变量个数(一维/二维/三维)。维度越多空间越大,先想能否压缩。
2.2 第二步:写转移方程
- 核心一步:
dp[i]如何由更小的dp[...]拼出来。用"最后一步"思考法——问题最后一次决策有几种选择。
爬楼梯:最后一步要么从 i-1 迈 1 级,要么从 i-2 迈 2 级
→ dp[i] = dp[i-1] + dp[i-2]爬楼梯:最后一步要么从 i-1 迈 1 级,要么从 i-2 迈 2 级
→ dp[i] = dp[i-1] + dp[i-2]2.3 第三步:初始化
- 找"边界状态",它们无法由转移得到,需要手工赋值(如
dp[0]、dp[1])。 - 常见哨兵技巧:多开一格/多开一行(如
dp = [[0]*(m+1) for _ in range(n+1)]),免去大量越界分支。
2.4 第四步:确定遍历顺序
- 原则:计算 dp[i] 时,它依赖的状态必须先算好。
- 一维常见正序;依赖左侧两个的也是正序;依赖"更小规模"的多维要考虑先遍历哪个维度。
- 滚动数组后要特别注意顺序:0-1 背包倒序、完全背包正序(见 4.2)。
2.5 第五步:举例验证
- 拿小例子(如 n=4)手推 dp 表,确认转移、初始化、遍历顺序都对,防止「样例过、边界挂」。
2.6 五步法模板
python
# 以爬楼梯为例的通用框架
def solve(n):
# 1 状态:dp[i]
dp = [0] * (n + 1)
# 2 转移:dp[i] = dp[i-1] + dp[i-2]
# 3 初始化
dp[0] = dp[1] = 1
# 4 遍历顺序
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
# 5 返回目标状态
return dp[n]# 以爬楼梯为例的通用框架
def solve(n):
# 1 状态:dp[i]
dp = [0] * (n + 1)
# 2 转移:dp[i] = dp[i-1] + dp[i-2]
# 3 初始化
dp[0] = dp[1] = 1
# 4 遍历顺序
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
# 5 返回目标状态
return dp[n]三、经典题拆解一:爬楼梯
3.1 分析
- 状态:
dp[i]= 到第 i 级有几种走法;转移:dp[i]=dp[i-1]+dp[i-2];初始化dp[0]=1, dp[1]=1。 - 滚动优化:只需保留前两个值,空间 O(1)。
- 变体题感:每次可走 1/2/3 步 → 三项和;不能连续走 2 步 → 状态加一维记录"上一步怎么走的"。
3.2 变体:最小花费爬楼梯
- 状态:
cost[i]从第 i 级出发的最小总花费;dp[i] = min(dp[i-1], dp[i-2]) + cost[i]。 - 与"方案数"的区别:转移是 min/max 而不是求和 → 求最优值用 min/max,求方案数用累加。
四、经典题拆解二:0-1 背包
4.1 状态与转移
- 问题:n 个物品,重量 w[i]、价值 v[i],容量 W,每件最多拿一次,求最大价值。
- 状态:
dp[i][j]= 前 i 个物品、容量 j 的最大价值。 - 转移(拿或不拿):
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])。
python
def knapsack(n, W, w, v):
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(W + 1):
if j < w[i - 1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1])
return dp[n][W]def knapsack(n, W, w, v):
dp = [[0] * (W + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(W + 1):
if j < w[i - 1]:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1])
return dp[n][W]4.2 二维 → 一维滚动数组
- 每行只依赖上一行 → 压成一维
dp[j],但容量 j 要倒序遍历,避免本轮物品被重复取。
python
def knapsack_1d(n, W, w, v):
dp = [0] * (W + 1)
for i in range(n):
for j in range(W, w[i] - 1, -1): # 关键:倒序
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
return dp[W]def knapsack_1d(n, W, w, v):
dp = [0] * (W + 1)
for i in range(n):
for j in range(W, w[i] - 1, -1): # 关键:倒序
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
return dp[W]- 判断是否 0-1:倒序;完全背包(无限件):正序。
4.3 变体:完全背包 / 组合数
- 完全背包:物品无限,容量正序遍历即可。
- 求"恰好装满":初始化
dp[0]=0,其余为-inf。 - 求方案数:
dp[j] += dp[j-w[i]];若还要求"不考虑顺序(组合)"先遍历物品、"考虑顺序(排列)"先遍历容量。
五、经典题拆解三:最长递增子序列 LIS
5.1 O(n²) DP
- 状态:
dp[i]= 以a[i]结尾的最长递增子序列长度。 - 转移:
dp[i] = max(dp[j] + 1),其中j < i 且 a[j] < a[i];答案 = max(dp)。
python
def lis(a):
n = len(a)
dp = [1] * n
for i in range(n):
for j in range(i):
if a[j] < a[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)def lis(a):
n = len(a)
dp = [1] * n
for i in range(n):
for j in range(i):
if a[j] < a[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp, default=0)5.2 贪心 + 二分 O(n log n)(延伸)
- 维护
tails:长度为 k 的递增子序列的最小结尾;新元素在 tails 上二分找第一个 ≥ 它的位置并替换。 - 只能求长度,不能还原具体子序列;面试先答 O(n²),再提可优化到 O(n log n)。
六、经典题拆解四:编辑距离
6.1 状态定义
- 把 word1 前 i 个字符变成 word2 前 j 个字符的最少操作数:
dp[i][j]。 - 操作:插入、删除、替换,各算 1 次。
6.2 转移与初始化
if word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] # 相同则不动
else: dp[i][j] = min(
dp[i-1][j] + 1, # 删除 word1[i-1]
dp[i][j-1] + 1, # 插入 word2[j-1]
dp[i-1][j-1] + 1 # 替换
)
初始化:dp[i][0] = i(全删)、dp[0][j] = j(全插)if word1[i-1] == word2[j-1]: dp[i][j] = dp[i-1][j-1] # 相同则不动
else: dp[i][j] = min(
dp[i-1][j] + 1, # 删除 word1[i-1]
dp[i][j-1] + 1, # 插入 word2[j-1]
dp[i-1][j-1] + 1 # 替换
)
初始化:dp[i][0] = i(全删)、dp[0][j] = j(全插)- 复杂度 O(n·m);滚动数组可压到 O(m) 空间。
- 同族题:最长公共子序列 LCS(转移只差在不等时取 max 而非 min)。
七、记忆化搜索 vs 递推
7.1 记忆化搜索写法(递归 + 备忘录)
python
from functools import lru_cache
@lru_cache(None)
def climb(i):
if i <= 1: return 1
return climb(i - 1) + climb(i - 2)from functools import lru_cache
@lru_cache(None)
def climb(i):
if i <= 1: return 1
return climb(i - 1) + climb(i - 2)7.2 两者对比
| 维度 | 记忆化搜索(自顶向下) | 递推(自底向上) |
|---|---|---|
| 代码可读性 | 贴近自然递归、易写 | 需想清填表顺序 |
| 计算范围 | 只算被依赖的状态 | 可能算全部状态 |
| 递归栈 | 有(深了溢出) | 无 |
| 适用 | 状态复杂/转移难枚举 | 常规题目首选 |
7.3 如何选择
- 二维及以上、转移需要枚举子集、状态"稀疏" → 记忆化更省心。
- 一维简单题、要求极致性能 → 递推 + 滚动数组。
八、回溯算法框架
8.1 回溯本质(决策树遍历)
- 回溯 = DFS + 撤销:在决策树上做深度优先搜索,走不通/收集到答案就回退。
- 与 DP 区别:回溯是"穷举所有路径",DP 是"合并重复子问题"。
全排列决策树(前两层):
[]
/ | \
1 2 3 ← 第一层选谁
/ \ ...
2 3 ← 第二层在剩余里选全排列决策树(前两层):
[]
/ | \
1 2 3 ← 第一层选谁
/ \ ...
2 3 ← 第二层在剩余里选8.2 框架模板(选择 / 撤销)
python
def backtrack(路径, 选择列表):
if 满足结束条件:
收集答案(路径) # 注意深拷贝
return
for 选择 in 选择列表:
做选择 # 加入路径 / 标记 used
backtrack(路径, 新的选择列表)
撤销选择 # 移除路径 / 清除 useddef backtrack(路径, 选择列表):
if 满足结束条件:
收集答案(路径) # 注意深拷贝
return
for 选择 in 选择列表:
做选择 # 加入路径 / 标记 used
backtrack(路径, 新的选择列表)
撤销选择 # 移除路径 / 清除 used- 三个要点:路径记录已做选择;选择列表决定下一步可选;撤销保证不同分支互不影响。
8.3 剪枝
- 在进入分支前判断"该分支不可能产生答案/更优答案"就跳过,能大幅提速。
- 常见剪枝:排序后跳过重复元素、当前和已超目标直接 return、可行性/上界估计(分支限界)。
九、回溯经典题
9.1 全排列
- 用
used布尔数组标记已选元素;每层在未用元素里选一个。
python
def permute(nums):
res, n = [], len(nums)
used = [False] * n
def dfs(path):
if len(path) == n:
res.append(path[:]); return
for i in range(n):
if used[i]: continue
used[i] = True
path.append(nums[i])
dfs(path)
path.pop()
used[i] = False
dfs([])
return resdef permute(nums):
res, n = [], len(nums)
used = [False] * n
def dfs(path):
if len(path) == n:
res.append(path[:]); return
for i in range(n):
if used[i]: continue
used[i] = True
path.append(nums[i])
dfs(path)
path.pop()
used[i] = False
dfs([])
return res9.2 组合 / 组合总和
- 组合用"起点下标"避免回头(
for i in range(start, n)),保证不重复、顺序无关。 - 组合总和 II(元素可重且每个只能用一次):先排序,同一层跳过与上一个相同的元素。
9.3 子集
- 每个元素"选/不选"两个分支;或按起点枚举长度。模板与组合几乎一致。
9.4 N 皇后
- 逐行放皇后,用三个集合记录"列、主对角线、副对角线"是否被占。
- 主对角线特征
row - col恒定,副对角线row + col恒定 → O(1) 判冲突。 - 时间 O(n!),剪枝后实际远小于;空间 O(n)。
9.5 去重套路总结
| 场景 | 手段 |
|---|---|
| 排列中元素位置去重(含重复元素的全排列) | used + 同层 if i>0 and nums[i]==nums[i-1] and not used[i-1]: continue |
| 组合中避免同一值同层重复选 | 排序 + 同层跳过 nums[i] == nums[i-1] |
| 防止顺序无关的重复路径 | 用 start 起点约束不回头 |
| 收集结果类型 | 子集/排列类题目注意 path[:] 深拷贝 |
十、贪心 vs DP
10.1 贪心的适用
- 贪心:每一步做当前看起来最优的局部选择,期望得到全局最优。
- 能用贪心的题有"贪心选择性质"(局部最优即全局最优),如:区间调度(按结束时间排序)、找零(硬币面额成倍数关系)、跳跃游戏、加油站。
- 贪心通常 O(n log n)(排序占大头),比 DP 快,但证明难、容易错。
10.2 对比表
| 维度 | 贪心 | 动态规划 |
|---|---|---|
| 决策方式 | 只看当前局部最优 | 考察所有子问题并取最优 |
| 是否需要"后悔" | 不需要 | 需要(保留多个候选) |
| 正确性证明 | 必须(贪心选择性质+最优子结构) | 转移方程正确即可 |
| 复杂度 | 常 O(n log n) | 常 O(n²) 或更高 |
| 典型 | 区间调度、Huffman | 背包、编辑距离 |
10.3 常见"伪贪心"陷阱
- 0-1 背包不能用"单位价值从高到低"贪(容量碎片会浪费);必须 DP。
- 找零
[1,3,4]凑 6:贪心 4+1+1=3 枚,最优 3+3=2 枚 → 面额不成倍数时贪心失效。 - 面试建议:能证明就贪心;证不了但有明显子结构就上 DP,先保证正确再谈优化。
Cao Note