小溪汇入河流,江河汇入大海。
动态规划将小问题的解汇集成大问题的答案,一步步引领我们走向解决问题的彼岸。
什么是动态规划
动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。
所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,
例如:有N件物品和一个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])。
但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。
所以贪心解决不了动态规划的问题。
其实大家也不用死扣动规和贪心的理论区别,后面做做题目自然就知道了。
而且很多讲解动态规划的文章都会讲最优子结构啊和重叠子问题啊这些,这些东西都是教科书的上定义,晦涩难懂而且不实用。
大家知道动规是由前一个状态推导出来的,而贪心是局部直接选最优的,对于刷题来说就够用了。
上述提到的背包问题,后序会详细讲解。
动态规划的解题步骤
做动规题目的时候,很多同学会陷入一个误区,就是以为把状态转移公式背下来,照葫芦画瓢改改,就开始写代码,甚至把题目AC之后,都不太清楚dp[i]表示的是什么。
这就是一种朦胧的状态,然后就把题给过了,遇到稍稍难一点的,可能直接就不会了,然后看题解,然后继续照葫芦画瓢陷入这种恶性循环中。
状态转移公式(递推公式)是很重要,但动规不仅仅只有递推公式。
对于动态规划问题,我将拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了!
- 确定dp数组(dp table)以及下标的含义
- 确定递推公式
- dp数组如何初始化
- 确定遍历顺序
- 举例推导dp数组
一些同学可能想为什么要先确定递推公式,然后在考虑初始化呢?
因为一些情况是递推公式决定了dp数组要如何初始化!
后面的讲解中我都是围绕着这五点来进行讲解。
可能刷过动态规划题目的同学可能都知道递推公式的重要性,感觉确定了递推公式这道题目就解出来了。
其实 确定递推公式 仅仅是解题里的一步而已!
动态规划入门题
509. 斐波那契数
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你n ,请计算 F(n) 。
示例 1:
- 输入:2
- 输出:1
- 解释:F(2) = F(1) + F(0) = 1 + 0 = 1
示例 2:
- 输入:3
- 输出:2
- 解释:F(3) = F(2) + F(1) = 1 + 1 = 2
示例 3:
- 输入:4
- 输出:3
- 解释:F(4) = F(3) + F(2) = 2 + 1 = 3
提示:
- 0 <= n <= 30
python
class Solution:
def fib(self, n: int) -> int:
# 排除 Corner Case
if n==0:
return 0
# 创建 dp table
dp=[0]*(n+1)
# 初始化 dp 数组
dp[0]=0
dp[1]=1
# 遍历顺序: 由前向后。因为后面要用到前面的状态
for i in range(2,n+1):
# 确定递归公式/状态转移公式
dp[i]=dp[i-1]+dp[i-2]
return dp[n]
70. 爬楼梯
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
- 输入: 2
- 输出: 2
- 解释: 有两种方法可以爬到楼顶。
- 1 阶 + 1 阶
- 2 阶
示例 2:
-
输入: 3
-
输出: 3
-
解释: 有三种方法可以爬到楼顶。
-
1 阶 + 1 阶 + 1 阶
-
1 阶 + 2 阶
-
2 阶 + 1 阶
-
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。
那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。
所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。
python
class Solution:
def climbStairs(self, n: int) -> int:
if n<3:
return n
dp=[0]*n
dp[0]=1
dp[1]=2
for i in range(2,n):
dp[i]=dp[i-1]+dp[i-2]
return dp[n-1]
'''
这道题的推导公式是这样得来的:
在到达第n层的上一步,我们只有两个选择,走一步,或者走两步。
如果是走一步,我们需要先通过 f(n-1)种方式到达 n-1 层
如果是走两步, 我们需要通过 f(n-2)种方式到达第 n - 2 层
所以综上有 f(n) = f(n-2) + f(n-1)
'''
746. 使用最小花费爬楼梯
旧题目描述:
数组的每个下标作为一个阶梯,第 i 个阶梯对应着一个非负数的体力花费值 cost[i](下标从 0 开始)。
每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。
请你找出达到楼层顶部的最低花费。在开始时,你可以选择从下标为 0 或 1 的元素作为初始阶梯。
示例 1:
- 输入:cost = [10, 15, 20]
- 输出:15
- 解释:最低花费是从 cost[1] 开始,然后走两步即可到阶梯顶,一共花费 15 。
示例 2:
- 输入:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
- 输出:6
- 解释:最低花费方式是从 cost[0] 开始,逐个经过那些 1 ,跳过 cost[3] ,一共花费 6 。
提示:
- cost 的长度范围是 [2, 1000]。
- cost[i] 将会是一个整型数据,范围为 [0, 999] 。
python
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp=[0]*(len(cost)+1)
dp[0]=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[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]。
'''
62.不同路径
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 "Start" )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 "Finish" )。
问总共有多少条不同的路径?
示例 1:
- 输入:m = 3, n = 7
- 输出:28
示例 2:
- 输入:m = 2, n = 3
- 输出:3
解释: 从左上角开始,总共有 3 条路径可以到达右下角。
- 向右 -> 向右 -> 向下
- 向右 -> 向下 -> 向右
- 向下 -> 向右 -> 向右
示例 3:
- 输入:m = 7, n = 3
- 输出:28
示例 4:
- 输入:m = 3, n = 3
- 输出:6
提示:
- 1 <= m, n <= 100
- 题目数据保证答案小于等于 2 * 10^9
机器人从(0 , 0) 位置出发,到(m - 1, n - 1)终点。
python
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 i in range(m):
dp[i][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[m-1][n-1]
63. 不同路径 II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为"Start" )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为"Finish")。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用 1 和 0 来表示。
示例 1:
- 输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
- 输出:2 解释:
- 3x3 网格的正中间有一个障碍物。
- 从左上角到右下角一共有 2 条不同的路径:
- 向右 -> 向右 -> 向下 -> 向下
- 向下 -> 向下 -> 向右 -> 向右
示例 2:
- 输入:obstacleGrid = [[0,1],[0,0]]
- 输出:1
提示:
- m == obstacleGrid.length
- n == obstacleGrid[i].length
- 1 <= m, n <= 100
- obstacleGrid[i][j] 为 0 或 1
62.不同路径 中我们已经详细分析了没有障碍的情况,有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。
因为从(0, 0)的位置到(i, 0)的路径只有一条,所以dp[i] [0]一定为1,dp[0] [j]也同理。
但如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。
如图:
下标(0, j)的初始化情况同理。
python
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m=len(obstacleGrid)
n=len(obstacleGrid[0])
if obstacleGrid[0][0]==1 or obstacleGrid[m-1][n-1]==1:
return 0
dp=[[0]*n for _ in range(m)]
for i in range(m):
if obstacleGrid[i][0]==0:
dp[i][0]=1
else:
break
for i in range(n):
if obstacleGrid[0][i]==0:
dp[0][i]=1
else:
break
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]
return dp[m-1][n-1]
343. 整数拆分
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
示例 1:
- 输入: 2
- 输出: 1
- 解释: 2 = 1 + 1, 1 × 1 = 1。
示例 2:
- 输入: 10
- 输出: 36
- 解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
- 说明: 你可以假设 n 不小于 2 且不大于 58。
dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。
可以想 dp[i]最大乘积是怎么得到的呢?
其实可以从1遍历j,然后有两种渠道得到dp[i].
一个是j * (i - j) 直接相乘。
一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。
python
class Solution:
def integerBreak(self, n: int) -> int:
# 假设对正整数 i 拆分出的第一个正整数是 j(1 <= j < i),则有以下两种方案:
# 1) 将 i 拆分成 j 和 i−j 的和,且 i−j 不再拆分成多个正整数,此时的乘积是 j * (i-j)
# 2) 将 i 拆分成 j 和 i−j 的和,且 i−j 继续拆分成多个正整数,此时的乘积是 j * dp[i-j]
if n==2:
return 1
dp=[0]*(n+1)
dp[2]=1
for i in range(3,n+1):
for j in range(1,i//2+1):
dp[i]=max(j*(i - j),j*dp[i-j],dp[i])
return dp[n]
96.不同的二叉搜索树
给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?
示例:
这道题目描述很简短,但估计大部分同学看完都是懵懵的状态,这得怎么统计呢?
了解了二叉搜索树之后,我们应该先举几个例子,画画图,看看有没有什么规律,如图:
n为1的时候有一棵树,n为2有两棵树,这个是很直观的。
来看看n为3的时候,有哪几种情况。
当1为头结点的时候,其右子树有两个节点,看这两个节点的布局,是不是和 n 为2的时候两棵树的布局是一样的啊!
(可能有同学问了,这布局不一样啊,节点数值都不一样。别忘了我们就是求不同树的数量,并不用把搜索树都列出来,所以不用关心其具体数值的差异)
当3为头结点的时候,其左子树有两个节点,看这两个节点的布局,是不是和n为2的时候两棵树的布局也是一样的啊!
当2为头结点的时候,其左右子树都只有一个节点,布局是不是和n为1的时候只有一棵树的布局也是一样的啊!
发现到这里,其实我们就找到了重叠子问题了,其实也就是发现可以通过dp[1] 和 dp[2] 来推导出来dp[3]的某种方式。
思考到这里,这道题目就有眉目了。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
如图所示:
在上面的分析中,其实已经看出其递推关系, dp[i] += dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量]
j相当于是头结点的元素,从1遍历到i为止。
所以递推公式:dp[i] += dp[j - 1] * dp[i - j]; ,j-1 为j为头结点左子树节点数量,i-j 为以j为头结点右子树节点数量
python
class Solution:
def numTrees(self, n: int) -> int:
dp = [0] * (n + 1) # 创建一个长度为n+1的数组,初始化为0
dp[0] = 1 # 当n为0时,只有一种情况,即空树,所以dp[0] = 1
for i in range(1, n + 1): # 遍历从1到n的每个数字
for j in range(1, i + 1): # 对于每个数字i,计算以i为根节点的二叉搜索树的数量
dp[i] += dp[j - 1] * dp[i - j] # 利用动态规划的思想,累加左子树和右子树的组合数量
return dp[n] # 返回以1到n为节点的二叉搜索树的总数量
01背包
完全背包
打家劫舍问题
198.打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
- 示例 1:
- 输入:[1,2,3,1]
- 输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。 偷窃到的最高金额 = 1 + 3 = 4 。
- 示例 2:
- 输入:[2,7,9,3,1]
- 输出:12 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
- 0 <= nums.length <= 100
- 0 <= nums[i] <= 400
决定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房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
python
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[len(nums)-1]
'''
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]
决定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房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
'''
213.打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。
示例 1:
- 输入:nums = [2,3,2]
- 输出:3
- 解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
- 示例 2:
- 输入:nums = [1,2,3,1]
- 输出:4
- 解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
- 示例 3:
- 输入:nums = [0]
- 输出:0
提示:
- 1 <= nums.length <= 100
- 0 <= nums[i] <= 1000
这道题目和198.打家劫舍是差不多的,唯一区别就是成环了。
对于一个数组,成环的话主要有如下三种情况:
- 情况一:考虑不包含首尾元素
- 情况二:考虑包含首元素,不包含尾元素
- 情况三:考虑包含尾元素,不包含首元素
注意我这里用的是"考虑",例如情况三,虽然是考虑包含尾元素,但不一定要选尾部元素! 对于情况三,取nums[1] 和 nums[3]就是最大的。
而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了。
python
class Solution:
def rob(self, nums: List[int]) -> int:
if len(nums)<=3:
return max(nums)
dp1=[0]*len(nums)
dp1[0]=nums[0]
dp1[1]=max(nums[0],nums[1])
for i in range(2,len(nums)-1):
dp1[i]=max(dp1[i-1],dp1[i-2]+nums[i])
dp2=[0]*len(nums)
dp2[1]=nums[1]
dp2[2]=max(nums[1],nums[2])
for i in range(3,len(nums)):
dp2[i]=max(dp2[i-1],dp2[i-2]+nums[i])
return max(dp1[len(nums)-2],dp2[len(nums)-1])
'''
原数组
[1,6,1,9,1]
情况一:
[ ,6,1,9, ]
情况二:
[1,6,1,9, ]
情况三:
[ ,6,1,9,1]
而情况二 和 情况三 都包含了情况一了,所以只考虑情况二和情况三就可以了
'''
337.打家劫舍 III
在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为"根"。 除了"根"之外,每栋房子有且只有一个"父"房子与之相连。一番侦察之后,聪明的小偷意识到"这个地方的所有房屋的排列类似于一棵二叉树"。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。
计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
python
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
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)
买卖股票问题
121. 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
- 示例 1:
- 输入:[7,1,5,3,6,4]
- 输出:5 解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
- 示例 2:
- 输入:prices = [7,6,4,3,1]
- 输出:0 解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
贪心
因为股票就买卖一次,那么贪心的想法很自然就是取最左最小值,取最右最大值,那么得到的差值就是最大利润。
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
ans=0
low=float('inf')
for i in range(len(prices)):
low=min(low,prices[i])
ans=max(prices[i]-low,ans)
return ans
动态规划
动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i] [0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?
其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。
dp[i] [1] 表示第i天不持有股票所得最多现金
注意这里说的是"持有","持有"不代表就是当天"买入"!也有可能是昨天就买入了,今天保持持有的状态
很多同学把"持有"和"买入"没区分清楚。
在下面递推公式分析中,我会进一步讲解。
2.确定递推公式
如果第i天持有股票即dp[i] [0], 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1] [0]
- 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i] [0]应该选所得现金最大的,所以dp[i] [0] = max(dp[i - 1] [0], -prices[i]);
如果第i天不持有股票即dp[i] [1], 也可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1] [1]
- 第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1] [0]
同样dp[i] [1]取最大的,dp[i] [1] = max(dp[i - 1] [1], prices[i] + dp[i - 1] [0]);
这样递推公式我们就分析完了
3.dp数组如何初始化
由递推公式 dp[i] [0] = max(dp[i - 1] [0], -prices[i]); 和 dp[i] [1] = max(dp[i - 1] [1], prices[i] + dp[i - 1] [0]);可以看出
其基础都是要从dp[0] [0]和dp[0] [1]推导出来。
那么dp[0] [0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0] [0] -= prices[0];
dp[0] [1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0] [1] = 0;
4.确定遍历顺序
从递推公式可以看出dp[i]都是由dp[i - 1]推导出来的,那么一定是从前向后遍历。
5.举例推导dp数组
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
dp=[[0]*2 for _ in range(len(prices))]
dp[0][0]=-prices[0]
for i in range(1,len(prices)):
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[len(prices)-1][1]
'''
dp[i][0],dp[i][1]
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]
同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
'''
122.买卖股票的最佳时机II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 示例 1:
- 输入: [7,1,5,3,6,4]
- 输出: 7 解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。
- 示例 2:
- 输入: [1,2,3,4,5]
- 输出: 4 解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
- 示例 3:
- 输入: [7,6,4,3,1]
- 输出: 0 解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
提示:
- 1 <= prices.length <= 3 * 10 ^ 4
- 0 <= prices[i] <= 10 ^ 4
这正是因为本题的股票可以买卖多次! 所以买入股票的时候,可能会有之前买卖的利润即:dp[i - 1] [1],所以dp[i - 1] [1] - prices[i]。
动态规划
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
dp=[[0]*2 for _ in range(len(prices))]
dp[0][0]=-prices[0]
for i in range(1,len(prices)):
dp[i][0]=max(dp[i-1][0],-prices[i]+dp[i-1][1])
dp[i][1]=max(dp[i-1][1],prices[i]+dp[i-1][0])
return dp[len(prices)-1][1]
贪心
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
ans=0
for i in range(1,len(prices)):
ans+=max(prices[i]-prices[i-1],0)
return ans
'''
最终利润是可以分解的,那么本题就很容易了
如何分解呢?
假如第 0 天买入,第 3 天卖出,那么利润为:prices[3] - prices[0]。
相当于(prices[3] - prices[2]) + (prices[2] - prices[1]) + (prices[1] - prices[0])。
收集每天的正利润就可以,收集正利润的区间,就是股票买卖的区间,而我们只需要关注最终利润,不需要记录区间。
那么只收集正利润就是贪心所贪的地方!
局部最优:收集每天的正利润,全局最优:求得最大利润。'''
123.买卖股票的最佳时机III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 示例 1:
- 输入:prices = [3,3,5,0,0,3,1,4]
- 输出:6 解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3。
- 示例 2:
- 输入:prices = [1,2,3,4,5]
- 输出:4 解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4。注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
- 示例 3:
- 输入:prices = [7,6,4,3,1]
- 输出:0 解释:在这个情况下, 没有交易完成, 所以最大利润为0。
- 示例 4:
- 输入:prices = [1] 输出:0
提示:
- 1 <= prices.length <= 10^5
- 0 <= prices[i] <= 10^5
关键在于至多买卖两次,这意味着可以买卖一次,可以买卖两次,也可以不买卖。
接来下我用动态规划五部曲详细分析一下:
- 确定dp数组以及下标的含义
一天一共就有五个状态,
- 没有操作 (其实我们也可以不设置这个状态)
- 第一次持有股票
- 第一次不持有股票
- 第二次持有股票
- 第二次不持有股票
dp[i] [j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i] [j]表示第i天状态j所剩最大现金。
需要注意:dp[i] [1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区。
例如 dp[i] [1] ,并不是说 第i天一定买入股票,有可能 第 i-1天 就买入了,那么 dp[i] [1] 延续买入股票的这个状态。
2.确定递推公式
达到dp[i] [1]状态,有两个具体操作:
- 操作一:第i天买入股票了,那么dp[i] [1] = dp[i-1] [0] - prices[i]
- 操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i] [1] = dp[i - 1] [1]
那么dp[i] [1]究竟选 dp[i-1] [0] - prices[i],还是dp[i - 1] [1]呢?
一定是选最大的,所以 dp[i] [1] = max(dp[i-1] [0] - prices[i], dp[i - 1] [1]);
同理dp[i] [2]也有两个操作:
- 操作一:第i天卖出股票了,那么dp[i] [2] = dp[i - 1] [1] + prices[i]
- 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i] [2] = dp[i - 1] [2]
所以dp[i] [2] = max(dp[i - 1] [1] + prices[i], dp[i - 1] [2])
同理可推出剩下状态部分:
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]);
3.dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0] [0] = 0;
第0天做第一次买入的操作,dp[0] [1] = -prices[0];
第0天做第一次卖出的操作,这个初始值应该是多少呢?
此时还没有买入,怎么就卖出呢? 其实大家可以理解当天买入,当天卖出,所以dp[0] [2] = 0;
第0天第二次买入操作,初始值应该是多少呢?应该不少同学疑惑,第一次还没买入呢,怎么初始化第二次买入呢?
第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后再买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。
所以第二次买入操作,初始化为:dp[0] [3] = -prices[0];
同理第二次卖出初始化dp[0] [4] = 0;
4.确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。
5.举例推导dp数组
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices)<=1:
return 0
dp=[[0]*5 for _ in range(len(prices))]
dp[0][0]=0
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[len(prices)-1][4]
188.买卖股票的最佳时机IV
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 示例 1:
- 输入:k = 2, prices = [2,4,1]
- 输出:2 解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2。
- 示例 2:
- 输入:k = 2, prices = [3,2,6,5,0,3]
- 输出:7 解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4。随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
提示:
- 0 <= k <= 100
- 0 <= prices.length <= 1000
- 0 <= prices[i] <= 1000
除了0以外,偶数就是卖出,奇数就是买入。
题目要求是至多有K笔交易,那么j的范围就定义为 2 * k + 1 就可以了。
python
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
if len(prices)<=1:
return 0
dp=[[0]*(k*2+1) for _ in range(len(prices))]
dp[0][0]=0
for i in range(1,k*2+1,2):
dp[0][i]=-prices[0]
for i in range(1,len(prices)):
for j in range(1,2*k,2):#1 3 5 ....
dp[i][j]=max(dp[i-1][j],dp[i-1][j-1]-prices[i])
dp[i][j+1]=max(dp[i-1][j+1],dp[i-1][j]+prices[i])
return dp[len(prices)-1][k*2]
309.最佳买卖股票时机含冷冻期
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。
设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
- 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
- 卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
示例:
- 输入: [1,2,3,0,2]
- 输出: 3
- 解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
具体可以区分出如下四个状态:
- 状态一:持有股票状态(今天买入股票,或者是之前就买入了股票然后没有操作,一直持有)
- 不持有股票状态,这里就有两种卖出股票状态
- 状态二:保持卖出股票的状态(两天前就卖出了股票,度过一天冷冻期。或者是前一天就是卖出股票状态,一直没操作)
- 状态三:今天卖出股票
- 状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices)<=1:
return 0
dp=[[0]*4 for _ in range(len(prices))]
dp[0][0]=-prices[0]
for i in range(1,len(prices)):
dp[i][0]=max(dp[i-1][0],max(dp[i-1][1],dp[i-1][3])-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[-1][1],dp[-1][2],dp[-1][3])
"""
dp[i][0]持有股票
dp[i][1]保持卖出股票
dp[i][2]当天卖出股票
dp[i][3]当天为冷冻期
"""
714.买卖股票的最佳时机含手续费
给定一个整数数组 prices,其中第 i 个元素代表了第 i 天的股票价格 ;非负整数 fee 代表了交易股票的手续费用。
你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。
返回获得利润的最大值。
注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。
示例 1:
- 输入: prices = [1, 3, 2, 8, 4, 9], fee = 2
- 输出: 8
解释: 能够达到的最大利润:
- 在此处买入 prices[0] = 1
- 在此处卖出 prices[3] = 8
- 在此处买入 prices[4] = 4
- 在此处卖出 prices[5] = 9
- 总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8.
注意:
- 0 < prices.length <= 50000.
- 0 < prices[i] < 50000.
- 0 <= fee < 50000.
相对于122.买卖股票的最佳时机II ,本题只需要在计算卖出操作的时候减去手续费就可以了,代码几乎是一样的。
python
class Solution:
def maxProfit(self, prices: List[int], fee: int) -> int:
if len(prices)<=1:
return 0
dp=[[0]*2 for _ in range(len(prices))]
dp[0][0]=-prices[0]
for i in range(1,len(prices)):
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][1],dp[-1][0])
#return dp[-1][1]
子序列问题
300.最长递增子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
示例 1:
- 输入:nums = [10,9,2,5,3,7,101,18]
- 输出:4
- 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
- 输入:nums = [0,1,0,3,2,3]
- 输出:4
示例 3:
- 输入:nums = [7,7,7,7,7,7,7]
- 输出:1
提示:
- 1 <= nums.length <= 2500
- -10^4 <= nums[i] <= 104
思路
首先通过本题大家要明确什么是子序列,"子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序"。
本题也是代码随想录中子序列问题的第一题,如果没接触过这种题目的话,本题还是很难的,甚至想暴力去搜索也不知道怎么搜。 子序列问题是动态规划解决的经典问题,当前下标i的递增子序列长度,其实和i之前的下标j的子序列长度有关系,那又是什么样的关系呢。
接下来,我们依然用动规五部曲来详细分析一波:
- dp[i]的定义
本题中,正确定义dp数组的含义十分重要。
dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度
为什么一定表示 "以nums[i]结尾的最长递增子序" ,因为我们在 做 递增比较的时候,如果比较 nums[j] 和 nums[i] 的大小,那么两个递增子序列一定分别以nums[j]为结尾 和 nums[i]为结尾, 要不然这个比较就没有意义了,不是尾部元素的比较那么 如何算递增呢。
2.状态转移方程
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。
所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。
3.dp[i]的初始化
每一个i,对应的dp[i](即最长递增子序列)起始大小至少都是1.
4.确定遍历顺序
dp[i] 是有0到i-1各个位置的最长递增子序列 推导而来,那么遍历i一定是从前向后遍历。
j其实就是遍历0到i-1,那么是从前到后,还是从后到前遍历都无所谓,只要吧 0 到 i-1 的元素都遍历了就行了。 所以默认习惯 从前向后遍历。
遍历i的循环在外层,遍历j则在内层,代码如下:
cpp
for (int i = 1; i < nums.size(); i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
}
if (dp[i] > result) result = dp[i]; // 取长的子序列
}
5.举例推导dp数组
python
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
if len(nums)<=1:
return len(nums)
dp=[1]*len(nums)
ans=1
for i in range(1,len(nums)):
for j in range(0,i):
if nums[i]>nums[j]:
dp[i]=max(dp[i],dp[j]+1)
ans=max(ans,dp[i])
return ans
'''
从任意位置开始,但以nums[i]元素作为结尾的所有 递增子序列中,最长的子序列长度为 dp[i]
对于每个元素nums[i],我们遍历它之前的所有元素nums[j](j从0到i-1)。
如果nums[j]小于nums[i],说明nums[i]可以接在nums[j]后面形成一个更长的递增子序列。
此时,以nums[i]结尾的最长递增子序列的长度至少是dp[j]+1。
我们通过比较dp[i]和dp[j]+1,取较大的值作为新的dp[i]
'''
674. 最长连续递增序列
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。
示例 1:
- 输入:nums = [1,3,5,4,7]
- 输出:3
- 解释:最长连续递增序列是 [1,3,5], 长度为3。尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
示例 2:
- 输入:nums = [2,2,2,2,2]
- 输出:1
- 解释:最长连续递增序列是 [2], 长度为1。
提示:
- 0 <= nums.length <= 10^4
- -10^9 <= nums[i] <= 10^9
如果 nums[i] > nums[i - 1],那么以 i 为结尾的连续递增的子序列长度 一定等于 以i - 1为结尾的连续递增的子序列长度 + 1 。
即:dp[i] = dp[i - 1] + 1;
注意这里就体现出和 300.最长递增子序列 的区别!
因为本题要求连续递增子序列,所以就只要比较nums[i]与nums[i - 1],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)。
既然不用j了,那么也不用两层for循环,本题一层for循环就行,比较nums[i] 和 nums[i - 1]。
python
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
if len(nums)<=1:
return len(nums)
dp=[1]*len(nums)
ans=1
for i in range(1,len(nums)):
if nums[i]>nums[i-1]:
dp[i]=dp[i-1]+1
ans=max(ans,dp[i])
return ans
718. 最长重复子数组
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
示例:
输入:
- A: [1,2,3,2,1]
- B: [3,2,1,4,7]
- 输出:3
- 解释:长度最长的公共子数组是 [3, 2, 1] 。
提示:
- 1 <= len(A), len(B) <= 1000
- 0 <= A[i], B[i] < 100
- 确定dp数组(dp table)以及下标的含义
dp[i] [j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i] [j]。 (特别注意: "以下标i - 1为结尾的A" 标明一定是 以A[i-1]为结尾的字符串 )
此时细心的同学应该发现,那dp[0] [0]是什么含义呢?总不能是以下标-1为结尾的A数组吧。
其实dp[i] [j]的定义也就决定着,我们在遍历dp[i] [j]的时候i 和 j都要从1开始。
那有同学问了,我就定义dp[i] [j]为 以下标i为结尾的A,和以下标j 为结尾的B,最长重复子数组长度。不行么?
行倒是行! 但实现起来就麻烦一点,需要单独处理初始化部分,在本题解下面的拓展内容里,我给出了 第二种 dp数组的定义方式所对应的代码和讲解,大家比较一下就了解了。
2.确定递推公式
根据dp[i] [j]的定义,dp[i][j]的状态只能由dp[i - 1] [j - 1]推导出来。
即当A[i - 1] 和B[j - 1]相等的时候,dp[i] [j] = dp[i - 1] [j - 1] + 1;
根据递推公式可以看出,遍历i 和 j 要从1开始!
3.dp数组如何初始化
根据dp[i] [j]的定义,dp[i] [0] 和dp[0] [j]其实都是没有意义的!
但dp[i] [0] 和dp[0] [j]要初始值,因为 为了方便递归公式dp[i] [j] = dp[i - 1] [j - 1] + 1;
所以dp[i] [0] 和dp[0] [j]初始化为0。
举个例子A[0]如果和B[0]相同的话,dp[1] [1] = dp[0] [0] + 1,只有dp[0] [0]初始为0,正好符合递推公式逐步累加起来。
4.确定遍历顺序
外层for循环遍历A,内层for循环遍历B。
那又有同学问了,外层for循环遍历B,内层for循环遍历A。不行么?
也行,一样的,我这里就用外层for循环遍历A,内层for循环遍历B了。
同时题目要求长度最长的子数组的长度。所以在遍历的时候顺便把dp[i] [j]的最大值记录下来。
代码如下:
cpp
for (int i = 1; i <= nums1.size(); i++) {
for (int j = 1; j <= nums2.size(); j++) {
if (nums1[i - 1] == nums2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
if (dp[i][j] > result) result = dp[i][j];
}
}
5.举例推导dp数组
python
class Solution:
def findLength(self, nums1: List[int], nums2: List[int]) -> int:
ans=0
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
if dp[i][j]>ans:
ans=dp[i][j]
return ans
'''
dp[i][j] :以下标i - 1为结尾的A,和以下标j - 1为结尾的B,最长重复子数组长度为dp[i][j]。
如果定义 dp[i][j]为 以下标i为结尾的A,和以下标j 为结尾的B,那么 第一行和第一列毕竟要进行初始化,如果nums1[i] 与 nums2[0] 相同的话,对应的 dp[i][0]就要初始为1, 因为此时最长重复子数组为1。 nums2[j] 与 nums1[0]相同的话,同理。
'''
1143.最长公共子序列
给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列的长度。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
若这两个字符串没有公共子序列,则返回 0。
示例 1:
- 输入:text1 = "abcde", text2 = "ace"
- 输出:3
- 解释:最长公共子序列是 "ace",它的长度为 3。
示例 2:
- 输入:text1 = "abc", text2 = "abc"
- 输出:3
- 解释:最长公共子序列是 "abc",它的长度为 3。
示例 3:
- 输入:text1 = "abc", text2 = "def"
- 输出:0
- 解释:两个字符串没有公共子序列,返回 0。
提示:
- 1 <= text1.length <= 1000
- 1 <= text2.length <= 1000 输入的字符串只含有小写英文字符。
1.确定dp数组(dp table)以及下标的含义
dp[i] [j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i] [j]
有同学会问:为什么要定义长度为[0, i - 1]的字符串text1,定义为长度为[0, i]的字符串text1不香么?
这样定义是为了后面代码实现方便,如果非要定义为长度为[0, i]的字符串text1也可以,其实就是简化了dp数组第一行和第一列的初始化逻辑。
2.确定递推公式
主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同
如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i] [j] = dp[i - 1] [j - 1] + 1;
如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。
即:dp[i] [j] = max(dp[i - 1] [j], dp[i] [j - 1]);
代码如下:
cpp
if (text1[i - 1] == text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
3.dp数组如何初始化
先看看dp[i] [0]应该是多少呢?
text1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i] [0] = 0;
同理dp[0] [j]也是0。
其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。
代码:
text
vector<vector<int>> dp(text1.size() + 1, vector<int>(text2.size() + 1, 0));
4.确定遍历顺序
从递推公式,可以看出,有三个方向可以推出dp[i] [j],如图:
那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。
5.举例推导dp数组
以输入:text1 = "abcde", text2 = "ace" 为例,dp状态如图:
最后红框dp[text1.size()] [text2.size()]为最终结果.
python
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
# 创建一个二维数组 dp,用于存储最长公共子序列的长度
dp = [[0] * (len(text2) + 1) for _ in range(len(text1) + 1)]
# 遍历 text1 和 text2,填充 dp 数组
for i in range(1, len(text1) + 1):
for j in range(1, len(text2) + 1):
if text1[i - 1] == text2[j - 1]:
# 如果 text1[i-1] 和 text2[j-1] 相等,则当前位置的最长公共子序列长度为左上角位置的值加一
dp[i][j] = dp[i - 1][j - 1] + 1
else:
# 如果 text1[i-1] 和 text2[j-1] 不相等,则当前位置的最长公共子序列长度为上方或左方的较大值
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
# 返回最长公共子序列的长度
return dp[len(text1)][len(text2)]
1035.不相交的线
在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。
现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足:
- nums1[i] == nums2[j]
- 且绘制的直线不与任何其他连线(非水平线)相交。
请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。
以这种方法绘制线条,并返回可以绘制的最大连线数。
绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,只要 nums1[i] == nums2[j],且直线不能相交!
直线不能相交,这就是说明在字符串nums1中 找到一个与字符串nums2相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,连接相同数字的直线就不会相交。
拿示例一nums1 = [1,4,2], nums2 = [1,2,4]为例,相交情况如图:
其实也就是说nums1和nums2的最长公共子序列是[1,4],长度为2。 这个公共子序列指的是相对顺序不变(即数字4在字符串nums1中数字1的后面,那么数字4也应该在字符串nums2数字1的后面)
这么分析完之后,大家可以发现:本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!
那么本题就和我们刚刚讲过的这道题目1143.最长公共子序列就是一样一样的了。
一样到什么程度呢? 把字符串名字改一下,其他代码都不用改,直接copy过来就行了。
python
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])
return dp[-1][-1]
53. 最大子序和
给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
- 输入: [-2,1,-3,4,-1,2,1,-5,4]
- 输出: 6
- 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]。
dp[i]只有两个方向可以推出来:
- dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
- nums[i],即:从头开始计算当前连续子序列和
一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);
动态规划
python
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
dp = [0] * len(nums)
dp[0] = nums[0]
result = dp[0]
for i in range(1, len(nums)):
dp[i] = max(dp[i-1] + nums[i], nums[i]) #状态转移公式
result = max(result, dp[i]) #result 保存dp[i]的最大值
return result
贪心
python
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
count=0
ans=float('-inf')
for i in range(len(nums)):
count+=nums[i]
if count>ans:
ans=count
if count<0:
count=0
return ans
'''
如果 -2 1 在一起,计算起点的时候,一定是从 1 开始计算,因为负数只会拉低总和,这就是贪心贪的地方count+num[i]
局部最优:当前"连续和"为负数的时候立刻放弃,从下一个元素重新计算"连续和",因为负数加上下一个元素 "连续和"只会越来越小。
全局最优:选取最大"连续和"'''
392.判断子序列
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
示例 1:
- 输入:s = "abc", t = "ahbgdc"
- 输出:true
示例 2:
- 输入:s = "axc", t = "ahbgdc"
- 输出:false
提示:
- 0 <= s.length <= 100
- 0 <= t.length <= 10^4
两个字符串都只由小写字符组成。
法1:
与最长公共子序列思路一样,求出二者最长公共子序列,最后在判断是否与s的长度相同即可。
python
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
dp=[[0]*(len(t)+1) for _ in range(len(s)+1)]
for i in range(1,len(s)+1):
for j in range(1,len(t)+1):
if s[i-1]==t[j-1]:
dp[i][j]=dp[i-1][j-1]+1
else:
dp[i][j]=max(dp[i][j-1],dp[i-1][j])
return len(s)==dp[len(s)][len(t)]
法2:
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:
- if (s[i - 1] == t[j - 1])
- t中找到了一个字符在s中也出现了
- if (s[i - 1] != t[j - 1])
- 相当于t要删除元素,继续匹配
python
#对t进行删除操作
#因为s[i - 1]t[j - 1]不相等,相当于不考虑(删除)t[j - 1] ,
#所以dp[i][j-1] 是看s[i - 1]与 t[j - 2]的比较结果
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
dp=[[0]*(len(t)+1) for _ in range(len(s)+1)]
for i in range(1,len(s)+1):
for j in range(1,len(t)+1):
if s[i-1]==t[j-1]:
dp[i][j]=dp[i-1][j-1]+1
else:
dp[i][j]=dp[i][j-1]
return len(s)==dp[len(s)][len(t)]
法3:
双指针
python
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
if len(s)==0:
return True
left=0
right=0
for i in range(len(t)):
if s[left]==t[right]:
left+=1
right+=1
if left==len(s):
return True
return False
115.不同的子序列
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
字符串的一个 子序列 是指,通过删除一些(也可以不删除)字符且不干扰剩余字符相对位置所组成的新字符串。(例如,"ACE" 是 "ABCDE" 的一个子序列,而 "AEC" 不是)
题目数据保证答案符合 32 位带符号整数范围。
提示:
- 0 <= s.length, t.length <= 1000
- s 和 t 由英文字母组成
例子
s:bagg,t:bag,当 "s的最后一个g" 和 "t的最后一个g" 相等时,
此时我们有两种选择,用这个g 或者是 不用这个g,此时"dp【i】【j】 = dp【i-1】【j-1】 + dp【i-1】【j】",然后有同学会疑问为什么是有两种选择,而不是直接在"dp【i-1】【j-1】上加1"就行了呢?
其实是这样的,当两个g相等时(无论相不相等),此时的dp【i】【j】需要继承上一个dp的数值,即不使用g的情况,哪怕s和t字符串的最后一个字母不相等,此时的dp【i】【j】也是一样需要继承前面的dp,(例如s:bagw,t:bag,其中w和g不相同,只能选择继承上一个dp,如果题目是要求连续的子序列,在这里就是不用继承上一个dp,因为不连续不用继承,即不满足条件不继承)也就是不使用g的情况,不使用g的情况 == w不等于g的情况,也就是dp【i】【j】 = dp【i-1】【j】的情况。
还是不懂的话,就看这个例子,s:bagg,t:bag,当不使用g的情况就是把s的最后一个g砍掉,变成s:bag,t:bag,此时s中有一个t,把这个结果继承给下一个dp;在使用g的情况下,s:ba,t:ba,看s中有多少t。把它们两个加起来就是末尾相等的结果。 dp【i】【j】 = dp【i-1】【j】 + dp【i-1】【j-1】这样写会好理解一些,dp = 继承 + 自己能组成的个数。
python
class Solution:
def numDistinct(self, s: str, t: str) -> int:
dp=[[0]*(len(t)+1) for i in range(len(s)+1)]
for i in range(len(s)+1):
dp[i][0]=1
for i in range(1,len(s)+1):
for j in range(1,len(t)+1):
if s[i-1]==t[j-1]:
dp[i][j]=dp[i-1][j]+dp[i-1][j-1]
else:
dp[i][j]=dp[i-1][j]
return dp[len(s)][len(t)]
'''
dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]
s[i - 1] 与 t[j - 1]相等
s[i - 1] 与 t[j - 1] 不相等
当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。
一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。即不需要考虑当前s子串和t子串的最后一位字母,所以只需要 dp[i-1][j-1]。
一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]。
'''
583. 两个字符串的删除操作
给定两个单词 word1 和 word2,找到使得 word1 和 word2 相同所需的最小步数,每步可以删除任意一个字符串中的一个字符。
示例:
- 输入: "sea", "eat"
- 输出: 2
- 解释: 第一步将"sea"变为"ea",第二步将"eat"变为"ea"
python
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(word1)+1):
dp[i][0]=i
for j in range(len(word2)+1):
dp[0][j]=j
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:
dp[i][j]=min(dp[i-1][j]+1,dp[i][j-1]+1)
return dp[-1][-1]
'''
法1:计算二者最长公共子序列,返回 二者字符串长度和-2*最长公共子序列
法2:
dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。
1.当word1[i - 1] 与 word2[j - 1]相同的时候
2.当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);
'''
72. 编辑距离
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
- 示例 1:
- 输入:word1 = "horse", word2 = "ros"
- 输出:3
- 解释: horse -> rorse (将 'h' 替换为 'r') rorse -> rose (删除 'r') rose -> ros (删除 'e')
- 示例 2:
- 输入:word1 = "intention", word2 = "execution"
- 输出:5
- 解释: intention -> inention (删除 't') inention -> enention (将 'i' 替换为 'e') enention -> exention (将 'n' 替换为 'x') exention -> exection (将 'n' 替换为 'c') exection -> execution (插入 'u')
提示:
- 0 <= word1.length, word2.length <= 500
- word1 和 word2 由小写英文字母组成
python
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(word1)+1):
dp[i][0]=i
for j in range(len(word2)+1):
dp[0][j]=j
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:
dp[i][j]=min(dp[i-1][j],dp[i][j-1],dp[i-1][j-1])+1
return dp[-1][-1]
'''i
if (word1[i - 1] == word2[j - 1])
不操作
if (word1[ - 1] != word2[j - 1])
增
删
换
a ab
if (word1[i - 1] != word2[j - 1]),此时就需要编辑了,如何编辑呢?
操作一:word1删除一个元素,那么就是以下标i - 2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 再加上一个操作。
即 dp[i][j] = dp[i - 1][j] + 1;
操作二:word2删除一个元素,那么就是以下标i - 1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 再加上一个操作。
即 dp[i][j] = dp[i][j - 1] + 1;
这里有同学发现了,怎么都是删除元素,添加元素去哪了。
word2添加一个元素,相当于word1删除一个元素,例如 word1 = "ad" ,word2 = "a",word1删除元素'd' 和 word2添加一个元素'd',变成word1="a", word2="ad", 最终的操作数是一样!
ab ac
操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增删加元素。
可以回顾一下,if (word1[i - 1] == word2[j - 1])的时候我们的操作 是 dp[i][j] = dp[i - 1][j - 1] 对吧。
那么只需要一次替换的操作,就可以让 word1[i - 1] 和 word2[j - 1] 相同。
所以 dp[i][j] = dp[i - 1][j - 1] + 1;
综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
'''
647. 回文子串
给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。
具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
示例 1:
- 输入:"abc"
- 输出:3
- 解释:三个回文子串: "a", "b", "c"
示例 2:
- 输入:"aaa"
- 输出:6
- 解释:6个回文子串: "a", "a", "a", "aa", "aa", "aaa"
提示:输入的字符串长度不会超过 1000 。
python
class Solution:
def countSubstrings(self, s: str) -> int:
dp=[[False]*len(s) for _ in range(len(s))]
ans=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
ans+=1
elif dp[i+1][j-1]:
dp[i][j]=True
ans+=1
return ans
'''
布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。
整体上是两种,就是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。
注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分。
'''
516.最长回文子序列
给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。
示例 1: 输入: "bbbab" 输出: 4 一个可能的最长回文子序列为 "bbbb"。
示例 2: 输入:"cbbd" 输出: 2 一个可能的最长回文子序列为 "bb"。
提示:
- 1 <= s.length <= 1000
- s 只包含小写英文字母
python
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
dp=[[0]*len(s) for _ in range(len(s))]
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]:
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][len(s)-1]
'''
dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]。
如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;
加入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]);
'''