跳转至

📖 动态规划完全指南( 40+经典问题 · 面试高频全覆盖)

重要性:⭐⭐⭐⭐⭐(面试最高频算法类型) 难度:⭐⭐⭐⭐ 学习时间: 3-4 周 前置知识:递归、数组、基本数据结构


📚 目录

  1. DP 基础
  2. 一维 DP 基础( 5 题)
  3. 二维 DP
  4. 背包问题专题
  5. 区间 DP
  6. 状态压缩 DP
  7. 树形 DP
  8. 股票买卖系列( 6 道)
  9. 字符串 DP
  10. DP 优化技巧
  11. DP 面试高频 15 题
  12. 刷题路线与检查清单

DP 基础

动态规划示意图

什么是动态规划

动态规划( Dynamic Programming , DP )是一种通过分解问题和存储结果来优化递归的算法思想。

核心三要素

  1. 重叠子问题:子问题重复出现,避免重复计算
  2. 最优子结构:大问题的最优解包含小问题的最优解
  3. 无后效性:当前状态不影响未来决策

DP vs 递归 vs 贪心

特性 递归 DP 贪心
重复计算 大量 ❌ 无 ✅ 无 ✅
存储结果 否 ❌ 是 ✅ 否 ❌
最优解 可能 保证 ✅ 不保证 ❌
效率 低(指数级)❌ 高(多项式)✅ 高 ✅
适用 无重叠子问题 有重叠子问题 特定问题

DP 解题四步法(必须掌握)

Text Only
第1步:定义状态
  → 明确DP数组/表的含义
  → dp[i] 或 dp[i][j] 或 dp[i][j][k]

第2步:状态转移方程
  → 找出状态之间的关系
  → dp[i] = f(dp[i-1], dp[i-2], ...)

第3步:初始化
  → 确定边界条件
  → dp[0] = ? dp[1] = ?

第4步:计算顺序
  → 从前往后或从后往前
  → 确保计算dp[i]时所需的前置状态已计算

DP 分类

Text Only
按状态维度:
  ├── 一维DP  (dp[i])
  ├── 二维DP  (dp[i][j])
  └── 多维DP  (dp[i][j][k])

按问题类型:
  ├── 计数型:有多少种方法 (爬楼梯、不同路径)
  ├── 最值型:最大/最小值 (打家劫舍、最大子数组和)
  ├── 存在型:是否可行 (单词拆分、正则匹配)
  └── 路径型:最优路径 (最小路径和、编辑距离)

按实现方式:
  ├── 自顶向下(记忆化搜索)
  └── 自底向上(迭代DP) ⭐ 推荐

DP 入门题( 5 题)

题目 1 :爬楼梯 ⭐⭐⭐

问题:有 n 阶楼梯,每次可以爬 1 阶或 2 阶,有多少种方法爬到楼顶?

示例

Text Only
输入:n = 3
输出:3
解释:1+1+1, 1+2, 2+1

解法 1 :递归(低效,会超时)

Python
def climb_stairs_recursive(n):
    """
    递归解法
    时间:O(2^n) - 指数级 ❌
    空间:O(n) - 递归栈
    """
    if n <= 2:
        return n

    return climb_stairs_recursive(n - 1) + climb_stairs_recursive(n - 2)

# 递归树(n=4):
#                4
#              /   \
#             3     2
#            / \   / \
#           2   2 1   1
#          / \
#         1   1
#
# 可以看到重复计算:2被计算了多次!
# 这就是需要优化的原因

解法 2 :记忆化递归(优化)

Python
def climb_stairs_memo(n, memo=None):
    """
    记忆化递归(自顶向下的DP)
    时间:O(n)
    空间:O(n)
    """
    if memo is None:
        memo = {}

    if n in memo:
        return memo[n]  # 直接返回存储的结果

    if n <= 2:
        return n

    memo[n] = climb_stairs_memo(n - 1, memo) + climb_stairs_memo(n - 2, memo)
    return memo[n]

解法 3 :动态规划(最优)⭐

Python
def climb_stairs_dp(n):
    """
    动态规划(自底向上)
    时间:O(n)
    空间:O(n) → 可优化到O(1)
    """
    if n <= 2:
        return n

    # dp[i] = 爬到第i阶的方法数
    dp = [0] * (n + 1)
    dp[1] = 1  # 爬到第1阶:1种方法 (1)
    dp[2] = 2  # 爬到第2阶:2种方法 (1+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):
    """
    空间优化版(只需保存前两个状态)
    时间:O(n)
    空间:O(1) ✅
    """
    if n <= 2:
        return n

    prev, curr = 1, 2  # dp[1], dp[2]

    for _ in range(3, n + 1):
        prev, curr = curr, prev + curr

    return curr

# 状态转移详解:
# n=1: 1种方法                    → dp[1] = 1
# n=2: 2种方法   (1+1, 2)         → dp[2] = 2
# n=3: 3种方法   (1+1+1, 1+2, 2+1) → dp[3] = dp[2] + dp[1] = 3
# n=4: 5种方法                     → dp[4] = dp[3] + dp[2] = 5
# n=5: 8种方法                     → dp[5] = dp[4] + dp[3] = 8
#
# 发现规律:斐波那契数列!
# dp[i] = dp[i-1] + dp[i-2]

状态转移图

Text Only
         到达第n阶
           /    \
      从第n-1阶   从第n-2阶
        (1步)     (2步)

dp[n] = dp[n-1] + dp[n-2]

关键点: - ✅ 定义状态: dp[i]表示爬到第 i 阶的方法数 - ✅ 状态转移: dp[i] = dp[i-1] + dp[i-2] - ✅ 初始化: dp[1]=1, dp[2]=2 - ✅ 计算顺序:从前往后


题目 2 :使用最小花费爬楼梯 ⭐⭐⭐

问题:给你一个整数数组 cost,其中 cost[i] 是从楼梯第 i 个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。请计算并返回达到楼梯顶部的最低花费。

示例

Text Only
输入:cost = [10, 15, 20]
输出:15
解释:从第1阶(下标1,花费15)开始,爬2阶到达顶部,总花费15

输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出:6

解法:动态规划

Python
def min_cost_climbing_stairs(cost):
    """
    时间:O(n)
    空间:O(1)
    """
    n = len(cost)

    # dp[i] = 到达第i阶的最低花费
    # 状态转移:dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])

    prev2, prev1 = 0, 0  # dp[0], dp[1](可以从地面直接到第0阶或第1阶)

    for i in range(2, n + 1):
        # 当前状态可以从前1阶或前2阶到达
        # 选项1:从第i-1阶爬1步,花费 = prev1 + cost[i-1]
        # 选项2:从第i-2阶爬2步,花费 = prev2 + cost[i-2]
        current = min(prev1 + cost[i - 1], prev2 + cost[i - 2])
        prev2, prev1 = prev1, current

    return prev1

# 图解(cost = [10, 15, 20]):
#
# 楼梯结构:
# 顶部(目标)
#   ↑
# 2阶(cost=20)
#   ↑
# 1阶(cost=15)
#   ↑
# 0阶(cost=10)
#   ↑
# 地面
#
# 起点选择:
# 选项1: 从地面到0阶,cost=0(可以直接站在第0阶)
# 选项2: 从地面到1阶,cost=0(可以直接站在第1阶)
#
# dp[0] = 0(在地面)
# dp[1] = 0(可以从地面直接到1阶)
# dp[2] = min(dp[1] + cost[1], dp[0] + cost[0])
#       = min(0 + 15, 0 + 10)
#       = 10
# dp[3] = min(dp[2] + cost[2], dp[1] + cost[1])
#       = min(10 + 20, 0 + 15)
#       = 15 ✓
#
# 最优路径:地面 → 第1阶(0) → 顶部(15)

题目 3 :打家劫舍 ⭐⭐⭐⭐

问题:小偷计划偷窃沿街的房屋,每间房屋内有现金。相邻的房屋装有防盗系统,偷窃相邻的房屋会触发警报。求能偷到的最大金额。

示例

Text Only
输入:[2, 7, 9, 3, 1]
输出:12
解释:偷窃第1、3、5间房屋 → 2 + 9 + 1 = 12

解法:动态规划

Python
def rob(nums):
    """
    时间:O(n)
    空间:O(1)
    """
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]

    # dp[i] = 偷窃前i间房屋能获得的最大金额
    # 状态转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i])

    prev2, prev1 = 0, nums[0]  # dp[-1], dp[0]

    for i in range(1, len(nums)):
        # 选项1: 不偷第i间,取prev1(前i-1间的最大金额)
        # 选项2: 偷第i间,取prev2 + nums[i](前i-2间的金额 + 当前的钱)
        current = max(prev1, prev2 + nums[i])
        prev2, prev1 = prev1, current

    return prev1

# 状态转移详解:
# nums = [2, 7, 9, 3, 1]
#
# 初始化:
# prev2 = 0(没有房屋)
# prev1 = 2(第0间)
#
# i=1 (nums[1]=7):
#   选项1: 不偷第1间,金额 = prev1 = 2
#   选项2: 偷第1间,金额 = prev2 + 7 = 0 + 7 = 7
#   dp[1] = max(2, 7) = 7
#
# i=2 (nums[2]=9):
#   选项1: 不偷第2间,金额 = prev1 = 7
#   选项2: 偷第2间,金额 = prev2 + 9 = 2 + 9 = 11
#   dp[2] = max(7, 11) = 11
#
# i=3 (nums[3]=3):
#   选项1: 不偷第3间,金额 = prev1 = 11
#   选项2: 偷第3间,金额 = prev2 + 3 = 7 + 3 = 10
#   dp[3] = max(11, 10) = 11
#
# i=4 (nums[4]=1):
#   选项1: 不偷第4间,金额 = prev1 = 11
#   选项2: 偷第4间,金额 = prev2 + 1 = 11 + 1 = 12
#   dp[4] = max(11, 12) = 12 ✓
#
# 最优方案:偷第0、2、4间 → 2 + 9 + 1 = 12

变体:打家劫舍 II (房屋围成圈)

Python
def rob_ii(nums):
    """
    房屋排成一圈(首尾相连)
    思路:分解为两个问题
    1. 偷窃[0, n-2](不偷最后一间)
    2. 偷窃[1, n-1](不偷第一间)
    取两者最大值
    """
    if len(nums) == 1:
        return nums[0]

    return max(rob_range(nums, 0, len(nums) - 1),
               rob_range(nums, 1, len(nums)))

def rob_range(nums, start, end):
    """偷窃nums[start..end-1]"""
    prev2, prev1 = 0, 0

    for i in range(start, end):
        current = max(prev1, prev2 + nums[i])
        prev2, prev1 = prev1, current

    return prev1

题目 4 :杨辉三角 ⭐⭐

问题:给定 numRows ,生成杨辉三角的前 numRows 行。

示例

Text Only
输入:numRows = 5
输出:
[
     [1],
    [1,1],
   [1,2,1],
  [1,3,3,1],
 [1,4,6,4,1]
]

解法:动态规划

Python
def generate_pascal_triangle(numRows):
    """
    时间:O(n²)
    空间:O(n²)
    """
    if numRows <= 0:
        return []

    triangle = [[1]]

    for i in range(1, numRows):
        prev_row = triangle[-1]
        current_row = [1]  # 每行第一个元素总是1

        # 中间元素 = 上一行的左上 + 右上
        for j in range(1, i):
            current_row.append(prev_row[j - 1] + prev_row[j])

        current_row.append(1)  # 每行最后一个元素总是1
        triangle.append(current_row)

    return triangle

# 状态转移详解:
# triangle[i][j] = triangle[i-1][j-1] + triangle[i-1][j]
#
#     1
#    1 1
#   1 2 1    → 2 = 1 + 1
#  1 3 3 1   → 3 = 1 + 2, 3 = 2 + 1
# 1 4 6 4 1  → 4 = 1 + 3, 6 = 3 + 3, 4 = 3 + 1

📌 二维 DP

题目 5 :最长公共子序列 (LCS) ⭐⭐⭐⭐

LeetCode 1143 | 面试超高频

Python
def longest_common_subsequence(text1: str, text2: str) -> int:
    """
    dp[i][j] = text1[:i] 和 text2[:j] 的最长公共子序列长度
    时间:O(m*n)  空间:O(m*n)
    """
    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   # 字符匹配,左上角+1
            else:
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])  # 取上方或左方最大
    return dp[m][n]

# 图解:text1="abcde", text2="ace"
#     ""  a  c  e
# ""   0  0  0  0
# a    0  1  1  1
# b    0  1  1  1
# c    0  1  2  2
# d    0  1  2  2
# e    0  1  2  3  ← LCS长度=3, LCS="ace"

题目 6 :编辑距离 ⭐⭐⭐⭐⭐

LeetCode 72 | 面试必考经典

Python
def min_distance(word1: str, word2: str) -> int:
    """
    dp[i][j] = word1[:i] → word2[:j] 的最少操作次数
    操作:插入/删除/替换
    时间:O(m*n)  空间:O(m*n)
    """
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]

    # 初始化:空串到word2[:j]需j次插入
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(m + 1):
        dp[i][0] = i

    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-1]
                    dp[i][j - 1],      # 插入 word2[j-1]
                    dp[i - 1][j - 1]   # 替换
                )
    return dp[m][n]

# 图解:word1="horse", word2="ros"
#      ""  r  o  s
# ""    0  1  2  3
# h     1  1  2  3
# o     2  2  1  2
# r     3  2  2  2
# s     4  3  3  2
# e     5  4  4  3  ← 最少3次操作
# 路径:horse → rorse(替换h→r) → rose(删除r) → ros(删除e)

题目 7 :不同路径 ⭐⭐⭐

LeetCode 62

Python
def unique_paths(m: int, n: int) -> int:
    """
    dp[i][j] = 到达(i,j)的不同路径数
    转移:dp[i][j] = dp[i-1][j] + dp[i][j-1](只能向右或向下)
    """
    dp = [[1] * n for _ in range(m)]  # 第一行/列只有1种走法
    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]

# 空间优化:O(n)
def unique_paths_opt(m: int, n: int) -> int:
    dp = [1] * n
    for i in range(1, m):
        for j in range(1, n):
            dp[j] += dp[j - 1]
    return dp[n - 1]

题目 8 :最小路径和 ⭐⭐⭐

LeetCode 64

Python
def min_path_sum(grid):
    """dp[i][j] = 到达(i,j)的最小路径和"""
    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] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i][j]
    return dp[m - 1][n - 1]

🎒 背包问题专题(面试超高频)

0-1 背包模板 ⭐⭐⭐⭐⭐

Python
def knapsack_01(weights, values, capacity):
    """
    0-1背包:每个物品只能选一次
    dp[i][w] = 前i个物品,容量为w时的最大价值
    时间:O(n*W)  空间:O(n*W) → 可优化到O(W)
    """
    n = len(weights)
    dp = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        for w in range(capacity + 1):
            dp[i][w] = dp[i - 1][w]  # 不选第i个物品
            if w >= weights[i - 1]:
                dp[i][w] = max(dp[i][w],
                    dp[i - 1][w - weights[i - 1]] + values[i - 1])  # 选第i个
    return dp[n][capacity]

# ★ 空间优化版(一维滚动数组,逆序遍历)
def knapsack_01_opt(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for w in range(capacity, weights[i] - 1, -1):  # ★ 逆序!
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[capacity]

# 关键:0-1背包必须逆序遍历容量,否则同一物品会被重复选取

完全背包模板 ⭐⭐⭐⭐

Python
def knapsack_complete(weights, values, capacity):
    """
    完全背包:每个物品可以选无限次
    与0-1背包唯一区别:容量正序遍历
    """
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for w in range(weights[i], capacity + 1):  # ★ 正序!
            dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
    return dp[capacity]

背包变种 1 :零钱兑换 ⭐⭐⭐⭐

LeetCode 322 | 完全背包变种

Python
def coin_change(coins, amount):
    """最少硬币数凑出amount(完全背包求最小值)"""
    dp = [float('inf')] * (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] != float('inf') else -1

背包变种 2 :零钱兑换 II (组合数)⭐⭐⭐⭐

LeetCode 518

Python
def change(amount, coins):
    """凑出amount的组合数(完全背包求组合数)"""
    dp = [0] * (amount + 1)
    dp[0] = 1
    for coin in coins:                    # ★ 外层遍历物品
        for j in range(coin, amount + 1): # 内层遍历容量
            dp[j] += dp[j - coin]
    return dp[amount]

# 注意:如果内外层反过来(先遍历容量再遍历物品),求的是排列数!
# LeetCode 377 组合总和Ⅳ 就是排列数版本

背包变种 3 :目标和 ⭐⭐⭐⭐

LeetCode 494 | 转化为 0-1 背包

Python
def find_target_sum_ways(nums, target):
    """
    将数组分为正数集P和负数集N:
    sum(P) - sum(N) = target
    sum(P) + sum(N) = total
    → sum(P) = (target + total) / 2
    转化为:从nums中选若干数使其和为(target+total)/2的方案数
    """
    total = sum(nums)
    if (target + total) % 2 != 0 or abs(target) > total:
        return 0
    bag = (target + total) // 2
    dp = [0] * (bag + 1)
    dp[0] = 1
    for num in nums:
        for j in range(bag, num - 1, -1):  # 逆序 = 0-1背包
            dp[j] += dp[j - num]
    return dp[bag]

背包变种 4 :分割等和子集 ⭐⭐⭐⭐

LeetCode 416 | 0-1 背包求存在性

Python
def can_partition(nums):
    """能否将数组分成两个和相等的子集 → 0-1背包目标=sum/2"""
    total = sum(nums)
    if total % 2 != 0:
        return False
    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True
    for num in nums:
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]
    return dp[target]

背包问题速查表

类型 遍历顺序 物品/容量 经典题目
0-1 背包 逆序 先物品后容量 416/494/1049
完全背包 正序 先物品后容量 322/518/279
组合数 正序 先物品后容量 518
排列数 正序 先容量后物品 377/70
多重背包 二进制拆分 竞赛题

📐 区间 DP

戳气球 ⭐⭐⭐⭐⭐

LeetCode 312

Python
def max_coins(nums):
    """
    dp[i][j] = 戳破(i,j)开区间内所有气球能获得的最大硬币数
    关键:逆向思维 — 考虑最后一个被戳破的气球k
    """
    nums = [1] + nums + [1]  # 两端加虚拟气球
    n = len(nums)
    dp = [[0] * n for _ in range(n)]

    for length in range(2, n):       # 区间长度
        for i in range(0, n - length):  # 左端点
            j = i + length              # 右端点
            for k in range(i + 1, j):   # k是最后一个被戳的
                dp[i][j] = max(dp[i][j],
                    dp[i][k] + dp[k][j] + nums[i] * nums[k] * nums[j])
    return dp[0][n - 1]

石子合并 ⭐⭐⭐⭐

Python
def merge_stones(stones):
    """相邻石子合并,求最小代价(经典区间DP)"""
    n = len(stones)
    prefix = [0] * (n + 1)
    for i in range(n):
        prefix[i + 1] = prefix[i] + stones[i]

    dp = [[float('inf')] * n for _ in range(n)]
    for i in range(n):
        dp[i][i] = 0

    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            for k in range(i, j):
                dp[i][j] = min(dp[i][j],
                    dp[i][k] + dp[k + 1][j] + prefix[j + 1] - prefix[i])
    return dp[0][n - 1]

🔢 状态压缩 DP

TSP 旅行商问题 ⭐⭐⭐⭐⭐

Python
def tsp(dist):
    """
    dp[mask][i] = 已访问集合为mask,当前在城市i时的最短路径
    mask用二进制表示已访问状态:0101表示访问了城市0和城市2
    时间:O(n² · 2^n)  空间:O(n · 2^n)
    """
    n = len(dist)
    INF = float('inf')
    dp = [[INF] * n for _ in range(1 << n)]
    dp[1][0] = 0  # 从城市0出发

    for mask in range(1 << n):
        for u in range(n):
            if dp[mask][u] == INF:
                continue
            if not (mask & (1 << u)):
                continue
            for v in range(n):
                if mask & (1 << v):  # v已经访问过
                    continue
                new_mask = mask | (1 << v)
                dp[new_mask][v] = min(dp[new_mask][v],
                                      dp[mask][u] + dist[u][v])

    full_mask = (1 << n) - 1
    return min(dp[full_mask][i] + dist[i][0] for i in range(n))

位运算技巧

Python
mask & (1 << i)      # 检查第i位是否为1(城市i是否已访问)
mask | (1 << i)      # 将第i位设为1(标记访问城市i)
mask ^ (1 << i)      # 翻转第i位
mask & (mask - 1)    # 去掉最低位的1
bin(mask).count('1') # 已访问城市数量

🌳 树形 DP

二叉树最大路径和 ⭐⭐⭐⭐⭐

LeetCode 124 | 面试高频

Python
def max_path_sum(root):
    """
    经典树形DP:自底向上传递信息
    每个节点返回:经过该节点的最大单边路径和
    全局维护:经过该节点的最大路径和(可包含两边)
    """
    result = [float('-inf')]

    def dfs(node):
        if not node:
            return 0
        left = max(dfs(node.left), 0)   # 负数不选
        right = max(dfs(node.right), 0)

        # 经过当前节点的最大路径(可能包含左右子树)
        result[0] = max(result[0], left + node.val + right)

        # 返回:向上传递的最大单边路径
        return node.val + max(left, right)

    dfs(root)
    return result[0]

打家劫舍 III (树形)⭐⭐⭐⭐

LeetCode 337

Python
def rob_tree(root):
    """
    dp返回: (偷当前节点的最大值, 不偷当前节点的最大值)
    """
    def dfs(node):
        if not node:
            return (0, 0)
        left = dfs(node.left)
        right = dfs(node.right)

        rob = node.val + left[1] + right[1]       # 偷当前 → 不能偷子节点
        not_rob = max(left) + max(right)           # 不偷当前 → 子节点可偷可不偷
        return (rob, not_rob)

    return max(dfs(root))

📈 股票买卖系列( 6 道完整解析)

统一状态机框架 ⭐⭐⭐⭐⭐

Python
# 所有股票问题都可以用统一框架:
# dp[i][k][0] = 第i天,最多k次交易,不持有股票的最大利润
# dp[i][k][1] = 第i天,最多k次交易,持有股票的最大利润
#
# 转移方程:
# dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])  # 不动 or 卖出
# dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])  # 不动 or 买入

买卖股票 I ( k=1 )⭐⭐⭐

LeetCode 121

Python
def max_profit_1(prices):
    """只能买卖一次"""
    min_price = float('inf')
    max_profit = 0
    for price in prices:
        min_price = min(min_price, price)
        max_profit = max(max_profit, price - min_price)
    return max_profit

买卖股票 II ( k=∞)⭐⭐⭐

LeetCode 122

Python
def max_profit_2(prices):
    """可以买卖无限次"""
    return sum(max(prices[i] - prices[i - 1], 0)
               for i in range(1, len(prices)))

买卖股票 III ( k=2 )⭐⭐⭐⭐

LeetCode 123

Python
def max_profit_3(prices):
    """最多买卖两次"""
    buy1 = buy2 = float('-inf')
    sell1 = sell2 = 0
    for price in prices:
        buy1 = max(buy1, -price)
        sell1 = max(sell1, buy1 + price)
        buy2 = max(buy2, sell1 - price)
        sell2 = max(sell2, buy2 + price)
    return sell2

买卖股票 IV ( k=任意)⭐⭐⭐⭐⭐

LeetCode 188

Python
def max_profit_4(k, prices):
    """最多买卖k次 — 通用解法"""
    n = len(prices)
    if k >= n // 2:  # k足够大,退化为无限次
        return sum(max(prices[i] - prices[i - 1], 0) for i in range(1, n))

    dp = [[0, float('-inf')] for _ in range(k + 1)]  # [sell, buy]
    for price in prices:
        for j in range(1, k + 1):
            dp[j][0] = max(dp[j][0], dp[j][1] + price)
            dp[j][1] = max(dp[j][1], dp[j - 1][0] - price)
    return dp[k][0]

含冷冻期 ⭐⭐⭐⭐

LeetCode 309

Python
def max_profit_cooldown(prices):
    """卖出后需冷冻一天"""
    if len(prices) < 2:
        return 0
    hold, sold, rest = float('-inf'), 0, 0
    for price in prices:
        prev_sold = sold
        sold = hold + price            # 持有 → 卖出
        hold = max(hold, rest - price) # 休息 → 买入
        rest = max(rest, prev_sold)    # 卖出后 → 休息(冷冻)
    return max(sold, rest)

含手续费 ⭐⭐⭐

LeetCode 714

Python
def max_profit_fee(prices, fee):
    """每次交易扣手续费"""
    hold, cash = float('-inf'), 0
    for price in prices:
        hold = max(hold, cash - price)
        cash = max(cash, hold + price - fee)
    return cash

股票系列速查表

题号 限制 核心思路 复杂度
121 k=1 记录最低价 O(n)
122 k=∞ 贪心/所有上涨段 O(n)
123 k=2 4 个状态变量 O(n)
188 k 任意 通用 DP 框架 O(nk)
309 k=∞+冷冻 三状态机 O(n)
714 k=∞+手续费 两状态+fee O(n)

🔤 字符串 DP

正则表达式匹配 ⭐⭐⭐⭐⭐

LeetCode 10

Python
def is_match(s: str, p: str) -> bool:
    """
    dp[i][j] = s[:i] 与 p[:j] 是否匹配
    '.' 匹配任意单字符
    '*' 匹配零个或多个前面的元素
    """
    m, n = len(s), len(p)
    dp = [[False] * (n + 1) for _ in range(m + 1)]
    dp[0][0] = True

    # 初始化:空串s可以匹配 a*b*c* 这种模式
    for j in range(2, n + 1):
        if p[j - 1] == '*':
            dp[0][j] = dp[0][j - 2]

    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if p[j - 1] == '*':
                # 匹配零次:dp[i][j-2]
                dp[i][j] = dp[i][j - 2]
                # 匹配一次或多次(前一字符匹配s[i-1])
                if p[j - 2] == '.' or p[j - 2] == s[i - 1]:
                    dp[i][j] = dp[i][j] or dp[i - 1][j]
            elif p[j - 1] == '.' or p[j - 1] == s[i - 1]:
                dp[i][j] = dp[i - 1][j - 1]
    return dp[m][n]

最长回文子序列 ⭐⭐⭐⭐

LeetCode 516

Python
def longest_palindrome_subseq(s: str) -> int:
    """dp[i][j] = s[i..j]中最长回文子序列长度"""
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n):
        dp[i][i] = 1

    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = dp[i + 1][j - 1] + 2
            else:
                dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    return dp[0][n - 1]

回文子串计数 ⭐⭐⭐⭐

LeetCode 647 | 中心扩展法更优

Python
def count_substrings(s: str) -> int:
    """dp[i][j] = s[i..j]是否为回文"""
    n = len(s)
    dp = [[False] * n for _ in range(n)]
    count = 0
    for length in range(1, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]):
                dp[i][j] = True
                count += 1
    return count

# 更优解法:中心扩展 O(n²) 时间 O(1) 空间
def count_substrings_expand(s: str) -> int:
    count = 0
    for center in range(2 * len(s) - 1):
        left = center // 2
        right = left + center % 2
        while left >= 0 and right < len(s) and s[left] == s[right]:
            count += 1
            left -= 1
            right += 1
    return count

单词拆分 ⭐⭐⭐⭐

LeetCode 139

Python
def word_break(s: str, wordDict) -> bool:
    """dp[i] = s[:i] 是否可以被拆分"""
    word_set = set(wordDict)
    dp = [False] * (len(s) + 1)
    dp[0] = True
    for i in range(1, len(s) + 1):
        for j in range(i):
            if dp[j] and s[j:i] in word_set:
                dp[i] = True
                break
    return dp[len(s)]

⚡ DP 优化技巧

1. 空间优化:滚动数组

Python
# 二维DP → 一维(当前行只依赖上一行时)
# 原始:dp[i][j] = dp[i-1][j-1] + dp[i-1][j]
# 优化:dp[j] = dp[j-1] + dp[j]  (注意遍历方向!)

# 0-1背包:逆序遍历(每个物品只选一次)
for w in range(capacity, weight - 1, -1):
    dp[w] = max(dp[w], dp[w - weight] + value)

# 完全背包:正序遍历(每个物品可选多次)
for w in range(weight, capacity + 1):
    dp[w] = max(dp[w], dp[w - weight] + value)

2. 记忆化搜索 vs 递推

Python
# 记忆化搜索(自顶向下) — 适合状态空间稀疏时
from functools import lru_cache

@lru_cache(maxsize=None)
def solve(i, j):
    if base_case: return ...
    return transition(solve(i-1, j), solve(i, j-1))

# 递推(自底向上) — 适合状态空间稠密时,通常更快
dp = [[0] * n for _ in range(m)]
for i in range(m):
    for j in range(n):
        dp[i][j] = transition(dp[i-1][j], dp[i][j-1])

3. 前缀和优化

Python
# 当转移需要区间和时,预处理前缀和避免重复计算
# 石子合并:dp[i][j] = min(dp[i][k] + dp[k+1][j] + sum(i..j))
# 用prefix[j+1] - prefix[i] 替代循环求和

4. 单调队列优化

Python
from collections import deque

def max_sliding_window_dp(nums, k):
    """滑动窗口最大值 — 单调队列 O(n)"""
    dq = deque()
    result = []
    for i, num in enumerate(nums):  # enumerate同时获取索引和值
        while dq and nums[dq[-1]] <= num:  # 负索引:从末尾倒数访问元素
            dq.pop()
        dq.append(i)
        if dq[0] <= i - k:
            dq.popleft()
        if i >= k - 1:
            result.append(nums[dq[0]])
    return result

🎯 DP 面试高频 15 题

Q1 :什么是动态规划?它和贪心有什么区别

A: DP 通过保存子问题结果避免重复计算,保证全局最优;贪心每步选局部最优,不一定全局最优。 DP 需要最优子结构+重叠子问题,贪心需要贪心选择性质+最优子结构。

Q2 :如何判断一个问题是否适合用 DP

A:三个特征:①有重叠子问题 ②有最优子结构 ③无后效性。常见关键词:"最大/最小/最长/方案数/是否可行"。

Q3 : 0-1 背包和完全背包的区别

A: 0-1 背包每个物品最多选一次,一维 DP 逆序遍历容量;完全背包每个物品可选无限次,正序遍历容量。

Q4 :编辑距离的时间和空间复杂度?能否优化

A:时间 O(mn),空间 O(mn)可优化到 O(min(m,n))用滚动数组。但如果需要回溯编辑操作路径,则不能空间优化。

Q5 : LCS 和最长公共子串有什么区别

A: LCS 子序列不要求连续, dp[i][j] = dp[i-1][j-1]+1 (匹配时)或 max(dp[i-1][j], dp[i][j-1])(不匹配时);最长公共子串要求连续,不匹配时 dp[i][j]=0 。

Q6 :股票买卖问题的统一框架是什么

A:状态机 DP : dp[i][k][0/1]表示第 i 天、最多 k 次交易、持有/不持有的最大利润。所有 6 道股票题都是这个框架的特例。

Q7 :区间 DP 的特点和模板

A:按区间长度从小到大枚举,内层枚举分割点。模板:for len ... for i ... for k ... dp[i][j] = optimize(dp[i][k], dp[k][j])

Q8 :状态压缩 DP 适用场景

A:状态集合较小( n≤20 )时用二进制表示集合状态。典型问题: TSP(n≤20)、铺砖(n≤12)、分配问题。

Q9 :树形 DP 和普通 DP 的区别

A:普通 DP 在数组/矩阵上转移,树形 DP 在树的 DFS 后序遍历中自底向上转移。返回值通常是元组(偷/不偷、选/不选等多种状态)。

Q10 :如何回溯 DP 的最优决策路径

A:保存 DP 表后,从终态逆向追踪:对于每个 dp[i][j],检查它是由哪个前驱状态转移来的。或额外维护 choice 数组记录每步决策。

Q11 :零钱兑换 I 和 II 的区别

A: I 求最少硬币数(最值型),取 min ; II 求组合数(计数型),取 sum 。两者都是完全背包但目标函数不同。

Q12 :最长递增子序列(LIS)的两种解法

A:①DP O(n²): dp[i]=以 nums[i]结尾的 LIS 长度;②贪心+二分 O(nlogn):维护 tails 数组,二分查找插入位置。

Q13 :记忆化搜索和递推 DP 哪个更好

A:记忆化搜索代码直观、只计算需要的状态(适合稀疏状态空间);递推 DP 无递归开销、可空间优化(适合稠密状态空间)。竞赛推荐记忆化,面试两种都行。

Q14 :多维 DP 如何空间优化

A:当当前层只依赖前一层时,可用滚动数组(两行交替)或一维数组。注意遍历方向:如果依赖 dp[i-1][j-1],逆序遍历;如果依赖 dp[i][j-1],正序遍历。

Q15 : DP 和分治有什么关系

A:都将大问题分解为子问题。区别: DP 的子问题重叠,用表存储结果;分治的子问题独立,不需存储。 DP 可以看作带记忆化的分治。


🎯 DP 刷题路线(按难度递进)

Text Only
第1周 — 一维DP基础:
  70 爬楼梯 → 746 使用最小花费 → 198 打家劫舍 → 213 打家劫舍II
  → 53 最大子数组和 → 152 最大乘积子数组

第2周 — 二维DP + 路径:
  62 不同路径 → 64 最小路径和 → 1143 LCS → 72 编辑距离
  → 5 最长回文子串 → 516 最长回文子序列

第3周 — 背包专题:
  416 分割等和子集 → 494 目标和 → 322 零钱兑换 → 518 零钱兑换II
  → 279 完全平方数 → 377 组合总和Ⅳ → 139 单词拆分

第4周 — 股票 + 进阶:
  121→122→123→188→309→714 股票系列
  → 10 正则匹配 → 312 戳气球 → 124 二叉树最大路径和 → 337 打家劫舍III

LeetCode题号索引:
  5/10/53/62/64/70/72/121/122/123/124/139/152/188/198/213
  279/300/309/312/322/337/377/416/494/516/518/647/714/746
  1049/1143

✅ 学习检查清单

  • 能用 DP 解题四步法分析任意 DP 问题
  • 0-1 背包和完全背包的区别和代码模板
  • 手写编辑距离、 LCS 、最长回文子序列
  • 股票 6 题统一框架全部理解
  • 区间 DP 模板(戳气球)
  • 状态压缩 DP 基本原理
  • 树形 DP 基本模式(返回元组)
  • 空间优化技巧(滚动数组/遍历方向)
  • 能在 30 分钟内独立完成中等 DP 题
  • 完成 30+道 LeetCode DP 题目