15.动态规划模式
动态规划(DP)涉及将问题分解为更小的子问题 ,并使用自底向上或自顶向下 的方法解决它们。
对于具有重叠子问题和最优子结构的问题使用此模式。
动态规划的核心是 "拆分子问题 + 记录重复子问题 + 利用最优子结构"
解题思路:
1. 明确状态定义
- 用 dp[i] / dp[i][j] 表示到第 i 个位置 / 前 i 个元素 / 第 i 行第 j 列时的最优值。
- 例子:
- 斐波那契:dp[i] = 第 i 个斐波那契数
- 01 背包:dp[i][j] = 前 i 个物品、容量 j 时的最大价值
- LIS:dp[i] = 以第 i 个元素结尾的最长递增子序列长度
- 例子:
2. 确定状态转移方程
- DP 的灵魂,即当前状态如何由之前状态推导而来。
- 斐波那契:dp[i] = dp[i-1] + dp[i-2]
- 01 背包:dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
- LIS:dp[i] = max(dp[j] + 1) for all j < i and nums[j] < nums[i]
3. 初始化 base case
- 边界条件:无法再拆分的最小子问题。
- 例子:
- 斐波那契:dp[0] = 0, dp[1] = 1
- 01 背包:dp[0][j] = 0(0 个物品价值为 0),dp[i][0] = 0(容量为 0 价值为 0)
4. 确定遍历顺序
- 自底向上(迭代):从 base case 开始,逐步计算到目标状态。
- 自顶向下(递归 + 记忆化):从目标状态开始,递归拆解并缓存子问题结果。
注意:有些问题(如 01 背包)需要逆序遍历容量以避免重复选择。
5. 提取最终答案
- 看状态定义,直接取 dp[n] / dp[m][n] 或遍历 dp 数组取最值。
代码模板:
1. 自底向上:
python
def dp_template(n, *args):
# 初始化dp数组
dp = [0] * (n + 1) # 或者二维数组 dp = [[0] * (col+1) for _ in range(row+1)]
# base case
dp[0] = 0
dp[1] = 1
# 遍历填充
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2] # 状态转移方程
# 返回结果
return dp[n]
2.自顶向下:
python
from functools import lru_cache
def dp_template(n):
@lru_cache(maxsize=None)
def dfs(i):
# base case
if i == 0:
return 0
if i == 1:
return 1
return dp[i-1] + dp[i-2]
return dfs(n)
爬楼梯(LeetCode#70)
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
示例 1:
输入:n = 2
输出:2
解释:有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
输入:n = 3
输出:3
解释:有三种方法可以爬到楼顶。
- 1 阶 + 1 阶 + 1 阶
- 1 阶 + 2 阶
- 2 阶 + 1 阶
提示:
1 <= n <= 45
题目求解
python
class Solution:
def climbStairs(self, n: int) -> int:
if n == 1: return 1
if n == 2: return 2
n1 = 1
n2 = 2
for i in range(3, n + 1):
n1, n2 = n2, n1 + n2
return n2
递归会超出时间限制。
打家劫舍 (LeetCode#198)
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
题目求解
python
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
if n == 0: return 0
dp = [0] * n
if n == 1: return nums[0]
if n == 2: return max(nums[0], nums[1])
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
return dp[n - 1]
感觉最重要的就是找到 动态规划的公式
零钱兑换(LeetCode#322)
题目描述
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
示例 2:
输入:coins = [2], amount = 3
输出:-1
示例 3:
输入:coins = [1], amount = 0
输出:0
提示:
1 <= coins.length <= 12
1 <= coins[i] <= 231 - 1
0 <= amount <= 104
题目求解
核心:dp[S]:表示 组成S所需最少的硬币,其中[c0, c1, c2,...c_{n-1}]为 可选的n枚硬币;
动态规划公式:dp[s] = dp[s - c] + 1 ------ 其中C是该如何确定,不知道就要一一枚举所有硬币
python
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
Max = amount + 1
dp = [Max] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for j in range(len(coins)):
if i - coins[j] >= 0:
dp[i] = min(dp[i], dp[i - coins[j]] + 1)
return -1 if dp[amount] > amount else dp[amount]
单词分割
dp[i]:表示字符串s前i个字符组成的字符串 s[0,... i-1]是否能够被空格拆分成若干个字典中出现的单词。
动态规划方程:dp[i] = dp[j] && check(s[j,...i-1])
代码:
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n = len(s)
dp = [False] * (n + 1)
wordSet = set(wordDict) # 用set查询快
dp[0] = True
for i in range(1, n + 1):
for j in range(0, i):
if dp[j] and s[j:i] in wordSet:
dp[i] = True
return dp[n]
最长公共子序列(LCS)(LeetCode#1143)
题目描述
给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
两个字符串的 公共子序列 是这两个字符串所共同拥有的子序列。
示例 1:
输入:text1 = "abcde", text2 = "ace"
输出:3
解释:最长公共子序列是 "ace" ,它的长度为 3 。
示例 2:
输入:text1 = "abc", text2 = "abc"
输出:3
解释:最长公共子序列是 "abc" ,它的长度为 3 。
示例 3:
输入:text1 = "abc", text2 = "def"
输出:0
解释:两个字符串没有公共子序列,返回 0 。
提示:
1 <= text1.length, text2.length <= 1000
text1 和 text2 仅由小写英文字符组成。
题目求解
python
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m = len(text1)
n = 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]
最长递增子序列(LIS) (LeetCode#300)
题目描述
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
输入:nums = [0,1,0,3,2,3]
输出:4
示例 3:
输入:nums = [7,7,7,7,7,7,7]
输出:1
提示:
1 <= nums.length <= 2500
-104 <= nums[i] <= 104
进阶:
你能将算法的时间复杂度降低到 O(n log(n)) 吗?
题目求解
python
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
dp = [1] * n
for i in range(1, n):
for j in range(0, i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
分割等和子集(LeetCode#416)
题目描述
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
提示:
1 <= nums.length <= 200
1 <= nums[i] <= 100
题目求解
类似于 0-1背包问题;目标:选一个子集让它和 = 数组和的一半,要是总数为奇数直接返回False
DP定义:
- 物品:数组里面的数字
- 背包容量:sum / 2
- 要求:正好装满背包
- dp:[0,..., sum / 2+1],dp[0]=False
- 状态转移:dp[j] = dp[j] or dp[j-num] (不选当前数 or 选择当前数)
代码实现:
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
nums_sum = sum(nums)
if nums_sum % 2 != 0: return False
target = nums_sum // 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背包系列
问题描述
有 n 个物品,每个物品:
- 重量 w[i]
- 价值 v[i]
- 背包容量 C,每个物品只能选 1 次。
求:不超重前提下的最大价值。
核心 DP 定义
- dp[i][j] = 前 i 个物品,背包容量为 j 时的最大价值
转移方程
- 不放第 i 个 :
dp[i][j] = dp[i-1][j] - 放第 i 个(前提:装得下) :
dp[i][j] = dp[i-1][j - w[i]] + v[i]
取最大:
python
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])
空间优化(一维 DP,必考)
倒序遍历容量,防止重复选:
python
for i in 1..n:
for j in C..w[i]:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
最终答案:dp[C]
题目模板:
python
C = 4 # 背包容量
w = [1, 2, 3] # 重量
v = [2, 3, 4] # 价值
n = len(w)
# 求最大价值
dp = [[0] * (C + 1) for _ in range(n + 1)]
# 行: 前i个物品 列:背包容量j
# 初始化:
# 0个物品:价值都是0;容量为0, 价值也都是0
# 开始填表
for i in range(1, n + 1):
for j in range(1, C + 1):
# 对于每个j, 装/不装,注意下标,物品 0..n dp 1..n+1
if j < w[i - 1]:
# 当前物品根本装不下
dp[i][j] = dp[i-1][j]
else:
# 装还是不装的 价值多
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i - 1]] + v[i - 1])
# print(dp)
print(dp[n][C])
优化版本------ 空间优化(一维DP)
倒序遍历容量,防止重复选:
python
for i in 1..n:
for j in C..w[i]:
dp[j] = max(dp[j], dp[j - w[i]] + v[i])
代码实现:
python
C = 4 # 背包容量
w = [1, 2, 3] # 重量
v = [2, 3, 4] # 价值
n = len(w)
# 求最大价值
dp = [0] * (C + 1)
# 行: 前i个物品 列:背包容量j
# 初始化:
# 0个物品:价值都是0;容量为0, 价值也都是0
# 开始填表
for i in range(1, n + 1):
# 逆序:保证每件物品只选一次
for j in range(C, w[i-1] - 1, -1):
dp[j] = max(dp[j], dp[j-w[i - 1]] + v[i - 1])
# print(dp)
print(dp[C])
变体 1:恰好装满背包的最大价值
题意:必须刚好用满容量,求最大价值。
初始化:
python
dp[0] = 0
dp[1...C] = -∞(表示不可达)
转移不变,最后:dp[C] 若仍为负:无法装满;否则就是答案
变体 2:方案数(装满背包有多少种方法)
题意:不计价值,只问多少种选法能恰好装满容量 C。
DP 定义:dp[j] = 容量 j 时的方案数
转移:dp[j] += dp[j - w[i]]
初始化:dp[0] = 1
变体 3:最小重量 / 最小花费 型背包
题意:要达到至少价值 V,求最小重量 / 成本。
DP 定义:dp[v] = 达到价值 v 所需的最小重量
转移:dp[v] = min(dp[v], dp[v - val[i]] + wei[i])
变体 4:二维费用 0-1 背包
题意:每个物品消耗 重量 + 体积,两个上限。
DP 定义:dp[j][k] = 容量 j,体积 k 时的最大价值
转移:dp[j][k] = max(dp[j][k], dp[j - w][k - v] + val)
三层循环:物品 → 重量逆序 → 体积逆序
0-1 背包 vs 完全背包(必区分)
| 类型 | 物品数量 | 容量遍历 |
|---|---|---|
| 0-1 背包 | 1 个 | 逆序 |
| 完全背包 | 无限个 | 正序 |
0-1 逆序 → 不重复选
完全正序 → 可重复选