算法刷题总结 (四) 动态规划

算法总结4 动态规划

在这里插入图片描述

动态规划的理解

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

动态规划中每一个状态一定是由上一个状态推导出来的。而贪心算法不同,贪心没有状态推导,而是从局部直接选最优的。

动态规划问题,将被拆解为如下五步:

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

注意: 有些题目非常相似,但实际上做法不同,比如1.5.4.2 里的 leetcode的 5. 最长回文子串与牛客网的HJ32 密码截取都是求最长回文的子串,它是字符串,而 1.5.4.3里的 516. 最长回文子序列求的是子序列的长度,它是数字。所以,求长度则使用动态规划,求字符串则使用回溯,贪心等等其他算法,当然使用动态规划也是可以做出来的,但是时间复杂度相对高一些。


1.1、基础问题1(线性DP)

1.1.1、509. 斐波那契数列

力扣题目链接

F(0) = 0,F(1) = 1
F(n) = F(n - 1) + F(n - 2),其中 n > 1

1一般解法:递归:

class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        else:
            return self.fib(n-1) + self.fib(n-2)

时间复杂度:O(2^n)
空间复杂度:O(n)

2高级解法:动态规划:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i]的含义是:第i个数的斐波那契数值是dp[i]
  2. 确定递推公式
    题目已经给我们了,状态转移方程:F(n) = F(n - 1) + F(n - 2),其中 n > 1
  3. dp数组如何初始化
    题目也直接给了我们,F(0) = 0,F(1) = 1
  4. 确定遍历顺序
    从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
  5. 举例推导dp数组
    按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:
0 1 1 2 3 5 8 13 21 34 55

如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。

综上,代码如下:

class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        dp = [0]*(n+1)
        dp[0] = 0
        dp[1] = 1
        for i in range(2, n+1):
            dp[i] = dp[i-1]+dp[i-2]
        return dp[n]

时间复杂度:O(n)
空间复杂度:O(n)

上面我们维护了一个长度为N+1的数组,实际上我们只需要维护两个数值就行,因为我们只需要最后一个数。修改数组为两个数值:

class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        dp1, p2 = 0, 1
        for i in range(2, n+1):
            dp1, dp2 = dp2, dp1+dp2
        return dp2

时间复杂度:O(n)
空间复杂度:O(1)


1.1.2、70. 爬楼梯

力扣题目链接

1一般解法:递归(超时):

class Solution:
    def climbStairs(self, n: int) -> int:
        if n<2:
            return n
        else:
            return self.climbStairs(n-1) + self.climbStairs(n-2)

2高级解法:动态规划:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i]的含义是:上第i个阶梯的步数为dp[i]
  2. 确定递推公式
    因为一次走一步或两步,dp[i-1]往上走一个台阶为dp[i],dp[i-2]往上走两个台阶为dp[i],那么dp[i] = dp[i-1]+dp[i-2]
  3. dp数组如何初始化
    这里比较有争议的点是,dp[0]是0还是1。按照公式递推,dp[0]应该是1,但是按照实际场景,0阶台阶走的步数应该是0。
    这里直接说明,不考虑dp[0]。如果初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义。
  4. 确定遍历顺序
    从递归公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
  5. 举例推导dp数组
    举例当n为5的时候,dp table(dp数组)应该是这样的:
    在这里插入图片描述
    可以看出这就是上一题的斐波那契数列,唯一的区别是,没有讨论dp[0]应该是什么,因为dp[0]在本题没有意义。

根据规则代码如下:

class Solution:
    def climbStairs(self, n: int) -> int:
    	# n为1 和 2
        if n<3:
            return n
        # 初始化
        # 一阶楼梯
        dp1 = 1
        # 二阶楼梯
        dp2 = 2
        # 递归公式
        for i in range(3, n+1):
            dp1, dp2 = dp2, dp1+dp2
        return dp2

1.1.3、746. 使用最小花费爬楼梯

力扣题目链接

高级解法:动态规划:

  1. 确定dp数组以及下标的含义:
    动态规划需要有一个数组来记录状态,这里只用一个一维数组dp[i]就可以了。
    dp[i]的定义:到达第i个台阶所花费的最少体力为dp[i]。
  2. 确定递推公式:
    因为一次走一步或者两步,那么可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]。
    因为是求花费的体力最小,那么一定是选最小的,所以dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i],这个意思是,选取前一步或者两步的最小值,加上走到当前台阶的体力,等于从头开始走到当前的总体力花费。
  3. dp数组如何初始化
    同理,题目中由台阶0或1开始,那么初始化dp[0] 和dp[1]就够了,后面的由公式递推。
dp0, dp1 = cost[0], cost[1]
  1. 确定遍历顺序
    因为是模拟台阶,而且dp[i]又dp[i-1]dp[i-2]推出,所以是从前到后遍历cost数组就可以了。

  2. 举例推导dp数组
    拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:
    请添加图片描述

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        dp = [0]*(n)
        dp[0] = cost[0]
        dp[1] = cost[1]
        for i in range(2,n):
            dp[i] = min(dp[i-1], dp[i-2]) + cost[i]
        return min(dp[n-1], dp[n-2])

时间复杂度:O(n)
空间复杂度:O(n)

这种写法或许更好理解,n+1才是顶部

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        
        dp = [0]*(n+1)
        for i in range(2, n+1):
            dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2])
        print(dp)
        return dp[-1]

这里同样可以只维持两个值就行了,从而使代码:
时间复杂度:O(n)
空间复杂度:O(1)

class Solution:
    def minCostClimbingStairs(self, cost: List[int]) -> int:
        n = len(cost)
        
        dp = [0, 0]
        for i in range(2, n+1):
            dp[0], dp[1] = dp[1], min(dp[1]+cost[i-1], dp[0]+cost[i-2])
        return dp[-1]

但为了直观,平时还是建议上述第一种写法。


1.2、基础问题2(线性DP)

1.2.1、62. 不同路径

力扣题目链接

1.一般解法,广度深度优先搜索,递归(超时):
可以转化为求二叉树叶子节点的个数

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        def dfs(i, j, m, n):
            # 越界
            if i>m or j>n:
                return 0
            # 找到一种方法
            if i==m and j==n:
                return 1
            # 递归,类似于走楼梯,而这里是往右或者往下走
            return dfs(i+1, j, m, n) + dfs(i, j+1, m, n)
        return dfs(1, 1, m, n)

在这里插入图片描述

2.动态规划:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i][j] :表示从(0, 0)出发,到(i, j) 有dp[i][j]条不同的路径。

  2. 确定递推公式
    要求dp[i][j],得由前面的一个点推导而来,有两个,即上面的点dp[i - 1][j] 和 左边的点dp[i][j - 1]。
    于是显而易见,公式为:dp[i][j] = dp[i - 1][j] + dp[i][j - 1],因为dp[i][j]只有这两个方向过来。

  3. dp数组的初始化
    如何初始化呢,首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。

所以初始化代码为:

for i in range(m):
	dp[i][0] = 0
for i in range(n):
	dp[0][j] = 0
  1. 确定遍历顺序
    这里要看一下递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1],dp[i][j]都是从其上方和左方推导而来,那么从左到右一层一层遍历就可以了。
    这样就可以保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值的。

  2. 举例推导dp数组
    如图所示:
    在这里插入图片描述
    第一次可能写成这样:

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
        dp = [[0]*n for _ in range(m)]

        for i in range(n):
            dp[0][i]=1
        for j in range(m):
            dp[j][0]=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[-1][-1]

但实际上,在创建dp的时候就可以都初始化为1,因为dp[i][j] = dp[i-1][j] + dp[i][j-1],原dp[i][j]的1值不影响。
综上,代码如下:

class Solution:
    def uniquePaths(self, m: int, n: int) -> int:
    	# 二维数组
        dp = [[1]*n for _ in range(m)]
        # 数组赋值
        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)
空间复杂度:O(m × n)

同样的,在理解上述方法中表格的值的规律的情况下,可以只用维护一个一维数组,来减少空间复杂度,下一行等于上一行的与前一个点的和。

class Solution:
    def uniquePaths(self, 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]

时间复杂度:O(m × n)
空间复杂度:O(n)

一般这第二种同样也可能不便于理解,使用第一种方法即可。


1.2.2、63. 不同路径Ⅱ

力扣题目链接

动态规划:
动规五部曲:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i][j] :表示从(0, 0)出发,到(i, j) 有dp[i][j]条不同的路径。
    这个同前面的题的含义
  2. 确定递推公式
    递推公式和 62.不同路径 一样,为dp[i][j] = dp[i - 1][j] + dp[i][j - 1]。
    但这里需要注意一点,因为有了障碍,(i, j)如果就是障碍的话应该就保持初始状态(初始状态为0)。

所以需要加入条件判断:

# 当(i, j)没有障碍的时候,再推导dp[i][j]
if obstacleGrid[i][j] == 0:
    dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

# 或者
if obstacleGrid[i][j] == 1:
	continue
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
  1. dp数组如何初始化
    62.不同路径 不同路径中我们给出如下的初始化:
dp = [[0]*n for _ in range(m)]
for i in range(m):
	dp[0][i] = 1
for j in range(n):
	dp[j][0] = 1
"""
虽然是初始化是:dp = [[1]*n for _ in range(m)],但这是简写,但实际上为上面的过程。
"""

在这里插入图片描述
但这道题,如上图,如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。正确的初始化如下:

dp = [[0]*n for _ in range(m)]
for i in range(m):
	if obstacleGrid[0][i] == 1:
		break
	dp[0][i] = 1
for j in range(n):
	if obstacleGrid[j][0] == 1:
		break
	dp[j][0] = 1

注意代码里for循环的终止条件,一旦遇到obstacleGrid[i][0] == 1的情况就停止dp[i][0]的赋值1的操作,dp[0][j]同理。

  1. 确定遍历顺序
    从递归公式dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 中可以看出,一定是从左到右一层一层遍历,这样保证推导dp[i][j]的时候,dp[i - 1][j] 和 dp[i][j - 1]一定是有数值。
for i in range(1, m):
	for j in range(1, n):
		if obstacleGrid[i][j] == 1:
			continue
		dp[i][j] = dp[i-1][j] + dp[i][j-1]
  1. 举例推导dp数组
    拿示例1来举例如题:
    在这里插入图片描述
    对应的dp table 如图:
    在这里插入图片描述
    综上所述:
class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        length = len(obstacleGrid)
        width = len(obstacleGrid[0])
        # 如果障碍物在起点或终点,直接返回0
        if obstacleGrid[0][0] == 1 or obstacleGrid[length-1][width-1]==1:
            return 0
        # 创建一个dp table
        dp = [[0]*width for _ in range(length)]
        # 两个循环,初始化 dp table
        for i in range(length):
            # 遇到障碍物,后续都为0
            if obstacleGrid[i][0] == 1:
                break
            dp[i][0] = 1
        for j in range(width):
            # 遇到障碍物,后续都为0
            if obstacleGrid[0][j] == 1:
                break
            dp[0][j] = 1
        # 填表
        for i in range(1, length):
            for j in range(1, width):
                if obstacleGrid[i][j] == 1:
                    continue
                dp[i][j] = dp[i-1][j] + dp[i][j-1]
        return dp[length-1][width-1]

时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度
空间复杂度:O(n × m)

class Solution:
    def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
        length = len(obstacleGrid)
        width = len(obstacleGrid[0])
        # 如果障碍物在起点或终点,直接返回0
        if obstacleGrid[0][0] == 1 or obstacleGrid[length-1][width-1]==1:
            return 0
        # 创建一个dp table
        dp = [0]*width
        # 一个循环,先从obstacleGrid第一行开始
        for i in range(width):
            # 遇到障碍物,后续都为0
            if obstacleGrid[0][i] == 1:
                break
            dp[i] = 1
        # 填表
        # 从第二行开始
        for i in range(1, length):
            # 这里不能从1开始,因为可能位置0有障碍物,所以从第0列开始
            for j in range(width):
                # 有障碍物,走法数量直接为0
                if obstacleGrid[i][j] == 1:
                    dp[j] = 0
                # 这里不能直接else,否则会用下面公式累加而改变第一列的值
                elif j!=0:
                    dp[j] += dp[j-1]
        return dp[width-1]

时间复杂度:O(n × m),n、m 分别为obstacleGrid 长度和宽度
空间复杂度:O(m)

本题是 62.不同路径 的障碍版,整体思路大体一致。但就算是做过 62.不同路径,在做本题也会有感觉遇到障碍无从下手。其实只要考虑到,遇到障碍dp[i][j]保持0就可以了。也有一些小细节,例如:初始化的部分,很容易忽略了障碍之后应该都是0的情况。


1.2.3、64. 最小路径和

64. 最小路径和

class Solution:
    def minPathSum(self, grid: List[List[int]]) -> int:
        row = len(grid)
        col = len(grid[0])

        dp = [[0]*col for _ in range(row)]
		# 初始化边缘,这些都是固定值,无需判断的
        dp[0][0]=grid[0][0]
        for i in range(1, row):
            dp[i][0]=grid[i][0]+dp[i-1][0]
        for i in range(1, col):
            dp[0][i]=grid[0][i]+dp[0][i-1]

        for i in range(1, row):
            for j in range(1, col):
            # 取最小值就行
                dp[i][j]=min(dp[i-1][j], dp[i][j-1])+grid[i][j]
        return dp[-1][-1]

1.2.4、343. 整数拆分

力扣题目链接

1 动态规划:
这道题求整数拆分后的最大乘积值的组合,使用动态规划来解,大的整数的拆分最大组合乘积等于其子整数的拆分最大组合乘积的乘积。

  1. 确定dp数组(dp table)以及下标的含义
    dp[i]:拆分数字i,可以得到的最大乘积为dp[i]。
  2. 确定递推公式
    递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘,加上dp[i],是跟上一次拆分组合比,每次保留最大值。
  3. dp的初始化
    有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。
    拆分0和拆分1的最大乘积是多少?这是无解的。
    这里只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1。
  4. dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。
    枚举j的时候,是从1开始的。i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。

所以遍历顺序为:

for i in range(3, n+1):
	for j in range(1, i//2+1):
		dp[i] = max(dp[i], max(j*(i-j), j*dp[i-j]))
  1. 举例推导dp数组
    举例当n为10 的时候,dp数组里的数值,如下:
    在这里插入图片描述
class Solution:
    def integerBreak(self, n: int) -> int:
    	# 数值加上一,索引才会到n
        dp = [0]*(n+1)
        # 初始化,从2开始,因为k>=2
        dp[2] = 1
        # 拆分成k,k>=2,2前面已经初始化,从3开始
        for i in range(3, n+1):
        	# 拆分成j 和 i-j, 例如:1*(3-1) = 2*(3-2)所以整除以一半,再加上一。
            for j in range(1, i//2+1):
                # 遍历j时,更新dp[i]的最大值
                # 这里j*(i-j)是将i拆分成两部分,j*dp[i-j]是将i拆分成两部分以上
                dp[i] = max(dp[i], max(j*(i-j), j*dp[i-j]))
        # dp中最后一个索引为n,即将n拆分的最大乘积
        return dp[-1]

时间复杂度:O(n^2)
空间复杂度:O(n)

2 数学证明:
本题也可以用贪心,每次拆成n个3,如果剩下是4,则保留4,然后相乘,但是这个结论需要数学证明其合理性,可以自行查阅研究。

class Solution:
    def integerBreak(self, n: int) -> int:
        if n==2:
            return 1
        if n==3:
            return 2
        if n==4:
            return 4
        result = 1
        while n>4:
            result *= 3
            n -= 3
        result *= n
        return result

1.2.5、96. 不同的二叉搜索树

力扣题目链接

动态规划:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i] : i个节点组成的二叉搜索树的个数为dp[i]。
  2. 确定递推公式
    在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
    j相当于是头结点的元素,从1遍历到i为止。
    所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
  3. dp数组如何初始化
    初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。
    那么dp[0]应该是多少呢?
    从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。
    从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。
    所以初始化dp[0] = 1
  4. 确定遍历顺序
    首先一定是遍历节点数,从递归公式:dp[i] += dp[j - 1] * dp[i - j]可以看出,节点数为i的状态是依靠 i之前节点数的状态。
    那么遍历i里面每一个数作为头结点的状态,用j来遍历。
for i in range(1, n+1):
	for j in range(1, i+1):
		dp[i] += dp[j-1]*dp[i-j]
  1. 举例推导dp数组
    n为5时候的dp数组状态如图:
    在这里插入图片描述
    综上,代码如下:
class Solution:
    def numTrees(self, n: int) -> int:
    	# 从0到n
        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[-1]

时间复杂度: O ( n 2 ) O(n^2) O(n2)
空间复杂度: O ( n ) O(n) O(n)



1.3、背包问题(背包DP)

在这里插入图片描述
背包问题,简单来说就是将单次、无限重复或者有限重复数量的物品,通过满足一定的题目需求,而放入有限大小的背包中。

而根据物品的三种不同规则,我们将分为三种背包问题:01背包,完全背包和多重背包。

它们的总结如下图:
在这里插入图片描述
但我们可以看到还有其他类型的背包在其中,但对于面试的话,其实掌握01背包,和完全背包,就够用了,最多可以再来一个多重背包。

至于其他的背包问题,面试几乎不会问,都是竞赛级别的了,leetcode上连多重背包的题目都没有,所以题库也告诉我们,01背包和完全背包就够用了。但如果你热爱学习,喜欢钻研,则一本背包问题的经典书籍送给你 背包问题九讲 2.0

理解背包问题,01背包非常基础和重要,因为其他问题基本由此为基础衍生而来。我们先从纯背包问题开始,去理解和学习,再去延伸和转化到leetcode题目。



1.3.1、01背包

一次数量的物品,根据要求放入背包中。

暴力解法:
每一件物品其实只有两个状态:取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 o ( 2 n ) o(2^n) o(2n),这里的n表示物品数量。

所以暴力的解法是指数级别的时间复杂度。进而才需要动态规划的解法来进行优化!


1.3.1.1、单次选择+最大价值

问题描述: n n n件物品和一个最多能背重量为 w w w的背包。第 i i i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],得到的价值是 v a l u e [ i ] value[i] value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大

简单例子:
w = 4 w=4 w=4重量的背包
n = 3 n=3 n=3件物品,表示如下:

重量 weight[i]价值 value[i]
物品0115
物品1320
物品2430

问背包能背的物品的最大价值是多少?

(1). 二维DP数组

动态规划五部曲

  1. 确定dp数组以及下标的含义
    对于背包问题,有一种写法, 是使用二维数组,即 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示从下标为 [ 0 − i ] [0-i] [0i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
    在这里插入图片描述

  2. 确定递归公式
    d p [ i ] [ j ] dp[i][j] dp[i][j]的含义为,从下标为 0 − i 0-i 0i的物品里任取,放进容量为j的背包,价值总和最大是多少。
    从两个方向可以推出 d p [ i ] [ j ] dp[i][j] dp[i][j]:

    • 不放物品i: 当物品i的重量大于背包j的重量时(即把背包清空也装不下),物品i无法放进背包中,所以被背包内的价值依然和前面相同,里面不放物品i的最大价值,此时 d p [ i ] [ j ] dp[i][j] dp[i][j]就是 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i1][j]
    • 放物品i: 想要放入一个物品,要此刻 d p [ i ] [ j ] dp[i][j] dp[i][j]的背包腾出物品i以及 w e i g h t [ i ] weight[i] weight[i]的重量,于是腾出后的价值为 d p [ i − 1 ] [ j − w e i g h t [ i ] ] dp[i-1][j-weight[i]] dp[i1][jweight[i]] i − 1 i-1 i1则为腾出i之后前面 0 0 0 - i − 1 i-1 i1的物品, j − w e i g h t [ i ] j-weight[i] jweight[i]为腾出 w e i g h t [ i ] weight[i] weight[i]的重量。放入 w e i g h t [ i ] weight[i] weight[i]重量的物品后的总价值为 d p [ i − 1 ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] dp[i-1][j-weight[i]]+value[i] dp[i1][jweight[i]]+value[i]
  3. 数组的初始化
    首先从 d p [ i ] [ j ] dp[i][j] dp[i][j]的定义出发,如果背包容量 j j j 0 0 0的话,即 d p [ i ] [ 0 ] dp[i][0] dp[i][0],无论是选取哪些物品,背包价值总和一定为 0 0 0。如图:
    在这里插入图片描述
    再看其他情况,状态转移方程 d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] ) dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]]+value[i]) dp[i][j]=max(dp[i1][j],dp[i1][jweight[i]]+value[i]),因为 i i i是由 i − 1 i-1 i1推导而来,所以 i = 0 i=0 i=0一定要初始化。
    即初始化 d p [ 0 ] [ j ] dp[0][j] dp[0][j],存放编号为0的物品时,各个容量的背包所能存放的最大值。
    于是:

    • w e i g h t [ 0 ] > j weight[0]>j weight[0]>j,说明当前背包的容量装不下第0个物品,那么 d p [ 0 ] [ j ] = 0 dp[0][j]=0 dp[0][j]=0
    • w e i g h t [ 0 ] < = j weight[0]<=j weight[0]<=j,说明当前的背包的重量能够装下第0个物品,那么 d p [ 0 ] [ j ] = v a l u e [ 0 ] dp[0][j]=value[0] dp[0][j]=value[0]
for j in range(bagweight):
	if j>=weight[0]:
		dp[0][j]=value[0]
	else:
		dp[0][j]=0

至于其他值,都会被递推公式推出结果,直接初始化微为即可,初始化完成后为:
在这里插入图片描述
4. 确定遍历顺序
有两个遍历的维度:物品与背包重量。
先遍历 物品还是先遍历背包重量呢?两者皆可,但是先遍历物品更好理解,比较贴合上面的图。

# 物品的编号
for i in range(1, len(weight)):
	# 包的大小
	for j in range(1, bagweight):
		# 大于包的大小,不放,与上一个物品价值相等
		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])
  1. 举例推导dp数组
    最终结果就是 d p [ 2 ] [ 4 ] dp[2][4] dp[2][4]
    在这里插入图片描述

整体代码(二维):

def bagpack_problem_2d_01_maxvalue(bag_size, weight, value) -> int: 
    dp = [[0 for _ in range(bag_size+1)] for _ in range(len(weight))]
    
    # 初始化dp数组
    # 第0列,0-rows行,因为背包大小为0,则直接为0
    # (可省略,因为初始化为0)
    for i in range(len(weight)): 
        dp[i][0] = 0
    # 第0行,1-rows列(因为0在前面已经赋值为0了,所以从1开始),小于等于背包大小的物品赋予物品大小的值,其余为0
    for j in range(1, bag_size+1):
        if j>=weight[0]:
            dp[0][j] = value[0]
    print(dp)
    # 更新dp数组: 先遍历物品, 再遍历背包. 
    for i in range(1, len(weight)):
         # 注意这里能写成for j in range(nums[i],target+1)这样吗?
		# 不行,因为前面的值需要if cur_weight > j 去将0填空,便于下一行的值的依赖
		# 所以这里不同于二维数组,不需要依赖,而直接到nums[i]为止
        for j in reversed(range(1, bag_size+1)):
            if weight[i]<=j:
                # 定义dp数组: dp[i][j] 前i个物品里,放进容量为j的背包,价值总和最大是多少。
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]]+ value[i])
            else: # 说明背包装不下当前物品. 
                dp[i][j] = dp[i - 1][j] # 所以不装当前物品. 
                
    print(dp)
    # [[0, 15, 15, 15, 15], [0, 15, 15, 20, 35], [0, 15, 15, 20, 35]]
    # max_value = dp[-1][-1] = 35

if __name__ == "__main__": 
	bag_size = 4
	weight = [1, 3, 4]
	value = [15, 20, 30]
	bagpack_problem_2d_01_value(bag_size, weight, value)


(2). 一维DP数组(滚动数组)

背包问题的状态其实都是可以压缩的。

在使用二维数组的时候,递推公式: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] ) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]) dp[i][j]=max(dp[i1][j],dp[i1][jweight[i]]+value[i])

如果把 d p [ i − 1 ] dp[i - 1] dp[i1]那一层拷贝到 d p [ i ] dp[i] dp[i]上,表达式完全可以是: d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] ) dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]) dp[i][j]=max(dp[i][j],dp[i][jweight[i]]+value[i])

与其把 d p [ i − 1 ] dp[i - 1] dp[i1]这一层拷贝到 d p [ i ] dp[i] dp[i]上,不如只用一个一维数组了,只用 d p [ j ] dp[j] dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

动态规划五部曲

  1. 确定 d p dp dp数组的定义
    在一维 d p dp dp数组中, d p [ j ] dp[j] dp[j]表示:容量为 j j j的背包,所背的物品价值可以最大为 d p [ j ] dp[j] dp[j]
  2. 一维 d p dp dp数组的递推公式
    d p [ j ] dp[j] dp[j]为 容量为 j j j的背包所背的最大价值,那么如何推导 d p [ j ] dp[j] dp[j]呢?
    d p [ j ] dp[j] dp[j]可以通过 d p [ j − w e i g h t [ i ] ] dp[j - weight[i]] dp[jweight[i]]推导出来, d p [ j − w e i g h t [ i ] ] dp[j - weight[i]] dp[jweight[i]]表示容量为 j − w e i g h t [ i ] j - weight[i] jweight[i]的背包所背的最大价值。
    d p [ j − w e i g h t [ i ] ] + v a l u e [ i ] dp[j - weight[i]] + value[i] dp[jweight[i]]+value[i] 表示 容量为 j j j - 物品 i i i重量 的背包 加上 物品 i i i的价值。(也就是容量为 j j j的背包,放入物品 i i i了之后的最大总价值即: d p [ j ] dp[j] dp[j]
    此时 d p [ j ] dp[j] dp[j]有两个选择,一个是取自己 d p [ j ] dp[j] dp[j] 相当于 二维dp数组中的 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i1][j],即不放物品 i i i,一个是取 d p [ j − w e i g h t [ i ] ] + v a l u e [ i ] dp[j - weight[i]] + value[i] dp[jweight[i]]+value[i],即放物品 i i i,指定是取最大的,因为是求最大价值,所以递推公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
  1. 一维dp数组如何初始化
    那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?
    看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。
    这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。
    那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
  2. 一维dp数组遍历顺序
for i in range(len(weight)):
	for j in reversed(range(weight[i], bagweight+1)):
		dp[j] = max(dp[j], dp[j-weight[i]]+value[i])

理解遍历顺序:
这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!
二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。
为什么呢?
倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

在二维dp中,这次要放的物品i,每次dp都依赖上一行没放物品i,即i-1时的dp(即dp[i]和dp[i-1][j-weight[i]]+value[i]),所以不会重复加i。

而一维中这次要放的物品i,每次都依赖该i行的前一个dp[j-weight[i]],这是个前后依赖关系,每次遍历扩大背包容量+1且尝试放入物品i,但是前一个dp[j-weight[i]]已经放入过i,这次+value[i]又会重复加入i,所以如果正序遍历,只要背包有空间,就会无限制的将i放入背包中,这涉及了后面要讲的完全背包问题。既然一维能够逆序,二维能否逆序呢?当然是可以的,可以运行下上面代码逆序进行结果验证与理解。

当逆序遍历时,dp[j-weight[i]]肯定是没放过i的,就不会出现重复添加的情况。对于第一行第一个物品的dp[j-weight[i]]肯定为0,而后面物品行的dp[j-weight[i]]结果为前面的物品的价值

举例:
举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。
为什么倒序遍历,就可以保证物品只放入一次呢?
倒序就是先算dp[2]
dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

理解嵌套顺序:
再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?不可以!
因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。
倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。
(这里如果读不懂,就再回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)
所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。

  1. 举例推导dp数组
    一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
    在这里插入图片描述

整体代码(一维):

def bag_problem():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bag_weight = 4
    # 初始化: 全为0
    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])

    print(dp)

bag_problem()

总结:
动态规划问题枚举的顺序只有一个原则:如果b状态的计算依赖于a状态,那么a状态必须在b状态之前被枚举和计算。

二维数组时,物品和背包的顺序,从小到大遍历,二者可以内外互换,物品顺序可颠倒,但初始化操作也要相应颠倒;而背包只可逆序,因为背包的结果与前面一次较小的背包结果有关,而前面的结果是上一层的拷贝,而不会受到这一层更新的干扰而重复累加,避免重复计算,重复计算会变成完全背包。

对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,所以二维无需逆序遍历,当然背包也可以逆序,不影响结果。

01背包最大重量/价值二维一维
初始化第0行0列初始化无需初始化
内外循序物品+背包 | 背包+物品物品+背包
物品顺序正序|逆序正序|逆序
背包顺序正序|逆序逆序
递推公式dp[i][j]=max(dp[i-1]j[j], dp[i-1][j-weight[i]]+value[i])dp[j]=max(dp[j], dp[j-weight[i]]+value[i])

1.3.1.2、单次选择+最大重量

问题描述: n n n件物品和一个最多能背重量为 w w w的背包。第i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],每件物品只能用一次,求解将哪些物品装入背包里物品重量总和最大

分析:
这个问题没有给每个物品的价值,也没有问最多能获得多少价值,而是问最多能装多满。
实际上在这里,如果我们把每个物品的大小当作每个物品的价值,就可以完美解决这个问题。

简单例子:
w = 4 w=4 w=4重量的背包
n = 3 n=3 n=3件物品,表示如下:
这里增设一列不存在的价值,值与重量相等,模拟等于上一题求最大价值。

重量 weight[i]价值 value[i] (不存在这列)
物品011
物品133
物品244

问背包能背的物品的最大价值(或者重量)是多少?

代码同上,只不过values与weights相等,那么只需把values改成weight即可。

整体代码(一维,滚动数组):

def bag_problem_1d_01_maxweight(bag_size, weight):
    # 初始化: 全为0
    dp = [0] * (bag_size + 1)

    # 先遍历物品, 再遍历背包容量
    for i in range(len(weight)):
        for j in reversed(range(weight[i], bag_size+1)):
            # 递归公式,这里注意不是+value[i]而是weight[i]
            dp[j] = max(dp[j], dp[j - weight[i]] + weight[i])
    print(dp)
    # [0, 1, 1, 3, 4, 5, 5, 7, 8, 8]
    # maxweight = dp[-1] = 8

if __name__ == "__main__": 
	bag_size = 9
	weight = [1, 3, 4, 7]
	bag_problem_1d_01_maxweight(bag_size, weight)

416. 分割等和子集

416. 分割等和子集
解题思路: 等分,先判断总和能否被2整除,能否进行分隔,不能直接False,能的话继续判断里面的数,分隔后能否分成相等的两部分,等分必然是总和值/2的值的大小的背包装满,能装满则说明能等分,不能装满则False。

二维数组:

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        # target既是背包大小也是最大价值,问是否能装满target大小的背包
        target = sum(nums)
        # 如果是奇数则不能均分,直接False,如果偶数则再后续加以判断
        if target%2==1:
            return False
        else:
            target //=2
        dp = [[0]*(target+1) for _ in range(len(nums))]
        # 遍历物品
        for i in range(len(nums)):
            # 遍历背包大小
            for j in range(target+1):
                # 背包小鱼物品大小
                if j<nums[i]:
                    dp[i][j] = dp[i-1][j]
                # 能放但不放,或者放进去那个大取哪个
                else:
                    dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]]+nums[i])
        return target == dp[-1][-1]

一维数组(滚动数组):

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        # target既是背包大小也是最大价值,问是否能装满target大小的背包
        target = sum(nums)
        # 如果是奇数则不能均分,直接False,如果偶数则再后续加以判断
        if target%2==1:
            return False
        else:
            target //=2
        dp = [0]*(target+1)
        # 遍历物品
        for i in range(len(nums)):
            # 遍历背包大小,逆序防止累加
            # for j in reversed(range(nums[i], target+1)):
            for j in range(target, nums[i]-1, -1):
                dp[j] = max(dp[j], dp[j-nums[i]]+nums[i])
        return target == dp[-1]

实际上,我们并不需要求和,而是把所有能通过累加得到的数字都标记为True,如果dp[-1]为True则说明能累加到总和的一半,具体代码如下:

class Solution:
    def canPartition(self, nums: List[int]) -> bool:
        sum_ = sum(nums)
        if sum_%2:
            return False
        sum_ //= 2
        dp = [False]*(sum_+1)
        dp[0] = True
        for num in nums:
            for j in reversed(range(num, sum_+1)):
                dp[j] = dp[j] or dp[j-num]
        # print(dp)
        return dp[-1]

1049. 最后一块石头的重量 II

1049. 最后一块石头的重量 II
该题与上一题很类似,隐藏的背包问题。最终的思路是将这个石头集合分成两部分,他们越相近,即离和中值越近,相差就越小。如果无法等分,那么因为这个中值为向下取整,所以离偏小的部分近一些,离偏大的部分远一些。那么以中值为背包,装满得到较小值部分的值,求石头的总体和,减去较小值部分,就为最大值部分,再减去一次较小值部分,得出最小的相差。

为什么不求较大值呢,因为较小值可以用中值找到最大背包,而较大值的最大背包找不到。

一维滚动数组:

class Solution:
    def lastStoneWeightII(self, stones: List[int]) -> int:
        su_m = sum(stones)
        target = su_m//2
        dp = [0]*(target+1)

        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 su_m-dp[-1]-dp[-1]

474. 一和零(双维度背包)

474. 一和零
二维背包问题,分别装0和1的数量的背包,两个数量用一个二维来存储。

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        dp = [[0]*(n+1) for _ in range(m+1)]
        # 遍历物品
        for s in strs:
            ones = s.count('1')
            zeros = s.count('0')
            # 遍历二维背包
            for i in range(m, zeros-1, -1):
                for j in range(n, ones-1, -1):
                    dp[i][j] = max(dp[i][j], dp[i-zeros][j-ones]+1)
        return dp[-1][-1]

1.3.1.3、单次选择+装满可能性总数

问题描述: n n n件物品和一个最多能背重量为 w w w的背包。第i件物品的重量是 w e i g h t [ i ] weight[i] weight[i],每件物品只能用一次,求解装满背包有哪些不同的方法。

分析:
这次和之前遇到的背包问题不一样了,之前都是求容量为j的背包,最多能装多少。

本题则是装满有几种方法。其实这就是一个组合问题了。求组合类问题的公式,都是类似这种:
1. 确定dp[j]的含义:
dp[j] 表示:填满j(包括j)这么大容积的包,有dp[j]种方法

2. 递推公式:
只要获得nums[i],凑成dp[j]就有dp[j - nums[i]] 种方法。

例如:dp[j],j 为5,

如果那么
已经有一个1(nums[i]) 的话有 dp[4]种方法 凑成 容量为5的背包
已经有一个2(nums[i]) 的话有 dp[3]种方法 凑成 容量为5的背包
已经有一个3(nums[i]) 的话有 dp[2]中方法 凑成 容量为5的背包
已经有一个4(nums[i]) 的话有 dp[1]中方法 凑成 容量为5的背包
已经有一个5 (nums[i])的话有 dp[0]中方法 凑成 容量为5的背包

那么凑整dp[5]有多少方法呢,也就是把 所有的 dp[j - nums[i]] 累加起来。

dp[j] += dp[j - nums[i]]

这个公式在后面在讲解背包解决排列组合问题的时候还会用到。

3. 初始化:
dp数组如何初始化?
从递推公式可以看出,在初始化的时候dp[0] 一定要初始化为1,因为dp[0]是在公式中一切递推结果的起源,如果dp[0]是0的话,递推结果将都是0。

4. 遍历顺序:
01背包问题一维dp的遍历,nums放在外循环,target在内循环,且内循环倒序。

简单例子:
w = 4 w=4 w=4重量的背包
n = 3 n=3 n=3件物品,表示如下:

重量 weight[i]
物品02
物品12
物品24

问有多少种装满背包的方法?
结果:[2, 2], [4]

def bagpack_problem_1d_01_poss_sum(bag_size, weight) -> int: 
    # 一维dp
    dp = [0]*(bag_size+1)
    # 初始化
    dp[0]=1
    
    # 第0行,1-rows列(因为0在前面已经赋值为0了,所以从1开始)
    # 先物品
    for i in range(len(weight)):
        # 01背包,防重复,逆序
        for j in reversed(range(weight[i], bag_size+1)):
            dp[j]+=dp[j-weight[i]]
            # print('inner',j,dp)
        # print('mid',dp)
    print(dp)
    # 结果 [1, 0, 2, 0, 2]

if __name__ == "__main__": 
    bag_size = 4
    weight = [2, 2, 4]
    bagpack_problem_1d_01_poss_sum(bag_size, weight)


494. 目标和

494. 目标和
将该题转化为背包装满的装法次数。

使用推导公式:

dp[j] += dp[j-nums[i]]

背包大小根据公式推导而来。如何推导呢?

由于nums中每个数都要用到,题目要求通过改变值的正负符号求和等于target,那么nums分为两个部分,left为正,right为负,left与right相减等于target。

nums总数量是有限的,确定了left,right也就确定了,那么我们可以将left大小当做背包大小来求装满left的次数即为总次数(当然right也可以,left和right他们是相互关联的)。left的大小如何确定?left = target+right,其中right是变动的,那么left大小也会跟着变,背包大小是变动的,这样无法计算。right又可以通过sum-left来固定,那么left = target + sum - left,可以得出left = (target+sum)/2。

left+right = sum
left-right = target
left = (target+sum)/2

left的值具体是什么意思?为什么left是个定值呢?
因为要用到nums的所有数去组成left+right,left变大,right一定变小,反之亦然,不存在另一个left-right也能得到target的。如果题目改成选几个值且通过正负改变得到target,那就必须要用回溯去做了。

我们来举个例子:
nums = [1, 2, 1, 1, 1, 1]
target = 3
只要left=5,right=2,就满足target=3。且不会存在第二种left和right的值相减得到target。
left=5有几种组合?这是背包问题了。

初始化时,需要赋值第一个值为1,其余为0不变

dp[0]=1

举例推导:
输入:nums: [1, 1, 1, 1, 1], S: 3

bagSize = (S + sum) / 2 = (3 + 5) / 2 = 4

dp数组状态变化如下:

在这里插入图片描述

代码整体如下:

class Solution:
    def findTargetSumWays(self, nums: List[int], target: int) -> int:
        # left+right = sum
        # left-right = target
        # left = (target+sum)/2
        s_um = sum(nums)
        # 目标值比所有值求和大,或者目标值不可分,那么方法为0
        if abs(target)>s_um or (target+s_um)%2:
            return 0
        # 背包大小,根据上面公式推导
        bagsize = (target+s_um)//2
        # 创建dp表
        dp = [0]*(bagsize+1)
        # 初始化第一个元素为1
        dp[0]=1
        # 同前面的二重循环
        for i in range(len(nums)):
            for j in range(bagsize, nums[i]-1, -1):
                # 递推公式,用来求背包装满的次数
                dp[j] += dp[j-nums[i]]
        return dp[-1]

注意:
有道题 39. 组合总和 大部分是使用回溯算法来做的,为什么不选用动态规划呢?因为题目所求的结果不同:

求个数列出组合
动态规划回溯爆搜

总结:

01背包最大重量/价值01背包最大重量/价值01背包可能性总数
维度二维一维一维
初始化第0行和第0列分别初始化无需初始化初始化dp[0]=1
内外循序物品+背包 | 背包+物品物品+背包物品+背包
物品顺序正序|逆序正序|逆序正序|逆序
背包顺序正序|逆序逆序逆序
递推公式dp[i][j]=max(dp[i-1]j[j], dp[i-1][j-weight[i]]+value[i])dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]

1.3.1.4、单次选择 + 装满的最大数量(等同于装满的最大价值)

装满的最大数量其实等同于装满的最大价值,将每个价值等同于1,即数量即可进行相似的计算。

474. 一和零(双维度背包)

474. 一和零
二维背包问题,分别装0和1的数量的背包,两个数量用一个二维来存储。

class Solution:
    def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
        dp = [[0]*(n+1) for _ in range(m+1)]
        # 遍历物品
        for s in strs:
            ones = s.count('1')
            zeros = s.count('0')
            # 遍历二维背包
            for i in range(m, zeros-1, -1):
                for j in range(n, ones-1, -1):
                    dp[i][j] = max(dp[i][j], dp[i-zeros][j-ones]+1)
        return dp[-1][-1]

1.3.2、完全背包

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。


1.3.2.1、重复选择+最大价值

问题描述: 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

简单例子:
w = 10 w=10 w=10重量的背包
各有 n = ∞ n=\infty n=件物品数量,表示如下:

重量 weight[i]价值 value[i]
物品021
物品135
物品252
物品374

问背包能背的物品的最大价值是多少?
答案如下:

二维dp:

def bagpack_problem_2d_01_value(bag_size, weight, value) -> int: 
    dp = [[0 for _ in range(bag_size+1)] for _ in range(len(weight))]
    
    # 初始化dp数组
    # 第0列,0-rows行,因为背包大小为0,则直接为0
    # (可省略,因为初始化为0)
    for i in range(len(weight)): 
        dp[i][0] = 0
    # 第0行,1-rows列(因为0在前面已经赋值为0了,所以从1开始),小于等于背包大小的物品赋予物品大小的值,其余为0
    for j in range(1, bag_size+1):
        if j>=weight[0]:
            dp[0][j] = value[0]
    print(dp)
    
    for i in range(1, len(weight)):
        for j in range(1, bag_size+1):
            if j<weight[i]:
                dp[i][j] = dp[i - 1][j]
            else:
                # 0-1背包
                # dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k*weight[i]]+ k*value[i])
                
                # 完全背包,设置k,一个物品重复k次,并且在小于j背包大小的情况下,求最大价值
                k = 1
                while k*weight[i]<=j:
                # 定义dp数组: dp[i][j] 前i个物品里,放进容量为j的背包,价值总和最大是多少。
                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k*weight[i]]+ k*value[i])
                    k+=1
    print(dp)
    # [[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
    # [0, 0, 1, 5, 5, 6, 10, 10, 11, 15, 15], 
    # [0, 0, 1, 5, 5, 6, 10, 10, 11, 15, 15], 
    # [0, 0, 1, 5, 5, 6, 10, 10, 11, 15, 15]]
    # max_value = dp[-1][-1] = 15
    # 如果是01背包,那么
    # [[0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1], 
    # [0, 0, 1, 5, 5, 6, 6, 6, 6, 6, 6], 
    # [0, 0, 1, 5, 5, 6, 6, 6, 7, 7, 8], 
    # [0, 0, 1, 5, 5, 6, 6, 6, 7, 7, 9]]
    # max_value = dp[-1][-1] = 9
if __name__ == "__main__": 
	bag_size = 10
	weight = [2, 3, 5, 7]
	value = [1, 5, 2, 4]
	bagpack_problem_2d_01_value(bag_size, weight, value)

一维dp(主要记住一维就行,一维用的最多):

def bagpack_problem_1d_inf_value(bag_size, weight, value) -> int: 
    dp = [0]*(bag_size+1)
    
    for i in range(len(weight)):
        for j in range(weight[i], bag_size+1):
            dp[j] = max(dp[j], dp[j-weight[i]]+value[i])
    print(dp) 
    # [0, 0, 1, 5, 5, 6, 10, 10, 11, 15, 15]
    # maxvalue = dp[-1] = 15

if __name__ == "__main__": 
	bag_size = 10
	weight = [2, 3, 5, 7]
	value = [1, 5, 2, 4]
	bagpack_problem_1d_inf_value(bag_size, weight, value)

对比下:
01背包的核心代码如下:

# 先遍历物品,再遍历背包
# 遍历物品
for i in range(len(weight)):
	# 遍历背包容量
	for j in reversed(range(weight[i]. bag_size)):
		dp[j] = max(dp[j], dp[j - weight[i]]+value[i])

我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。

而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

# 先遍历物品,再遍历背包
# 遍历物品
for i in range(len(weight)):
	# 遍历背包容量
	for j in range(weight[i], bagWeight+1):
		dp[j] = max(dp[j], dp[j-weight[i]]+value[i])

在这里插入图片描述
01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

遍历物品在外层循环,遍历背包容量在内层循环,状态如图:
在这里插入图片描述
遍历背包容量在外层循环,遍历物品在内层循环,状态如图:
在这里插入图片描述
看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])。

先遍历背包再遍历物品,代码如下:

# 先遍历背包,再遍历物品
# 遍历背包
for j in range(bagWeight+1):
	# 遍历物品
	for i in range(len(weight)):
		if j-weight[i]>=0:
			dp[j] = max(dp[j-weight[i]]+value[i])

整体代码:

# 先遍历物品,再遍历背包
def bag_pack1():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bag_weight = 4

    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])
    
    print(dp[bag_weight])

# 先遍历背包,再遍历物品
def bag_pack2():
    weight = [1, 3, 4]
    value = [15, 20, 30]
    bag_weight = 4

    dp = [0]*(bag_weight + 1)

    for j in range(bag_weight + 1):
        for i in range(len(weight)):
            if j >= weight[i]: 
            	dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
    
    print(dp[bag_weight])


if __name__ == '__main__':
    bag_pack1()
    bag_pack2()

总结:
因为是完全背包,物品可叠加,那么一维数组顺序遍历即可:

01背包最大重量/价值01背包最大重量/价值01背包可能性总数完全背包最大重量/价值
维度二维一维一维一维
初始化第0行和第0列分别初始化无需初始化初始化dp[0]=1无需初始化
内外循序物品+背包 | 背包+物品物品+背包物品+背包物品+背包|背包+物品
物品顺序正序|逆序正序|逆序正序|逆序正序|逆序
背包顺序正序|逆序逆序(避免重复)逆序正序(为了重复)
递推公式dp[i][j]=max(dp[i-1]j[j], dp[i-1][j-weight[i]]+value[i])dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]dp[j]=max(dp[j], dp[j-weight[i]]+value[i])

1.3.2.2、重复选择+组合+装满可能性总数
518. 零钱兑换 II(组合问题)

518. 零钱兑换 II

递推公式:
求背包装满的次数,根据公式:dp[j] = dp[j-nums[i]]

遍历顺序:
因为纯完全背包求得装满背包的最大价值是多少,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行。所以纯完全背包是能凑成总和就行,不用管怎么凑的。

而本题要求凑成总和的组合数,元素之间明确要求没有顺序,那么每个方案个数是为组合数。

那么本题,两个for循环的先后顺序可就有说法了。

结果是求组合数,那么遍历顺序为:
先遍历物品,后遍历背包:

# 遍历物品
for i in range(len(coins)):
	# 遍历背包容量
	for j in range(coins[i], amount+1):
		dp[j] += dp[j-coins[i]]

如果是求排列数,那么遍历顺序为:

# 遍历背包容量
for j in range(amount+1):
	# 遍历物品
	for i in range(len(coins)):
		if j-coins[i]>=0:
			dp[j]+=dp[j-coins[i]]

整体代码:

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[-1]

总结:
通过该例子,我们知道,在完全背包问题的装满可能性总数中,物品和背包的顺序在排列和组合问题中是有影响的:

排列组合
背包+物品物品+背包

综上:

01背包最大重量/价值01背包最大重量/价值01背包可能性总数完全背包最大重量/价值完全背包可能性总数
维度二维一维一维一维一维
初始化第0行和第0列分别初始化无需初始化初始化dp[0]=1无需初始化初始化dp[0]=1
内外循序物品+背包 | 背包+物品物品+背包物品+背包物品+背包|背包+物品排列: 背包+物品|组合: 物品+背包
物品顺序正序|逆序正序|逆序正序|逆序正序|逆序正序|逆序
背包顺序正序|逆序逆序(避免重复)逆序正序(为了重复)正序
递推公式dp[i][j]=max(dp[i-1]j[j], dp[i-1][j-weight[i]]+value[i])dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]

1.3.2.3、重复选择+排列+装满可能性总数
377. 组合总和 Ⅳ

377. 组合总和 Ⅳ
完全背包,两个循环顺序执行
求次数,dp[j] = dp[j-nums[i]],并且dp[0]=1
求排列,先背包后物品。

根据上一题的规律,可以很轻易的做出此题:

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 nums:
                if j-i>=0:
                    dp[j]+=dp[j-i]
        return dp[-1]

70. 爬楼梯

70. 爬楼梯
同上题的思路,这是背包里求排列问题,即:1、2 步 和 2、1 步都是上三个台阶,但是这两种方法不一样。所以需将target放在外循环,将nums放在内循环。每一步可以走多次,这是完全背包,内循环需要从前向后遍历。

class Solution:
    def climbStairs(self, n: int) -> int:
        dp = [0]*(n+1)
        dp[0]=1
		# 遍历背包数,楼梯总长度
        for j in range(n+1):
        	# 遍历物品,台阶数,1或2 
            for i in [1,2]:
                if j-i>=0:
                    dp[j]+=dp[j-i]
        return dp[-1]

139. 单词拆分

139. 单词拆分

1.dp数组含义
dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。

2.递推公式
如果确定dp[j] 是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。

所以递推公式是 if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么 dp[i] = true。

3.dp数组的初始化
从递推公式中可以看出,dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都都是false了。

下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。

4.遍历顺序
题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。
“apple”, “pen” 是物品,那么我们要求 物品的组合一定是 “apple” + “pen” + “apple” 才能组成 “applepenapple”。

“apple” + “apple” + “pen” 或者 “pen” + “apple” + “apple” 是不可以的,那么我们就是强调物品之间顺序。

所以说,本题一定是 先遍历 背包,再遍历物品。所以是排列问题。

5.举例推导dp[i]
以输入: s = “leetcode”, wordDict = [“leet”, “code”]为例,dp状态如图:
在这里插入图片描述

所以整体代码如下:

class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        dp = [False]*(len(s)+1)
        dp[0]=True
		# 排列问题,先背包
        for j in range(1, len(s)+1):
        	# 后物品,即单词
            for i in wordDict:
                if j>=len(i):
                # or前的dp[j]为True时防止被后面为False的覆盖
                # or后的部分判断是否有True,即匹配的单词
                    dp[j] = dp[j] or dp[j-len(i)] and s[j-len(i):j]==i
        return dp[-1]

1.3.2.4、重复选择 + 装满的最少数量
322. 零钱兑换

322. 零钱兑换

1.dp数组的含义:
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]

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], dp[j - coin[i]] + 1)

3.数组初始化
dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;

其他下标对应的数值呢?考虑到递推公式的特性,如果默认初始化为0,那么每次求min的时候,都会取默认值0而不是真实最小值,那么默认0就不可取。所以dp[j]必须初始化为一个最大的数,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。所以下标非0的元素都是应该是最大值。

代码如下:

dp = [float('inf')]*(amount+1)
dp[0] = 0

4.遍历顺序
求所需钱币最小个数,所以并不讲究物品和背包的先后顺序,因为既不是排列也不是组合。

5.举例推导dp数组
以输入:coins = [1, 2, 5], amount = 5为例:
在这里插入图片描述
整理代码如下:

class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        '''版本一'''
        # 初始化
        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

    def coinChange1(self, coins: List[int], amount: int) -> int:
        '''版本二'''
        # 初始化
        dp = [float("inf")]*(amount + 1)
        dp[0] = 0
        # 遍历物品
        for j in range(1, amount + 1):
            # 遍历背包
            for coin in coins:
                if j >= coin:
                	dp[j] = min(dp[j], dp[j - coin] + 1)
        return dp[amount] if dp[amount] != float("inf") else -1

总结:

01背包最大重量/价值01背包最大重量/价值01背包可能性总数完全背包最大重量/价值完全背包可能性总数完全背包最少数量
维度二维一维一维一维一维一维
初始化第0行和第0列分别初始化无需初始化初始化dp[0]=1无需初始化初始化dp[0]=1dp = [float(“inf”)]*(amount + 1) dp[0] = 0
内外循序物品+背包 | 背包+物品物品+背包物品+背包物品+背包|背包+物品排列: 背包+物品|组合: 物品+背包背包+物品|物品+背包
物品顺序正序|逆序正序|逆序正序|逆序正序|逆序正序|逆序正序
背包顺序正序|逆序逆序(避免重复)逆序正序(为了重复)正序正序
递推公式dp[i][j]=max(dp[i-1]j[j], dp[i-1][j-weight[i]]+value[i])dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]dp[j]=max(dp[j], dp[j-weight[i]]+value[i])dp[j]+=dp[j-nums[i]]dp[j] = min(dp[j], dp[j - coin[i]] + 1)

279. 完全平方数

279. 完全平方数

这题同上一题零钱兑换,思路类似,不过得自创平方数集合作为物品。

class Solution:
    def numSquares(self, n: int) -> int:
        nums = [i*i for i in range(1, n+1) if i*i<=n]
        dp = [float('inf')]*(n+1)
        dp[0]=0
		# 先遍历物品,再遍历背包
        for i in nums:
            for j in range(i, n+1):
                dp[j] = min(dp[j], dp[j-i]+1)

        return dp[-1]

也可以先遍历背包,再遍历物品

# 遍历背包
        for j in range(1, n + 1):
            # 遍历物品
            for num in nums:
                if j >= num:
                    dp[j] = min(dp[j], dp[j - num] + 1)

2707. 字符串中的额外字符

2707. 字符串中的额外字符
322. 零钱兑换 和 139. 单词拆分 的 结合变体。

class Solution:
    def minExtraChar(self, s: str, dictionary: List[str]) -> int:
        n = len(s)
        # 01背包/完全背包 装满的最小次数改版
        # 一维数组dp[i]表示下标[0,i]构成的字符串使用dic字典中的单词后,还剩下的字符个数。
        dp = [float('inf')]*(n+1)
        dp[0]=0
        # dp = [0]*(n+1)
        for j in range(1, n+1):
            # 每次假设不能被拼接而剩余的字母数+1
            dp[j] = dp[j-1]+1
            for word in dictionary:
                # min(dp[j], ...) 保留最小的
                # 能够被拼接则取去掉该单词的dp值
                if j>=len(word) and s[j-len(word):j]==word:
                    dp[j] = min(dp[j], dp[j-len(word)])
                    # dp[j] = dp[j-len(word)]
        # print(dp)
        return dp[-1]


1.3.3、多重背包

这个问题与 0-1 背包的区别在于,0-1 背包中每种物品有且只有一个,而多重背包中一种物品有nums[i] 个。

对于多重背包,我在力扣上还没发现对应的题目,所以这里就做一下简单介绍,大家大概了解一下。

有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?

每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

例如:

背包最大重量为10。

物品为:

重量 weight[i]价值 value[i]数量
物品01152
物品13203
物品24302

问背包能背的物品最大价值是多少?

和如下情况有区别么?

重量 weight[i]价值 value[i]数量
物品01151
物品01151
物品13201
物品13201
物品13201
物品24301
物品24301
毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。

这种方式来实现多重背包的代码如下:

def test_multi_pack1():
    '''版本一:改变物品数量为01背包格式'''
    weight = [1, 3, 4]
    value = [15, 20, 30]
    nums = [2, 3, 2]
    bag_weight = 10
    for i in range(len(nums)):
        # 将物品展开数量为1
        while nums[i] > 1:
            weight.append(weight[i])
            value.append(value[i])
            nums[i] -= 1
    
    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])
    
    print(" ".join(map(str, dp))

if __name__ == '__main__':
    test_multi_pack1()

时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量

也有另一种实现方式,就是把每种商品遍历的个数放在01背包里面在遍历一遍。

代码如下:(详看注释)

def test_multi_pack2():
    '''版本:改变遍历个数'''
    weight = [1, 3, 4]
    value = [15, 20, 30]
    nums = [2, 3, 2]
    bag_weight = 10

    dp = [0]*(bag_weight + 1)
    for i in range(len(weight)):
        for j in range(bag_weight, weight[i] - 1, -1):
            # 以上是01背包,加上遍历个数
            for k in range(1, nums[i] + 1):
                if j - k*weight[i] >= 0:
                    dp[j] = max(dp[j], dp[j - k*weight[i]] + k*value[i])

    print(" ".join(map(str, dp)))


if __name__ == '__main__':
    test_multi_pack2()

时间复杂度:O(m × n × k),m:物品种类个数,n背包容量,k单类物品数量
从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。

当然还有那种二进制优化的方法,其实就是把每种物品的数量,打包成一个个独立的包。

和以上在循环遍历上有所不同,因为是分拆为各个包最后可以组成一个完整背包,具体原理我就不做过多解释了,大家了解一下就行,面试的话基本不会考完这个深度了,感兴趣可以自己深入研究一波。

总结:
多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。

至于背包九讲里面还有混合背包,二维费用背包,分组背包等等这些,大家感兴趣可以自己去学习学习,这里也不做介绍了,面试也不会考。


参考

Algorithm and Data Structure Notes
LeetCode BackPack 背包问题
代码随想录
随想录视频



1.4、打家劫舍(线性DP)

在这里插入图片描述

1.4.1、198. 打家劫舍

198. 打家劫舍
每个家庭有不同金钱数量,盗窃的家庭不能相邻,求最大盗窃金额。

1.确定dp数组(dp table)以及下标的含义
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。

2.确定递推公式
决定dp[i]的因素就是第i房间偷还是不偷。

如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。

如果不偷第i房间,那么dp[i] = dp[i - 1],即考 虑i-1房,但是不偷该房。

然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);

3.dp数组如何初始化
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]

从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);

4.确定遍历顺序
dp[i] 是根据dp[i - 2] 和 dp[i - 1] 推导出来的,那么一定是从前到后遍历。

5.举例推导dp数组
以示例二,输入[2,7,9,3,1]为例。
在这里插入图片描述

class Solution:
    def rob(self, nums: List[int]) -> int:
        if len(nums)<=2:
            return max(nums)

        dp = [0]*len(nums)
        dp[0] = nums[0]
        dp[1] = max(nums[0], nums[1])

        for i in range(2, len(nums)):
            dp[i] = max(dp[i-2]+nums[i], dp[i-1])
        return dp[-1]

1.4.2、213. 打家劫舍 II

213. 打家劫舍 II
承接上题,但是房子是环状,即首位相连的。

那么要考虑第一个房子和最后一个房子会相临,只能取其一。

所以,我们把房子变成两种情况:

  1. 不考虑第一个房子 nums[1:]
  2. 不考虑最后一个房子nums[:-1]

以上两种情况取最大值后再去取他们两个之间的最大值,可以通过上题的解法来解了:

class Solution:
    def rob(self, nums: List[int]) -> int:
       
        def find_max(num):
            n = len(num)
            if n<=2:
                return max(num)
            dp = [0]*n
            dp[0] = num[0]
            dp[1] = max(num[0], num[1])

            for i in range(2, n):
                dp[i] = max(dp[i-1], dp[i-2]+num[i])
            return dp[-1]
            
        n = len(nums)
        if n<=2:
            return max(nums)
        else:
            return max(find_max(nums[1:]), find_max(nums[:-1]))
            

1.4.3、337.打家劫舍 III

337.打家劫舍 III
题目思路类似第一种类型,但是需要掌握二叉树的遍历。

使用一个长度为2的结构存储。位置0为不选,位置1为选。

class Solution:
    def rob(self, root: Optional[TreeNode]) -> int:
        # dp数组(dp table)以及下标的含义:
        # 1. 下标为 0 记录 **不偷该节点** 所得到的的最大金钱
        # 2. 下标为 1 记录 **偷该节点** 所得到的的最大金钱
        dp = self.traversal(root)
        return max(dp)

    # 要用后序遍历, 因为要通过递归函数的返回值来做下一步计算
    def traversal(self, node):
        
        # 递归终止条件,就是遇到了空节点,那肯定是不偷的
        if not node:
            return (0, 0)

        left = self.traversal(node.left)
        right = self.traversal(node.right)

        # 不偷当前节点, 偷子节点
        val_0 = max(left[0], left[1]) + max(right[0], right[1])

        # 偷当前节点, 不偷子节点
        val_1 = node.val + left[0] + right[0]

        return (val_0, val_1)


1.5、股票问题 -(状态DP)

像这样的股票问题,使用动态规划,创建两列即两个状态的二维数组,照常初始化,但需要根据题意去得出特定的递推公式,便可完成该题。

在这里插入图片描述

1.5.0、LCR 091. 粉刷房子

LCR 091. 粉刷房子

class Solution:
    def minCost(self, costs: List[List[int]]) -> int:
        row, col = len(costs), len(costs[0])

        dp = [costs[0]]+[[0]*3 for _ in range(row-1)]

        for i in range(1, row):
            dp[i][0] = min(dp[i-1][1], dp[i-1][2]) + costs[i][0]
            dp[i][1] = min(dp[i-1][0], dp[i-1][2]) + costs[i][1]
            dp[i][2] = min(dp[i-1][0], dp[i-1][1]) + costs[i][2]
        return min(dp[-1])

1.5.1、121. 买卖股票的最佳时机 - 只能买卖一次

121. 买卖股票的最佳时机

贪心算法:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        low = float('inf')
        result = 0
        # 遍历,每一个值当做最大值,其中可能存在最小值,进行替换
        # 右边当最大值,在其左边找最小值
        for i in range(len(prices)):
            low = min(low, prices[i])
            result = max(result, prices[i]-low)
        return result

递推公式,两种状态:
要么持有该股票:那么要么之前就买了dp[i-1][0],保持原样,要么刚买-prices[i]
要么不持有该股票:那么之前就没买dp[i-1][1],也保持原样,要么刚卖prices[i]+dp[i-1],取差值。

动态规划:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 创建二维数组,两个状态,持有和不持有
        length = len(prices)
        if length==0:
            return 0
        dp = [[0, 0] for _ in range(length)]
        # 初始化,第一天持有则买入,第一天不持有则仍然为0
        dp[0][0] = -prices[0]
        dp[0][1] = 0
        # 开始遍历
        for i in range(1, length):
        	# 持有股票
            dp[i][0] = max(dp[i-1][0], -prices[i])
            # 不持有股票
            dp[i][1] = max(dp[i-1][1], prices[i]+dp[i-1][0])
        return dp[-1][1]

滚动数组:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        length = len(prices)
        dp = [[0] * 2 for _ in range(2)] #注意这里只开辟了一个2 * 2大小的二维数组
        dp[0][0] = -prices[0]
        dp[0][1] = 0
        for i in range(1, length):
            dp[i % 2][0] = max(dp[(i-1) % 2][0], -prices[i])
            dp[i % 2][1] = max(dp[(i-1) % 2][1], prices[i] + dp[(i-1) % 2][0])
        return dp[(length-1) % 2][1]

进一步改进:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        length = len(prices)
        dp0, dp1 = -prices[0], 0 #注意这里只维护两个常量,因为dp0的更新不受dp1的影响
        for i in range(1, length):
            dp1 = max(dp1, dp0 + prices[i])
            dp0 = max(dp0, -prices[i])
        return dp1

1.5.2、122. 买卖股票的最佳时机 II - 能买卖多次

122. 买卖股票的最佳时机 II
这里重申一下dp数组的含义:

dp[i][0] 表示第i天持有股票所得现金。
dp[i][1] 表示第i天不持有股票所得最多现金

如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来:

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
  • 第i天买入股票,所得现金就是昨天不持有股票的所得现金减去 今天的股票价格 即:dp[i - 1][1] - prices[i]
    注意这里和121. 买卖股票的最佳时机 唯一不同的地方,就是推导dp[i][0]的时候,第i天买入股票的情况。

在121. 买卖股票的最佳时机 中,因为股票全程只能买卖一次,所以如果买入股票,那么第i天持有股票即dp[i][0]一定就是 -prices[i]。

而本题,因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润。那么第i天持有股票即dp[i][0],如果是第i天买入股票,所得现金就是昨天不持有股票的所得现金 减去 今天的股票价格 即:dp[i - 1][1] - prices[i]。

再来看看如果第i天不持有股票即dp[i][1]的情况, 依然可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
  • 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]
class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 步骤同上一题
        dp = [[0,0] for _ in range(len(prices))]
        dp[0][0] = -prices[0]
        dp[0][1] = 0

        for i in range(1, len(prices)):
            #注意这里是和121. 买卖股票的最佳时机唯一不同的地方
            dp[i][0] = max(dp[i-1][0], dp[i-1][1]-prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-1][0]+prices[i])
        return dp[-1][1]

滚动数组:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        length = len(prices)
        dp = [[0] * 2 for _ in range(2)] #注意这里只开辟了一个2 * 2大小的二维数组
        dp[0][0] = -prices[0]
        dp[0][1] = 0
        for i in range(1, length):
            dp[i % 2][0] = max(dp[(i-1) % 2][0], dp[(i-1) % 2][1] - prices[i])
            dp[i % 2][1] = max(dp[(i-1) % 2][1], dp[(i-1) % 2][0] + prices[i])
        return dp[(length-1) % 2][1]

其他解法:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        # 创建dp table
        # 含义第N天的最大利润
        dp = [0] * (len(prices)+1)
        # 初始化,第0天和第一天没有利润,为0,当然这里多余
        # dp[0] = dp[1] = 0
        # 从第二天开始产生正负利润
        for i in range(2, len(prices)+1):
            # 当前第N天的利润等于,max(之前的利润,今天的利润+之前的利润)
            # 这里取最大值,若今天产生负利润,则不买,只取之前的利润
            dp[i] = max(dp[i-1], prices[i-1]-prices[i-2]+dp[i-1])
        return dp[-1]

1.5.3、123. 买卖股票的最佳时机 III

123. 买卖股票的最佳时机 III

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        if len(prices) == 0:
            return 0
        dp = [[0] * 5 for _ in range(len(prices))]
        dp[0][1] = -prices[0]
        dp[0][3] = -prices[0]
        for i in range(1, len(prices)):
            dp[i][0] = dp[i-1][0]
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
            dp[i][2] = max(dp[i-1][2], dp[i-1][1] + prices[i])
            dp[i][3] = max(dp[i-1][3], dp[i-1][2] - prices[i])
            dp[i][4] = max(dp[i-1][4], dp[i-1][3] + prices[i])
        return dp[-1][4]

官方降低复杂度解法:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        if len(prices) == 0:
            return 0
        dp = [0] * 5 
        dp[1] = -prices[0]
        dp[3] = -prices[0]
        for i in range(1, len(prices)):
            dp[1] = max(dp[1], dp[0] - prices[i])
            dp[2] = max(dp[2], dp[1] + prices[i])
            dp[3] = max(dp[3], dp[2] - prices[i])
            dp[4] = max(dp[4], dp[3] + prices[i])
        return dp[4]

1.5.4、188. 买卖股票的最佳时机 IV

188. 买卖股票的最佳时机 IV

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        if len(prices) == 0:
            return 0
        dp = [[0] * (2*k+1) for _ in range(len(prices))]
        for j in range(1, 2*k, 2):
            dp[0][j] = -prices[0]
        for i in range(1, len(prices)):
            for j in range(0, 2*k-1, 2):
                dp[i][j+1] = max(dp[i-1][j+1], dp[i-1][j] - prices[i])
                dp[i][j+2] = max(dp[i-1][j+2], dp[i-1][j+1] + prices[i])
        return dp[-1][2*k]

滚动数组:

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        if len(prices) == 0: return 0
        dp = [0] * (2*k + 1)
        for i in range(1,2*k,2):
            dp[i] = -prices[0]
        for i in range(1,len(prices)):
            for j in range(1,2*k + 1):
                if j % 2:
                    dp[j] = max(dp[j],dp[j-1]-prices[i])
                else:
                    dp[j] = max(dp[j],dp[j-1]+prices[i])
        return dp[2*k]

1.5.5、309. 最佳买卖股票时机含冷冻期

309. 最佳买卖股票时机含冷冻期

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        if n == 0:
            return 0
        dp = [[0] * 4 for _ in range(n)]
        dp[0][0] = -prices[0] #持股票
        for i in range(1, n):
            dp[i][0] = max(dp[i-1][0], max(dp[i-1][3], dp[i-1][1]) - prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-1][3])
            dp[i][2] = dp[i-1][0] + prices[i]
            dp[i][3] = dp[i-1][2]
        return max(dp[n-1][3], dp[n-1][1], dp[n-1][2])

1.5.6、714. 买卖股票的最佳时机含手续费

714. 买卖股票的最佳时机含手续费

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        n = len(prices)
        dp = [[0] * 2 for _ in range(n)]
        dp[0][0] = -prices[0] #持股票
        for i in range(1, n):
            dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i] - fee)
        return max(dp[-1][0], dp[-1][1])

通用模板:搞定所有股票问题 - 从记忆化递归 到 递推 再到 滚动数组

学习了很多别的题解,总结出了一套比较通用的题解模板。


188. 买卖股票的最佳时机 IV

188. 买卖股票的最佳时机 IV
递归搜索 + 保存计算结果 = 记忆化递归

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        n = len(prices)
        @cache
        def dfs(i, j, hold):
            if j < 0:
                return -inf
            if i == n:
                return -inf if hold else 0
            if hold:
                # 如果当前持有股票
                # 1. 下一天依旧持有 2.下一天卖出
                return max(dfs(i+1, j, True), dfs(i+1, j, False)+prices[i])
            return max(dfs(i+1, j-1, True)-prices[i], dfs(i+1, j, False))
        return dfs(0, k, False)

1:1翻译成递推

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        n = len(prices)

        dp = [[[0, -inf] for _ in range(k+1)] for _ in range(n)]
        dp[0][0][1] = -prices[0]

        for i in range(n):
            for j in reversed(range(1, k+1)):
                dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1]+prices[i])
                dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0]-prices[i])
        return dp[-1][-1][0]

空间优化 - 滚动数组

class Solution:
    def maxProfit(self, k: int, prices: List[int]) -> int:
        n = len(prices)
        # 将 dp[j][0] 初始化为 0,表示在第 0 天不进行任何操作且没有持有股票的最大利润为 0
        # 将 dp[j][0] 初始化为 -inf,表示在第 0 天不可能完成 j 笔交易且持有股票,并且保证买入股票时为负数的成本能正确为max值

        dp = [[0, -inf] for _ in range(k+1)]
        dp[0][1] = -prices[0]

        for i in range(n):
            for j in reversed(range(1, k+1)):
                dp[j][0] = max(dp[j][0], dp[j][1]+prices[i])
                dp[j][1] = max(dp[j][1], dp[j-1][0]-prices[i])
        return dp[-1][0]

121. 买卖股票的最佳时机

121. 买卖股票的最佳时机

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)

        # 因为我们只能选择一天卖出股票,所以只由一种状态
        # dp表示在第i天卖出股票所能获得的最大利润
        dp = [0]*n
        minPrice = prices[0]

        for i in range(1, n):
            minPrice = min(minPrice, prices[i])
            dp[i] = max(dp[i-1], prices[i]-minPrice)
        return dp[-1]

122. 买卖股票的最佳时机 II

122. 买卖股票的最佳时机 II
递归搜索 + 保存计算结果 = 记忆化递归

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        @cache
        def dfs(i, hold):
            if i == n:
                return -inf if hold else 0
            if hold:
                return max(dfs(i+1, False)+prices[i], dfs(i+1, True))
            return max(dfs(i+1, False), dfs(i+1, True)-prices[i]) 
        return dfs(0, False)

1:1翻译成递推

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [[0, -inf] for _ in range(n)]
        # 下面这句也可以去掉
        # 因为当i=0时, dp[0][1]=-prices[0]
        dp[0][1] = -prices[0]

        for i in range(n):
            dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i])
        
        return dp[-1][0]

空间优化 - 滚动数组

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [0, -inf]
        # 下面这句也可以去掉
        # 因为当i=0时, dp[1]=-prices[0]
        dp[1] = -prices[0]

        for i in range(n):
            dp[0] = max(dp[0], dp[1]+prices[i])
            dp[1] = max(dp[1], dp[0]-prices[i])
        
        return dp[0]

123. 买卖股票的最佳时机 III

123. 买卖股票的最佳时机 III
递归搜索 + 保存计算结果 = 记忆化递归

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        @cache
        def dfs(i, j, hold):
            if j<0:
                return -inf
            if i == n:
                return -inf if hold else 0
            if hold:
                return max(dfs(i+1, j, True), dfs(i+1, j, False)+prices[i])
            return max(dfs(i+1, j, False), dfs(i+1, j-1, True)-prices[i])
        return dfs(0, 2, False)

1:1翻译成递推

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [[[0, -inf] for _ in range(2+1)] for _ in range(n)]
        dp[0][0][1] = -prices[0]

        for i in range(n):
            for j in reversed(range(1, 2+1)):
                dp[i][j][0] = max(dp[i-1][j][0], dp[i-1][j][1]+prices[i])
                dp[i][j][1] = max(dp[i-1][j][1], dp[i-1][j-1][0]-prices[i])
        return dp[-1][-1][0]

空间优化 - 滚动数组

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [[0, -inf] for _ in range(2+1)]
        dp[0][1] = -prices[0]

        for i in range(n):
            for j in reversed(range(1, 2+1)):
                dp[j][0] = max(dp[j][0], dp[j][1]+prices[i])
                dp[j][1] = max(dp[j][1], dp[j-1][0]-prices[i])
        return dp[-1][0]

309. 买卖股票的最佳时机含冷冻期

309. 买卖股票的最佳时机含冷冻期
递归搜索 + 保存计算结果 = 记忆化递归

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        @cache
        def dfs(i: int, hold: bool) -> int:
            if i >= n:
                return 0 if not hold else -inf
            if hold:
            	# 当前持有卖掉后,跳一天,即i+2
                return max(dfs(i + 1, True), dfs(i + 2, False) + prices[i])
            return max(dfs(i + 1, False), dfs(i + 1, True) - prices[i])
        return dfs(0, False)

1:1翻译成递推

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [[0, -inf] for _ in range(n)]
        dp[0][1] = -prices[0]

        # 注意dp[i-1]可能越界,i从1开始
        for i in range(1, n):
            dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-2][0]-prices[i])
            
        return dp[-1][0]

空间优化 - 滚动数组

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        n = len(prices)
        dp = [0, -inf]
        dp[1] = -prices[0]
        # 记录前一天
        tmp = 0

        for i in range(n):
            tmp, dp[0], dp[1] = dp[0], max(dp[0], dp[1]+prices[i]),max(dp[1], tmp-prices[i])
        return dp[0]

714. 买卖股票的最佳时机含手续费

714. 买卖股票的最佳时机含手续费
递归搜索 + 保存计算结果 = 记忆化递归

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        n = len(prices)
        @cache
        def dfs(i, hold):
            if i == n:
                return -inf if hold else 0
            if hold:
                return max(dfs(i+1, True), dfs(i+1, False)+prices[i])
            return max(dfs(i+1, True)-prices[i]-fee, dfs(i+1, False))
        return dfs(0, False)

1:1翻译成递推

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        n = len(prices)
        dp = [[0, -inf] for _ in range(n)]
        dp[0][1] = -prices[0]-fee
        for i in range(n):
            dp[i][0] = max(dp[i-1][0], dp[i-1][1]+prices[i])
            dp[i][1] = max(dp[i-1][1], dp[i-1][0]-prices[i]-fee)
        return dp[-1][0]

空间优化 - 滚动数组

class Solution:
    def maxProfit(self, prices: List[int], fee: int) -> int:
        n = len(prices)
        dp = [0, -inf]
        dp[1] = -prices[0]-fee
        for i in range(n):
            dp[0] = max(dp[0], dp[1]+prices[i])
            dp[1] = max(dp[1], dp[0]-prices[i]-fee)
        return dp[0]

教你一步步思考动态规划!股票问题通用解法!



1.5、子序列问题(线性DP)

在这里插入图片描述

1.5.1、子序列(不连续)

1.5.1.1、300. 最长递增子序列(优化:贪心+二分)

力扣题目链接
从左开始遍历每个点,再从左到该点进行遍历和比较,比它小则累加,这种加法保证了是有序的。

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        # 全部初始化为最小的子序列1,用来存储该位置的最小子序列
        dp = [1]*len(nums)
        dp[0]=1
        for i in range(1, len(nums)):
        	# 因为是非连续的子序列,所以需要遍历前面所有点
            for j in range(i):
                if nums[j]<nums[i]:
                # 注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。
                    dp[i] = max(dp[i], dp[j]+1)
        return max(dp)

最优做法,二分法:
维护一个递增序列stack,当stack为空或前值大于stack[-1]则直接添加,若小于等于,则使用二分法,查找该值能在stack中插入的位置进行替换。

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        dp = [nums[0]]
        n = len(nums)

        for i in range(1, n):
            if dp[-1]<nums[i]:
                dp.append(nums[i])
            else:
                left, right = 0, len(dp)-1

                while left<=right:
                    mid = (left+right)//2
                    if dp[mid]<nums[i]:
                        left+=1
                    else:
                        right-=1
                
                dp[left] = nums[i]
        
        return len(dp)

使用内置bisect

import bisect
class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        n = len(nums)
        stack = [nums[0]]

        for i in range(1, n):
            if stack[-1]<nums[i]:
                stack.append(nums[i])
            else:
                ind = bisect.bisect_left(stack, nums[i])
                stack[ind] = nums[i]
        return len(stack)

可参考:面试题 17.08. 马戏团人塔


1.5.1.2、1143. 最长公共子序列

力扣题目链接

注意与 1.5.2.2、718. 最长重复子数组 的区分,可以先尝试做718. 最长重复子数组再来尝试这题。

这题要考虑不连续的公共子串,匹配不相等时,该如何处理值:
在这里插入图片描述
有三个方向推到dp[i][j],斜对角为匹配相等时,前面匹配的字符+这次匹配到一次也就是1;匹配不相等时,上和左取最大值,取上可能这一行都不匹配,取左可能前面有匹配的而后面不匹配。

class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        dp = [[0]*(len(text2)+1) for _ in range(len(text1)+1)]

        for i in range(1, len(text1)+1):
            for j in range(1, len(text2)+1):
                if text1[i-1] == text2[j-1]:
                    # 匹配相等,把斜上角的值填过来+1,斜上角为各个字符串上一步匹配相等的次数
                    dp[i][j] = dp[i-1][j-1] + 1
                else:
                    # dp[i][j-1],有过相等但后续匹配不相等时,把左一个值向右填充
                    # dp[i-1][j],匹配完全不相等,把上一个值向下填充
                    # dp[i][j-1], text2在该字母上的最大子序列
                    # dp[i-1][j], text1在该字母上的最大子序列
                    # 两个字母不相等,
                    dp[i][j] = max(dp[i-1][j], dp[i][j-1]) 
                    # dp[i-1][j-1] 可加上不影响结果
        return dp[-1][-1]

在这里插入图片描述
这一题是公共连续序列,对于其拓展,还有题型 最长公共子串


1.5.1.3、1035. 不相交的线

力扣题目链接
连线是有序的,实际上是求最大公共子序列。
求两个字符串的公共子序列,则需要二元数组。

class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        dp = [[0]*(len(nums2)+1) for _ in range(len(nums1)+1)]

        for i in range(1, len(nums1)+1):
            for j in range(1, len(nums2)+1):
                if nums1[i-1]==nums2[j-1]:
                    dp[i][j] = dp[i-1][j-1]+1
                else:
                    dp[i][j] = max(dp[i-1][j], dp[i][j-1])
                print(dp)
        return dp[-1][-1]

这一题的本质是与 1.5.1.2、1143. 最长公共子序列 相同,代码与过程分析相同。


1.5.1.4、673. 最长递增子序列的个数

力扣题目链接

class Solution:
    def findNumberOfLIS(self, nums: List[int]) -> int:
        n, max_len, ans = len(nums), 1, len(nums)
        dp = [1] * n
        cnt = [1] * n

        for i in range(n):
            for j in range(i):
                if nums[i]>nums[j]:
                    # 长度更长
                    if dp[j]+1>dp[i]:
                        dp[i] = dp[j]+1
                        cnt[i] = 0
                    # 累加能使长度更长的序列
                    if dp[j]+1==dp[i]:
                        cnt[i] += cnt[j]

                    if max_len<dp[j]+1:
                        max_len = dp[j]+1
                        ans = 0
                    if max_len == dp[j]+1:
                        ans += cnt[j]
        return ans

1.5.2、子序列(连续)

1.5.2.1、674. 最长连续递增序列

力扣题目链接

这道题注意和上个单元的 1.5.1.1、300. 最长上升子序列 的区分,连续和不连续。

class Solution:
    def findLengthOfLCIS(self, nums: List[int]) -> int:
        dp = [1]*len(nums)
		# 这里只用一层循环,因为是连续子串,所以只用比较相邻的值就行
        for i in range(1, len(nums)):
            if nums[i]>nums[i-1]:
                dp[i] = dp[i-1]+1
        return max(dp)

或者 增加些内存来减少用时:

class Solution:
    def findLengthOfLCIS(self, nums: List[int]) -> int:
        dp = [1]*len(nums)
        result = 1
        for i in range(1, len(nums)):
            if nums[i]>nums[i-1]:
                dp[i] = dp[i-1]+1
            if dp[i]>result:
                result = dp[i]
        return result

在这里插入图片描述


1.5.2.2、718. 最长重复子数组

力扣题目链接

class Solution:
    def findLength(self, nums1: List[int], nums2: List[int]) -> int:
        # 这里创建二维dp table,记录两个字符串的匹配数(注意i与j的顺序)
        dp = [[0]*(len(nums2)+1) for _ in range(len(nums1)+1)]
        # 取最大值
        result = 0
        # 两层循环,遍历两个字符串,顺序可以调换
        for i in range(1, len(nums1)+1):
            for j in range(1, len(nums2)+1):
                # 判断当前索引的值是否相等
                if nums1[i-1]==nums2[j-1]:
                    # 等于各个前面一个的值的匹配数+1
                    dp[i][j] = dp[i-1][j-1]+1
                if dp[i][j]>result:
                    result = dp[i][j]
        return result

在这里插入图片描述
时间复杂度: O ( n × m ) O(n × m) O(n×m),n 为A长度,m为B长度
空间复杂度: O ( n × m ) O(n × m) O(n×m)

滚动数组:


时间复杂度: O ( n × m ) O(n × m) O(n×m),n 为A长度,m为B长度
空间复杂度: O ( m ) O(m) O(m)


1.5.2.3、53. 最大子序和

力扣题目链接

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        # 从0到第i个数的最大值
        dp = [0]*(len(nums))
        # 初始化,第一个
        dp[0] = nums[0]
		# 从第二个开始遍历
        for i in range(1, len(nums)):
        	# 前一个最大值+该索引的值是否大,大就相加,不大就只保留索引值
            dp[i] = max(nums[i], nums[i]+dp[i-1])
        return max(dp)

在这里插入图片描述


1.5.3、编辑距离

1.5.3.1、392. 判断子序列

力扣题目链接

这题的思路可以参考 最长公共子序列,如果最长长度等于s的长度即为True。

但是这题又有点不同,它是给定字符串 s 和 t ,判断 s 是否为 t 的子序列,那么实际上不能改变s的值,而在t上进行删除,直到s能与t相同。

但是最长公共子序列,我们

class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        n1 = len(s)
        n2 = len(t)
		# dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。
        dp = [[0]*(n2+1) for _ in range(n1+1)]

        for i in range(1, n1+1):
            for j in range(1, n2+1):
                if s[i-1]==t[j-1]:
                # 因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1
                    dp[i][j] = dp[i-1][j-1]+1
                else:
                # s[i-1]与t[j-1]这两个字符不相等,那么dp[j]和dp[j-1]相等,该字符也就相当于被删除了
                # dp[i]就不进行-1了,因为判断 s 是否为 t 的子序列,s不能改变,所以只有dp[i][j] = dp[i][j-1]
                # 此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了
                    dp[i][j] = dp[i][j-1]
        return dp[-1][-1]==n1

这题是简单难度,其实可以用双指针来做,遍历t,定位一个s的指针,在t中找到s的字符则指针后移,继续在t中找:

class Solution:
    def isSubsequence(self, s: str, t: str) -> bool:
        ind = 0
        if not s:
            return True
        for alpha in t:
            if ind == len(s):
                break
            if alpha==s[ind]:
                ind+=1
        return ind==len(s)

1.5.3.2、115. 不同的子序列

力扣题目链接

这题和上题很像,但是是求s 的 子序列 中 t 出现的个数。

既然求个数,我们不能再在字符匹配的过程中做+1等操作,那是统计序列长度了。

每一个dp比如dp[j-1]表示删除一个字母子序是否相等,这算一个操作,我们将这样的操作总和加起来就是总出现个数。

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        # dp table 出现的个数
        dp = [[0]*(len(s)+1) for _ in range(len(t)+1)]
        # t若为空,则在s中都能出现一次,所以第一行初始化为1
        # dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。
        for i in range(len(s)+1):
            dp[0][i] = 1
        # s为空,则不能被t的任何任何字母组成
        # 所以 dp[i][0] = 0不变
        # for i in range(1, len(t)+1):
        # 	dp[i][0] = 0
        # 创建dp时已经初始化过了,这里省略,但是分析时还是需要表明出来。

        # i到前面的字母(t) 出现在 j到之前的字母(s) 出现的次数
        for i in range(1, len(t)+1):
            for j in range(1, len(s)+1):
            # 当前字符相等,要么前面字符都相等=dp[i-1][j-1]
            # 要么s[j-1]当前字母重复了,所以s[j-2]
                if t[i-1] == s[j-1]:
                    # dp[i-1][j-1] 两个字符串s与t,退一个字母匹配的次数
                    # dp[i][j-1] s字符串 向左退一次匹配的次数,前面有重复的就会累加
                    # 例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。
					# 当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。
                    dp[i][j] = dp[i-1][j-1]+dp[i][j-1]
                else:
                    dp[i][j] = dp[i][j-1]
        return dp[-1][-1]

举例:s为列,t为行:

s = 'bagg'
t = 'bag'
    _   b   a   g   g
_ [|1|  1   1   1   1 ]
b [|0|=|1|  1   1   1 ]
a [ 0  |0|=|1| |1|  1 ]
g [ 0   0  |0|=|1|= 2 ]

举例:s为行,t为列:
在这里插入图片描述


1.5.3.3、583. 两个字符串的删除操作

力扣题目链接
确定递推公式
当word1[i - 1] 与 word2[j - 1]相同的时候
当word1[i - 1] 与 word2[j - 1]不相同的时候
当word1[i - 1] 与 word2[j - 1]相同的时候,dp[i][j] = dp[i - 1][j - 1];

当word1[i - 1] 与 word2[j - 1]不相同的时候,有三种情况:

情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1

情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1

情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2

那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1})

因为 dp[i][j - 1] + 1 = dp[i - 1][j - 1] + 2,所以递推公式可简化为:dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1)

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        # 建表
        dp = [[0]*(len(word2)+1) for _ in range(len(word1)+1)]
        # 初始化
        for i in range(len(word2)+1):
            dp[0][i]=i
        for i in range(len(word1)+1):
            dp[i][0]=i
        # 填表
        for i in range(1, len(word1)+1):
            for j in range(1, len(word2)+1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    # word1删除,word2删除,word1和word2都删除一个
                    dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+2)
                    # 可以简化为下面
                    # dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1)
        return dp[-1][-1]

在这里插入图片描述


1.5.3.4、72. 编辑距离

力扣题目链接

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        dp = [[0]*(len(word2)+1) for _ in range(len(word1)+1)]
        # 初始化,删除为空值需要的步数
        for i in range(len(word2)+1):
            dp[0][i] = i
        for i in range(len(word1)+1):
            dp[i][0] = i
        # 循环
        for i in range(1, len(word1)+1):
            for j in range(1, len(word2)+1):
            # word1[i - 1] 与 word2[j - 1]相等了,那么就不用编辑了
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    # (or 增加word2[j-1])删除word1[i-1],选择之前的word1[:i-1],即dp[i-1][j],最后操作数+1
                    # (or 增加word1[i-1])删除word2[j-1],选择之前的word2[:j-1],即dp[i][j-1],最后操作数+1
                    # dp[i-1][j-1] 表示前word1[:i-1]和word2[:j-1]相等时,word1[i-1]和word2[j-1]不相等,那么替换即可,增加一次操作数。
                    # 上面三种操作,取一个最少次数就行。 
                    dp[i][j] = min(dp[i-1][j]+1, dp[i][j-1]+1, dp[i-1][j-1]+1)
        return dp[-1][-1]

在这里插入图片描述
leetcode题解参考



1.5.4、回文

1.5.4.1、647. 回文子串

力扣题目链接

  1. 确定dp数组(dp table)以及下标的含义
    布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
  2. 确定递推公式
    整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。
    当s[i]与s[j]不相等,dp[i][j]一定是false。
    当s[i]与s[j]相等时,有如下三种情况:
    情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
    情况二:下标i 与 j相差为1,例如aa,也是回文子串
    情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

递归公式:

if s[i] == s[j]:
	if j-i<=1:
		result+=1
		dp[i][j] = True
	else if dp[i+1][j-1]:
		result+=1
		dp[i][j] = True

result就是统计回文子串的数量。

这里没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]初始化的时候,就初始为false。

  1. dp数组如何初始化
    dp[i][j]在ij时可以初始化为True,但是在遍历时,j-i<=2已经涵盖了ij的情况,所以dp[i][j]直接设为False,当然对i==j的位置初始化为True也行。

  2. 确定遍历顺序
    首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。

dp[i + 1][j - 1] 在 dp[i][j]的左下角,如图:
在这里插入图片描述
如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。

所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的。

有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。

  1. 举例推导dp数组
    举例,输入:“aaa”,dp[i][j]状态如下:
    在这里插入图片描述
    图中有6个true,所以就是有6个回文子串。

注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分。

综上,代码如下:

动态规划:

class Solution:
    def countSubstrings(self, s: str) -> int:
        # 暴力求解是双循环,所以这里创建仿造,创建二元数组
        dp = [[False]*(len(s)) for _ in range(len(s))]
        result = 0
        # 从后往前遍历
        for i in range(len(s)-1, -1, -1):
            for j in range(i, len(s)):
                # 字符串字母相同
                if s[i]==s[j]:
                    # 自身或者相邻
                    if j-i<=1:
                        dp[i][j]=True
                        result+=1
                    # 不相邻,相隔大于1
                    # 该字符串是否为回文=首字母+1,结尾字母-1
                    # 就是前后都往内部缩一个字母是否为回文
                    elif dp[i+1][j-1]:
                        dp[i][j]=True
                        result+=1
        return result

时间复杂度:O(n^2)
空间复杂度:O(n^2)

这是有初始化的代码:

class Solution:
    def countSubstrings(self, s: str) -> int:
        n = len(s)

        dp = [[0]*n for _ in range(n)]

        for i in range(n):
            dp[i][i] = 1
		# 逆序遍历,因为dp[i+1][j-1]需要提前知道下一行的值。
        for i in reversed(range(n)):
            for j in range(i+1, n):
                if s[i]==s[j]:
                    if j-i<=2 or dp[i+1][j-1]:
                        dp[i][j] = 1
        # print(dp)
        return (sum(map(sum, dp)))

以aaa为例寻找所有子回文串的过程:

	a    a    a
a   a    aa  aaa
a   0    a   aa
a   0    0   a

暴力遍历:
双层循环,每次后移一个字母判断回文

class Solution:
    def countSubstrings(self, s: str) -> int:
        result = 0
        # 暴力遍历
        for i in range(1, len(s)+1):
            for j in range(i):
                if s[j:i] == s[j:i][::-1]:
                    result+=1
        return result

双指针法:

class Solution:
    def countSubstrings(self, s: str) -> int:
        result = 0
        for i in range(len(s)):
            # 以i为中心
            result += self.extend(s, i, i, len(s))
            # 以i+1为中心
            result += self.extend(s, i, i+1, len(s))
        return result

    def extend(self, s, i, j, n):
        res = 0
        while i>=0 and j<n and s[i]==s[j]:
            i-=1
            j+=1
            res+=1
        return res

时间复杂度:O(n^2)
空间复杂度:O(1)


1.5.4.2、5. 最长回文子串

5. 最长回文子串

动态规划:

class Solution:
    def longestPalindrome(self, s: str) -> str:
    	# 用来存储子串字符串 
        dp = [['']*(len(s)) for _ in range(len(s))]
        # 斜对角为1
        for i in range(len(s)):
            dp[i][i]=s[i]
        max_length = ''
        # 第一层循环:整体上从后往前开始递归
        # 第二层循环:内部从头往后遍历递归
        # 相同的已经初始化为1,则从+1下一个开始直到终点
        for i in range(len(s)-1, -1, -1):
            for j in range(i+1, len(s)):
                # 当两个位置的字母相等时,并且(中间为回文)或者(相邻或间隔一个)
                if s[i]==s[j] and (dp[i+1][j-1] or (j-i<=2)):
                    dp[i][j] = s[i]+dp[i+1][j-1]+s[j]
            max_length = max(max_length, max(dp[i], key=len), key=len)
        # print(dp)
        return max_length

单中心点遍历:
选择中心点,一个或者两个,同时向外扩散,边界相等为回文则继续,不相等终止,判断是否为最大回文字符串。

class Solution:
    def longestPalindrome(self, s: str) -> str:
        max_str = ''
        def judge(l,r):
            while 0<=l and r<len(s) and s[l]==s[r]:
                    l-=1
                    r+=1
                
            nonlocal max_str
            max_str = max(max_str, s[l+1:r], key=len)
            
        for i in range(len(s)):
            judge(i,i)
            judge(i,i+1)
        return max_str

相似题型:
HJ32 密码截取 - 牛客网

import sys

for line in sys.stdin:
    line = line[:-1]
    # dp[i][j]表示从i到j是否为回文
    # create dp table
    dp = [[0]*len(line) for _ in range(len(line))]
    # initial table
    for i in range(len(line)):
        dp[i][i]=1
    max_long = 0
    # go through table
    # 一头一尾开始遍历,行逆序
    for i in range(len(line)-1, -1, -1):
        for j in range(i+1, len(line)):
            # 字母相等
            if line[i] == line[j]:
                # 判断中间字符是否相等,或者两个字符相邻,无中间字符
                if dp[i+1][j-1] or j-i==1:
                    # 加上i和j上的两个字符
                    dp[i][j] = dp[i+1][j-1]+2
            # 去掉非连续
            # else:
                # dp[i][j] = max(dp[i+1][j], dp[i][j-1])
        # 每一次起点遍历,保存最大值
        if max_long<max(dp[i]):
            max_long = max(dp[i])
    print(max_long)

这里,可以像上题一样的写法,先不初始化,双循环内的判断条件可以改为,1.奇:i-j=0,2.偶:i-j=1,3.大于2的情况,除开两端的中间为回文,即:
在这里插入图片描述


1.5.4.3、516. 最长回文子序列

力扣题目链接
这一题与上一题的区别:回文子串是要连续的,回文子序列可不是连续的。

  1. 确定dp数组(dp table)以及下标的含义
    dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]。
  2. 确定递推公式
    如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2
    在这里插入图片描述
    如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。
    加入s[j]的回文子序列长度为dp[i + 1][j]。
    加入s[i]的回文子序列长度为dp[i][j - 1]。
    那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
    在这里插入图片描述
  3. dp数组如何初始化
    首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。
    所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。
    其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。
for i in range(len(s)):
	dp[i][i]=1
  1. 确定遍历顺序
    从递推公式dp[i][j] = dp[i + 1][j - 1] + 2 和 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 可以看出,dp[i][j]是依赖于dp[i + 1][j - 1] 和 dp[i + 1][j],
    也就是从矩阵的角度来说,dp[i][j] 下一行的数据。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的。
    递推公式:dp[i][j] = dp[i + 1][j - 1] + 2,dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 分别对应着下图中的红色箭头方向,如图:
    在这里插入图片描述
for i in range(len(s)-1, -1, -1):
	for j in range(i+1, len(s)):
		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])
  1. 举例推导dp数组
    输入s:“cbbd” 为例,dp数组状态如图:
    在这里插入图片描述
class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        # dp[i][j]表示从i到j的最长子序列
        dp = [[0]*len(s) for _ in range(len(s))]
        # 初始化,对角线为1
        for i in range(len(s)):
            dp[i][i]=1
        # 循环填表,从下到上,从左到右
        for i in range(len(s)-1, -1, -1):
            for j in range(i+1, len(s)):
                if s[i]==s[j]:
                	# 加入这一代码块可以去掉前面对角线初始化
                	# if i==j:
                        #d p[i][j]=1
                    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][-1]

1.5.5、最大正方形问题

1.5.5.1、221. 最大正方形

221. 最大正方形

  • 如果该位置的值是 0,则 dp(i,j)=0,因为当前位置不可能在由 1 组成的正方形中;
  • 如果该位置的值是 1,则 dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的 dp值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:
dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1

也就是取最小长度的1才能满足正方形,短板效应。
在这里插入图片描述

class Solution:
    def maximalSquare(self, matrix: List[List[str]]) -> int:
        row, col = len(matrix), len(matrix[0])
        
        dp = [[0]*col for _ in range(row)]
        max_width = 0
        for i in range(row):
            for j in range(col):
                if matrix[i][j]=='1':
                    if i==0 or j==0:
                        dp[i][j]=1
                    else:
                        dp[i][j] = min(dp[i-1][j], dp[i][j-1],dp[i-1][j-1])+1 
                max_width = max(max(dp[i]), max_width)
        return max_width**2

1.5.5.2、1277. 统计全为 1 的正方形子矩阵

1277. 统计全为 1 的正方形子矩阵

该题与上一题基本一致,但注意求矩阵个数则要把值都加起来。为什么矩阵的值即个数呢?可以自行仿照上题的图验证下。

class Solution:
    def countSquares(self, matrix: List[List[int]]) -> int:
        row, col = len(matrix), len(matrix[0])
        dp = [[0]*col for _ in range(row)]

        count = 0

        for i in range(row):
            for j in range(col):
                if matrix[i][j]==1:
                    if i==0 or j==0:
                        dp[i][j]=1
                    else:
                        dp[i][j] = min(dp[i-1][j], dp[i][j-1],dp[i-1][j-1])+1
                    count+=dp[i][j]
        return count

1.6、练习

1.6.1、面试题 08.11. 硬币

面试题 08.11. 硬币
取模就是取余%

class Solution:
    def waysToChange(self, n: int) -> int:
        dp = [0]*(n+1)
        dp[0]=1

        for i in [1,5,10,25]:
            for j in range(i, n+1):
                dp[j] += dp[j-i]
        return dp[-1]%1000000007


  • 19
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 14
    评论
评论 14
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值