高级 #algorithm#dp

动态规划

动态规划(Dynamic Programming, DP)把问题分解为重叠子问题,通过"记忆化"避免重复计算——最优子结构 + 状态转移方程是 DP 的核心

🪜 爬楼梯——一个简单的例子

假设你要爬 10 级台阶。每次可以走 1 级或 2 级。问:爬到第 10 级,一共有多少种不同的走法?

暴力递归的思路:

  • 到第 10 级 = 从第 9 级走 1 级 + 从第 8 级走 2 级
  • 到第 9 级 = 从第 8 级走 1 级 + 从第 7 级走 2 级
  • ……
def climb_stairs(n):
    if n <= 1:
        return 1
    return climb_stairs(n-1) + climb_stairs(n-2)

# 但这样计算 climb_stairs(10) 时,climb_stairs(5) 被计算了无数次!

画一下调用树就会发现——大量的子问题被重复计算climb_stairs(5) 在计算 climb_stairs(10) 的过程中被算了无数次。

动态规划(Dynamic Programming, DP)就是来解决这个问题的:把子问题的答案”记住”(缓存),下次再用时直接取。

📝 **类比:期末考试复习”

你要复习 5 章内容。

暴力递归式复习:每次遇到一个知识点都从头推导一遍——“哦,这个要用到第一章的知识,我先把第一章看一遍”——结果每次都要重看第一章。

动态规划式复习:先把第一章学完,记在笔记本上。学第二章时,如果用到第一章的知识,直接翻笔记本。学第三章时也是如此。

关键区别:你用一个”笔记本”记住了已经算过的结果。这就是 DP 的核心——记忆化


🔑 DP 的两个关键特征

一个问题能用 DP 解决,必须满足两个条件:

① 最优子结构(Optimal Substructure)

大问题的最优解包含子问题的最优解。

  • 爬楼梯:到第 10 级的方法数 = 到第 9 级的方法数 + 到第 8 级的方法数
  • 最短路径:A→D 的最短路径 = min(A→B 最短 + B→D, A→C 最短 + C→D)
  • 背包问题:容量 W 的最大价值 = max(装物品 i 的方案, 不装物品 i 的方案)

② 重叠子问题(Overlapping Subproblems)

不同的子问题会重复出现。

  • 爬楼梯:climb(7) 在计算 climb(9)climb(8) 时都会用到
  • 斐波那契:fib(5) 在计算 fib(6)fib(7) 时都会用到
  • 如果子问题完全不重叠,分治就够了,不需要 DP

🛠️ DP 的两种实现方式

方式 1:自顶向下 + 记忆化(Memoization)

保持递归的结构,但把算过的结果存起来:

def fib_memo(n, memo=None):
    if memo is None:
        memo = {}
    
    if n in memo:          # 已经算过了 → 直接取
        return memo[n]
    if n <= 1:             # 基线条件
        return n
    
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]

方式 2:自底向上 + 填表(Tabulation)

从最小的子问题开始,逐步往上推理:

def fib_dp(n):
    if n <= 1:
        return n
    
    dp = [0] * (n + 1)
    dp[1] = 1
    
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]  # 状态转移方程
    
    return dp[n]

# 空间优化版——不需要数组,只用两个变量
def fib_dp_optimized(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

对比

维度自顶向下(记忆化)自底向上(填表)
思路递归 + 缓存迭代 + 表格
代码和原递归思路一致,改造成本低需要先设计 DP 表格
性能有递归开销通常更快(无函数调用)
空间可能少算不需要的子问题通常要填满整个表格
直觉”我需要什么就算什么""从小问题逐步构建到大问题”

📝 DP 解题四步法

案例:0/1 背包问题

有一个背包,容量为 W。有 n 个物品,每个物品有重量 weight[i] 和价值 value[i]。在不超过背包容量的前提下,如何装能让总价值最大?

第 1 步:定义状态

状态:dp[i][w] = 考虑前 i 个物品、背包容量为 w 时,能获得的最大价值。

第 2 步:找出状态转移方程

# 对于第 i 个物品,有两种选择:
# 1. 不装:dp[i][w] = dp[i-1][w](和前面 i-1 个物品的结果一样)
# 2. 装:dp[i][w] = dp[i-1][w-weight[i]] + value[i](腾出空间装上)
# 取两者中的较大值

dp[i][w] = max(dp[i-1][w], dp[i-1][w-weight[i]] + value[i])

第 3 步:确定初始条件

dp[0][w] = 0  # 0 个物品,价值为 0
dp[i][0] = 0  # 容量为 0,价值为 0

第 4 步:确定遍历顺序

def knapsack(weights, values, W):
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    
    for i in range(1, n + 1):
        for w in range(1, W + 1):
            if weights[i-1] <= w:
                # 可以装:选不装和装中价值更大的
                dp[i][w] = max(dp[i-1][w], 
                              dp[i-1][w-weights[i-1]] + values[i-1])
            else:
                # 装不下,只能不装
                dp[i][w] = dp[i-1][w]
    
    return dp[n][W]

空间优化(滚动数组)

def knapsack_optimized(weights, values, W):
    n = len(weights)
    dp = [0] * (W + 1)  # 一维就够了
    
    for i in range(n):
        for w in range(W, weights[i] - 1, -1):
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    
    return dp[W]

💡 为什么内层循环要倒序? 因为正序会重复使用同一个物品(变成完全背包),倒序保证每个物品只使用一次(0/1 背包)。


🧩 经典 DP 问题汇总

问题状态定义转移方程复杂度
斐波那契dp[i] = 第 i 个数dp[i] = dp[i-1] + dp[i-2]O(n)
爬楼梯dp[i] = 到第 i 阶的方法dp[i] = dp[i-1] + dp[i-2]O(n)
0/1 背包dp[i][w] = 前 i 个物品容量 w 的最大价值max(不装, 装)O(nW)
最长公共子序列dp[i][j] = text1[:i] 和 text2[:j] 的 LCS相等=dp[i-1][j-1]+1, 不等=max(左,上)O(mn)
最长递增子序列dp[i] = 以 nums[i] 结尾的 LIS 长度dp[i] = max(dp[j]+1) for j < iO(n²)
编辑距离dp[i][j] = word1[:i] → word2[:j] 的最小操作min(增, 删, 改)O(mn)

最长公共子序列(LCS)详解

def lcs(text1, text2):
    m, n = len(text1), len(text2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if text1[i-1] == text2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1  # 字符相等,LCS 长度 +1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])  # 取较大的
    
    return dp[m][n]

print(lcs("abcde", "ace"))  # 3 → "ace"

🤔 什么时候用 DP?

你的问题 → 是否具有"最优子结构"?
  ├─ 否 → 分治、贪心、或者暴力搜索
  └─ 是 → 子问题是否"重叠"?
       ├─ 否 → 分治就够了
       └─ 是 → 用 DP!

常见的 DP 信号

  • “求最大值/最小值”
  • “求有多少种方案”
  • “判断是否可行”
  • 问题可以被”划分”成更小的同类问题

📝 小结

概念一句话
动态规划(DP)把问题分解成重叠子问题,记住答案避免重复计算
最优子结构大问题的最优解由子问题最优解构成
重叠子问题同一个子问题被多次遇到
状态(State)DP 表中每个格子的定义——“什么情况下什么值”
状态转移方程状态之间的递推关系——“怎么从一个状态算到下一个”
记忆化(Memoization)自顶向下——递归 + 缓存
填表(Tabulation)自底向上——从小问题逐步构建

🎯 小练习:有一个 3×3 的网格,从左上角走到右下角,每次只能向右或向下走。有多少条不同的路径?提示:定义 dp[i][j] = 到达 (i,j) 的路径数。写出转移方程并计算。

为什么先学这个? DP 是最重要的算法设计思想之一。和它密切相关的是贪心算法——贪心是 DP 的”简化版”,每步只做局部最优选择。