动态规划:为什么暴力算法会有重复子问题
第一步:先明确 “子问题” 和 “重复子问题” 的定义
在算法中,“子问题” 不是泛指 “小一点的问题”,而是具有明确 “状态参数” 的、可独立求解的问题单元。
- 状态参数:描述子问题核心信息的变量(比如 01 背包中的 “剩余物品范围” 和 “剩余背包容量”,斐波那契中的 “第 n 项”)。
- 重复子问题:若两个子问题的 “所有状态参数完全相同”,则它们是重复子问题 —— 意味着这两个子问题的解完全一致,无需重复计算。
第二步:以 01 背包为例,拆解暴力递归的重复子问题
01 背包的暴力解法是 “递归枚举每个物品的‘选 / 不选’”,我们通过具体路径分析重复子问题的产生过程。
1. 01 背包的暴力递归逻辑回顾
暴力递归函数定义:dfs(i, c)
= 考虑前i+1
个物品(0~i
)、剩余容量c
时的最大价值。
对每个物品i
,有两种选择:
- 不选:递归调用
dfs(i-1, c)
; - 选(若
w[i] ≤ c
):递归调用v[i] + dfs(i-1, c - w[i])
。
2. 暴力枚举的 “路径冗余” 导致重复子问题
假设我们有 3 个物品:w = [2,3,4],v = [3,4,5]
,背包容量C=7
。我们分析 “计算dfs(2, 7)
(前 3 个物品,容量 7)” 时的路径:
为了计算dfs(2,7)
,需要先计算两个子问题:
- 不选物品 2(重量 4):需计算
dfs(1,7)
(前 2 个物品,容量 7); - 选物品 2(重量 4):需计算4 ≤7,因此需计算
5 + dfs(1, 7-4)=5 + dfs(1,3)
(前 2 个物品,容量 3)。
接下来计算dfs(1,7)
(前 2 个物品,容量 7):
dfs(1,7)
又依赖两个子问题:
- 不选物品 1(重量 3):需计算
dfs(0,7)
(前 1 个物品,容量 7); - 选物品 1(重量 3):需计算
4 + dfs(0, 7-3)=4 + dfs(0,4)
(前 1 个物品,容量 4)。
再计算dfs(1,3)
(前 2 个物品,容量 3):
dfs(1,3)
也依赖两个子问题:
- 不选物品 1(重量 3):需计算
dfs(0,3)
(前 1 个物品,容量 3); - 选物品 1(重量 3):需计算
4 + dfs(0, 3-3)=4 + dfs(0,0)
(前 1 个物品,容量 0)。
现在重点来了:
如果我们继续枚举其他路径(比如 “选物品 0、不选物品 1” 和 “不选物品 0、选物品 1”),会发现:
计算dfs(0,7)
、dfs(0,4)
、dfs(0,3)
、dfs(0,0)
这些子问题时,它们的状态参数(i=0
+ 不同c
)会在多个不同的 “选 / 不选” 组合路径中反复出现。
比如dfs(0,3)
(前 1 个物品,容量 3):
- 路径 1:不选物品 2 → 不选物品 1 → 处理物品 0(此时状态是i=0, c=3);
- 路径 2:选物品 2 → 不选物品 1 → 处理物品 0(此时状态也是i=0, c=3)。
这两条完全不同的路径,最终指向了同一个子问题(i=0, c=3),暴力解法会对这个子问题重复计算两次 —— 这就是重复子问题的根源。
第三步:再看斐波那契,理解 “指数级重复” 的本质
斐波那契的暴力递归(无记忆化)是 “重复子问题” 的极端案例,能更直观看到 “重复计算的爆炸式增长”。
1. 斐波那契的暴力递归逻辑
fib(n) = fib(n-1) + fib(n-2)
,终止条件fib(0)=0
,fib(1)=1
。
2. 递归树中的重复子问题
以fib(5)
为例,其递归树如下:
fib(5)
├─ fib(4)
│ ├─ fib(3)
│ │ ├─ fib(2)
│ │ │ ├─ fib(1) # 重复计算
│ │ │ └─ fib(0) # 重复计算
│ │ └─ fib(1) # 重复计算
│ └─ fib(2)
│ ├─ fib(1) # 重复计算
│ └─ fib(0) # 重复计算
└─ fib(3)├─ fib(2)│ ├─ fib(1) # 重复计算│ └─ fib(0) # 重复计算└─ fib(1) # 重复计算
可以看到:
fib(3)
被计算了 2 次,fib(2)
被计算了 3 次,fib(1)
被计算了 5 次 —— 子问题的重复次数随n
呈指数级增长。- 暴力解法没有记录这些子问题的解,每次遇到
fib(k)
都要重新递归到底,导致时间复杂度达到O(2^n)
。
第四步:总结暴力算法产生重复子问题的核心原因
暴力算法(尤其是递归枚举类)之所以会有重复子问题,本质是 **“枚举路径的冗余” 与 “子问题状态的共享” 之间的矛盾 **:
- 枚举路径的冗余:暴力解法为了覆盖 “所有可能的解”,会枚举大量不同的选择路径(比如 01 背包的 “选 / 不选” 组合、斐波那契的 “先算 n-1 还是 n-2”)。这些路径看似不同,但在 “逐步拆解为子问题” 的过程中,会不可避免地交汇到相同的状态。
- 子问题状态的共享:子问题的解只由 “状态参数” 决定(与到达该状态的路径无关)。比如 01 背包的dfs(i,c),无论通过 “选 A 不选 B” 还是 “不选 A 选 B” 到达,解都是相同的 —— 但暴力解法没有利用这种 “共享性”,而是对每条路径上的相同状态重复计算。
关键对比:为什么分治算法(如归并排序)没有重复子问题?
很多人会疑惑:“分治也拆分子问题,为什么没有重复子问题?”—— 这能帮我们进一步理解本质:
分治算法(如归并排序、快速排序)的子问题是 “不重叠、无共享” 的。比如归并排序将数组拆分为 “左半部分” 和 “右半部分”,左半部分的子问题和右半部分的子问题完全独立(状态参数无交集),不会出现 “同一个状态被两个不同子问题依赖” 的情况,因此无需处理重复子问题。
而暴力递归(如 01 背包、斐波那契)的子问题是 “重叠、共享” 的 —— 不同的父问题可能依赖同一个子问题,这才导致了重复计算。
最终结论
暴力算法的核心目标是 “枚举所有可能解”,但为了覆盖所有解,它会产生大量 “路径冗余”;而子问题的解只由 “状态参数” 决定(与路径无关),这就导致不同路径会反复遇到相同的子问题 —— 这就是暴力算法存在重复子问题的根本原因。
而动态规划(DP)的核心价值,正是通过 “记忆化” 或 “递推表” 记录这些重复子问题的解,让每个子问题只计算一次,从而将时间复杂度从暴力的 “指数级” 降到 “多项式级”。