动态规划(简称DP,Dynamic Programming):最热门、最重要的算法之一。面试中大量出现,整体偏难。
举例:看谁说更多的我爱你
class FibonacciTest:
def __init__(self):
self.count = 0
def main(self, n):
self.fibonacci(n)
print(f"n: {n}, count: {self.count}")
def fibonacci(self, n):
print("我爱你")
self.count += 1
if n == 0:
return 1
elif n == 1 or n == 2:
return n
else:
return self.fibonacci(n - 1) + self.fibonacci(n - 2)
if __name__ == '__main__':
for i in range(32):
FibonacciTest().main(i)
斐波那契数列,重复打印 “我爱你”
n=20时,count=13529;n=30时,count=1664079
n=30时,count高达160多万,因为里面存在大量的重复计算,数越大,重复越多。
计算 f(8) 时,f(6)、f(5)等需要重复计算,这就是重叠子问题
优化:
主要问题是很多数据到会频繁重复计算
将计算的结果保存到一个一维数组中,arr[n] = f(n)
执行的时候如果某个位置已经被计算出来就更新对应位置的数组值,下次计算的时候可直接读取
记录化搜索
在执行递归之前先查数组看是否被计算过,如果重复计算了,就直接读取,这就叫记忆化搜索
class FibonacciTest:
def __init__(self):
self.count = 0
self.arr = []
def main(self, n):
self.arr = [-1] * (n + 1)
self.fibonacci(n)
print(f"n: {n}, count: {self.count}")
def find_in_arr(self, n):
return self.arr[n] if 0 <= n < len(self.arr) else -1
def fibonacci(self, n):
# print("我爱你")
self.count += 1
if n == 1 or n == 2:
self.arr[n] = n
return n
elif self.arr[n] != -1:
return self.arr[n]
else:
self.arr[n] = self.fibonacci(n - 1) + self.fibonacci(n - 2)
return self.arr[n]
if __name__ == '__main__':
FibonacciTest().main(20)
n=20时,count=37;n=30时,count=57。
递归计算大为减少
本部分通过多个路径相关的问题来解释和分析DP
LeetCode62
https://leetcode.cn/problems/unique-paths/
思路分析
本题是经典的递归问题
分析可以看出,对于一个m x n的矩阵,求路径总数的方法 search(m, n) = search(m-1, n) + search(m, n-1)
总的路径就是叶子结点数,图中有6个,这与二叉树的递归遍历本质上是一样的
代码实现
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
if m == 1 or n == 1:
return 1
return self.uniquePaths(m - 1, n) + self.uniquePaths(m, n - 1)
if __name__ == '__main__':
print(Solution().uniquePaths(3, 2))
注:功能没有问题,LeetCode上提交,判断超时了
在第一炮中,存在重复计算的问题,可以结合二维数组实现记忆化搜索
从数可以看到,在递归的过程中,存在重复计算的情况
例如 {1,1} 出现了两次,如果m=n,{1,0} 和 {0,1}的后续计算也是一样的
从二维数组的角度,例如在位置(1,1)处,不管是从(0,1)还是(1,0)到来,接下来都会产生2种走法,因此不必每次都重新遍历
可以采取一个二维数组来进行记忆化搜索
每个格子的数字:表示从起点开始到达当前位置有几种方式
代码实现
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
arr = [[-1] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if i == 0 or j == 0:
arr[i][j] = 1
else:
arr[i][j] = arr[i - 1][j] + arr[i][j - 1]
return arr[m - 1][n - 1]
上面的缓存空间使用的二维数组,占空间太大;可以使用滚动数组来优化此问题
滚动数组
以上可以简化为一个大小为n的一维数组来解决
上面这几个一维数组拼接起来就是原先的二维数组
这种反复更新数组的策略就是滚动数组,计算公式 dp[j] = dp[j] + dp[j-1]
代码实现
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
arr = [1] * n
for i in range(1, m):
for j in range(1, n):
arr[j] = arr[j] + arr[j - 1]
return arr[-1]
总结
本题涵盖了DP里的多个方面,比如重复子问题、记忆化搜索、滚动数组等等
这就是最简单的动态规划了,只不过我们这里的规划是 dp[j] = dp[j] + dp[j-1],不用进行复杂的比较和计算
这个题目非常重要,对后面理解递归、动态规划等算法有非常大的作用
上面的题目(LeetCode62)还有个重要的问题体现的不明显:最优子结构
LeetCode64
https://leetcode.cn/problems/minimum-path-sum/
思路分析
这道题目就是在上面题目的基础上,增加了路径成本的概念。
由于题目限定只能 往下 或者 往右,可以按照当前位置可由哪些位置移动过来进行分析
引入新概念
状态与状态转移方程
状态:就是下面表格更新到最后的二维数组(?不太理解,简单的就是状态转移方程计算出来的值)
状态转移方程:通过前面格子状态计算后面格子状态的公式就叫状态转移方程
数组表达:
f[i][j] 从(0,0)开始到达位置(i,j)的最小路径成本总和,f[m-1][n-1]就是我们最终的答案
起始状态 f[0][0] = grid[0][0]
状态转移方程
f[i][j] = min(f[i-1][j], f[i][j-1]) + grid[i][j]
注:确定状态转移方程就是要找递推关系,通常我们会从分析首尾两端的变化规律入手
代码实现
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
arr = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
arr[i][j] = grid[i][j]
elif i == 0:
arr[i][j] = arr[i][j - 1] + grid[i][j]
elif j == 0:
arr[i][j] = arr[i - 1][j] + grid[i][j]
else:
arr[i][j] = min(arr[i][j - 1], arr[i - 1][j]) + grid[i][j]
return arr[m - 1][n - 1]
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
continue
elif i == 0:
grid[i][j] = grid[i][j - 1] + grid[i][j]
elif j == 0:
grid[i][j] = grid[i - 1][j] + grid[i][j]
else:
grid[i][j] = min(grid[i][j - 1], grid[i - 1][j]) + grid[i][j]
return grid[-1][-1]
class Solution:
def minPathSum(self, grid: List[List[int]]) -> int:
m, n = len(grid), len(grid[0])
arr = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if i == 0 and j == 0:
arr[i][j] = grid[i][j]
else:
top = arr[i - 1][j] + grid[i][j] if i - 1 >= 0 else float('inf')
left = arr[i][j - 1] + grid[i][j] if j - 1 >= 0 else float('inf')
arr[i][j] = min(top, left)
return arr[-1][-1]
LeetCode120
https://leetcode.cn/problems/triangle/description/
本题就是LeetCode64最小路径和的简单变换
思路分析
处理过程如下:
为了方便处理,我们可以先处理第1列和对角线
引入新概念 无后效性
无后效性:
我们转移某个状态需要用到某个值,但是并不关心该值是如何而来的
或者说,当前某个状态确定后,之后的状态转移与之前的决策无关
确定一道题目是否可以用 DP 解决,要从有无后效性进行分析。有后效性,不能用DP;无后效性,可以用DP。
本题中
找递推关系,确定状态
f[i][j] 代表到达某个点的最小路径和,min(f[n-1][i]) 就是答案,最后一行的每列的路径和的最小值
代码实现
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
m = len(triangle)
n = len(triangle[-1])
arr = [[0] * n for _ in range(m)]
for i in range(m):
for j in range(n):
if j > i:
continue
elif j == 0:
arr[i][j] = arr[i - 1][j] + triangle[i][j]
elif j == i:
arr[i][j] = arr[i - 1][j - 1] + triangle[i][j]
else:
arr[i][j] = min(arr[i - 1][j], arr[i - 1][j - 1]) + triangle[i][j]
return min(arr[-1])
if __name__ == '__main__':
print(Solution().minimumTotal([[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]])) # 11
代码优化
class Solution:
def minimumTotal(self, triangle: List[List[int]]) -> int:
m = len(triangle)
arr = [[0] * m for _ in range(m)]
arr[0][0] = triangle[0][0]
for i in range(1, m):
arr[i][0] = arr[i - 1][0] + triangle[i][0]
for j in range(1, i):
arr[i][j] = min(arr[i - 1][j], arr[i - 1][j - 1]) + triangle[i][j]
arr[i][i] = arr[i - 1][i - 1] + triangle[i][i]
return min(arr[m - 1])
题目拓展
类似题目还有 LeetCode931 下降路径最小和 和 LeetCode1289 下降路径最小和II
DP要满足 无后效性
例如:
上面路径问题,从左上角走到右下角,两个问题:
分析:
回溯与DP的比较
回溯:能解决,但是解决效率不高
DP:计算效率高,但是不能找到满足要求的路径
如何区分:
DP只关心当前结果是什么,怎么来的就不管了,所以动态规划无法获得完整的路径
回溯能够获得一条甚至所有满足要求的完整路径
DP的基本思想:
注:既然要找“最”值,必然要做的就是穷举来找所有的可能,然后选择“最”的那个,这就是为什么在DP代码中大量判断逻辑都会被套上min()或者max()
既然穷举,那为啥还要有 DP 的概念?
既然记忆化能解决问题,为啥DP这么难?
状态转移方程
DP代码的基本模板
// 初始化base case,也就是初始化刚开始的几种场景,有几种枚举几种
dp[0][0][...] = base case
// 进行状态转移
for 状态1 in 状态1的所有值
for 状态2 in 状态2的所有值
for ...
dp[状态1][状态2][...] = 求最值max(选择1, 选择2, ...)
我们一般写状态规划只有一两层,不会太深,代码看起来特别简洁
动态规划的常见类型
常见类型比较多,从形式上看,有坐标型、序列型、划分型、区间型、背包型、博弈型等等
解题基本思路是一致的
一般来说,DP题目有以下三种基本的类型
计数相关
有多少方式走到右下角,有多少种方式选出K个数使得***等
不关心具体路径是什么
求最大最小值,最多最少等
例如最大数字和、最长上升子序列长度、最长公共子序列、最长回文序列等
求存在性
例如取石子游戏,先手是否必胜,能不能选出K个数使得***等
但是不管哪一种,解决问题的模板是类似的,都是:
以上是我们分析DP问题的核心模板
总结