Skip to content

动态规划

本章目标:掌握 DP 五步法,能独立完成一维、二维、区间、状态压缩等各类 DP 题。

你将学到

  • DP 五步法:状态定义 → 转移方程 → 初始条件 → 计算顺序 → 答案位置
  • 一维 DP(爬楼梯、LIS、最大子数组)
  • 二维 DP(背包、编辑距离、LCS、不同路径)
  • 区间 DP(回文子串、戳气球)
  • 状态压缩(位运算 DP)
  • 记忆化搜索 vs 自底向上

1. 概念

动态规划是面试中最重要的算法,核心思想是将问题分解为重叠子问题,避免重复计算。与分治不同,DP 的子问题有大量重叠。

DP 适用的两个条件:

  1. 最优子结构:原问题的最优解包含子问题的最优解
  2. 重叠子问题:不同的递归路径会反复求解同一个子问题

DP 五步法

  1. 定义状态dp[i]dp[i][j] 代表什么?
  2. 写出转移方程dp[i] 由哪些子状态推出?
  3. 初始条件dp[0]dp[0][0] 是多少?
  4. 计算顺序:从小到大还是从大到小?拓扑序保证依赖已计算。
  5. 答案位置:最终结果在 dp[n] 还是 dp 数组中的最大值?

2. 核心模板

一维 DP:最长递增子序列 LIS

python
def lengthOfLIS(nums):
    """O(n²) DP 解法"""
    if not nums:
        return 0
    n = len(nums)
    dp = [1] * n  # dp[i] = 以 nums[i] 结尾的最长递增子序列长度
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

LIS 的 O(n log n) 优化(贪心 + 二分):

python
from bisect import bisect_left

def lengthOfLIS_optimized(nums):
    tails = []  # tails[i] = 长度为 i+1 的递增子序列的最小尾元素
    for num in nums:
        pos = bisect_left(tails, num)
        if pos == len(tails):
            tails.append(num)
        else:
            tails[pos] = num
    return len(tails)

二维 DP:编辑距离

python
def minDistance(word1, word2):
    """将 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],    # 删除
                    dp[i][j-1],    # 插入
                    dp[i-1][j-1]   # 替换
                )
    return dp[m][n]

复杂度

  • 一维 DP:时间 O(n) 或 O(n²),空间 O(n)
  • 二维 DP:时间 O(mn),空间 O(mn) 或 O(n)(滚动数组优化)

3. 经典题目精讲

LeetCode 70 · 爬楼梯

题意:每次爬 1 或 2 级台阶,有多少种方法爬到第 n 级?

思路dp[i] = dp[i-1] + dp[i-2],本质就是斐波那契。

python
def climbStairs(n):
    if n <= 2:
        return n
    prev, curr = 1, 2
    for _ in range(3, n + 1):
        prev, curr = curr, prev + curr
    return curr

LeetCode 322 · 零钱兑换

题意:给定硬币面额和金额,求凑成金额所需最少硬币数。

思路:完全背包 DP。dp[i] = 金额 i 所需最少硬币数。

python
def coinChange(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i:
                dp[i] = min(dp[i], dp[i - coin] + 1)
    return dp[amount] if dp[amount] != float('inf') else -1

LeetCode 416 · 分割等和子集

题意:判断数组能否分成两个子集使它们的和相等。

思路:转化为 0-1 背包——能否选若干元素之和等于 sum/2。

python
def canPartition(nums):
    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]

LeetCode 1143 · 最长公共子序列

题意:找两个字符串的最长公共子序列长度。

python
def longestCommonSubsequence(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]

4. 易错点

  • ⚠️ 0-1 背包内层循环必须逆序,完全背包正序
  • ⚠️ dp 数组初始化时 dp[0] 的含义要仔细确认
  • ⚠️ 滚动数组优化时只保留上一行/列,注意覆盖顺序
  • ⚠️ 记忆化搜索用 @lru_cache 时注意参数必须可哈希

5. 推荐刷题清单

题号题名难度关键
5最长回文子串Medium区间 DP
53最大子数组和Medium一维 DP
70爬楼梯Easy斐波那契
72编辑距离Medium二维 DP
152乘积最大子数组Medium一维 DP
198打家劫舍Medium状态机 DP
300最长递增子序列MediumLIS
322零钱兑换Medium完全背包
416分割等和子集Medium0-1 背包
1143最长公共子序列Medium二维 DP

延伸阅读

基于 VitePress 构建 · 部署于 Cloudflare Pages