动态规划专题

本文详细介绍了动态规划在各种IT技术问题中的应用,包括一维和二维动态规划问题,如爬楼梯、打家劫舍、等差数列划分、最大路径和等。同时,还讨论了分割类型题,如完全平方数、解码方法、最长递增子序列等。文章还涉及子序列问题,如最长连续递增子数组、最长公共子序列、不相交的线、编辑距离等,以及回文子串问题。动态规划在这些问题中通过状态转移方程找到最优解,有效地解决了复杂问题。
摘要由CSDN通过智能技术生成

动态规划(Dynamic Programming, DP)

在查找有很多重叠子问题的情况的最优解时有效。
它将问题重新组合成子问题。为了避免多次解决这些子问题,它们的结果都逐渐被 计算并被保存,从简单的问题直到整个问题都被解决。因此,动态规划保存递归时的结果,因而不会在解决同样的问题时花费时间 · · · · · · 动态规划只能应用于有最优子结构的问题。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决。”

在一些情况下,动态规划可以看成是带有 状态记录 的优先搜索。状态记录的意思为,如果一个子问题在优先搜索时已经计算过一次,我们可以把它的结果储存下来,之后遍历到该子问题的时候可以直接返回储存的结果。
动态规划是 自下而上 的,即先解决子问题,再解决父问题;
而用带有状态记录的优先搜索是 自上而下 的,即从父问题搜索到子问题,若重复搜索到同一个子问题则进行状态记录,防止重复计算。

  • 如果题目需求的是最终状态,那么使用 动态搜索 比较方便;
  • 如果题目需要输出所有的路径,那么使用带有状态记录的 优先搜索 会比较方便。

解决动态规划问题的 关键 是找到 状态转移方程,这样我们可以通过计算和储存子问题的解来求解最终问题。

1. 基本动态规划:一维

1.1 爬楼梯(70)

题目描述:
给定 n 节台阶,每次可以走一步或走两步,求一共有多少种方式可以走完这些台阶。

解题思路:
这是十分经典的斐波那契数列题。定义一个数组 dp,dp[i] 表示走到第 i 阶的方法数。
因为我们每次可以走一步或者两步,所以第 i 阶可以从第 i-1 或 i-2 阶到达。
换句话说,走到第 i 阶的方法数即为走到第 i-1 阶的方法数加上走到第 i-2 阶的方法数。这样我们就得到了状态转移方程 dp[i] = dp[i-1] + dp[i-2]。注意边界条件的处理。

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

进一步的,我们对动态规划进行空间压缩。
因为 dp[i] 只与 dp[i-1] 和 dp[i-2] 有关,因此可以只用两个变量来存储 dp[i-1] 和 dp[i-2],使得原来的 O(n) 空间复杂度优化为 O(1) 复杂度。

def climbStairs(self, n: int) -> int:
	if n<=2:return n
	pre1,pre2=1,2
	cur = 0
	for i in range(3,n+1):
		cur = pre1+pre2
		pre2=pre1
		pre1=cur
	return cur
		
1.2 打家劫舍(198)

题目描述:
假如你是一个劫匪,并且决定抢劫一条街上的房子,每个房子内的钱财数量各不相同。如果
你抢了两栋相邻的房子,则会触发警报机关。求在不触发机关的情况下最多可以抢劫多少钱。

解题思路:
定义一个数组 dp,dp[i] 表示抢劫到第 i 个房子时,可以抢劫的最大数量。
我们考虑 dp[i],此时可以抢劫的最大数量有两种可能:

  1. 选择不抢劫这个房子,此时累计的金额即为dp[i-1];
  2. 选择抢劫这个房子,那么此前累计的最大金额只能是 dp[i-2],因为我们不能够抢劫第 i-1 个房子,否则会触发警报机关。
    因此本题的状态转移方程为 dp[i] = max(dp[i-1],nums[i-1] + dp[i-2])。
def rob(self, nums: List[int]) -> int:
	if len(nums)<=2:return max(nums)
	n = len(nums)
	dp = [0]*(n+1)
	for i in range(1,n+1):
		dp[i] = max(dp[i-1],nums[i-1]+dp[i-2])
	return dp[n]

同样的,我们可以像题目 70 那样,对空间进行压缩。

def rob(self, nums: List[int]) -> int:
	if len(nums)<=2:return max(nums)
	n = len(nums)
	pre1,pre2=0,0
	cur = 0 
	for i in range(n):
		cur = max(pre1,pre2+nums[i])
		pre2 = pre1
		pre1 = cur
	return cur
1.3 等差数列划分(413)

题目描述:
给定一个数组,求这个数组中连续且等差的子数组一共有多少个。

解题思路:
这道题略微特殊,因为要求是等差数列,可以很自然的想到子数组必定满足 num[i] - num[i-1]
= num[i-1] - num[i-2]。然而由于我们对于 dp 数组的定义通常为以 i 结尾的,满足某些条件的子数
组数量,而等差子数组可以在任意一个位置终结,因此此题在最后需要对 dp 数组求和。

  1. 定义dp(i)表示以第i个数结尾的等差数列的个数。
  2. 判断如果nums[i]-nums[i-1] == nums[i-1]-nums[i-2],说明以i结尾的数字可以组成一个等差数列,那么dp[i]+=1。
    同时要考虑长度不止为3的组成等差数列的情况,那么我们想一下nums[i-1]表示什么?nums[i-1]表示以第i-1个数字结尾组成的等差数列的个数。
    那么当以i结尾的数字可以组成等差数列,同时第i-1个数字结尾也可以组成等差数列的时候dp[i]+=dp[i-1]即为第i个数字结尾组成的等差数列的个数。
    最终返回sum(dp)
  3. 考虑初始化,dp数组全为0即可,遍历从i=2开始遍历。
def numberOfArithmeticSlices(self, nums: List[int]) -> int:
        if len(nums)<3:
            return 0
        n = len(nums)
        f = [0]*(n+1)
        for i in range(2,n):
            if nums[i]-nums[i-1] == nums[i-1]-nums[i-2]:
                f[i]=1+f[i-1]
        return sum(f)
1.4 最大子段和(洛谷P1115)

题目描述:
给出一个长度为 n 的序列 a,选出其中连续且非空的一段使得这段和最大。

解题思路:

  • 如果一个数 + 上一个有效序列得到的结果比这个数大,那么该数也属于这个有效序列。
  • 如果一个数 + 上一个有效序列得到的结果比这个数小,那么这个数单独成为一个新的有效序列
  • 第一个数为一个有效序列
n = int(input().strip())
nums = list(map(int,input().strip().split()))
f = [0]*n
for i in range(n):
	f[i] = max(f[i-1]+nums[i],nums[i])
print(max(f))

2.基本动态规划:二维

2.1 最小路径和(64)

题目描述:
给定一个 m × n 大小的非负整数矩阵,求从左上角开始到右下角结束的、经过的数字的和最
小的路径。每次只能向右或者向下移动。
在这里插入图片描述
解题思路:
我们可以定义一个同样是二维的 dp 数组,其中 dp[i][j] 表示从左上角开始到 (i, j) 位置的最
优路径的数字和。
因为每次只能向下或者向右移动
状态转移方程dp[i][j]=min(dp[i-1][j], dp[i][j-1]) + grid[i][j],其中 grid 表示原数组。
其中对第一行预第一列特殊处理,因为在第一行只能从左走到右;第一列同理

def minPathSum(self, grid: List[List[int]]) -> int:
        m,n = len(grid),len(grid[0])
        dp = [[0]*(n+1) for i in range(m+1)]
		dp[0][0] = grid[0][0]
        for i in range(m):
            for j in range(n):
                if j ==0:
                    dp[i][j] = dp[i-1][j]+grid[i][j]
                elif i==0:
                    dp[i][j] = dp[i][j-1]+grid[i][j]
                else:
                    dp[i][j] = min(dp[i-1][j],dp[i][j-1])+grid[i][j]

        return dp[m-1][n-1]	

状态压缩:
因为 dp 矩阵的每一个值只和 左边上面 的值相关,我们可以使用空间压缩将 dp 数组压缩为一维。
对于第 i 行,在遍历到第 j 列的时候,因为第 j-1 列已经更新过了,所以 dp[j-1] 代表 dp[i][j-1]的值;
而 dp[j] 待更新,当前存储的值是在第 i-1 行的时候计算的,所以代表 dp[i-1][j] 的值。

def minPathSum(self, grid: List[List[int]]) -> int:
	m,n = len(grid),len(grid[0])
    dp = [0]*(n)
    for i in range(m):
    	for j in range(n):
    		if i==0 and j==0:
    			dp[j]=grid[i][j]
    		elif i==0:
    			dp[j]=dp[j-1]+grid[i][j]
    		elif j==0:
    			dp[j]=dp[j]+grid[i][j]
    		else:
    			dp[j] = min(dp[j],dp[j-1])+grid[i][j]
    return dp[n-1]
2.2 01矩阵(542)

题目描述:
给定一个由 0 和 1 组成的二维矩阵,求每个位置到最近的 0 的距离。

解题思路:
一般来说,因为这道题涉及到四个方向上的最近搜索,所以很多人的第一反应可能会是广度
优先搜索。但是对于一个大小 O(mn) 的二维数组,对每个位置进行四向搜索,最坏情况的时间复
杂度(即全是 1)会达到恐怖的 O ( m 2 n 2 ) O({m^2}{n^2}) O(m2n2)
一种办法是使用一个 dp 数组做 memoization,使得广度优先搜索不会重复遍历相同位置;
另一种更简单的方法是,我们从左上到右下进行一次动态搜索,再从右下到左上进行一次动态搜索。两次动态搜索即可完成四个方向上的查找。

对于任一点 ( i , j ) (i, j) (i,j)距离 0 0 0 的距离为:
在这里插入图片描述
于是我们尝试将问题分解:

  1. 距离 ( i , j ) (i, j) (i,j) 最近的 0 0 0 的位置,是在其 「左上,右上,左下,右下」4个方向之一;
  2. 因此我们分别从四个角开始递推,就分别得到了位于「左上方、右上方、左下方、右下方」距离 ( i , j ) (i,j) (i,j) 的最近的 0 0 0 的距离,取 m i n min min 即可;​
  3. 通过上两步思路,我们可以很容易的写出 4 4 4 个双重 f o r for for 循环,动态规划的解法写到这一步其实已经完全 OK 了;
  4. 从四个角开始的 4 次递推,其实还可以优化成从任一组对角开始的 2 次递推,比如只写从左上角、右下角开始递推就行了
    • 首先从左上角开始递推 d p [ i ] [ j ] dp[i][j] dp[i][j] 是由其 「左方」和 「左上方」的最优子状态决定的;
    • 然后从右下角开始递推 d p [ i ] [ j ] dp[i][j] dp[i][j] 是由其 「右方」和 「右下方」的最优子状态决定的;
    • 看起来第一次递推的时候,把「右上方」的最优子状态给漏掉了,其实不是的,因为第二次递推的时候「右方」的状态在第一次递推时已经包含了「右上方」的最优子状态了;
    • 看起来第二次递推的时候,把「左下方」的最优子状态给漏掉了,其实不是的,因为第二次递推的时候「右下方」的状态在第一次递推时已经包含了「左下方」的最优子状态了。
def updateMatrix(self, mat: List[List[int]]) -> List[List[int]]:
        n,m=len(mat),len(mat[0])
        dp = [[0]*m for i in range(n)]
        for i in range(n):
        	for j in range(m):
        		dp[i][j] = 0 if mat[i][j]==0 else 10000
        
        # 左上角开始
        for i in range(n):
        	for j in range(m):
        		if i-1>=0:
        			dp[i][j] = min(dp[i][j],dp[i-1][j]+1)
        		if j-1>=0:
        			dp[i][j] = min(dp[i][j],dp[i][j-1]+1)
        
        # 右下角开始
		for i in range(n-1,-1,-1):
			for j in range(m-1,-1,-1):
				if i+1<n:
					dp[i][j] = min(dp[i][j],dp[i+1][j]+1)
				if j+1<m:
					dp[i][j]=min(dp[i][j],dp[i][j+1]+1)
        return dp     
2.3 最大正方形(221)

题目描述:
给定一个二维的 0-1 矩阵,求全由 1 构成的最大正方形面积。
在这里插入图片描述解题思路:
对于在矩阵内搜索正方形或长方形的题型,一种常见的做法是定义一个二维 dp 数组,其中 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示满足题目条件的、 ( i , j ) (i, j) (i,j) 为右下角的正方形或者长方形的属性
对于本题,则表示以 ( i , j ) (i, j) (i,j) 为右下角的全由 1 构成的最大正方形面积。

  • 如果当前位置是 0,那么 d p [ i ] [ j ] = 0 dp[i][j] = 0 dp[i][j]=0
  • 如果当前位置是 1, 则 d p ( i , j ) dp(i,j) dp(i,j) 的值由其上方、左方和左上方的三个相邻位置的 d p dp dp 值决定。具体而言,当前位置的元素值等于三个相邻位置的元素中的最小值加 1,状态转移方程如下:
    d p ( i , j ) = m i n ( d p ( i − 1 , j ) , d p ( i − 1 , j − 1 ) , d p ( i , j − 1 ) ) + 1 dp(i, j)=min(dp(i−1, j), dp(i−1, j−1), dp(i, j−1))+1 dp(i,j)=min(dp(i1,j),dp(i1,j1),dp(i,j1))+1

此外,还需要考虑边界条件。如果 i 和 j 中至少有一个为 0,则以位置 ( i , j ) (i,j) (i,j) 为右下角的最大正方形的边长只能是 1,因此 d p ( i , j ) = 1 。 dp(i,j)=1。 dp(i,j)=1

dp(i,j) 表示以 (i,j) 为右下角的正方形的最大边长,如果 (i,j) 为“0”,以 (i,j) 为右下角不可能构成全为“1”的正方形 dp(i,j)=0,如果 (i,j) 为“1”,至少可以获得边长为 1 的正方形,还能不能变大只能向左向上扩展边长,这个时候需要看正上,左边和左上三个点,因为扩展定会将这三个相邻点包含进来,如果三个点中最小值为 0,那么扩展后肯定不行,如果最小值为 1,那么三个点都为 1,定能扩展成边长为 2 的正方形,同理能扩展到最大的是 min(左,上,左上) + 1。

例:
在这里插入图片描述

class Solution:
    def maximalSquare(self, matrix: List[List[str]]) -> int:
        if len(matrix) == 0 or len(matrix[0]) == 0:
            return 0
        
        maxSide = 0
        rows, columns = len(matrix), len(matrix[0])
        dp = [[0] * columns for _ in range(rows)]
        for i in range(rows):
            for j in range(columns):
                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
                    maxSide = max(maxSide, dp[i][j])
        
        maxSquare = maxSide * maxSide
        return maxSquare

3. 分割类型题

3.1 完全平方数(279)

题目描述:
给定一个正整数,求其最少可以由几个完全平方数相加构成。
在这里插入图片描述
解题思路:
对于分割类型题,动态规划的状态转移方程通常并不依赖相邻的位置,而是依赖于满足分割条件的位置
我们定义一个一维矩阵 d p dp dp,其中 d p [ i ] dp[i] dp[i] 表示数字 i i i 最少可以由几个完全平方数相加构成。
在本题中,位置 i 只依赖 i − k 2 {i - k}^2 ik2 的位置,如 i − 1 、 i − 4 、 i − 9 i - 1、i - 4、i - 9 i1i4i9 等等,才能满足完全平方分割的条件。因此 dp[i] 可以取的最小值即为 1 + m i n ( d p [ i − 1 ] , d p [ i − 4 ] , d p [ i − 9 ] ⋅ ⋅ ⋅ ) 。 1 + min(dp[i-1], dp[i-4], dp[i-9] · · · )。 1+min(dp[i1],dp[i4],dp[i9]⋅⋅⋅)

class Solution:
    def numSquares(self, n: int) -> int:
        # f[i] 表示最少平方数组成n
        if n<1:return 0
        if n==1:return 1
        if n==2:return 2
        f = [float('inf')]*(n+1)
        f[0] = 0
        for i in range(1,n+1):
            for j in range(1,i+1):
                if j*j >i:break
                f[i] = min(f[i],f[i-j*j]+1)
            
        return f[n]
3.2 解码方法(91)

题目描述:
已知字母 A-Z 可以表示成数字 1-26。给定一个数字串,求有多少种不同的字符串等价于这个
数字串。
在这里插入图片描述
解题思路:
f i {f_i} fi 表示字符串 s s s 的前 i i i 个字符 s [ 0.. i ] s[0..i] s[0..i] 的解码,在进行状态转移时,我们可以考虑最后一次解码使用了 s s s 中的哪些字符,那么会有下面的两种情况:

  1. 使用了一个字符,即 s [ i ] s[i] s[i] 进行解码,那么只要 s [ i ] ≠ 0 s[i] \neq 0 s[i]=0,它就可以被解码成 A ∼ I A∼I AI中的某个字母。由于剩余的前 i − 1 i-1 i1 个字符的解码方法数为 f i − 1 f_{i-1} fi1 ,因此我们可以写出状态转移方程: f i = f i − 1 f_{i}=f_{i-1} fi=fi1, 其中 s [ i ] ≠ 0 s[i]\neq 0 s[i]=0
  2. 使用了两个字符,即 s [ i − 1 ] s[i-1] s[i1] s [ i ] s[i] s[i] 进行编码。与第一种情况类似, s [ i − 1 ] s[i-1] s[i1] 不能等于 0,并且 s [ i − 1 ] s[i-1] s[i1] s [ i ] s[i] s[i] 组成的整数必须小于等于 26,这样它们就可以被解码成 J ∼ Z J∼Z JZ 中的某个字母。由于剩余的前 i − 2 i-2 i2个字符的解码方法数为 f i − 2 f_{i-2} fi2,因此我们可以写出状态转移方程:
    f i = f i − 2 f_{i}=f_{i-2} fi=fi2, 其中 s [ i − 1 ] ≠ 0 s[i-1]\neq 0 s[i1]=0 并且 s [ i − 1 ] ∗ 10 + s [ i ] < = 26 s[i-1]*10+s[i]<=26 s[i1]10+s[i]<=26

将上面的两种状态转移方程在对应的条件满足时进行累加,即可得到 f i f_i fi 的值。在动态规划完成后,最终的答案即为 f n f_n fn

class Solution:
    def numDecodings(self, s: str) -> int:
        n = len(s)
        f = [1] + [0] * n
        for i in range(1, n + 1):
            if s[i - 1] != '0':
                f[i] += f[i - 1]
            if i > 1 and s[i - 2] != '0' and int(s[i-2:i]) <= 26:
                f[i] += f[i - 2]
        return f[n]
3.3 单词拆分(139)

题目描述:
给定一个字符串和一个字符串集合,求是否存在一种分割方式,使得原字符串分割后的子字
符串都可以在集合内找到。

解题思路:
类似于完全平方数分割问题,这道题的分割条件由集合内的字符串决定,因此在考虑每个分
割位置时,需要遍历字符串集合,以确定当前位置是否可以成功分割。注意对于位置 0,需要初
始化值为真。
在这里插入图片描述

  1. 初始化 d p = [ F a l s e , ⋯   , F a l s e ] dp=[False,⋯ ,False] dp=[False,,False],长度为 n+1。n 为字符串长度。 d p [ i ] dp[i] dp[i]表示 s 的前 i 位是否可以用 w o r d D i c t wordDict wordDict 中的单词表示。
  2. 初始化 d p [ 0 ] = T r u e dp[0]=True dp[0]=True,空字符可以被表示。
  3. 遍历字符串的所有子串,遍历开始索引 i,遍历区间 [ 0 , n ) [0,n) [0,n)
    • 遍历结束索引 j,遍历区间 [ i + 1 , n + 1 ) [i+1,n+1) [i+1,n+1)
      • d p [ i ] = T r u e dp[i]=True dp[i]=True s [ i , ⋯   , j ) s[i,⋯ ,j) s[i,,j) w o r d l i s t wordlist wordlist 中: d p [ j ] = T r u e dp[j]=True dp[j]=True
        解释: d p [ i ] = T r u e dp[i]=True dp[i]=True 说明 s 的前 i 位可以用 w o r d D i c t wordDict wordDict 表示,则 s [ i , ⋯   , j ) s[i,⋯ ,j) s[i,,j)出现在 w o r d D i c t wordDict wordDict中,说明 s 的前 j 位可以表示。
  4. 返回 d p [ n ] dp[n] dp[n]
class Solution:
    def wordBreak(self, s: str, wordDict: List[str]) -> bool:
        n = len(s)
        dp = [False]*(n+1)
        dp[0] = True
        for i in range(n):
            for j in range(i+1,n+1):
                if dp[i] and s[i:j] in wordDict:
                    dp[j]=True
        return dp[n]

4. 子序列问题

4.1 最长递增子序列(300)

题目描述:
给定一个未排序的整数数组,求最长的递增子序列
在这里插入图片描述
解题思路:
方法一:

  1. 定义状态:
    dp[i] 表示:以 nums[i] 结尾 的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素;

  2. 状态转移方程:
    如果一个较大的数接在较小的数后面,就会形成一个更长的子序列。只要 nums[i] 严格大于在它位置之前的某个数,那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列。
    d p [ i ] = m a x ( d p [ j ] ) + 1 , 其中 0 ≤ j < i 且 n u m [ j ] < n u m [ i ] dp[i]=max(dp[j])+1,其中0≤j<i且num[j]<num[i] dp[i]=max(dp[j])+1,其中0j<inum[j]<num[i]

  3. 初始化:
    dp[i] = 1, 1 个字符显然是长度为 1的上升子序列。

  4. 输出:
    不能返回最后一个状态值,最后一个状态值只表示以 nums[len - 1] 结尾的「上升子序列」的长度,状态数组 dp 的最大值才是题目要求的结果。

def lengthOfLIS(self, nums: List[int]) -> int:
	n = len(nums)
	dp = [1]*(n+1)
	for i in range(1,n):
		for j in range(i):
			if nums[i]>nums[j]:
				dp[i] = max(dp[j]+1,dp[i])
	return max(dp)

方法二:
考虑一个简单的贪心,如果我们要使上升子序列尽可能的长,则我们需要让序列上升得尽可能慢,因此我们希望每次在上升子序列最后加上的那个数尽可能的小。

基于上面的贪心思路,我们维护一个数组 tail[i],表示长度为 i 的最长上升子序列的末尾元素的最小值,用 len 记录目前最长上升子序列的长度,起始时 len 为 1,tail[1]=nums[0]。

1 .定义新状态(特别重要)
tail[i] 表示:长度为 i + 1 的 所有 上升子序列的结尾的最小值。
说明:

  • 数组 tail 不是问题中的「最长上升子序列」。数组 tail 只是用于求解 LIS 问题的状态数组;
  • tail[0] 表示长度为 1 的所有上升子序列中,结尾最小的元素的数值。以题目中的示例为例 [10, 9, 2, 5, 3, 7, 101, 18] 中,容易发现长度为 2 的所有上升子序列中,结尾最小的是子序列 [2, 3] ,因此 tail[1] = 3;
  • 下标和长度有数值为 1 的偏差;
  1. 状态转移方程
    从直觉上看,数组 tail 也是一个严格上升数组。下面是证明。
    在这里插入图片描述

因为只需要维护状态数组 tail 的定义,它的长度就是最长上升子序列的长度。下面说明在遍历中,如何维护状态数组 tail 的定义。

  1. 在遍历数组 nums 的过程中,看到一个新数 num,如果这个数 严格 大于有序数组 tail 的最后一个元素,就把 num 放在有序数组 tail 的后面,否则进入第 2 点;

  2. 在有序数组 tail 中查找第 1 个等于大于 num 的那个数,试图让它变小;

    • 如果有序数组 tail 中存在 等于 num 的元素,什么都不做,因为以 num 结尾的最短的「上升子序列」已经存在;
    • 如果有序数组 tail 中存在 大于 num 的元素,找到第 1 个,让它变小,这样我们就找到了一个 结尾更小的相同长度的上升子序列。
def lengthOfLIS(self, nums: List[int]) -> int:
	n = len(nums)
	if n<2:return n
	tail = [nums[0]]
	for i in range(1,n):
		if nums[i]>tail[-1]:
			tail.append(nums[i])
			continue
		left,right = 0,len(tail)-1
		# 找到第1个 >= nums[i] 的元素,尝试让那个元素更小
		while left<right:
			mid = (left+right)//2
			if tail[mid]<nums[i]:
				left = mid+1
			else:
				right = mid
		tail[left] = nums[i]
		
	return len(tail)

4.2 最长连续递增子序列(637)

题目描述:
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
解题思路:

  1. 确定dp数组(dp table)以及下标的含义
    dp[i]:以下标 i 为结尾的数组的连续递增的子序列长度为 dp[i]。

  2. 确定递推公式
    如果 nums[i + 1] > nums[i],那么以 i+1 为结尾的数组的连续递增的子序列长度 一定等于 以 i 为结尾的数组的连续递增的子序列长度 + 1 。
    即:dp[i+1] = dp[i] + 1;

    注意这里就体现出和动态规划:300.最长递增子序列的区别
    因为本题要求连续递增子序列,所以就必要比较nums[i + 1]与nums[i],而不用去比较nums[j]与nums[i] (j是在0到i之间遍历)。

def findLengthOfLCIS(nums):
	n = len(nums)
	dp = [1]*n
	for i in range(1,n):
		if nums[i]>nums[i-1]:
			dp[i] = dp[i-1]+1
	return max(dp)
4.3 最长重复子数组(718)

题目描述:
给两个整数数组 A 和 B ,返回两个数组中公共的、长度最长的子数组的长度。
解题思路:

  1. A 、B数组各抽出一个前缀子数组,单看它们的末尾项,如果它们俩不一样——以它们俩为末尾项形成的公共子数组的长度为0:dp[i][j] = 0
  2. 如果它们俩一样,以它们俩为末尾项的公共子数组,长度保底为1——dp[i][j]至少为 1,要考虑它们俩的前缀数组——dp[i-1][j-1]能为它们俩提供多大的公共长度
    如果它们俩的前缀数组的「末尾项」不相同,前缀数组提供的公共长度为 0——dp[i-1][j-1] = 0
    以它们俩为末尾项的公共子数组的长度——dp[i][j] = 1
    如果它们俩的前缀数组的「末尾项」相同
    前缀部分能提供的公共长度——dp[i-1][j-1],它至少为 1
    以它们俩为末尾项的公共子数组的长度 dp[i][j] = dp[i-1][j-1] + 1
class Solution:
    def findLength(self, nums1: List[int], nums2: List[int]) -> int:
        n,m = len(nums1),len(nums2)
        dp = [[0]*(1+m) for i in range(n+1)]
        res = 0
        for i in range(1,n+1):
            for j in range(1,m+1):
                if nums1[i-1]==nums2[j-1]:
                    dp[i][j] = dp[i-1][j-1]+1
                res = max(res,dp[i][j])
        return res

此题与1143题很类似。但1143题找的可以是不连续子串的最大长度,而此题是连续子串的最大长度。所以DP里不同之处在于两点:

  1. dp的定义。1143题定义dp[i][j]为截止到nums1[:i]和nums2[:j]的最长公共子序列的长度。此题dp的定义是nums1[:i]和nums2[:j]的最大"公共后缀""子数组长度。此题dp的定义保证了所求得的dp值反映了连续的子数组匹配,即nums1[:i]和nums2[:j]的末尾部分是匹配的。
  2. 状态的转移。1143题找的截止到nums1[:i]和nums2[:j]的最长公共子序列的长度,所以即便是尾部不相等,都要考虑截止到nums1[:i - 1]和nums2[:j],和截止到nums1[:i]和nums2[:j - 1]的状态(取dp[i - 1][j]和dp[i][j - 1]的较大值)。但此题中,如果尾部不相等,表明没有公共后缀(从后向前看),所以dp[i][j] = 0。
4.4 最长公共子序列(1143)

题目描述:
给定两个字符串,求它们最长的公共子序列长度。
在这里插入图片描述
解题思路:

  1. 确定dp数组(dp table)以及下标的含义
    d p [ i ] [ j ] dp[i][j] dp[i][j] 代表考虑 s 1 s1 s1 的前 i − 1 i - 1 i1 个字符、考虑 s 2 s2 s2 的前 j − 1 j−1 j1 的字符,形成的最长公共子序列长度。
  2. 确定递推公式
    • s1[i-1] == s2[j-1] 时 :dp[i][j] =dp[i-1][j-1]+1
    • s1[i-1] != s2[j-1] 时:那就看看s1[0, i - 2]与s2[0, j - 1]的最长公共子序列 和 s1[0, i - 1]与s2[0, j - 2]的最长公共子序列,取最大的。
      即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
class Solution:
    def longestCommonSubsequence(self, text1: str, text2: str) -> int:
        n,m = len(text1),len(text2)
        dp = [[0]*(1+m) for i in range(n+1)]

        for i in range(1,n+1):
            for j in range(1,m+1):
                if text1[i-1]==text2[j-1]:
                    dp[i][j] = dp[i-1][j-1]+1
                else:
                    dp[i][j] = max(dp[i-1][j],dp[i][j-1])
        return dp[n][m]
4.5 不相交的线(1035)

题目描述:
在两条独立的水平线上按给定的顺序写下 nums1 和 nums2 中的整数。

现在,可以绘制一些连接两个数字 nums1[i] 和 nums2[j] 的直线,这些直线需要同时满足:

  1. nums1[i] == nums2[j]
  2. 且绘制的直线不与任何其他连线(非水平线)相交。
    请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。

以这种方法绘制线条,并返回可以绘制的最大连线数。
在这里插入图片描述

解题思路:
绘制一些连接两个数字 A[i] 和 B[j] 的直线,只要 A[i] == B[j],且直线不能相交!

直线不能相交,这就是说明在字符串A中 找到一个与字符串B相同的子序列,且这个子序列不能改变相对顺序,只要相对顺序不改变,链接相同数字的直线就不会相交。

本题说是求绘制的最大连线数,其实就是求两个字符串的最长公共子序列的长度!

class Solution:
    def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
        n,m = len(nums1),len(nums2)
        dp = [[0]*(m+1) for i in range(n+1)]
        for i in range(1,n+1):
            for j in range(1,m+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[n][m]
4.7 392.判断子序列

题目描述:
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"是"abcde"的一个子序列,而"aec"不是)。
解题思路:

  1. 确定dp数组以及下标的含义
    dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。
  2. 确定递推公式
    在确定递推公式的时候,首先要考虑如下两种操作,整理如下:
    1. if (s[i - 1] == t[j - 1])
      t 中找到了一个字符在 s 中也出现了
      dp[i][j] = dp[i - 1][j - 1] + 1
    2. if (s[i - 1] != t[j - 1])
      相当于 t 要删除元素,t 如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看 s[i-1] 与 t[j-2] 的比较结果了
      dp[i][j] = dp[i][j - 1]
  3. dp数组初始化
class Solution:
    def numDistinct(self, s: str, t: str) -> int:
   		n,m = len(s),len(t)
   		dp = [[0]*(m+1) for i in range(n+1)]
   		for i in range(1,n+1):
   			for j in range(1,m+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 True if dp[n][m]==n else False
4.8 115.不同的子序列

题目描述:
给定一个字符串 s 和一个字符串 t ,计算在 s 的子序列中 t 出现的个数。
在这里插入图片描述
解题思路:
在这里插入图片描述
初始值设置:
从递推公式 f [ i ] [ j ] = f [ i − 1 ] [ j − 1 ] + f [ i − 1 ] [ j ] ; f[i][j] = f[i - 1][j - 1] + f[i - 1][j]; f[i][j]=f[i1][j1]+f[i1][j]; f [ i ] [ j ] = f [ i − 1 ] [ j ] ; f[i][j] = f[i - 1][j]; f[i][j]=f[i1][j]; 中可以看出 f [ i ] [ 0 ] 和 f [ 0 ] [ j ] f[i][0] 和f[0][j] f[i][0]f[0][j]是一定要初始化的。

每次当初始化的时候,都要回顾一下 f [ i ] [ j ] f[i][j] f[i][j] 的定义,不要凭感觉初始化。
f [ i ] [ 0 ] f[i][0] f[i][0] 表示什么呢?
f [ i ] [ 0 ] f[i][0] f[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。
那么 f [ i ] [ 0 ] f[i][0] f[i][0] 一定都是1,因为也就是把以 i-1 为结尾的 s,删除所有元素,出现空字符串的个数就是1。
再来看 f [ 0 ] [ j ] f[0][j] f[0][j]:空字符串 s 可以随便删除元素,出现以 j-1 为结尾的字符串t的个数。

那么 f [ 0 ] [ j ] f[0][j] f[0][j]一定都是0,s 如论如何也变成不了 t。

最后就要看一个特殊位置了,即: f [ 0 ] [ 0 ] f[0][0] f[0][0] 应该是多少。

f [ 0 ] [ 0 ] f[0][0] f[0][0] 应该是1,空字符串 s,可以删除0个元素,变成空字符串 t。

class Solution:
    def numDistinct(self, s: str, t: str) -> int:
        n,m = len(s),len(t)
        dp=[[0]*(m+1) for i in range(n+1)]
        for i in range(n+1):
            dp[i][0]=1
        for i in range(1,n+1):
            for j in range(1,m+1):
                if s[i-1]==t[j-1]:
                    dp[i][j]=dp[i-1][j-1]+dp[i-1][j]
                else:
                    dp[i][j]=dp[i-1][j]
        return dp[n][m]
4.9 编辑距离(72)

题目描述:
给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

在这里插入图片描述
dp数组初始化:
在回顾一下 d p [ i ] [ j ] dp[i][j] dp[i][j] 的定义。

d p [ i ] [ j ] dp[i][j] dp[i][j] 表示以下标 i-1 为结尾的字符串 word1,和以下标 j-1 为结尾的字符串 word2,最近编辑距离为 d p [ i ] [ j ] dp[i][j] dp[i][j]

那么 d p [ i ] [ 0 ] 和 d p [ 0 ] [ j ] dp[i][0] 和 dp[0][j] dp[i][0]dp[0][j] 表示什么呢?

d p [ i ] [ 0 ] dp[i][0] dp[i][0] :以下标 i-1 为结尾的字符串 word1,和空字符串 word2,最近编辑距离为 d p [ i ] [ 0 ] dp[i][0] dp[i][0]

那么 d p [ i ] [ 0 ] dp[i][0] dp[i][0] 就应该是 i,对空字符串做添加元素的操作就可以了,即: d p [ i ] [ 0 ] = i dp[i][0] = i dp[i][0]=i;

同理 d p [ 0 ] [ j ] = j dp[0][j] = j dp[0][j]=j

操作状态详解:
当 word1[i-1] != word2[j-1] 时就需要编辑了,如何编辑呢?

  1. 操作一:word1 增加一个元素,使其 word1[i-1] 与 word2[j-1] 相同,那么就是以下标 i-2 为结尾的word1 与 j-1 为结尾的 word2 的最近编辑距离 加上一个增加元素的操作。
    d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + 1 ; dp[i][j] = dp[i - 1][j] + 1; dp[i][j]=dp[i1][j]+1;

  2. 操作二:word2 添加一个元素,使其 word1[i-1] 与 word2[j-1] 相同,那么就是以下标 i-1 为结尾的word1 与 j-2 为结尾的 word2 的最近编辑距离 加上一个增加元素的操作。
    d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + 1 ; dp[i][j] = dp[i][j - 1] + 1; dp[i][j]=dp[i][j1]+1;

这里有同学发现了,怎么都是添加元素,删除元素去哪了。
word2 添加一个元素,相当于 word1 删除一个元素,例如 word1 = “ad” ,word2 = “a”,word2添加一个元素 d,也就是相当于word1删除一个元素 d,操作数是一样!

  1. 操作三:替换元素,word1 替换 word1[i-1],使其与 word2[j-1] 相同,此时不用增加元素,那么以下标 i-2 为结尾的 word1 与 j-2 为结尾的 word2 的最近编辑距离 加上一个替换元素的操作。
    d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + 1 ; dp[i][j] = dp[i-1][j-1] + 1; dp[i][j]=dp[i1][j1]+1;
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        n = len(word1)
        m = len(word2)
        if n*m ==0:
            return m if n==0 else n
        # dp[i][j] word1[:i] 转换为 word2[:j] 的最小编辑次数
        dp = [[0]*(n+1) for i in range(m+1)]
        for i in range(m+1):
            dp[i][0] = i
        for j in range(n+1):
            dp[0][j] = j
        
        for i in range(1,m+1):
            for j in range(1,n+1):
                if word1[j-1] == word2[i-1]:
                    dp[i][j] = dp[i-1][j-1]
                else:
                    dp[i][j] = 1+min(dp[i-1][j-1],dp[i-1][j],dp[i][j-1])

        return dp[m][n]
        
4.10 回文子串(647)

题目描述:

解题思路:


4.11 最长回文子序列(516)

题目描述:

解题思路:


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值