动态规划即Dynamic Programming,简化DP。如果某一问题有很多重叠子问题,那么使用动态规划是最有效的解决方法
- 动态规划中的每一个状态一定是由上一个状态推导出来的 ,这一点就区别于贪心算法,贪心算法没有状态推导,而是从局部直接选最优的
- 贪心算法解决不了动态规划的问题

1、动态规划包含哪几类题目:
- 动规基础类题目:如斐波那契数列、爬楼梯
- 背包问题:01背包、完全背包、多重背包
- 打家劫舍
- 股票问题
- 子序列问题:最长递增子序列、最长连续递增子序列、编辑距离
2、 **动态规划题目的解题步骤:**动规五部曲
- 确定dp数组(状态转移时会定义dp table。一维?二维?)及下标的含义
- 确定递推公式(状态转移公式)
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组:打印,用来debug
要先确定递推公式,再考虑初始化dp数组。因为递推公式决定了如何初始化dp数组
3、动态规划应该如何排查问题
- 把状态转移在dp数组中的具体情况模拟一遍
- 若代码没通过,把dp数组打印出来
- 如果打印出来和自己预先模拟推导是一样的,那么就是递归公式、初始化或者遍历顺序有问题
- 如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题
题目分类

基础题目
509. 斐波那契数
dp法
- dp[i] 为第 i 个斐波那契数的数值
- 递推公式为 dp[i] = dp[i-1] + dp[i-2]
- 初始化 dp[0] = 0,dp[1] = 1
- 从前往后遍历,因为 dp[i] 依赖 dp[i-1] 和 dp[i-2]
python
class Solution:
def fib(self, n: int) -> int:
if n <= 1:
return n
dp = [0] * (n+1) # 0~n的数组
dp[0], dp[1] = 0, 1
for i in range(2, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
- 时间复杂度和空间复杂度都是 O(n)
以上代码可以优化,因为只需要维护两个数值就可以了,不需要记录整个数列。优化后空间复杂度为 O(1)
python
class Solution:
def fib(self, n: int) -> int:
if n <= 1:
return n
pre1, pre2 = 0, 1
for _ in range(2, n+1):
cur = pre1 + pre2
pre1, pre2 = pre2, cur
return cur
递归法
python
class Solution:
def fib(self, n: int) -> int:
if n <= 1:
return n
return self.fib(n-1) + self.fib(n-2)
- 时间复杂度:O(2^n)。对于
fib(n),每次调用会产生两个子调用:fib(n-1)、fib(n-2)。递归树的深度是 n,但节点数量呈指数级增长 - 空间复杂度:O(n),为递归调用栈深度
70. 爬楼梯
爬到第一层楼梯有一种方法,爬到第二层楼梯有两种方法。那么第一层楼梯再跨两步就到第三层,第二层楼梯再跨一步就到第三层。所以到第三层楼梯的状态可以由到第二层楼梯和到第一层楼梯的状态推导出来。那么就可以想到动态规划了。即:到三阶有几种方法 = 到一阶有几种方法 + 到二阶有几种方法,以此类推,即当前状态依赖前两种状态
- dp[i] 表示到达第 i 阶有 dp[i] 种方法,由前两种状态推导出来
- 递推公式为 dp[i] = dp[i-1] + dp[i-2]

- dp[0] 初始化没有意义,且题目中说了n是正整数;dp[1] = 1,dp[2] = 2,然后从 i=3 开始递推
- 从前向后遍历
python
class Solution:
def climbStairs(self, n: int) -> int:
if n <= 2:
return n
dp = [0] * (n+1)
dp[1], dp[2] = 1, 2
for i in range(3, n+1):
dp[i] = dp[i-1] + dp[i-2]
return dp[n]
优化空间复杂度的版本:
python
class Solution:
def climbStairs(self, n: int) -> int:
if n <= 2:
return n
pre1, pre2 = 1, 2
for _ in range(3, n+1):
cur = pre1 + pre2
pre1, pre2 = pre2, cur
return cur
本题dp数组的数值其实就是斐波那契数列,和 509题唯一的区别是,本题没有讨论 dp[0] 应该是什么,因为没有意义
【进阶】一步一个台阶,两个台阶,三个台阶,直到m个台阶,有多少种方法爬到n阶楼顶?
- 完全背包问题
- leetcode上没有原题
746. 使用最小花费爬楼梯
可以选择下标0或1的位置开始,站在0或1的位置并不消耗体力值,要往上跳才消耗
顶楼是 len(cost) 的位置,故dp数组的大小 = cost数组的大小 + 1
- 一维dp数组,dp数组的下标记录的是到哪个台阶了,dp数组对应的值代表最小消耗。因此dp数组的含义为:到达下标 i 的位置所需要的最小花费为 dp[i]
- 可以有两个途径得到dp[i],一个是dp[i-1],一个是dp[i-2] (因为题目描述说了可选择向上爬一个或者两个台阶)。dp[i-1] 跳到 dp[i] 需要花费 dp[i-1] + cost[i-1],dp[i-2] 跳到 dp[i] 需要花费 dp[i-2] + cost[i-2],所以递推公式为 dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
- dp[0] = dp[1] = 0,因为到达下标0或1的位置不需要跳,题目说了可以从这俩位置开始跳,跳的时候才花费体力值
- dp[i] 由 dp[i-1]、dp[i-2] 推出,因此一定是从前向后遍历
python
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp = [0] * (len(cost)+1)
dp[0] = dp[1] = 0
for i in range(2, len(cost)+1):
dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
return dp[len(cost)]
因为 dp[i] 是由前两位推导出来的,所以可以不用定义完整的dp数组了。优化空间复杂度的版本:
python
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp0 = dp1 = 0
for i in range(2, len(cost)+1):
dpi = min(dp1+cost[i-1], dp0+cost[i-2])
dp0 = dp1
dp1 = dpi
return dpi
- 两个版本时间复杂度都是 O(n)
- 空间复杂度从 O(n) -> O(1)
另一种理解题意的写法
跳到0的位置和1的位置所需要的花费分别为cost[0]、cost[1]
到终点时不需要花费体力值
- dp数组的大小 = cost数组的大小
- 那么递推公式变为 dp[i] = min(dp[i-1], dp[i-2]) + cost[i](每当爬上一个台阶都要花费对应的体力值)
- 初始化 dp[0] = cost[0],dp[1] = cost[1]
python
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp = [0] * len(cost)
dp[0], dp[1] = cost[0], cost[1]
for i in range(2, len(cost)):
dp[i] = min(dp[i-1], dp[i-2]) + cost[i]
# 最后一步不用花费体力值,所以取倒数第一步和倒数第二步的最小值
return min(dp[-1], dp[-2])
优化空间复杂度的版本:
python
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp0, dp1 = cost[0], cost[1]
for i in range(2, len(cost)):
dpi = min(dp0, dp1) + cost[i]
dp0 = dp1
dp1 = dpi
return min(dp0, dp1)
62. 不同路径
每次只能向下或者向右移动一步
深度优先搜索
图论深搜超时,时间复杂度为 O(2^(m+n-1) - 1)
动态规划
- 二维dp数组,**dp[i][j] 表示从 [0,0] 位置走到 [i,j] 位置有多少种不同的路径。**终点为 [m-1, n-1] 位置
- 想要求 dp[i][j],只能由两个方向推导出来,即 dp[i-1][j] 和 dp[i][j-1],前者向下再走一步,后者向右再走一步即可。因此递推公式为 dp[i][j] = dp[i-1][j] + dp[i][j-1]
- 如何初始化呢?首先 dp[i][0] 一定都是1,因为从 [0,0] 位置到 [i,0] 位置的路径只有一条(纵向);dp[0][j] 也同理,一定都是1(横向)
- 从左往右遍历,从上往下遍历(因为初始值在左边和上边)
python
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[0] * n for _ in range(m)] # m×n
# 初始化
for i in range(m):
dp[i][0] = 1
for j in range(n):
dp[0][j] = 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(m×n)
63. 不同路径II
相比62题多了障碍物
- 递推公式要加一个条件,即当 [i,j] 位置没有障碍物时,才递推
- 初始化时,障碍之后、障碍之下,dp取值全是0,不需要初始化为1,因为无法走到。即一旦遇到 obstacleGrid[i][0] == 1 的情况就停止 dp[i][0] 的赋值1的操作,dp[0][j] 同理
- 另外,若起始位置或终止位置有障碍,直接返回0

python
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
# 如果初始位置或终点位置有障碍,直接返回0
if obstacleGrid[0][0] == 1 or obstacleGrid[m-1][n-1] == 1:
return 0
dp = [[0] * n for _ in range(m)] # m×n
# 初始化
for i in range(m):
if obstacleGrid[i][0] == 0: # 没有障碍物时才做初始化
dp[i][0] = 1
else:
break # 遇到障碍物,后面的位置都无法到达
for j in range(n):
if obstacleGrid[0][j] == 0:
dp[0][j] = 1
else:
break # 遇到障碍物,后面的位置都无法到达
# 递推公式
for i in range(1, m):
for j in range(1, n):
if obstacleGrid[i][j] == 0: # 没有障碍物时才递推
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m-1][n-1]
- 时间复杂度:O(n×m),n、m 分别为 obstacleGrid 的长度和宽度
- 空间复杂度:O(n×m)
注意初始化时要加
break,break确保了障碍物之后的位置保持为 0,因为障碍物会阻断路径,后面的位置都不可达,不应该赋值为1
343. 整数拆分
只有拆之后的数数值近似相等,乘积才尽可能大
- dp[i] 为对数字 i 进行拆分,拆分后得到的最大乘积
- 如何对 i 进行拆分?
- 如果是拆成两个数的情况,即 j × (i-j),其中 j 是遍历从 1 ~ i-1 的所有情况
- 如果是拆成三个及三个数以上,即 j × dp[i-j],后者相当于拆分 i-j
- 比较上面两种情况的最大值,递推公式:dp[i] = max(dp[i], (i-j) * j, dp[i-j] * j)

- 0拆分不了,所以 dp[0] 没有意义;1同理。因此初始化 dp[2] = 1,表示拆分数字2得到的最大乘积是1
- 从 i=3 开始遍历,遍历到 i <= n 位置,最后求的就是 dp[n];再遍历j,j去拆分i,要取各种 j 拆分的情况
- j=1; j < i-1 ( j 的结束条件是 j < i-1 ,其实 j < i 也是可以的,不过可以节省一步,例如让 j = i-1 的话,其实在 j = 1 的时候,这一步就已经拆出来了,重复计算)
- 这里有个优化:j=1; j <= i/2(拆一个数时,尽可能给它拆成近似相同的m个数,乘积才尽可能最大)
python
class Solution:
def integerBreak(self, n: int) -> int:
dp = [0] * (n+1)
dp[2] = 1
for i in range(3, n+1): # i: [3, n]
for j in range(1, i-1): # j: [1, i-1)
dp[i] = max(dp[i], j*(i-j), j*dp[i-j])
return dp[n]
j 的取值范围可以优化,其它代码不变:
python
for j in range(1, i//2+1): # j: [1, i//2]
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
96. 不同的二叉搜索树【难】


- 当1为头结点的时候,其右子树有两个节点,这两个节点的布局和 n为2 的时候两棵树的布局是一样的
- 当2为头结点的时候,其左右子树都只有一个节点,布局和 n为1 的时候只有一棵树的布局也是一样的
- 当3为头结点的时候,其左子树有两个节点,这两个节点的布局和 n为2 的时候两棵树的布局也是一样的
- 注意,仅看布局,数值大小无所谓
- 重叠子问题:可以通过 dp[1] 和 dp[2] 推导出来 dp[3] 的某种方式

(1)dp数组的含义,输入是i,输出是有 dp[i] 种不同的二叉搜索树(1~i 为节点组成的二叉搜索树的个数)
(2)分别以 1~i 为头结点的所有情况相加,用 j 来枚举。以 j 为头结点,它的左子树有 j-1 个节点,右子树有 i-j 个节点
- 递推关系: dp[i] += dp[以 j 为头结点左子树节点数量] * dp[以 j 为头结点右子树节点数量]
- j 相当于是头结点的元素,从1遍历到 i
- 所以递推公式:dp[i] += dp[j-1] * dp[i-j]
(3)初始化 dp[0]=1(空二叉树也是一种二叉搜索树),dp[1]=1(这个可以不初始化了,从递推公式中根据 dp[0] 是可以推出来的)

(4)从小到大遍历,dp[i] 都是依赖前面的状态推导出来的
python
class Solution:
def numTrees(self, n: int) -> int:
dp = [0] * (n+1)
dp[0] = 1
for i in range(1, n+1):
for j in range(1, i+1):
dp[i] += dp[j-1] * dp[i-j]
return dp[n]
- 时间复杂度:O(n^2)
- 空间复杂度:O(n)
背包问题

- 01背包: 有n种物品,**每种物品只有一个(一个物品只能放入背包一次)。**每个物品有自己的重量和价值,有一个最多只能放重量为m的背包,问这个背包最多能装价值为多少的物品?
- 完全背包: 有n种物品,每种物品有无限个(一个物品如果可以重复多次放入背包,则是~)
- 多重背包: 有n种物品,每种物品的个数各不相同

背包问题难点在初始化&遍历顺序
01背包
- 纯01背包:装满背包的最大价值
- 分割等和子集:能不能装满背包,能的话返回True,不能的话返回False
- 最后一块石头的重量II:尽可能装满背包,求背包能装的最大重量
- 目标和:给背包的容量,求有多少种方式能把这个背包装满,装不满则输出0
- 一和零:装满背包最多有多少个物品
纯粹的01背包问题
**问题描述:**有n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大?
暴力解法: 每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法 搜索出所有的情况,那么时间复杂度就是O(2^n),这里的n表示物品数量

二维dp
(1)dp[i][j] 表示从下标为 [0-i] 的物品里任意取,放进容量为 j 的背包,价值总和最大是多少

(2)从以下两个方向可以推导 dp[i][j],递推公式就是在两种情况里取max
- **不放物品i:**背包容量为 j,里面不放物品 i 的最大价值是dp[i-1][j]
- 放物品i:
- 背包空出物品 i 的容量后,背包容量为 j - weight[i]
- dp[i-1][j - weight[i]] 为背包容量为 j - weight[i] 且不放物品 i 的最大价值
- 那么 dp[i-1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值
以 dp[1][4] 的状态来举例,有两种情况:
- 不放物品1, 那么背包的价值应该是 dp[0][4],即容量为4的背包,只放物品0的情况(从正上方推导而来)
- 放物品1, 那么背包要先留出物品1的容量,目前容量是4,物品1的容量为3,此时背包剩下容量为1。容量为1,只考虑放物品0的最大价值是 dp[0][1],这个值我们之前就计算过。所以放物品1的情况 = dp[0][1] + 物品1的价值(从左上方推导而来)

(3)初始化
- 第一列: 从dp[i][j] 的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0
- 第一行: 从递推公式可以看出,i 是由 i-1 推导出来,那么i为0的时候就一定要初始化
- dp[0][j] 即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值
- 那么很明显当
j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小 - 当
j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品
- **其他(即 i 和 j 都非0):**dp[i][j] 是由左上方数值推导出来的,其他下标初始为什么数值都可以,因为都会被覆盖
- 注:一开始就统一把dp数组初始化为0更方便一些

(4)二维dp数组时,先遍历物品还是先遍历背包重量都是可以的
- 虽然两个for循环遍历的次序不同,但是 dp[i][j] 所需要的数据都来自二维矩阵的左上角,根本不影响 dp[i][j] 公式的推导
下面的代码以先遍历物品再遍历背包重量为例
python
def func(weight, value, bag_weight):
# 二维dp数组
dp = [[0] * (bag_weight+1) for _ in range(len(value))]
# 初始化
for j in range(weight[0], bag_weight+1):
dp[0][j] = value[0]
# 先遍历物品再遍历背包
for i in range(1, len(weight)):
for j in range(bag_weight+1):
if j < weight[i]:
dp[i][j] = dp[i-1][j]
else:
dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i])
return dp[len(weight)-1][bag_weight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
print(func(weight, value, bag_weight)) # 35
把两个for循环颠倒过来,其他代码不变,也是ok的。即:
python
for j in range(bag_weight+1):
for i in range(1, len(weight)):
一维dp(滚动数组)
把二维dp数组降为一维dp数组(对于01背包问题,dp数组的状态是可以压缩的),空间复杂度还降低了一个数量级
- 把 dp[i-1] 那一层的数据复制到 dp[i] 上
- 这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接复制到当前层
(1)在一维dp数组中,dp[j]表示:容量为j的背包,所背物品的最大价值
(2)有两个选择,递推公式即取两者的max
- **不放物品i:**dp[j]
- **放物品i:**dp[j] 可以通过 dp[j - weight[i]] 推导出来,dp[j - weight[i]] 表示容量为 j - weight[i] 的背包所背的最大价值。此时再放入物品i,最大价值为 dp[j - weight[i]] + value[i]
- 可以看出相对于二维dp数组的写法,一维就是把 dp[i][j] 中 i 的维度去掉了
python
# 二维
dp[i][j] = max(dp[i-1][j], dp[i-1][j - weight[i]] + value[i]);
# 一维
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
(3)初始化:都初始化为0
- dp[0] 就应该是0,因为容量为0的背包所背的物品的最大价值就是0
- 其他下标:如果题目中给的价值都是正整数,那么非零下标的数组都初始化为0,这样才能让dp数组在遍历递推公式的过程取的是最大的价值,而不是被初始值覆盖了
(4)必须先遍历物品,后遍历背包,不能反过来;且遍历背包时需要倒序遍历(从大到小)
- 为什么一维dp数组遍历背包时要倒序遍历?**倒序遍历是为了保证物品i只被放入一次背包,**正序遍历,物品0就会被重复加入多次

- **为什么二维dp数组遍历背包的时候不用倒序呢?**因为对于二维dp,dp[i][j] 都是通过上一层即dp[i-1][j] 计算而来,本层的 dp[i][j] 并不会被覆盖
- 为什么不能先遍历背包再遍历物品? 因为一维dp背包容量一定是要倒序遍历,如果遍历背包容量放在上一层,那么每个 dp[j] 就只会放入一个物品 ,即背包里只放入了一个物品。为什么?
- 当背包容量固定时,内层循环是在竞争这个容量
- 对于固定的容量 j,所有物品都在争夺这个容量
- 每次更新都是基于之前的 dp 值(可能已经包含了其他物品)
- 但由于容量 j 是固定的,最终只会选择价值最大的单个物品
- 这就像每个容量都在独立地选择一个最优的单个物品,而不是考虑物品的组合
python
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
dp = [0] * 5
# 错误:先遍历背包,再遍历物品
for j in range(bag_weight, -1, -1): # 容量从大到小
for i in range(len(weight)): # 遍历物品
if j >= weight[i]:
dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
# 执行过程
初始: dp = [0, 0, 0, 0, 0]
j=4时:
i=0: dp[4] = max(0, dp[3]+15) # dp[3]=0 → 15
i=1: dp[4] = max(15, dp[1]+20) # dp[1]=0 → 20
i=2: dp[4] = max(20, dp[0]+30) # dp[0]=0 → 30
dp[4] = 30 # 只选了物品2
j=3时:
i=0: dp[3] = max(0, dp[2]+15) # dp[2]=0 → 15
i=1: dp[3] = max(15, dp[0]+20) # dp[0]=0 → 20
i=2: j=3 < weight[2]=4,跳过
dp[3] = 20 # 只选了物品1
j=2时:
i=0: dp[2] = max(0, dp[1]+15) # dp[1]=0 → 15
i=1: j=2 < weight[1]=3,跳过
i=2: j=2 < weight[2]=4,跳过
dp[2] = 15 # 只选了物品0
j=1时:
i=0: dp[1] = max(0, dp[0]+15) # 15
其他物品重量都大于1,跳过
dp[1] = 15 # 只选了物品0
# 最终结果
dp[4] = 30 ❌ (只放了物品2)
正确答案应该是 35 (物品0+物品1)
(5)正确的dp数组如下:

python
def func(weight, value, bag_weight):
# 一维dp数组
dp = [0] * (bag_weight+1)
# 先遍历物品再遍历背包
for i in range(len(weight)):
# 遍历背包要倒序
for j in range(bag_weight, weight[i]-1, -1):
dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
return dp[bag_weight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
print(func(weight, value, bag_weight)) # 35
面试题
- 实现一个二维数组的01背包
- 为什么两个for循环的嵌套顺序是这样的?顺序反过来行不行?
- 初始化的逻辑是什么?
- 实现一个一维数组的01背包
- 一个遍历物品的for循环嵌套一个遍历背包容量(倒序遍历)的for循环
- 两个for循环的顺序反过来行不行?为什么?
46. 携带研究材料(第六期模拟笔试)
二维dp
一维dp(滚动数组)
当前层是由上一层推导出来的,把上一层拷贝到当前层,直接在当前层进行计算,把新的值覆盖到当前层之中。然后下一轮计算的时候再从当前层取值
相当于把一个矩阵压缩成一行,每次计算都更新这一行数据
01背包问题的应用
416. 分割等和子集
因为每个元素只能使用一次,所以为01背包(一个物品只能放入背包一次)。只要集合中出现 sum/2 的子集总和,就算这个数组可以分割成两个相同元素和的子集了
- 背包的体积为 sum/2
- 背包中要放入的物品(集合中的元素)的重量和价值都是元素的数值
- 如果背包正好装满,说明找到了总和为 sum/2 的子集
- 背包中的每一个元素不可重复放入
(1)dp数组的定义
- 01背包中,dp[j] 表示: 容量为j的背包,所背物品的价值最大可以为 dp[j]
- 如果背包容量为 target = sum/2 ,dp[target] 就是装满背包之后的总价值。因为本题中每一个元素的数值既是重量也是价值,所以当 dp[target] == target 的时候,背包就装满了
- dp[j] 表示总容量为j的背包最大可以凑成的总和
dp[j]的数值一定是小于等于j的。如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和 j
(2)递推公式
- 01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
- 本题相当于在背包里放入数值,那么物品i的重量是nums[i],价值也是nums[i]
- 所以递推公式为:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
(3)初始化
- 从 dp[j] 的定义来看,首先dp[0]一定是0
- 题目中给出的价值都是正整数,那么非0下标的数组都初始化为0即可。这样才能让dp数组在递推的过程中取最大的价值,而不是被初始值覆盖
如果题目给的价值都是正整数,那么非0下标都初始化为0就可以了;如果题目给的价值有负数,那么非0下标就要初始化为负无穷
- 总和 ≤ 20000,背包的最大容量只需其中一半,所以初始化一个大小为 10001 的数组就够了

(4)遍历顺序
- 如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层
- 且内层for循环倒序遍历
python
class Solution:
def canPartition(self, nums: List[int]) -> bool:
if sum(nums) % 2 != 0:
return False
target = sum(nums) // 2 # 背包容量
dp = [0] * 10001 # dp[i]中的i表示背包内元素的总和
for i in range(len(nums)):
for j in range(target, nums[i]-1, -1):
dp[j] = max(dp[j], dp[j-nums[i]]+nums[i])
# 集合中的元素正好可以凑成总和target
if dp[target] == target:
return True
return False
- 时间复杂度:O(n^2)
- 空间复杂度:O(n),虽然dp数组大小为一个常数,但是为大常数(比较大的常数)
1049. 最后一块石头的重量II
把石头尽可能地分成两堆,这两堆的重量如果相似的话,那么相撞之后所剩的值就是最小值
- 一堆的石头重量是sum,那么就尽可能拼成重量为 sum / 2 的石头堆。这样剩下的石头堆也是尽可能接近 sum/2 的重量
- 那么此时问题就是有一堆石头,每个石头都有自己的重量,是否可以装满最大重量为 sum / 2的背包
- 上题【416. 分割等和子集】是求背包是否正好装满,而本题是求背包最多能装多少
(1)dp数组的定义
- dp[j] 为装满容量为j的背包的最大重量
- 每个元素既是重量也是价值
(2)递推公式
- 本题中,石头的重量是 stones[i],价值也是 stones[i]
- dp[j] = max(dp[j], dp[j - stones[i]] + stones[i])
(3)初始化

- 背包容量是 sum/2 = 3000 / 2 = 1500(极端情况下),因此dp数组定义大小为1501(为了防止边界情况)即可
- 数值都初始化为0
(4)遍历顺序
- 如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历
- dp[target] 是容量为target的背包所能背的最大重量。那么分成两堆石头,一堆石头的总重量是 dp[target],另一堆石头的总重量是 sum - dp[target]
- 由于 target = sum / 2(向下取整),因此后者 sum - dp[target] 一定 ≥ 前者 dp[target]
- 因此后者 - 前者 = sum - dp[target] - dp[target] 就是我们要求的结果(相撞之后剩下的最小石头重量)
python
class Solution:
def lastStoneWeightII(self, stones: List[int]) -> int:
dp = [0] * 1501
target = sum(stones) // 2
for i in range(len(stones)):
for j in range(target, stones[i]-1, -1):
dp[j] = max(dp[j], dp[j-stones[i]] + stones[i])
return sum(stones) - dp[target] - dp[target]
- 时间复杂度:O(m × n) , m是石头总重量(准确的说是总重量的一半),n为石头块数
- 空间复杂度:O(m)
494. 目标和
回溯
参考【39. 组合总和】问题,每个元素放+还是放-,有两种方案。时间复杂度为O(2^n),可能超时
【39. 组合总和】如果仅仅问有多少种方法可以凑成目标和,可以使用背包。但若要求的是每种方法具体的组合,则只能使用回溯算法
动态规划
假设加法集合left(正数集合里所有元素的和),减法集合right,那么有
- left + right = sum(所有元素的和)
- left - right = target(目标和)
进而推出 left = (target + sum) / 2,为背包的容量(向下取整)。此时问题就转化为:装满容量为left的背包有几种方法?(有多少种组合使挑出的元素的和等于left)
- 如果整除不了,说明找不出这样的集合,返回0
- 每个物品(即题目中的元素)只使用一次,所以是01背包问题
(1)dp数组的定义
- dp[j] 表示:装满j(包括j)这么大容量的背包,有dp[j]种方法
(2)递推公式
- 在不考虑 nums[i] 的情况下,装满容量为 j-nums[i] 的背包,有 dp[j - nums[i]] 种方法
- 那么只要找到 nums[i],凑成 dp[j] 就有 dp[j - nums[i]] 种方法
- 求组合类问题的递推公式都是类似:dp[j] += dp[j - nums[i]]。这个公式在后面讲解使用背包解决排列组合问题时还会用到
在求装满背包有几种方法的情况下,递推公式一般都为 dp[j] += dp[j - nums[i]],后面【完全背包】还会用到这个递推公式
(3)初始化
- dp[0] = 1。有两种理解:(1)dp[0]是一切递推结果的起源,如果dp[0]是0,则递推结果都是0;(2)理论解释:装满背包容量为0的方法有一种,放0件物品
- 非0下标:初始化为0
(4)遍历顺序
- 遍历物品(nums)放在外循环,遍历背包(target)在内循环,且内循环倒序(为了保证物品只使用一次)
python
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
sum_nums = sum(nums)
bag = (sum_nums + target) // 2
if abs(target) > sum_nums:
return 0
if (sum_nums + target) % 2 != 0:
return 0
dp = [0] * (bag + 1)
dp[0] = 1
for i in range(len(nums)):
for j in range(bag, nums[i]-1, -1):
dp[j] += dp[j - nums[i]]
return dp[bag]
- 时间复杂度:O(n × m),n为正整数个数,m为背包容量
- 空间复杂度:O(m),m为背包容量
474. 一和零【有两个维度的01背包】
数组中的元素(不同长度的字符串)就是物品,每种物品只有一个
m和n相当于是一个有两个维度的背包,装满背包最多可以有多少个物品
(1)dp数组的定义
- dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]
- 即最大背 dp[i][j] 个物品,最终求的是 dp[m][n]
(2)递推公式
- 01背包的递推公式为:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
- 本题重量相当于x个0,y个1,因为第二项类比为 dp[i - x][j - y] + 1(+1表示把物品放进来)

(3)初始化
- dp[0][0] = 0,背包容量为0时,所背物品的最大个数自然为0
- 非0下标都初始化为0即可。因为物品价值不会是负数,初始为0,保证递推的时候dp[i][j]不会被初始值覆盖
(4)遍历顺序
- 外层for循环遍历物品,内层for循环遍历背包容量且从后向前遍历
- 物品就是strs里的字符串,背包容量就是题目描述中的m和n
- 遍历背包容量的两层for循环先后顺序没有讲究,都是物品重量的一个维度,先遍历哪个都行
python
class Solution:
def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
dp = [[0] * (n+1) for _ in range(m+1)] # m+1行,n+1列
for idx in range(len(strs)):
zero_cnt, one_cnt = 0, 0 # 每个字符串单独统计0和1的个数
for char in strs[idx]:
if char == '1':
one_cnt += 1
else:
zero_cnt += 1
# 遍历背包,背包重量有m和n两个维度
for i in range(m, zero_cnt-1, -1):
for j in range(n, one_cnt-1, -1):
dp[i][j] = max(dp[i][j], dp[i-zero_cnt][j-one_cnt] + 1)
return dp[m][n]
完全背包
和01背包的区别:完全背包中每个物品可以使用无数次
纯粹的完全背包问题
问题描述: 有n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包,背包里物品的价值总和最大?

代码上和01背包的区别:遍历顺序
- 完全背包:遍历背包容量时为正序遍历(区别于01背包的倒序遍历)
- 完全背包:一维dp数组两个for循环的嵌套顺序无所谓(区别于01背包,二维dp数组的两个for循环遍历的先后顺序是可以颠倒的,一维dp数组的两个for循环一定是先物品再背包)

cpp
// 01背包核心代码
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量,倒序遍历,保证每个物品只被添加一次
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
// 完全背包核心代码
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量,每个物品可以被添加多次,所以要从小到大遍历
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
// 完全背包先遍历背包再遍历物品,也可
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
完整代码(Python版本)
先遍历物品,再遍历背包:
python
def func(weight, value, bag_weight):
# 一维dp数组
dp = [0] * (bag_weight+1)
# 先遍历物品再遍历背包
for i in range(len(weight)):
for j in range(weight[i], bag_weight+1):
dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
return dp[bag_weight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
print(func(weight, value, bag_weight)) # 60
先遍历背包,再遍历物品:
python
def func(weight, value, bag_weight):
# 一维dp数组
dp = [0] * (bag_weight+1)
# 先遍历背包再遍历物品
for j in range(bag_weight+1):
for i in range(len(weight)):
if weight[i] <= j:
dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
return dp[bag_weight]
if __name__ == "__main__":
weight = [1, 3, 4]
value = [15, 20, 30]
bag_weight = 4
print(func(weight, value, bag_weight)) # 60
**对于纯完全背包问题,其for循环的先后顺序是可以颠倒的。**因为纯完全背包问题关注的是元素能否凑成总和,和凑成总和的元素顺序没有关系
如果问装满背包有几种方式,那么两个for循环的先后顺序就有很大区别了,取决于元素之间要求有顺序(排列)还是没有顺序(组合)
52. 携带研究材料(第七期模拟笔试)
完全背包问题的应用
纯粹的完全背包:装满背包的最大价值
零钱兑换II:装满背包有多少种方法,每种方法集合里不强调元素顺序
组合总和IV:装满背包有多少种方法,每种方法集合里强调元素顺序
零钱兑换:装满背包最少用多少件物品
- 求组合数:不强调元素之间的顺序。需要先遍历物品,再遍历背包
- 518
- 求排列数:强调元素之间的顺序。需要先遍历背包,再遍历物品
- 377、多步爬楼梯、139
- 求最小数:有顺序和没有顺序都无所谓,所以for循环的两种写法都可以
- 322、279
518. 零钱兑换II【求组合】
【494. 目标和】那题每个物品只能使用一次,而本题每个物品可以使用无限次。其他都一样,这两题可以对比着看
(1)dp数组的定义
- dp[j] 表示:装满j(包括j)这么大容量的背包,有 dp[j] 种方法
- 最终要求的就是 dp[amount]
(2)递推公式
- 放每一个物品的情况做一个累加:dp[j] += dp[j - coins[i]]
(3)初始化
- dp[0] = 1。有两种理解:(1)dp[0]是一切递推结果的起源,如果dp[0]是0,则递推结果都是0;(2)理论解释:装满背包容量为0的方法有一种,放0件物品
- 非0下标:初始化为0。这样累计 dp[j - coins[i]] 的时候才不会影响真正的dp[j]
(4)遍历顺序
- 本题物品=硬币,背包=金钱总额
- 求组合数必须要先遍历物品,再遍历背包,不可反过来
python
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
dp = [0] * (amount + 1)
dp[0] = 1
for i in range(len(coins)):
for j in range(coins[i], amount+1):
dp[j] += dp[j - coins[i]]
return dp[amount]
先遍历物品再遍历背包是组合数 ,先遍历背包再遍历物品是排列数
377. 组合总和IV【求排列】
- 本题不限制元素的使用次数 -> 完全背包
- 本题顺序不同的序列被视作不同的集合 -> 排列 -> 需要注意for循环的先后顺序(先背包后物品)
本题仅求排列总和的个数,并不需要把所有的排列都列出来(只能使用回溯)
(1)dp数组的定义
- dp[j] 表示:凑成目标正整数为 j 的排列个数
(2)递推公式
- dp[j] += dp[j - nums[i]](求装满背包有几种方法的递推公式都是一样的,关键在于遍历顺序)
(3)初始化
- dp[0] = 1,这样递归其他 dp[j] 的时候才会有数值基础。题目中说了给定目标值是正整数,所以dp[0] = 1是没有意义的,仅仅是为了推导递推公式
- 非0下标:初始化为0
(4)遍历顺序
- 先背包后物品
python
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
dp = [0] * (target + 1)
dp[0] = 1
for j in range(target+1):
for i in range(len(nums)):
if nums[i] <= target:
dp[j] += dp[j - nums[i]]
return dp[target]
对于纯完全背包类问题(求装满背包的最大价值,或能不能装满背包),两层for循环怎么颠倒都可以
但是完全背包在不同场景下,如问装满这个背包有多少种方法时,此时要区分求的是组合数(不强调集合里元素的顺序)还是排列数(强调集合里元素的顺序)
- 组合数:先遍历物品,再遍历背包
- 排列数:先遍历背包,再遍历物品
多步爬楼梯【求排列】
**问题描述:**假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬至多 m (1 <= m < n) 个台阶。你有多少种不同的方法可以爬到楼顶呢?(是【70. 爬楼梯】的加强版)
- 1阶,2阶,.... m阶就是物品,楼顶n就是背包
- 每一阶可以重复使用,例如跳了1阶,还可以继续跳1阶 ------ 完全背包
- 问跳到楼顶有几种方法,其实就是问装满背包有几种方法
(1)dp数组的定义
- dp[j] 表示:爬到有 j 个台阶的楼顶,有 dp[j] 种方法
(2)递推公式
- dp[j] += dp[j - i],i表示的是一次爬几个台阶
(3)初始化
- dp[0] = 1
- 非0下标:初始化为0
(4)遍历顺序
- 这是背包里求排列问题,即:1、2步 和 2、1步都是上三个台阶,但是这两种爬楼梯的方法不一样
- 所以需将target(这里为楼顶n)放在外循环,nums(这里为一次跳几阶)放在内循环
python
def func(n, m):
# 一维dp数组
dp = [0] * (n+1)
dp[0] = 1
# 先遍历背包再遍历物品
for j in range(1, n+1): # 注意从1开始
for i in range(1, m+1): # 注意从1开始
if i <= j:
dp[j] += dp[j - i]
return dp[n]
if __name__ == "__main__":
print(func(3, 2)) # 3
测试用例的含义:

57. 爬楼梯(第八期模拟笔试)【求排列】
322. 零钱兑换【求最小数】
每种硬币的数量是无限的 -> 典型的完全背包问题 -> 装满背包最少用多少件物品
(1)dp数组的定义
- dp[j] 表示:凑足总额为j所需钱币的最少个数为dp[j]
- 最终求的是 dp[amount]
(2)递推公式
- 凑足总额为 j - coins[i] 的最少个数为dp[j - coins[i]],那么只需加上一个钱币coins[i],即dp[j - coins[i]] + 1,就是dp[j](考虑coins[i])
- 所以 dp[j] 要取所有 dp[j - coins[i]] + 1 中的最小值
- 递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j])
(3)初始化
- dp[0] = 0,因为凑足总金额为0所需钱币的个数一定是0
- 非0下标:考虑到递推公式的特性,dp[j] 必须初始化为一个最大的数,否则就会在 min(dp[j - coins[i]] + 1, dp[j]) 比较的过程中被初始值覆盖,所以下标非0的元素都应该初始化为最大值
(4)遍历顺序
- 本题求钱币的最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数 。所以本题并不强调集合是组合还是排列
- 如果求组合数就是外层for遍历物品,内层for遍历背包
- 如果求排列数就是外层for遍历背包,内层for遍历物品
- 因此本题for循环的顺序无所谓
python
# 写法1
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# coins(物品)放在外循环,amount(背包)放在内循环
dp = [float("inf")] * (amount + 1)
dp[0] = 0
for i in range(len(coins)):
for j in range(coins[i], amount+1):
dp[j] = min(dp[j], dp[j-coins[i]]+1)
return dp[amount] if dp[amount] != float("inf") else -1
# 写法2
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
# amount(背包)放在外循环,coins(物品)放在内循环
dp = [float("inf")] * (amount + 1)
dp[0] = 0
for j in range(amount+1):
for i in range(len(coins)):
if j >= coins[i]:
dp[j] = min(dp[j], dp[j-coins[i]]+1)
return dp[amount] if dp[amount] != float("inf") else -1
279. 完全平方数【求最小数】
不会有完全平方数的和凑不到n的情况,因为存在1
翻译一下,题意为:完全平方数就是物品(可以无限使用),正整数n就是背包,问凑满这个背包最少有多少个物品?
(1)dp数组的定义
- dp[j] 表示:和为j的完全平方数的最少数量为dp[j]
(2)递推公式
- dp[j] 可以由 dp[j - i * i] 推出, dp[j - i * i] + 1 便可以凑成 dp[j]
- 此时要选择最小的dp[j],所以递推公式:dp[j] = min(dp[j - i * i] + 1, dp[j])
(3)初始化
- dp[0] = 0。完全是为了推导递推公式,无实际含义
- 非0下标:初始为最大值
(4)遍历顺序
- 求最小数,for循环顺序无所谓
python
# 写法1
class Solution:
def numSquares(self, n: int) -> int:
dp = [float("inf")] * (n+1)
dp[0] = 0
for j in range(1, n+1): # 背包
for i in range(1, int(j**0.5) + 1): # 物品
dp[j] = min(dp[j], dp[j - i**2] + 1)
return dp[n]
# 写法2
class Solution:
def numSquares(self, n: int) -> int:
dp = [float("inf")] * (n+1)
dp[0] = 0
for i in range(1, int(n**0.5)+1): # 物品,即i**2
for j in range(i*i, n+1): # 背包
dp[j] = min(dp[j], dp[j - i**2] + 1) # j从i**2开始,为的是数组下标不出现负数
return dp[n]
139. 单词拆分【求排列】
回溯(超时版)
使用回溯算法枚举字符串的所有分割情况
- 枚举所有字符串的分割情况,然后判断字符串是否在字典中出现过
- 参考【131. 分割回文串】
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
wordSet = set(wordDict) # 转换为哈希集合,提高查找效率
return self.backtracking(s, wordSet, 0)
def backtracking(self, s, wordSet, start_index):
# 已经遍历到字符串末尾,返回True
if start_index >= len(s):
return True
# 遍历所有可能的拆分位置
for i in range(start_index, len(s)):
word = s[start_index: i+1] # 截取子串
# 如果截取的子串在字典中,并且后续部分也可以被拆分成单词,返回True
if word in wordSet and self.backtracking(s, wordSet, i+1):
return True
# 无法进行有效拆分,返回False
return False

- 时间复杂度:O(2^n),每个单词都有两个状态 ------ 切割和不切割
- 空间复杂度:O(n),计算了系统调用栈的空间
回溯(优化版)
递归的过程中有很多重复计算,可以使用数组保存递归过程中计算的结果,这种方法叫做记忆化递归:
- 使用memory数组保存每次计算的以startIndex为起始下标的结果
- 如果 memory[startIndex] 里已经被赋值了,直接用 memory[startIndex] 的结果
python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
wordSet = set(wordDict)
return self.backtracking(s, wordSet, 0, [-1]*len(s)) # 初始化为-1
def backtracking(self, s, wordSet, start_index, memory):
if start_index >= len(s):
return True
# 如果不是初始值,则直接使用其数值
if memory[start_index] != -1:
return memory[start_index]
for i in range(start_index, len(s)):
word = s[start_index: i+1]
if word in wordSet and self.backtracking(s, wordSet, i+1, memory):
memory[start_index] = 1 # 记录以startIndex开始的子字符串是可以被拆分的
return True
memory[start_index] = 0 # 记录以startIndex开始的子字符串是不可以被拆分的
return False
上述代码的时间复杂度其实也是 O(2^n),只不过对特定的数据集优化效果明显
动态规划
反过来想,字典里的单词能不能组成字符串
- 单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满
- 拆分时可以重复使用字典中的单词,说明是一个完全背包
(1)dp数组的定义
- dp[j] 表示:字符串长度为j,dp[j] 赋值为true,表示可以拆分为一个或多个在字典中出现过的单词
- 最终求的是 dp[s.size()]
(2)递推公式
- 如果确定 dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true(j < i)
(3)初始化
- dp[0] = 0
- 从递推公式可以看出,dp[i] 的状态依靠于 dp[j] 是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面的dp[i]就都是false了
- dp[0]表示字符串为空,但题目中说了 "
1 <= s.length <= 300",所以测试数据中不会出现为0的情况,dp[0]初始为true完全就是为了推导公式,无实际意义
- 非0下标:下标非0的dp[i]初始化为false,只要没有被覆盖,就说明这些字符串都是不可拆分为一个或多个在字典中出现过的单词
(4)遍历顺序
- 本题其实求的是排列数
- 一定是先遍历背包,再遍历物品

python
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
wordSet = set(wordDict)
dp = [False] * (len(s) + 1)
dp[0] = True
# 先遍历背包,再遍历物品
for i in range(1, len(s)+1): # 背包就相当于字符串s,所以从1开始(字符串非空)
for j in range(i): # j < i
word = s[j: i]
if word in wordSet and dp[j] == True:
dp[i] = True
return dp[len(s)]