跳转至

📖 动态规划完全指南(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题目