动态规划(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],此时可以抢劫的最大数量有两种可能:
- 选择不抢劫这个房子,此时累计的金额即为dp[i-1];
- 选择抢劫这个房子,那么此前累计的最大金额只能是 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 数组求和。
- 定义dp(i)表示以第i个数结尾的等差数列的个数。
- 判断如果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) - 考虑初始化,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 的距离为:
于是我们尝试将问题分解:
- 距离 ( i , j ) (i, j) (i,j) 最近的 0 0 0 的位置,是在其 「左上,右上,左下,右下」4个方向之一;
- 因此我们分别从四个角开始递推,就分别得到了位于「左上方、右上方、左下方、右下方」距离 ( i , j ) (i,j) (i,j) 的最近的 0 0 0 的距离,取 m i n min min 即可;
- 通过上两步思路,我们可以很容易的写出 4 4 4 个双重 f o r for for 循环,动态规划的解法写到这一步其实已经完全 OK 了;
- 从四个角开始的 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(i−1,j),dp(i−1,j−1),dp(i,j−1))+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
i−k2 的位置,如
i
−
1
、
i
−
4
、
i
−
9
i - 1、i - 4、i - 9
i−1、i−4、i−9 等等,才能满足完全平方分割的条件。因此 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[i−1],dp[i−4],dp[i−9]⋅⋅⋅)。
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 中的哪些字符,那么会有下面的两种情况:
- 使用了一个字符,即 s [ i ] s[i] s[i] 进行解码,那么只要 s [ i ] ≠ 0 s[i] \neq 0 s[i]=0,它就可以被解码成 A ∼ I A∼I A∼I中的某个字母。由于剩余的前 i − 1 i-1 i−1 个字符的解码方法数为 f i − 1 f_{i-1} fi−1 ,因此我们可以写出状态转移方程: f i = f i − 1 f_{i}=f_{i-1} fi=fi−1, 其中 s [ i ] ≠ 0 s[i]\neq 0 s[i]=0
- 使用了两个字符,即
s
[
i
−
1
]
s[i-1]
s[i−1]和
s
[
i
]
s[i]
s[i] 进行编码。与第一种情况类似,
s
[
i
−
1
]
s[i-1]
s[i−1] 不能等于 0,并且
s
[
i
−
1
]
s[i-1]
s[i−1] 和
s
[
i
]
s[i]
s[i] 组成的整数必须小于等于 26,这样它们就可以被解码成
J
∼
Z
J∼Z
J∼Z 中的某个字母。由于剩余的前
i
−
2
i-2
i−2个字符的解码方法数为
f
i
−
2
f_{i-2}
fi−2,因此我们可以写出状态转移方程:
f i = f i − 2 f_{i}=f_{i-2} fi=fi−2, 其中 s [ i − 1 ] ≠ 0 s[i-1]\neq 0 s[i−1]=0 并且 s [ i − 1 ] ∗ 10 + s [ i ] < = 26 s[i-1]*10+s[i]<=26 s[i−1]∗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,需要初
始化值为真。
- 初始化 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 中的单词表示。
- 初始化 d p [ 0 ] = T r u e dp[0]=True dp[0]=True,空字符可以被表示。
- 遍历字符串的所有子串,遍历开始索引 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 位可以表示。
- 若
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。
- 遍历结束索引 j,遍历区间
[
i
+
1
,
n
+
1
)
[i+1,n+1)
[i+1,n+1):
- 返回 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)
题目描述:
给定一个未排序的整数数组,求最长的递增子序列
解题思路:
方法一:
-
定义状态:
dp[i] 表示:以 nums[i] 结尾 的「上升子序列」的长度。注意:这个定义中 nums[i] 必须被选取,且必须是这个子序列的最后一个元素; -
状态转移方程:
如果一个较大的数接在较小的数后面,就会形成一个更长的子序列。只要 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,其中0≤j<i且num[j]<num[i] -
初始化:
dp[i] = 1, 1 个字符显然是长度为 1的上升子序列。 -
输出:
不能返回最后一个状态值,最后一个状态值只表示以 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 的偏差;
- 状态转移方程
从直觉上看,数组 tail 也是一个严格上升数组。下面是证明。
因为只需要维护状态数组 tail 的定义,它的长度就是最长上升子序列的长度。下面说明在遍历中,如何维护状态数组 tail 的定义。
-
在遍历数组 nums 的过程中,看到一个新数 num,如果这个数 严格 大于有序数组 tail 的最后一个元素,就把 num 放在有序数组 tail 的后面,否则进入第 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)
题目描述:
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
解题思路:
-
确定dp数组(dp table)以及下标的含义
dp[i]:以下标 i 为结尾的数组的连续递增的子序列长度为 dp[i]。 -
确定递推公式
如果 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 ,返回两个数组中公共的、长度最长的子数组的长度。
解题思路:
- A 、B数组各抽出一个前缀子数组,单看它们的末尾项,如果它们俩不一样——以它们俩为末尾项形成的公共子数组的长度为0:dp[i][j] = 0
- 如果它们俩一样,以它们俩为末尾项的公共子数组,长度保底为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里不同之处在于两点:
- dp的定义。1143题定义dp[i][j]为截止到nums1[:i]和nums2[:j]的最长公共子序列的长度。此题dp的定义是nums1[:i]和nums2[:j]的最大"公共后缀""子数组长度。此题dp的定义保证了所求得的dp值反映了连续的子数组匹配,即nums1[:i]和nums2[:j]的末尾部分是匹配的。
- 状态的转移。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)
题目描述:
给定两个字符串,求它们最长的公共子序列长度。
解题思路:
- 确定dp数组(dp table)以及下标的含义
d p [ i ] [ j ] dp[i][j] dp[i][j] 代表考虑 s 1 s1 s1 的前 i − 1 i - 1 i−1 个字符、考虑 s 2 s2 s2 的前 j − 1 j−1 j−1 的字符,形成的最长公共子序列长度。 - 确定递推公式
- 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] 的直线,这些直线需要同时满足:
- nums1[i] == nums2[j]
- 且绘制的直线不与任何其他连线(非水平线)相交。
请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。
以这种方法绘制线条,并返回可以绘制的最大连线数。
解题思路:
绘制一些连接两个数字 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"不是)。
解题思路:
- 确定dp数组以及下标的含义
dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。 - 确定递推公式
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:- if (s[i - 1] == t[j - 1])
t 中找到了一个字符在 s 中也出现了
dp[i][j] = dp[i - 1][j - 1] + 1 - 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]
- if (s[i - 1] == t[j - 1])
- 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[i−1][j−1]+f[i−1][j]; 和
f
[
i
]
[
j
]
=
f
[
i
−
1
]
[
j
]
;
f[i][j] = f[i - 1][j];
f[i][j]=f[i−1][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] 时就需要编辑了,如何编辑呢?
-
操作一: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[i−1][j]+1; -
操作二: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][j−1]+1;
这里有同学发现了,怎么都是添加元素,删除元素去哪了。
word2 添加一个元素,相当于 word1 删除一个元素,例如 word1 = “ad” ,word2 = “a”,word2添加一个元素 d,也就是相当于word1删除一个元素 d,操作数是一样!
- 操作三:替换元素,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[i−1][j−1]+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)
题目描述:
解题思路: