动态规划
一、简介
动态规划(dynamic programming)是通过组合子问题来求解原问题的方法,它应用于解决子问题重叠的情况,即不同子问题具有公共的子问题。
通常动态规划可以按照如下四个步骤进行设计:
1.刻画一个最优解的结构特征;
2.递归地定义最优解的值;
3.计算最优解的值,通常采用自底向上的方法;
4.利用计算出的信息构造一个最优解(按照要求,可有可无)。
首先什么样的问题适合用动态规划解决?
- 符合“一个模型三个特征”的问题。
- 那么问题又来了,什么是“一个模型,三个特征”?
- “一个模型”👉:指 多阶段决策最优解模型;
- “三个特征”👉:分别是最优子结构、无后效性和重复子问题。
二、”动态规划“的解题思路
- 状态转移表法(回溯算法实现 - 定义状态 - 画递归树 - 找重复子问题 - 画状态转移表 - 根据递推关系填表 - 将填表过程翻译成代码);
- 状态转移方程法(找最优子结构 - 写状态转移方程 - 将状态转移方程翻译成代码)。
-
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。
三、练习题
1. 入门:
斐波那契数列,现在要求输入一个正整数 n ,请你输出斐波那契数列的第 n 项
# -*- coding:utf-8 -*- class Solution: def Fibonacci(self, n): # write code here #斐波拉契数的边界条件: F(0)=0 和 F(1)=1 if n < 2: return n else: a, b = 0, 1 for i in range(n-1): a, b = b, a + b #状态转移方程,每次滚动更新数组 return b
2. 简单:
2.1 跳台阶
一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个 n 级的台阶总共有多少种跳法(先后次序不同算不同的结果)。
分析5阶台阶的答案,可以发现规律,总跳法数排列为斐波那契数列。
所以我们可以:
直接从子树求得答案。过程是从下往上。
我们可以通过推理来找出。
第3位可以由第1阶台阶跳2阶或第2阶段跳1阶段得出。
第4位可以由第2阶台阶跳2阶或第3阶段跳1阶段得出。
第5位可以由第3阶台阶跳2阶或第4阶段跳1阶段得出。
.....
最后可以得出:
dp[i]=dp[i-1]+dp[i-2];dp[i]=dp[i−1]+dp[i−2];
其中dp[i]表示跳到第i个台阶的走法
class Solution: def jumpFloor(self, number): # write code here dp = [0]*50 dp[1] = 1 dp[2] = 2 for i in range(3,number+1): dp[i]=dp[i-1] +dp[i-2] return dp[number]
时间复杂度:O(n) 空间复杂度:O(n) ###继续优化 发现计算f[5]的时候只用到了f[4]和f[3], 没有用到f[2]...f[0],所以保存f[2]..f[0]是浪费了空间。 只需要用3个变量即可。
class Solution: def jumpFloor(self, number): # write code here a,b,c =1,1,1 for i in range(2,number+1): c = a+b a = b b = c return c
时间复杂度:O(n) 空间复杂度:O(1) 完美!
或者只用两个变量,c覆盖b
class Solution: def jumpFloor(self , number: int) -> int: # write code here a, b = 1, 1 for i in range(number-1): a, b = b, a+b return b
2.2 最小花费爬楼梯
给定一个整数数组 cost ,其中 cost[i] 是从楼梯第i 个台阶向上爬需要支付的费用,下标从0开始。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0 或下标为 1 的台阶开始爬楼梯。
示例1
[2,5,20]
5
示例2
[1,100,1,1,1,90,1,1,80,1]
6
动态规划, 每次走的步骤取决于前两个的状态; 状态转移方程: 当长度 < 2 时,直接返回 cost[0]; 当长度 >= 2 时,当前值取决于min(dp[i-1], dp[i-2]) + cost[i]; 代码如下:
class Solution: def minCostClimbingStairs(self , cost: List[int]) -> int: # write code here if len(cost)==1: return cost[0] n = len(cost) dp = [cost[0], cost[1]] # 辅助数组 for i in range(2,n): dp.append(min(dp[i-1], dp[i-2]) + cost[i]) return min(dp[-1], dp[-2])
2.3 给定两个字符串str1和str2,输出两个字符串的最长公共子序列。如果最长公共子序列为空,则返回"-1"。目前给出的数据,仅仅会存在一个最长的公共子序列
示例1
"1A2C3D4B56","B1D23A456A"
"123456"
示例2
"abc","def"
"-1"
示例3
"abc","abc"
"abc"
分析:假设两个字符串s1,s2, 将两个字符串的第一个字符进行比较,分两种情况
1. 相等:比较s1,s2的第二个字符,此时最大的公共子序列长度为两个字符串之前的字符串最长子序列长度加一
2. 不相等
1. 将s1的第二个字符s1[1]与s2第一个字符s2[0]进行比较
2. 将s2的第二个字符s2[1]与s2第一个字符s1[0]进行比较
此时两种情况的最大值为当前最大的公共子序列长度
由此构建状态转移表,以当前两个子字符串的公共子序列的长度为元素构建状态转移表如下:
根据两种不通情况得出两个式子:
dp[i][j] = dp[i-1][j-1] + 1
dp[i][j] = max(dp[i][j-1], dp[i-1][j])
代码实现:
class Solution: def LCS(self , s1: str, s2: str) -> str: # write code here m, n = len(s1), len(s2) dp = [[0]*(n+1) for _ in range(m+1)] # 状态转移表【【0,0】,【0,0】,【0,0】】 for i in range(1, m+1): for j in range(1, n+1): if s1[i-1] == s2[j-1]: # 字符串索引从0开始 dp[i][j] = dp[i-1][j-1] + 1 # 当前dp格的值等于左上角的值+1 (相当于两个字符串当前指针指向的位置的公共子序列等于各自索引之前的字符串的公共子序列数加一) else: dp[i][j] = max(dp[i][j-1], dp[i-1][j]) # 回溯子序列,状态转移表往回推,相等 ans = '' i, j = m,n while i > 0 and j > 0: # 由于用i-1做比较所以只需要到1 if s1[i-1] == s2[j-1]: ans += s1[i-1] i -= 1 j -= 1 elif dp[i][j-1] > dp[i-1][j]: j -= 1 # 那边大往那边移 else: i -= 1 if not ans : return -1 return ans[::-1]
2.3 给定两个字符串str1和str2,输出两个字符串的最长公共子串
示例1
"1AB2345CD","12345EF"
"2345"
动态规划题解:
定义dp[i][j]表示字符串str1中第i个字符和str2种第j个字符为最后一个元素所构成的最长公共子串。如果要求dp[i][j],也就是str1的第i个字符和str2的第j个字符为最后一个元素所构成的最长公共子串,我们首先需要判断这两个字符是否相等。
-
如果不相等,那么他们就不能构成公共子串,也就是
dp[i][j]=0; -
如果相等,我们还需要计算前面相等字符的个数,其实就是dp[i-1][j-1],所以
dp[i][j]=dp[i-1][j-1]+1;
时间复杂度:O(m*n),m和n分别表示两个字符串的长度
空间复杂度:O(m*n)
代码:
class Solution: def LCS(self , str1: str, str2: str) -> str: # write code here m, n = len(str1), len(str2) dp = [[0]*(n+1) for _ in range(m+1)] max_len = 0 last_ind = 0 for i in range(1, m+1): for j in range(1, n+1): if str1[i-1] == str2[j-1]: dp[i][j] = dp[i-1][j-1] + 1 if dp[i][j] > max_len: max_len = dp[i][j] last_ind = i else: dp[i][j] = 0 return str1[last_ind-max_len: last_ind]
动态规划代码优化:
上面我们使用的是二维数组,我们发现计算当前位置的时候之和左上角的值有关,所以我们可以把二维数组变为一维数组,注意第2个for循环要进行倒叙,因为后面的值要依赖前面的值,如果不倒叙,前面的值会被覆盖,导致结果错误
class Solution: def LCS(self , str1: str, str2: str) -> str: # write code here m, n = len(str1), len(str2) dp = [0]*(n+1) max_len = 0 last_ind = 0 for i in range(1, m+1): for j in range(n, 0, -1): if str1[i-1] == str2[j-1]: dp[j] = dp[j-1] + 1 if dp[j] > max_len: max_len = dp[j] last_ind = i else: dp[j] = 0 return str1[last_ind-max_len: last_ind]
时间复杂度:O(m*n),m和n分别表示两个字符串的长度
空间复杂度:O(n),只需要一个一维数组即可
动态规划方法在leetcode或牛客做题,python可能会导致运行超时,其它语言没问题,另外可以用,
滑动截取子串法:
- 界定左界限left
- i是随着循环次数往后移的这个没问题,为什么left不用每次循环之初清零让这个窗口从头来呢?
- 在我的理解中left不更新从头再来是没有必要的,因为如果left没问题的话就继续走了,感觉也是被截断那种连不上就从现在开始再来一遍
class Solution: def LCS(self , str1: str, str2: str) -> str: # write code here left = 0 for i in range(len(str1)+1): if str1[i-left:i] in str2: res = str1[i-left:i] left += 1 return res
2.4 求路径:
示例1
2,1
1
示例2
2,2
2
题解:
代码:
class Solution: def uniquePaths(self , m: int, n: int) -> int: # write code here dp = [[1]*n for row in range(m)] 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]
2.5 矩阵的最小路径和:
代码:
class Solution: def minPathSum(self , matrix: List[List[int]]) -> int: # write code here m, n = len(matrix), len(matrix[0]) dp = [[0]*(n+1) for _ in range(m+1)] for i in range(1, m+1): for j in range(1, n+1): if i == 1: dp[i][j] = dp[i][j-1] + matrix[i-1][j-1] # 注意索引映射到matrix里时,i=i-1 elif j == 1: dp[i][j] = dp[i-1][j] + matrix[i-1][j-1] else: dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + matrix[i-1][j-1] return dp[-1][-1]
观察发现matrix可以直接作为dp矩阵,可以在原矩阵上修改,所以还可以优化代码节省空间,这里就不列出了。