动态规划
一、动态规划概述
适用 DP 的问题通常具备:重叠子问题(同一子问题在递归中多次出现)、最优子结构(当前最优可由子问题最优得到)、无后效性(当前状态一旦确定,后续决策不受之前如何到达该状态的影响)。与分治的区别:分治子问题不重叠,DP 子问题重叠,故需要「存结果」避免重复计算。
二、两种实现方式
记忆化搜索(Memoization):写递归时,在进入子问题前先查缓存(如字典或数组),若已算过则直接返回;否则递归算完再写入缓存。自顶向下,逻辑接近「递归定义」,易写。递推填表:确定状态与转移顺序后,从最小规模开始用循环填表(如 dp[0], dp[1], …),直到得到目标状态。自底向上,通常空间可优化(如斐波那契只需两个变量)。
三、斐波那契:从递归到 DP
F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)。朴素递归 O(2^n);记忆化后每个 n 只算一次,O(n);递推同样 O(n) 时间、可压成 O(1) 空间。
from typing import Dict
def fib_memo(n: int, memo: Dict[int, int] | None = None) -> int:
"""记忆化搜索:先查 memo,无则递归并存入。"""
if memo is None:
memo = {}
if n <= 0:
return 0
if n == 1:
return 1
if n in memo:
return memo[n]
memo[n] = fib_memo(n - 1, memo) + fib_memo(n - 2, memo)
return memo[n]
def fib_dp(n: int) -> int:
"""递推:dp[i]=dp[i-1]+dp[i-2],只保留最近两个变量。"""
if n <= 0:
return 0
if n == 1:
return 1
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
四、爬楼梯与最大子数组和
爬楼梯:一次可爬 1 或 2 阶,到第 n 阶的方案数。dp[i]=到达第 i 阶的方案数,dp[i]=dp[i-1]+dp[i-2],与斐波那契同构。最大子数组和:dp[i]=以 nums[i] 结尾的最大子数组和,dp[i]=max(nums[i], dp[i-1]+nums[i]),答案为 max(dp)。可省掉数组,只保留「以当前结尾的最大和」与「全局最大」两个变量,O(n) 时间 O(1) 空间。
from typing import List
def climb_stairs(n: int) -> int:
"""爬楼梯:dp[i]=dp[i-1]+dp[i-2],dp[0]=1, dp[1]=1。"""
if n <= 1:
return 1
a, b = 1, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b
def max_subarray_sum(nums: List[int]) -> int:
"""最大子数组和:dp[i]=以 i 结尾的最大和,dp[i]=max(nums[i], dp[i-1]+nums[i])。"""
if not nums:
return 0
cur = nums[0]
best = cur
for i in range(1, len(nums)):
cur = max(nums[i], cur + nums[i])
best = max(best, cur)
return best
五、最长递增子序列(LIS)
求数组中最长严格递增子序列的长度(子序列不要求连续)。状态:dp[i]=以 nums[i] 结尾的 LIS 长度。转移:dp[i]=1+max{dp[j] : j<i 且 nums[j]<nums[i]},若不存在 j 则 dp[i]=1。答案 max(dp)。时间 O(n²)。更优的有 O(n log n) 的二分+贪心写法(维护「长度为 k 的递增子序列的最小末尾」),可作为扩展。
from typing import List
def length_of_lis(nums: List[int]) -> int:
"""
最长递增子序列:dp[i]=以 nums[i] 结尾的 LIS 长度。
dp[i] = 1 + max{ dp[j] : j
六、小结
DP 针对重叠子问题,用记忆化或递推避免重复计算。先定义状态、再写转移方程与边界、确定计算顺序。下一章讲贪心算法。
七、例题
以下例题使用 Python 3 类型标注与注释。
例题 1:最小路径和
题目描述:m×n 网格,从左上到右下只能向右或向下走,路径上数字和为路径和。求最小路径和。
思路:dp[i][j]=从 (0,0) 到 (i,j) 的最小路径和;dp[i][j]=grid[i][j]+min(dp[i-1][j], dp[i][j-1]),边界为第一行第一列累加。
from typing import List
def min_path_sum(grid: List[List[int]]) -> int:
"""dp[i][j]=到(i,j)的最小路径和;可原地用 grid 或新开二维表,也可压成一维(按行扫)。"""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
dp: List[int] = [0] * cols
dp[0] = grid[0][0]
for j in range(1, cols):
dp[j] = dp[j - 1] + grid[0][j]
for i in range(1, rows):
dp[0] += grid[i][0]
for j in range(1, cols):
dp[j] = grid[i][j] + min(dp[j - 1], dp[j])
return dp[cols - 1]
讲解:每行只依赖上一行,用一维数组滚动即可。时间 O(m×n),空间 O(n)。
例题 2:打家劫舍
题目描述:一排房屋,nums[i] 为第 i 家的金额,不能连续偷相邻两家。求能偷到的最大金额。
思路:dp[i]=偷到第 i 家(含)为止的最大金额;dp[i]=max(dp[i-1], dp[i-2]+nums[i]),即不偷 i 或偷 i。
from typing import List
def rob(nums: List[int]) -> int:
"""dp[i]=考虑前 i 家的最大金额;偷 i 则不能偷 i-1,不偷 i 则等于 dp[i-1]。"""
if not nums:
return 0
if len(nums) == 1:
return nums[0]
a, b = nums[0], max(nums[0], nums[1])
for i in range(2, len(nums)):
a, b = b, max(b, a + nums[i])
return b
讲解:只依赖前两项,用两个变量滚动。时间 O(n),空间 O(1)。
例题 3:零钱兑换
题目描述:不同面额硬币 coins,凑成金额 amount 所需的最少硬币数;若无法凑成返回 -1。
思路:dp[w]=凑成金额 w 的最少硬币数。dp[w]=min{dp[w-c]+1 : c in coins 且 w≥c},dp[0]=0,无法凑成的保持 inf。
from typing import List
def coin_change(coins: List[int], amount: int) -> int:
"""dp[w]=凑成 w 的最少硬币数;dp[0]=0,dp[w]=min(dp[w-c]+1) for c in coins。"""
INF = 10**9
dp: List[int] = [INF] * (amount + 1)
dp[0] = 0
for w in range(1, amount + 1):
for c in coins:
if w >= c:
dp[w] = min(dp[w], dp[w - c] + 1)
return -1 if dp[amount] == INF else dp[amount]
讲解:按金额从小到大填表,保证算 dp[w] 时 dp[w-c] 已算好。时间 O(amount×len(coins)),空间 O(amount)。
例题 4:最长公共子序列(LCS)
题目描述:两个字符串 text1、text2,求最长公共子序列(不要求连续)的长度。
思路:dp[i][j]=text1[0:i] 与 text2[0:j] 的 LCS 长度。若 text1[i-1]==text2[j-1] 则 dp[i][j]=dp[i-1][j-1]+1;否则 dp[i][j]=max(dp[i-1][j], dp[i][j-1])。
from typing import List
def longest_common_subsequence(text1: str, text2: str) -> int:
"""dp[i][j]=text1[:i] 与 text2[:j] 的 LCS 长度;可压成两行或一行(需保留上一行)。"""
m, n = len(text1), len(text2)
dp: List[int] = [0] * (n + 1)
for i in range(1, m + 1):
prev = 0
for j in range(1, n + 1):
tmp = dp[j]
if text1[i - 1] == text2[j - 1]:
dp[j] = prev + 1
else:
dp[j] = max(dp[j], dp[j - 1])
prev = tmp
return dp[n]
讲解:按行递推,dp[j] 用到了上一行的 dp[j-1] 和「上一行同列的旧值」,用 prev 保存。时间 O(m×n),空间 O(n)。