AIGC标识 动态规划入门 — 从记忆化到状态转移

动态规划入门 — 从记忆化到状态转移


一、动态规划到底是什么

动态规划(Dynamic Programming,简称 DP)这个名字容易让人误以为它和"动态"有什么关系。实际上,它的核心思想非常朴素:把一个大问题拆成若干个小问题,先解决小问题,再把小问题的答案组合起来解决大问题

和贪心不同的是,DP 不着急做决定——它会耐心地把所有子问题的答案都算出来,存好,然后在需要时直接取用,不重复计算。

打个比方:你要从北京开车到上海,中间经过多个城市。贪心策略是"每到一座城市就选最近的那条路",DP 则是"先把所有路线的最短距离都算出来,最后从北京到上海的最短路线一定是某条子路线加上一段新路"。

DP 的三要素

  1. 状态定义:用什么量来描述子问题?比如 dp[i] 表示"前 i 个元素的最优解",dp[i][j] 表示"前 i 个物品放入容量 j 的背包的最优解"。
  2. 状态转移方程:大问题的答案怎么从小问题的答案推导出来?比如 dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  3. 初始条件(边界):最小的子问题怎么求解?比如 dp[0] = 0dp[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] = 0dp[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 解题的通用思路

  1. 定义状态:想清楚用什么量描述子问题(一维 dp[i]?二维 dp[i][j]?)
  2. 找转移方程:大问题的答案怎么从已有答案推出?
  3. 定边界条件:最小的子问题怎么初始化?
  4. 确定遍历顺序:填表时,从哪个方向走?一般是从左到右、从上到下
  5. 考虑空间优化:如果 dp[i] 只依赖 dp[i-1],可以压缩维度

什么时候该用 DP

  • 问题有重叠子问题(递归会重复计算同样的子问题)
  • 问题有最优子结构(大问题的最优解由子问题的最优解构成)
  • 不满足贪心选择性质(局部最优不足以推导全局最优)

六、本章小结

  • 动态规划 = 记忆化 + 最优子结构 + 递推填表
  • 从递归 → 记忆化 → DP 是三步进化,核心都是避免重复计算
  • 0-1 背包:二维 dp[i][j],状态转移 max(不选, 选),可压缩为一维
  • LCS:二维 dp[i][j],字符匹配时对角线+1,不匹配时取上方/左方最大值
  • DP 解题五步:定义状态 → 转移方程 → 边界条件 → 遍历顺序 → 空间优化
posted @ 2026-08-03 09:21  FfHUCisI  阅读(4)  评论(0)    收藏  举报