10 · 动态规划

来源: labuladong DP 四步法 + 代码随想录 DP 五部曲 + 灵茶山艾府 DP 分类
核心价值: DP 是带记忆化的穷举,是算法面试中的”区分题”
题量: 15 题(含多维 DP,Hot 100 中占比最大的算法思想)


一、本质理解

labuladong 的精辟定义:

动态规划 = 穷举 + 记忆化

DP 的两个核心问题

问题说明解决方式
如何穷举不重不漏地遍历所有可能的解状态转移方程
如何聪明地穷举避免重复计算备忘录(memo)/ DP 表

DP 适合的问题特征

特征说明反例
最优子结构子问题的最优解能推导出原问题的最优解走迷宫(需要全局信息)
重叠子问题子问题被重复计算斐波那契数列
无后效性子问题的解一旦确定,不受后续决策影响股票问题(需要状态维度)

二、方法论

labuladong DP 四步法

第一步:确定 base case(最简单情况下的答案)
第二步:确定状态(原问题和子问题中变化的量)
第三步:确定选择(导致状态变化的操作)
第四步:明确 dp 数组/函数的含义

代码随想录 DP 五部曲

第一步:确定 dp 数组以及下标的含义
第二步:确定递推公式(状态转移方程)
第三步:dp 数组如何初始化(base case)
第四步:确定遍历顺序
第五步:举例推导 dp 数组(手动验证)

两种实现方式

# 方式一:自顶向下(带备忘录的递归)→ 更适合思考
memo = {}
def dp(state):
    if state in memo:
        return memo[state]
    if base_case:
        return base_val
    # 做选择
    res = min/max(选择1, 选择2, ...)
    memo[state] = res
    return res
 
# 方式二:自底向上(迭代)→ 更高效
dp = [base_val] * (n + 1)
for i in range(1, n + 1):
    for choice in choices:
        dp[i] = min/max(dp[i], dp[i - choice] + cost)
return dp[n]

三、DP 常见模型

模型 1:线性 DP(一维)

# 爬楼梯 —— 最基础的 DP
def climb_stairs(n):
    if n <= 2:
        return n
    dp = [0] * (n + 1)
    dp[1], dp[2] = 1, 2
    for i in range(3, n + 1):
        dp[i] = dp[i - 1] + dp[i - 2]
    return dp[n]
 
# 空间优化
def climb_stairs_optimized(n):
    if n <= 2:
        return n
    a, b = 1, 2
    for _ in range(3, n + 1):
        a, b = b, a + b
    return b

模型 2:打家劫舍(选或不选)

# dp[i] = max(dp[i-1], dp[i-2] + nums[i])
def rob(nums):
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    dp = [0] * len(nums)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
    return dp[-1]
 
# 空间优化版
def rob_optimized(nums):
    prev2, prev1 = 0, 0
    for num in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + num)
    return prev1

模型 3:背包 DP

# 0-1 背包:dp[j] = max(dp[j], dp[j - w] + v)  倒序遍历!
def knapsack_01(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for w, v in zip(weights, values):
        for j in range(capacity, w - 1, -1):  # 倒序!
            dp[j] = max(dp[j], dp[j - w] + v)
    return dp[capacity]
 
# 完全背包:dp[j] = max(dp[j], dp[j - w] + v)  正序遍历!
def knapsack_complete(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for w, v in zip(weights, values):
        for j in range(w, capacity + 1):  # 正序!
            dp[j] = max(dp[j], dp[j - w] + v)
    return dp[capacity]

零钱兑换(完全背包):

def coin_change(coins, amount):
    dp = [amount + 1] * (amount + 1)
    dp[0] = 0
    for coin in coins:
        for j in range(coin, amount + 1):
            dp[j] = min(dp[j], dp[j - coin] + 1)
    return dp[amount] if dp[amount] != amount + 1 else -1

模型 4:子序列 DP

# 最长递增子序列 LIS —— O(n^2)
def length_of_lis(nums):
    if not nums:
        return 0
    dp = [1] * len(nums)
    for i in range(len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)
 
# 进阶:贪心 + 二分 O(n log n)
def length_of_lis_optimized(nums):
    tails = []
    for num in nums:
        idx = bisect_left(tails, num)
        if idx == len(tails):
            tails.append(num)
        else:
            tails[idx] = num
    return len(tails)
# 最长公共子序列 LCS —— 二维 DP
def longest_common_subsequence(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
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
    
    return dp[m][n]

模型 5:编辑距离

def edit_distance(word1, word2):
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # 初始化边界
    for i in range(m + 1):
        dp[i][0] = i  # 删除 i 次
    for j in range(n + 1):
        dp[0][j] = j  # 插入 j 次
    
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(
                    dp[i - 1][j],      # 删除 word1[i]
                    dp[i][j - 1],      # 插入 word2[j]
                    dp[i - 1][j - 1]   # 替换
                )
    
    return dp[m][n]

模型 6:路径 DP

# 不同路径
def unique_paths(m, n):
    dp = [[1] * n for _ in range(m)]
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
    return dp[m - 1][n - 1]
 
# 最小路径和
def min_path_sum(grid):
    m, n = len(grid), len(grid[0])
    dp = [[0] * n for _ in range(m)]
    dp[0][0] = grid[0][0]
    # 初始化边界
    for i in range(1, m):
        dp[i][0] = dp[i - 1][0] + grid[i][0]
    for j in range(1, n):
        dp[0][j] = dp[0][j - 1] + grid[0][j]
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])
    return dp[m - 1][n - 1]

四、遍历顺序速查

背包类型外层循环内层循环遍历方向
0-1 背包物品容量容量倒序
完全背包物品容量容量正序
组合数(求方案数)容量物品视情况
排列数(求方案数)容量物品容量在外+正序

五、Hot 100 DP 题目清单

Day 12:DP 入门 + 贪心

题号题目难度DP 模型建议用时
70爬楼梯Easy线性 DP(斐波那契)20 min
118杨辉三角Easy模拟构造20 min
198打家劫舍Medium线性 DP(选/不选)25 min
279完全平方数Medium完全背包 DP30 min
322零钱兑换Medium完全背包 DP30 min
139单词拆分MediumDP + 哈希集30 min

Day 13:进阶 DP + 多维 DP

题号题目难度DP 模型建议用时
300最长递增子序列Medium子序列 DP35 min
152乘积最大子数组Medium线性 DP(同时维护最大最小)30 min
416分割等和子集Medium0-1 背包 DP30 min
32最长有效括号Hard线性 DP(分类讨论)40 min
62不同路径Medium路径 DP25 min
64最小路径和Medium路径 DP25 min
5最长回文子串Medium区间 DP35 min
1143最长公共子序列Medium二维 DP30 min
72编辑距离Hard二维 DP40 min

六、易错点与技巧

  1. 先想清楚 dp 含义再写代码:dp[i] 到底代表什么?是”以 i 结尾”还是”前 i 个”?
  2. 初始化仔细:base case 想清楚,特别是边界条件
  3. 遍历顺序:0-1 背包倒序,完全背包正序,这是经典考点
  4. 空间优化:很多二维 DP 可以优化为一维,但新手先写二维再优化
  5. 手动验证:写完后用小数据手动推导一遍,确认 dp 值正确

七、复杂度总结

DP 模型时间复杂度空间复杂度可优化空间
线性 DPO(n)O(n) → O(1)
子序列 DPO(n²)O(n)
背包 DPO(n × cap)O(cap)
二维 DPO(m × n)O(m × n) → O(n)
区间 DPO(n²)O(n²)

八、参考与延伸