面试必刷TOP101:动态规划(67-71,Python实现)
67.不同路径的数目(一)(小试牛刀)
67.1 递归
首先我们在左上角第一个格子的时候,有两种行走方式:如果向右走,相当于后面在一个
(
n
−
1
)
∗
m
(n−1)∗m
(n−1)∗m 的矩阵中查找从左上角到右下角的不同路径数;而如果向下走,相当于后面在一个
n
∗
(
m
−
1
)
n∗(m−1)
n∗(m−1) 的矩阵中查找从左上角到右下角不同的路径数。而
(
n
−
1
)
∗
m
(n−1)∗m
(n−1)∗m 的矩阵与
n
∗
(
m
−
1
)
n∗(m−1)
n∗(m−1) 的矩阵都是
n
∗
m
n∗m
n∗m 矩阵的子问题,因此可以使用递归。
step 1:(终止条件) 当矩阵变长n减少到1的时候,很明显只能往下走,没有别的选择了,只有1条路径;同理m减少到1时也是如此。因此此时返回数量为1。
step 2:(返回值) 对于每一级都将其两个子问题返回的结果相加返回给上一级。
step 3:(本级任务) 每一级都有向下或者向右两种路径选择,分别进入相应分支的子问题。
class Solution:
def uniquePaths(self , m: int, n: int) -> int:
if m == 1 or n == 1:
return 1
else:
return self.uniquePaths(m-1,n) + self.uniquePaths(m,n-1)
时间复杂度:
O
(
m
n
)
O(mn)
O(mn),其中 m、n 分别为矩阵的两边长,递归过程对于每个 m 最多都要经过每一种n。
空间复杂度:
O
(
m
+
n
)
O(m+n)
O(m+n),递归栈的最大深度为矩阵两边从 m、n 都到了 1。
67.2 动态规划
如果我们此时就在右下角的格子,那么能够到达该格子的路径只能是它的上方和它的左方两个格子,因此从左上角到右下角的路径数应该是从左上角到它的左边格子和上边格子的路径数之和,因此可以动态规划。
step 1:用
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 表示大小为
i
∗
j
i∗j
i∗j 的矩阵的路径数量,下标从 1 开始。
step 2:(初始条件) 当
i
i
i 或者
j
j
j 为 1 的时候,代表矩阵只有一行或者一列,因此只有一种路径。
step 3:(转移方程) 每个格子的路径数只会来自它左边的格子数和上边的格子数,因此状态转移为
d
p
[
i
]
[
j
]
=
d
p
[
i
−
1
]
[
j
]
+
d
p
[
i
]
[
j
−
1
]
dp[i][j]=dp[i−1][j]+dp[i][j−1]
dp[i][j]=dp[i−1][j]+dp[i][j−1]。
class Solution:
def uniquePaths(self , m: int, n: int) -> int:
dp = [[0] * (n+1) for i in range(m+1)]
for i in range(1,m+1):
for j in range(1,n+1):
# 只有1行的时候,只有一种路径
if i == 1:
dp[i][j] = 1
continue
# 只有1列的时候,只有一种路径
if j == 1:
dp[i][j] = 1
continue
# 路径数等于左方格子的路径数加上上方格子的路径数
dp[i][j] = dp[i-1][j] + dp[i][j-1]
return dp[m][n]
时间复杂度:
O
(
m
n
)
O(mn)
O(mn),其中 m、n 分别为矩阵的两边长,两层遍历填充整个 dp 数组。
空间复杂度:
O
(
m
n
)
O(mn)
O(mn),辅助空间 dp 数组为二维数组。
67.3 组合数学
从矩阵左上角走到矩阵右下角,总共需要往下走
m
−
1
m−1
m−1 步,往右走
n
−
1
n−1
n−1 步,不同的走法路径在于往下和往右的组合情况,即在一堆往下和往右的序列中,每种排列的情况,序列一共有
m
+
n
−
2
m+n−2
m+n−2 个位置,选择其中
n
−
1
n−1
n−1 个位置作为往下,即不同的走法有
C
m
+
n
−
2
n
−
1
=
(
m
+
n
−
2
)
!
(
n
−
1
)
!
(
m
−
1
)
!
C^{n-1}_{m+n-2}=\frac{(m+n-2)!}{(n-1)!(m-1)!}
Cm+n−2n−1=(n−1)!(m−1)!(m+n−2)!种情况。
class Solution:
def fun(self,n:int):
sum = 1
for i in range(1,n+1):
sum = sum * i
return sum
def uniquePaths(self , m: int, n: int) -> int:
return self.fun(m+n-2) // (self.fun(m-1) * self.fun(n-1))
时间复杂度:
O
(
n
)
O(n)
O(n),计算过程需要从 1 遍历到 n。
空间复杂度:
O
(
1
)
O(1)
O(1),常数级变量,无额外辅助空间。
68.矩阵的最小路径和(小试牛刀)
68.1 动态规划
最朴素的解法莫过于枚举所有的路径,然后求和,找出其中最大值。但是像这种有状态值可以转移的问题,我们可以尝试用动态规划。
step 1:我们可以构造一个与矩阵同样大小的二维辅助数组,其中
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j] 表示以
(
i
,
j
)
(i,j)
(i,j) 位置为终点的最短路径和,则
d
p
[
0
]
[
0
]
=
m
a
t
r
i
x
[
0
]
[
0
]
dp[0][0]=matrix[0][0]
dp[0][0]=matrix[0][0]。
step 2:很容易知道第一行与第一列,只能分别向右或向下,没有第二种选择,因此第一行只能由其左边的累加,第一列只能由其上面的累加。
step 3:边缘状态构造好以后,遍历矩阵,补全矩阵中每个位置的dp数组值:如果当前的位置是
(
i
,
j
)
(i,j)
(i,j),上一步要么是
(
i
−
1
,
j
)
(i−1,j)
(i−1,j) 往下,要么就是
(
i
,
j
−
1
)
(i,j−1)
(i,j−1) 往右,那么取其中较小值与当前位置的值相加就是到当前位置的最小路径和,因此状态转移公式为
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
−
1
]
[
j
]
,
d
p
[
i
]
[
j
−
1
]
)
+
m
a
t
r
i
x
[
i
]
[
j
]
dp[i][j]=min(dp[i−1][j],dp[i][j−1])+matrix[i][j]
dp[i][j]=min(dp[i−1][j],dp[i][j−1])+matrix[i][j]。
step 4:最后移动到
(
n
−
1
,
m
−
1
)
(n−1,m−1)
(n−1,m−1) 的位置就是到右下角的最短路径和。
class Solution:
def minPathSum(self , matrix: List[List[int]]) -> int:
n = len(matrix)
m = len(matrix[0])
dp = [[0] * (m+1) for i in range(n+1)]
dp[0][0] = matrix[0][0]
# 处理第1列
for i in range(1,n):
dp[i][0] = matrix[i][0] + dp[i-1][0]
# 处理第1行
for j in range(1,m):
dp[0][j] = matrix[0][j] + dp[0][j-1]
# 动态规划
for i in range(1,n):
for j in range(1,m):
dp[i][j] = matrix[i][j] + min(dp[i][j-1],dp[i-1][j])
return dp[n-1][m-1]
时间复杂度:
O
(
m
n
)
O(mn)
O(mn),单独遍历矩阵的一行一列,然后遍历整个矩阵。
空间复杂度:
O
(
m
n
)
O(mn)
O(mn),辅助矩阵
d
p
dp
dp 为二维数组。
69.把数字翻译成字符串 (小试牛刀)
69.1 动态规划
对于普通数组1-9,译码方式只有一种,但是对于11-19,21-26,译码方式有可选择的两种方案,因此我们使用动态规划将两种方案累计。
step 1:用辅助数组
d
p
dp
dp 表示前i个数的译码方法有多少种。
step 2:对于一个数,我们可以直接译码它,也可以将其与前面的 1 或者 2 组合起来译码:如果直接译码,则
d
p
[
i
]
=
d
p
[
i
−
1
]
dp[i]=dp[i−1]
dp[i]=dp[i−1];如果组合译码,则
d
p
[
i
]
=
d
p
[
i
−
2
]
dp[i]=dp[i−2]
dp[i]=dp[i−2]。
step 3:对于只有一种译码方式的,选上种
d
p
[
i
−
1
]
dp[i−1]
dp[i−1] 即可,对于满足两种译码方式(10,20不能)则是
d
p
[
i
−
1
]
+
d
p
[
i
−
2
]
dp[i−1]+dp[i−2]
dp[i−1]+dp[i−2]。
step 4:依次相加,最后的
d
p
[
l
e
n
g
t
h
]
dp[length]
dp[length] 即为所求答案。
class Solution:
def solve(self , nums: str) -> int:
# 排除 0
if nums == '0':
return 0
# 排除只有一种可能的 10 和 20
if nums == '10' or nums == '20':
return 1
# 当 0 的前面不是 1 或 2 的时候,无法译码,0 种
for i in range(1,len(nums)):
if nums[i] == '0':
if nums[i-1] != '1' and nums[i-1] != '2':
return 0
dp = [1 for i in range(len(nums)+1)]
for i in range(2,len(nums)+1):
# 在 11-19,21-26 之间的情况
if nums[i-2] == '1' and nums[i-1] != '0' or nums[i-2] == '2' and nums[i-1] > '0' and nums[i-1] < '7':
dp[i] = dp[i-1] + dp[i-2]
else:
dp[i] = dp[i-1]
return dp[len(nums)]
时间复杂度:
O
(
n
)
O(n)
O(n),两次遍历都是单层。
空间复杂度:
O
(
n
)
O(n)
O(n),辅助数组
d
p
dp
dp。
70.兑换零钱(一)(小试牛刀)
本题属于背包问题的一种简化版本,学习完本题的思路帮助你解决相似的背包问题。
70.1 动态规划
step 1:可以用
d
p
[
i
]
dp[i]
dp[i] 表示要凑出
i
i
i 元钱需要最小的货币数。
step 2:一开始都设置为最大值
a
i
m
+
1
aim+1
aim+1,因此货币最小 1 元,即货币数不会超过
a
i
m
aim
aim。
step 3:初始化
d
p
[
0
]
=
0
dp[0]=0
dp[0]=0。
step 4:后续遍历 1 元到
a
i
m
aim
aim 元,枚举每种面值的货币都可能组成的情况,取每次的最小值即可,转移方程为
d
p
[
i
]
=
m
i
n
(
d
p
[
i
]
,
d
p
[
i
−
a
r
r
[
j
]
]
+
1
)
dp[i]=min(dp[i],dp[i−arr[j]]+1)
dp[i]=min(dp[i],dp[i−arr[j]]+1)。
step 5:最后比较
d
p
[
a
i
m
]
dp[aim]
dp[aim] 的值是否超过
a
i
m
aim
aim,如果超过说明无解,否则返回即可。
class Solution:
def minMoney(self , arr: List[int], aim: int) -> int:
if aim < 1:
return 0
# dp[i] 代表凑齐 i 元最少需要多少货币数,初始化为 aim+1
dp = [(aim+1) for i in range(aim+1)]
dp[0] = 0
# 遍历 1 到 aim 元
for i in range(1,aim+1):
# 每种面值的货币都要枚举
for j in range(len(arr)):
# 面值 arr[j] 不超过要凑的钱 i 才能用
if arr[j] <= i:
dp[i] = min(dp[i], dp[i-arr[j]]+1)
# 如果最终答案大于 aim 代表无解
if dp[aim] > aim:
return -1
else:
return dp[aim]
时间复杂度:
O
(
n
⋅
a
i
m
)
O(n·aim)
O(n⋅aim),第一层遍历枚举 1 元到
a
i
m
aim
aim 元,第二层遍历枚举
n
n
n 种货币面值。
空间复杂度:
O
(
a
i
m
)
O(aim)
O(aim),辅助数组
d
p
dp
dp 的大小。
71.最长上升子序列(一)(小试牛刀)
71.1 动态规划
要找到最长的递增子序列长度,每当我们找到一个位置,它是继续递增的子序列还是不是,它选择前面哪一处接着才能达到最长的递增子序列,这类有状态转移的问题常用方法是动态规划。
class Solution:
def LIS(self , arr: List[int]) -> int:
if len(arr) == 0:
return 0
dp = [1] * len(arr)
for i in range(1,len(arr)):
for j in range(i):
# 可能 j 不是所需要的最大的,因此需要 dp[i] < dp[j] + 1
if arr[i] > arr[j] and dp[i] < dp[j] + 1:
dp[i] = dp[j] + 1
return max(dp)
时间复杂度:
O
(
n
2
)
O(n^2)
O(n2),其中 n 为数组长度,两层遍历循环。
空间复杂度:
O
(
n
)
O(n)
O(n),辅助数组
d
p
dp
dp 的空间。