动态规划入门 — 从记忆化到状态转移
动态规划入门 — 从记忆化到状态转移
一、动态规划到底是什么
动态规划(Dynamic Programming,简称 DP)这个名字容易让人误以为它和"动态"有什么关系。实际上,它的核心思想非常朴素:把一个大问题拆成若干个小问题,先解决小问题,再把小问题的答案组合起来解决大问题。
和贪心不同的是,DP 不着急做决定——它会耐心地把所有子问题的答案都算出来,存好,然后在需要时直接取用,不重复计算。
打个比方:你要从北京开车到上海,中间经过多个城市。贪心策略是"每到一座城市就选最近的那条路",DP 则是"先把所有路线的最短距离都算出来,最后从北京到上海的最短路线一定是某条子路线加上一段新路"。
DP 的三要素
- 状态定义:用什么量来描述子问题?比如
dp[i]表示"前 i 个元素的最优解",dp[i][j]表示"前 i 个物品放入容量 j 的背包的最优解"。 - 状态转移方程:大问题的答案怎么从小问题的答案推导出来?比如
dp[i] = max(dp[i-1], dp[i-2] + nums[i])。 - 初始条件(边界):最小的子问题怎么求解?比如
dp[0] = 0,dp[1] = nums[0]。
DP vs 贪心 vs 递归(再对比)
| 递归 | 贪心 | 动态规划 | |
|---|---|---|---|
| 子问题 | 独立递归求解 | 只看一步 | 先解所有子问题存表 |
| 重复计算 | 大量重复 | 无 | 查表避免重复 |
| 最优保证 | 不保证 | 需要证明贪心选择性质 | 依赖最优子结构 |
| 复杂度 | 可能指数级 | O(n log n) | O(n) ~ O(n²) |
| 适用场景 | 分治、树遍历 | 区间调度、MST | 背包、序列、路径 |
二、从递归到记忆化再到 DP
递归的痛点:重复计算
以斐波那契数列为例。fib(n) = fib(n-1) + fib(n-2),递归写法:
func fib(n int) int {
if n <= 1 {
return n
}
return fib(n-1) + fib(n-2)
}
这个写法时间复杂度 O(2ⁿ),因为 fib(5) 会算 fib(4)+fib(3),而 fib(4) 又会算 fib(3)+fib(2)——fib(3) 被重复计算了。
记忆化递归:加一个备忘录
func fibMemo(n int, memo map[int]int) int {
if n <= 1 {
return n
}
if v, ok := memo[n]; ok {
return v // 已经算过了,直接返回
}
memo[n] = fibMemo(n-1, memo) + fibMemo(n-2, memo)
return memo[n]
}
加上备忘录后,每个子问题只算一次,时间复杂度变成 O(n)。记忆化递归本质上就是 DP——只是用递归从上往下推,而标准 DP 是用循环从下往上填表。
标准 DP:从下往上填表
func fibDP(n int) int {
if n <= 1 {
return n
}
dp := make([]int, n+1)
dp[0] = 0
dp[1] = 1
for i := 2; i <= n; i++ {
dp[i] = dp[i-1] + dp[i-2]
}
return dp[n]
}
从 dp[0] 开始逐步填到 dp[n],每个位置只需要查前两个值,不需要递归调用栈。
总结:递归 → 记忆化(加缓存)→ DP(改为循环填表),这是从暴力到高效的三步进化。
三、经典问题一:0-1 背包
问题描述
有 N 个物品,每个物品有重量 wᵢ 和价值 vᵢ,背包容量为 W。每个物品只能选一次(0-1 选择),求背包能装的最大价值。
上一课学过分数背包可以用贪心,但 0-1 背包贪心失效(因为拿了一个物品后剩余容量的最优解和之前的选择强耦合),必须用 DP。
状态定义
dp[i][j]:从前 i 个物品中选,放入容量为 j 的背包,能获得的最大价值。
状态转移方程
考虑第 i 个物品:
- 不选:
dp[i][j] = dp[i-1][j](和没考虑第 i 个物品一样) - 选(前提 j ≥ wᵢ):
dp[i][j] = dp[i-1][j-wᵢ] + vᵢ(去掉这个物品的重量,加上这个物品的价值)
取两者较大值:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-wᵢ] + vᵢ) // 当 j ≥ wᵢ
dp[i][j] = dp[i-1][j] // 当 j < wᵢ(装不下)
边界:dp[0][j] = 0(没有物品可选),dp[i][0] = 0(容量为 0 装不了任何东西)
Go 实现
package main
import "fmt"
// Item 表示一个物品
type Item struct {
Name string
Weight int
Value int
}
// Knapsack01 解决 0-1 背包问题
func Knapsack01(items []Item, capacity int) (int, []string) {
n := len(items)
dp := make([][]int, n+1)
for i := range dp {
dp[i] = make([]int, capacity+1)
}
// 填表
for i := 1; i <= n; i++ {
w := items[i-1].Weight
v := items[i-1].Value
for j := 0; j <= capacity; j++ {
dp[i][j] = dp[i-1][j] // 不选当前物品
if j >= w && dp[i-1][j-w]+v > dp[i][j] {
dp[i][j] = dp[i-1][j-w] + v // 选当前物品
}
}
}
// 回溯找出选了哪些物品
selected := []string{}
j := capacity
for i := n; i >= 1; i-- {
if dp[i][j] != dp[i-1][j] {
selected = append(selected, items[i-1].Name)
j -= items[i-1].Weight
}
}
// 反转列表(回溯是从后往前)
for l, r := 0, len(selected)-1; l < r; l, r = l+1, r-1 {
selected[l], selected[r] = selected[r], selected[l]
}
return dp[n][capacity], selected
}
func main() {
items := []Item{
{"金条", 10, 60},
{"银块", 20, 100},
{"铜锭", 30, 120},
}
capacity := 50
maxValue, selected := Knapsack01(items, capacity)
fmt.Printf("背包容量: %d\n", capacity)
fmt.Printf("最大价值: %d\n", maxValue)
fmt.Printf("选择的物品: %v\n", selected)
}
运行输出(预期):
背包容量: 50
最大价值: 220
选择的物品: [银块 铜锭]
这正好验证了上一课的分析——贪心在这个场景下只能得到 160,而 DP 得到了 220 的最优解。
空间优化:一维 DP
注意到 dp[i][j] 只依赖上一行 dp[i-1][...],所以可以把二维表压缩成一维。关键:j 需要从大到小遍历,否则会覆盖上一行的值。
func Knapsack01Optimized(items []Item, capacity int) int {
dp := make([]int, capacity+1)
for _, item := range items {
// 从大到小遍历,保证 dp[j-w] 还是上一轮的值
for j := capacity; j >= item.Weight; j-- {
if dp[j-item.Weight]+item.Value > dp[j] {
dp[j] = dp[j-item.Weight] + item.Value
}
}
}
return dp[capacity]
}
四、经典问题二:最长公共子序列(LCS)
问题描述
给定两个字符串 text1 和 text2,找出它们最长的公共子序列的长度。子序列不要求连续,但要求保持相对顺序。
比如 "ABCBDAB" 和 "BDCAB" 的最长公共子序列是 "BCAB",长度 4。
状态定义
dp[i][j]:text1 前 i 个字符与 text2 前 j 个字符的最长公共子序列长度。
状态转移方程
- 如果
text1[i-1] == text2[j-1]:两个字符匹配,LCS 长度加 1。
dp[i][j] = dp[i-1][j-1] + 1 - 如果
text1[i-1] != text2[j-1]:两个字符不匹配,LCS 长度取两种情况的最大值。
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
边界:dp[0][j] = 0,dp[i][0] = 0(空串与任何串的 LCS 为 0)
Go 实现
package main
import "fmt"
// LCS 计算最长公共子序列长度
func LCS(text1, text2 string) int {
m, n := len(text1), len(text2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if text1[i-1] == text2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else {
// 取上方或左方的较大值
if dp[i-1][j] > dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
}
return dp[m][n]
}
// LCSString 回溯找出具体的公共子序列
func LCSString(text1, text2 string) string {
m, n := len(text1), len(text2)
dp := make([][]int, m+1)
for i := range dp {
dp[i] = make([]int, n+1)
}
for i := 1; i <= m; i++ {
for j := 1; j <= n; j++ {
if text1[i-1] == text2[j-1] {
dp[i][j] = dp[i-1][j-1] + 1
} else if dp[i-1][j] > dp[i][j-1] {
dp[i][j] = dp[i-1][j]
} else {
dp[i][j] = dp[i][j-1]
}
}
}
// 回溯构造子序列
result := []byte{}
i, j := m, n
while := func() bool { return i > 0 && j > 0 }
for while() {
if text1[i-1] == text2[j-1] {
result = append(result, text1[i-1])
i--
j--
} else if dp[i-1][j] > dp[i][j-1] {
i--
} else {
j--
}
}
// 反转(回溯是从末尾往前拼的)
for l, r := 0, len(result)-1; l < r; l, r = l+1, r-1 {
result[l], result[r] = result[r], result[l]
}
return string(result)
}
func main() {
s1 := "ABCBDAB"
s2 := "BDCAB"
length := LCS(s1, s2)
subseq := LCSString(s1, s2)
fmt.Printf("text1: %s\n", s1)
fmt.Printf("text2: %s\n", s2)
fmt.Printf("LCS 长度: %d\n", length)
fmt.Printf("LCS 内容: %s\n", subseq)
}
运行输出(预期):
text1: ABCBDAB
text2: BDCAB
LCS 长度: 4
LCS 内容: BCAB
LCS 的应用
- diff 工具:Git 比较两个版本的代码差异,底层就是 LCS
- DNA 序列比对:比较两个基因序列的相似性
- 拼写检查:比较输入词和词典中的词,找出最接近的
五、DP 解题的通用思路
- 定义状态:想清楚用什么量描述子问题(一维 dp[i]?二维 dp[i][j]?)
- 找转移方程:大问题的答案怎么从已有答案推出?
- 定边界条件:最小的子问题怎么初始化?
- 确定遍历顺序:填表时,从哪个方向走?一般是从左到右、从上到下
- 考虑空间优化:如果 dp[i] 只依赖 dp[i-1],可以压缩维度
什么时候该用 DP
- 问题有重叠子问题(递归会重复计算同样的子问题)
- 问题有最优子结构(大问题的最优解由子问题的最优解构成)
- 不满足贪心选择性质(局部最优不足以推导全局最优)
六、本章小结
- 动态规划 = 记忆化 + 最优子结构 + 递推填表
- 从递归 → 记忆化 → DP 是三步进化,核心都是避免重复计算
- 0-1 背包:二维 dp[i][j],状态转移
max(不选, 选),可压缩为一维 - LCS:二维 dp[i][j],字符匹配时对角线+1,不匹配时取上方/左方最大值
- DP 解题五步:定义状态 → 转移方程 → 边界条件 → 遍历顺序 → 空间优化

浙公网安备 33010602011771号