动态规划
动态规划是日常算法中经常会用到的一个算法,也是leetcode中霸占困难题最多的狗皮膏药。这篇文章给出多个经典的dp例子,希望能帮助到你。
当我第一次听到dp这个名词的时候,是大一有一个同学在上课提出的。那时还是懵懂无知,动态规划这么牛皮的名字我想学!但无奈自己的懒占据上风,现在大三了才来填坑。
动态规划其实自我感觉就是一个拖着一个柜子的逆回溯。
自我认为,回溯的核心在于这个值选或者不选,如果选该怎么办,是什么推导式。而如果不选该怎么办,又是什么推导式。而动态规划其实也就是一个记住了中间过程答案的逆回溯。更何况,动态规划真的就是“规划”么?不是的,动态规划也是专注与一点:去找地推方程,与回溯类似是吧。
我们先来看看百度的一个解读:
动态规划常常适用于有重叠子问题和最优子结构性质的问题,动态规划方法所耗时间往往远少于朴素解法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题。(著名题目:斐波那契,正常情况下,从后往前,N由N-1+N-2组成,而这里就会出现重复计算的地方。因此我们可以使用DP,可以引入一个缓存,将N的值记录进去即可。这样时间复杂度就从2**n降到n)
通常许多子问题非常相似,为此动态规划法试图仅仅解决每个子问题一次,具有天然剪枝的功能,从而减少计算量:一旦某个给定子问题的解已经算出,则将其记忆化存储,以便下次需要同一个子问题解之时直接查表。这种做法在重复子问题的数目关于输入的规模呈指数增长时特别有用。
是不是似懂非懂,又不是非常懂?而下面这篇文章是知乎的,评论大神太多了,这篇文章的一些思路也采取了一些他们的想法:https://www.zhihu.com/question/39948290
那么,废话不多说,我们来进入第一题,最常见的入门dp:斐波那契数列
斐波那契
斐波那契的题目是每本算法书必备的,下面是题目:
斐波那契数列:1,1,2,3,5,8,13……
它遵循这样的规律:当前值是前两个值的和
问:第n个数的值为多少
看到这个规律,很快就能想到这个递归公式: F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n)=F(n - 1) + F(n - 2) F(n)=F(n−1)+F(n−2),下面我们就用dp和回溯分别看看他们的差距。
我们先来简单的:回溯(递归)法求解
def fib(n):
if n == 1 or n == 2:
return 1
else:
return fib(n-1) + fib(n - 2)
上面的代码时一个非常非常常见的递归方程。假设我们的n为6,我们来看看他的整个结构图。
看到了么?如果使用递归,F(3)至少算了3次,这些都是没用的计算,更何况现在n才6,如果到了100,那不得等到自己孩子都打酱油了?因此我们得对它进行改进。然后有人就会想到,如果我把重复的值记录下来呢?没错,你已经跨入dp狗皮膏药店的门槛了。我们只需要创建一个字典或者n长度的列表,把n的值存入相应位置即可。
下面是使用dp的求解法:
def Fib(n):
a = [1 for _ in range(n)]
for i in range(2, n):
a[i] = a[i - 1] + a[i - 2]
return a[n - 1]
如果运行之后就会发现,这两个时间差是真的非常大。从这里我们也可以开始一个简单的小总结,要完成动态规划,我们可以把它分成三步:
- 寻找和建立状态转移方程:这是整个dp的核心,没办法给出一个合理的公式给你套,但做题百变,套路自现。例如上面的斐波那契,我们很快就能找到递推公式: F ( n ) = F ( n − 1 ) + F ( n − 2 ) F(n)=F(n - 1) + F(n - 2) F(n)=F(n−1)+F(n−2)。而等差数列,我们也可以类似的找出一个递推公式 F ( n ) = F ( n − 1 ) + d F(n)=F(n - 1) + d F(n)=F(n−1)+d
- 缓存以复用以往结果:这个就是回溯的柜子,你走到一个地方,发现是新的地方,那就收集民情,把他们记录好放到柜子里。等下一次再来的时候,你就不用去重新搜索民情,直接打开柜子就行。在斐波那契额中,我们的a就是柜子,将所有的信息都存放在里面,因此整个时间复杂度从递归时的 O ( 2 n ) O(2^n) O(2n)到现在的 O ( n ) O(n) O(n),可以说是一个代码生奇迹吧。
- 按顺序从小往大算:这里的小和大是问题的规模,也就是我们所说的从 F ( 1 ) F(1) F(1)到 F ( n ) F(n) F(n)一步一步走。还是斐波那契,我们是从2开始到n-1,再通过计算结果辅助下一次的计算。
当我们得出这样一个小结论之后,接下来,我们就来看看一个普通的题目
不同路径系列
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
链接:https://leetcode-cn.com/problems/unique-paths
可以先自己思考三分钟,下面也是我自己的一种思路,并不适合所有人,更何况也是在别人基础上上进行添加的。
首先,明确一点,我们在一个格子里,只能通过往下或者右走,那就是该格子可以是它左边和上面的格子的路径总和(这也就是最优子结构)。当然还有另一种,即这个格子的有效路径就是它下面的格子总路径加上右边的格子总路径。这两个区别在于:第一个是我们正常的思路,即一个格子从出发开始考虑,一步一步的走,而第二种则是从finish开始,采用倒退的方式(递推,从结束开始,也就是上面说的逆回溯)。
因此,我们还是可以很容易的写出我们的递推方程: F ( x , y ) = F ( x − 1 , y ) + F ( x , y − 1 ) F(x,y) = F(x - 1, y) + F(x, y - 1) F(x,y)=F(x−1,y)+F(x,y−1)
当完成我们的递推方程之后,我们也就成功大半了。接下来就是创建回溯的柜子(缓存结果)。如果我们再使用斐波那契的一维数组就很难适合我们了,应为你无法更加全面的掌握整个棋盘信息,因此我们就需要将整个棋盘上升到二维。
# 棋盘创建,以n列m行为例
res = [[1 for _ in range(n)] for _ in range(m)] # 这也是最正常的创建方式,下面是大佬的创建方式。这样可以将内存占用尽可能的减小
res = [[1] * n] * m # 但这样有一个缺点,就是第一个动,其他后续的地方也会跟着动,再II里面就会显现出来
最后就是从小到大,即我们从(1,1)开始到(m - 1,n - 1)结束,看出来了,需要使用两层循环来结束整个过程。下面就是整个代码了:
def uniquePaths(m: int, n: int):
res = [[1] * n] * m
for i in range(1, m):
for j in range(1, n):
res[i][j] = res[i - 1][j] + res[i][j - 1]
return res[-1][-1]
为什么从1,1开始,这是你从起点开始走,第一行和第一列你肯定只能按照一条路径走,即要么一直往右走到(0,n),要么一直往下走到(m,0)。而且我们初始化值是1,因此直接从(1,1)开始就行。
ok,现在你的第二只脚也有一半进入殿堂了,接下来,我们对这个问题进行一个小小的升级。
不同路径II
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用
1
和0
来表示。链接:https://leetcode-cn.com/problems/unique-paths-ii
输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]]
输出:2
解释:
3x3 网格的正中间有一个障碍物。
从左上角到右下角一共有 2 条不同的路径:
- 向右 -> 向右 -> 向下 -> 向下
- 向下 -> 向下 -> 向右 -> 向右
应为这里的行驶路径还是不变,只能按照右或者下进行行走,因此我们还是原来的状态转移方程: F ( x , y ) = F ( x − 1 , y ) + F ( x , y − 1 ) F(x,y) = F(x - 1, y) + F(x, y - 1) F(x,y)=F(x−1,y)+F(x,y−1)。
虽然棋盘是题目给我们的,但是处于方便,我们还是可以自己创建一个棋盘:(这里会说明为什么要使用麻烦的创建棋盘方式)
res = [[1 for _ in range(n)] for _ in range(m)]
如果你使用res = [[1] * n] * m来创建棋盘,可能会遇到下面这种情况:
>>> m = 5 >>> n = 5 >>> res = [[1] * n] * m >>> res [[1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1]] >>> res[0][1] = 0 >>> res [[1, 0, 1, 1, 1], [1, 0, 1, 1, 1], [1, 0, 1, 1, 1], [1, 0, 1, 1, 1], [1, 0, 1, 1, 1]]
看到了吗?你只是想改(0,1)的值,但是后面的值也跟着改了,这是因为*使用了浅拷贝,他们都是指向同一个内存地址,因此当第一个发生改变时,后面也会跟着改变。那你会问为什么我们上次的还是得出正确答案?因为对于一个没有阻碍的棋盘来说,最上面的这一条路径肯定是1,因为只能一步一步从左边走过来,但是如果是有阻碍的,我们就需要考虑阻碍,对于第一行,如果存在一个阻碍,那么接下来所有的格子都不可能达到。下面我们根据图片进行简单的理解。
因此,理解了这个,我们就可以编写代码了。
def uniquePathsWithObstacles(obstacleGrid):
m = len(obstacleGrid)
n = len(obstacleGrid[0])
res = [[1 for _ in range(n)] for _ in range(m)]
if m == n == 1 and obstacleGrid[0][0] == 0:
return 1
# 用来将第一行或者第一列进行初始化,如果存在路障,则剩下的每一格都是路障。
flag = False
for j in range(n):
if flag:
res[0][j] = 0
else:
if obstacleGrid[0][j] == 1:
flag = True
res[0][j] = 0
flag = False
for i in range(m):
if flag:
res[i][0] = 0
else:
if obstacleGrid[i][0] == 1:
flag = True
res[i][0] = 0
# 主要的工作地
for i in range(1, m):
for j in range(1, n):
print(i, j)
print(res)
if obstacleGrid[i][j] == 1:
res[i][j] = 0
else:
res[i][j] = res[i - 1][j] + res[i][j - 1]
return res[-1][-1]
下面是leetcode一位网友总结的关于DP与回溯的精辟说法:
我说句题外话,就是何时使用【回溯】,何时使用【动态规划】,用大白话说,就是:
首先看取值范围,递归回溯一维数组,100就是深度的极限了(何况本题是100²) 2.如果是求走迷宫的【路径】,必然是回溯;如果是走迷宫的【路径的条数】,必然是dp--------(这个竟然屡试不爽!!!!)
当你走到这里,你就已经算是了解dp狗皮膏药的一些皮毛了。接下来,我们更具更多的例题来加深我们对dp的认识。
三角形最小路径和
给定一个三角形 triangle ,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。
链接:https://leetcode-cn.com/problems/triangle
我们先来看下给出的例子:
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
2
3 4
6 5 7
4 1 8 3自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
同样的,可以自己先思考3分钟。
还记得dp的三个步骤么?创建转移方程、保存记录和从小到大
1. 转移方程
题目中也给出了我们想要的一个答案,那就是每一步只能移动到下一行中相邻的结点上
。也就是说a[i][j]
它只能走到a[i+1][j]
或者a[i+1][j+1]
。
我们在看一下这个结构,是一个金字塔的结构,那么我们希望的方式肯定是dp方程对后一个就是我们所想要的答案,因此我们就需要从button(底部)开始,一步一步往上推,最终我们的答案就是dp[0][0]
。而这样的一个方式我们就称之为递推。
接下来,也知道要怎么走了,也同时知道了如何定义,那整个转移方程也就出来了:
d
p
[
i
]
[
j
]
=
d
p
[
i
]
[
j
]
+
m
i
n
(
d
p
[
i
+
1
]
[
j
]
,
d
p
[
i
+
1
]
[
j
+
1
]
)
dp[i][j] = dp[i][j] + min(dp[i + 1][j],dp[i + 1][j + 1])
dp[i][j]=dp[i][j]+min(dp[i+1][j],dp[i+1][j+1])
下面,我们来看一下整个递推的过程
首先,我们从3开始,3可以走到5和6,因此从5和6开始递推上去的时候,因为5比6小,所以3的位置就变成8.再到4,4可以走到6和7,同时6比7小,那么4的位置就变成10.最后到2,2可以走到3(现在是8)和4(现在是10).但是8比10大,因此2的位置就变成了10.也就是说这样一个小三角形他的最小路径和为10.
2. 缓存
我们可以使用原先的列表进行存储。
3. 从小到大
在第一步我们就了解过,为了使我们最后的结果保留在(0,0),因此我们就需要从底部开始往上走
4. 代码展示
# 三角形最小路径和
def minimumTotal(triangle):
for i in range(len(triangle) - 1, -1, -1):
for j in range(len(triangle[i])):
triangle[i][j] += min(triangle[i+1][j], triangle[i+1][j+1])
return triangle[0][0]
其实上面这个思路,就是dp的另一个方式:递归–》存储–》递推。即你先想递归的方法是咋样的,然后将结果寻找合适的数据结构进行存储,最后就是利用状态转移方程进行递推回去。
小总结
你也看到了,我们的缓存和从小到大是可以再转移方程之前先考虑的,那我们就可以把三部曲变成两不,即下面的步骤:
- 状态定义:确定如何存储每一个值,和确定如何进行递推。
- 转移方程求解:确定状态转移方程。
乘积最大子序列
给你一个整数数组
nums
,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。输入: [2,3,-2,4] 输出: 6 解释: 子数组 [2,3] 有最大乘积 6。
首先拿到题目,我们还是老规矩,自己思考三分钟。
题目中要注意两个点,1、是最大的值,2、必须是子序列。因此我们在考虑的时候,一定要理清思路,一步一步走,理不清就画图。
1. 状态定义
首先,我们先来想一下,要几维的数组来存储。如果是1维,我们就每次在相对应的位置存储前方到该点的最大值,那么 d p [ i ] = m a x ( d p [ i − 1 ] ∗ n u m s [ i ] , n u m s [ i ] ) dp[i] = max(dp[i - 1]*nums[i],nums[i]) dp[i]=max(dp[i−1]∗nums[i],nums[i]),想一下,这样真的可行么?
我们看到,如果nums的值为[1,1,2,3,-1,-2,4]
,按照正常情况,我们输出的值应该是48,但是这里最大的也才6,这是应为当我们遇到负数的时候,一个正数乘以复数肯定比原本的负数小,因此此时dp[i]存储的就是自己本身。但它并没有考虑到后面如果也有负数可以负负取消的情况。因此一维数组是肯定不行的。我们就使用二维数组,其中第一个位置存放达到当前位置的最大值,而第二个用来存放达到当前位置的最小值。毕竟最小值乘上一个负数就变成了接下来的最大值来了。到最后我们只需要查看所有dp[i][0]
的最大值返回即可。
因此我们的缓存就可以这样表示:
dp = [[nums[0], nums[0]] for _ in range(n)]
一直卡着我思路的是为什么要用列表,而不用单独的一个max和min来存储。
我们继续看这个例题:[2,3,-2,4]
,而他所对应dp分别是[[2, 2], [6, 3], [-2, -12], [4, -48]]
。
如果我们使用max,则结果是max = 2,max = 6 max = 6 max = 24
这是应为他并没有考虑到连续性,虽然到-2的时候,最大值的确比自己大,但是如果把6当作最大值,这样就会失去连续性。导致最终的答案不是我们所预想的。
2. 转移方程
说了这么多,其实转移方程也非常简单,如果这个数是有理数,那么现在的最大值就是自己与上一个最大值的乘积与自己的最大值 m a x ( d p [ i ] [ 0 ] ∗ n u m s [ i ] , n u m s [ i ] ) max(dp[i][0]*nums[i],nums[i]) max(dp[i][0]∗nums[i],nums[i]),而最小值也是同理。如果这个数是负数,那么就是上一个的最小值与自己的乘积即可。
if nums[i] >= 0:
dp[i][0] = max(nums[i], dp[i - 1][0] * nums[i])
dp[i][1] = min(nums[i], dp[i - 1][1] * nums[i])
else:
dp[i][0] = max(nums[i], dp[i - 1][1] * nums[i])
dp[i][1] = min(nums[i], dp[i - 1][0] * nums[i])
知道了这么多“秘密”,那就得开始写代码了
3. 代码展示
def maxProduct(nums):
n = len(nums)
dp = [[nums[0], nums[0]] for _ in range(n)]
max_num = nums[0]
for i in range(1, n):
if nums[i] >= 0:
dp[i][0] = max(nums[i], dp[i - 1][0] * nums[i])
dp[i][1] = min(nums[i], dp[i - 1][1] * nums[i])
else:
dp[i][0] = max(nums[i], dp[i - 1][1] * nums[i])
dp[i][1] = min(nums[i], dp[i - 1][0] * nums[i])
max_num = max(max_num, nums[i][0]) # 这个是拿来判断当前的最大值,不会进入dp参与任何事情,如果没有这个变量也行,只需要在结尾添加一个循环,然后找出最大值即可
return max_num
简单的dp差不多就可以讲到这里,我们接下来完成一系列的组合体:股票买卖问题
股票买卖系列
121. 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock
对于该题,思路非常简单,就是那最小值去减他后面的最大值。我们先不用dp,等到后面的几道例题我们在使用一个万能公式来一次性解决这些问题。
因为没有什么技巧可言,这里就直接上代码了。
def maxProfit(prices):
min_num = prices[0]
max_num = 0
for i in range(1, len(prices)):
if prices[i] <= min_num:
min_num = prices[i]
else:
max_num = max(max_num, prices[i] - min_num)
return max_num
122. 买卖股票的最佳时机II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-ii
注意一个点,可以多次买一个股票,也就是说今天你把这只股票卖出去,今天还是可以买回来的。那思路就可以变成这样:你在第n天,判断第n+1天的股票是否比自己的股票贵,如果是,那就今天买入,明天卖出即可。
也因为是简单题,就直接上代码了。
def maxProfit(prices):
max_num = 0
for i in range(len(prices) - 1):
if prices[i + 1] > prices[i]:
max_num += prices[i + 1] - prices[i]
return max_num
最简单的两题也就完成了,我们开始来试着实现一下这两题的状态定义。
首先我们想一下一维数组可以么?如果使用一维数组,那么每个位置就是存储第i天的最大利润。那利润怎么算?对于这一天,我们可以买入股票,也可以卖出股票。如果我们买入股票,那么今天的利润就是昨天的利润减去今天的股票价格,卖出就是昨天的利润加上今天的股票价格。
但是你怎么知道昨天手里有没有股票呢?所以为了添加这个标志,我们就需要再扩大一个维度。将一维升级到二维。第二维的第一个表示没有股票,第二个表示有股票。
思路简单了吧。如果今天有股票,那就是昨天的没有股票加上今天买入股票,或者今天保持不动直接,延续昨天的有股票,取他们的最大值。如果今天没有股票,那就是昨天的有股票加上今天卖出股票,或者今天保持昨天的有股票,取他们的最大值即可。
分析完状态,那就看也i来编写转移方程了:
# 第二维中,0表示手里没有股票,1表示手里有股票
for i in range(n): # 从第0天到第n-1天
max_profit[i][0] = max(max_profit[i - 1][0], max_profit[i - 1][1] + price[i])
max_profit[i][1] = max(max_profit[i - 1][1], max_profit[i - 1][0] - price[i])
对于进行一次,你只需要加个判断即可。而无数次则直接循环开启,畅通无阻。
123. 买卖股票的最佳时机III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iii
难度上升了,这里将股票交易次数变成了两次,即你不仅要判断手里有没有股票,还要判断当前已经交易了几次(我们设定,卖出股票后,k才加1,如果是买入,则k不变)。不难发现,上面的状态定义又落后了,我们上面只能再无数次的情况下玩耍,而如果要保证买卖次数,那就必须再增加一个维度,用MP[i][k][j]
来保存第几次交易的结果。这里i表示天数,k表示交易次数,j同样是手中是否有股票。
那么,思路就是这样:首先第i天的第k次交易如果是没有股票,那么就是第i-1天的第k此交易保持不动或者第i-1天的第k-1次交易有股票到今天的卖出股票的两个利润的最大值。其次是第i天的第k次交易有股票的情况,那么就是第i-1天第k-1次保持不动或者是第i-1天的第k次没有股票减去今天的股票加个的最大值(因为是买入,则k是不变的)。下面就是转移方程了:
for i in range(n): # 从第0天到第n-1天
for j in range(K + 1): # 交易0次到交易K次,注意本题的K是2,而我们下一题就是K,所以这两个是一样的,也就一起讲了
MP[i][k][0] = max(MP[i - 1][k - 1][0], MP[i - 1][k - 1][1] + price[i])
MP[i][k][1] = max(MP[i - 1][k - 1][1], MP[i - 1][k][0] - price[i])
而我们最后,也只需要比较MP[i - 1][0-K][0]
,获取的最大值就是我们所想要的答案。这是考虑了K的情况,而我们的这道题K只是2,所以代码也还比较简单。下面就是我们代码
def maxProfit(prices):
profit = [[[0 for _ in range(2)] for _ in range(3)] for _ in range(len(prices))]
profit[0][0][0], profit[0][0][1] = 0, -prices[0]
profit[0][1][0], profit[0][1][1] = float("-inf"), float("-inf")
profit[0][2][0], profit[0][2][1] = float("-inf"), float("-inf")
for i in range(1, len(prices)):
profit[i][0][0] = profit[i - 1][0][0]
profit[i][0][1] = max(profit[i - 1][0][1], profit[i - 1][0][0] - prices[i])
profit[i][1][0] = max(profit[i - 1][1][0], profit[i - 1][0][1] + prices[i])
profit[i][1][1] = max(profit[i - 1][1][1], profit[i - 1][1][0] - prices[i])
profit[i][2][0] = max(profit[i - 1][2][0], profit[i - 1][1][1] + prices[i])
end = len(prices) - 1
return max(profit[end][0][0], profit[end][1][0], profit[end][2][0])
188. 买卖股票的最佳时机VI
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iv
具体的就不再说了,上面的原理可以完全放到这里。
代码展示:
def maxProfit(k, prices):
if len(prices) <= 1:
return 0
if k == 0:
return 0
profit = [[[0 for _ in range(2)] for _ in range(k + 1)] for _ in range(len(prices))]
profit[0][0][0], profit[0][0][1] = 0, -prices[0]
for i in range(1, k + 1):
profit[0][i][0], profit[0][i][1] = float("-inf"), float("-inf")
for i in range(1, len(prices)):
profit[i][0][0] = profit[i - 1][0][0]
profit[i][0][1] = max(profit[i - 1][0][1], profit[i - 1][0][0] - prices[i])
for j in range(1, k):
profit[i][j][0] = max(profit[i - 1][j][0], profit[i - 1][j - 1][1] + prices[i])
profit[i][j][1] = max(profit[i - 1][j][1], profit[i - 1][j][0] - prices[i])
profit[i][k][0] = max(profit[i - 1][k][0], profit[i - 1][k - 1][1] + prices[i])
end = len(prices) - 1
max_pro = 0
for i in range(k + 1):
max_pro = max(max_pro, profit[end][i][0])
return max_pro
再次升级:手里可以同时握有X支股票
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成K笔交易。并且手里可以同时握有X支股票。
其实这个还是和第三题和第四题类似,只需要把原来的j(代表0和1)再次设置一层循环。
所以,我们的状态定义就是MP[i][k][x]
。其中i是[0,n-1]
,k是[0,K]
,而x则是[0,X]
。我们的状态转移方程就变成如下结果:
for i in range(n):
for k in range(K + 1):
for x in range(X + 1):
MP[i][k][x] = max(MP[i - 1][k][x], MP[i - 1][k - 1][x + 1] + price[i], MP[i - 1][k][x - 1] - price[i])
小总结
我们从I一步一步到VI再到自己的升级版,会发现dp的思路也是有一定的路数的。首先,我们从题目给出的数组维度出发,判断能否满足要求,如果不能满足,我们就再次对缓存进行升维。当然,最重要的是你得跳过开始和结束,去想中间的一步是怎么通过前面的一步得来的,然后将整个过程泛化到整个循环。最后代码编写部分。在编写代码的时候,一定要注意边界的判定。上面代码中,0的i-1是-1,这肯定是错误的,并且最后一次交易肯定是不存在股票的时候利润最大,也因此不需要多遍历了。
不知不觉,已经写了这么多题目了,那么后面的几题应该也能灵活自如了吧。
最长上升系列
1. 最大上升子序列
给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
链接:https://leetcode-cn.com/problems/longest-increasing-subsequence
这题是可以不连续的,而如果是连续的,那就直接递归暴力就行,而这题虽然也可以暴力(说的p话太多了!!!前面哪一题不可以暴力),但是时间复杂度都到了 O ( 2 n ) O(2^n) O(2n)。因此我们还是使用dp来耍一耍。
最长系列都是dp的加家常便饭。如果想了解更多的,也可以去leetcode自己尝试一下。
还是老步骤,先状态定义,先来看看一维数组可以么?如果是一维数组,那么第i位即代表包含这个数的最长上升子序列(也就是最优子结构)。这个可行么?如果想不明白,我们从倒数第二个看,如果倒数第二个的最长上升子序列为5,并且刚好最后一个数比倒数第二个大,那么最后一个的最长上升子序列就是6。因此我们如何判断第i个数的最长上升子序列呢?我们就只需要查找出从0到i-1中某个元素j,这个j刚好比第i个数小而且是较大的值(为什么不是最大,是因为没法确定前面的最大值能否符合DP[j] < DP[i]),那第i个数就是第j个数的值加一。
下面就是状态转移方程:
for i in range(1, len(nums)):
max_num = 0
for j in range(i):
if DP[j] > max_num and nums[j] < nums[i]:
max_num = DP[j]
DP[i] = max_num + 1
这样下来,我们的时间复杂度就可以降到 O ( n 2 ) O(n^2) O(n2),下面就是代码示例
def lengthOfLIS(nums):
DP = [1] * len(nums)
for i in range(1, len(nums)):
max_num = 0
for j in range(i):
if DP[j] > max_num and nums[j] < nums[i]:
max_num = DP[j]
DP[i] = max_num + 1
return max(DP)
而这题的姊妹题,也是使用类似的方法。
2. 递增的三元子序列
给你一个整数数组 nums ,判断这个数组中是否存在长度为 3 的递增子序列。
如果存在这样的三元组下标 (i, j, k) 且满足 i < j < k ,使得 nums[i] < nums[j] < nums[k] ,返回 true ;否则,返回 false 。
链接:https://leetcode-cn.com/problems/increasing-triplet-subsequence
同样是这样的求解方式,只需要在DP[i]之前判断是否是3就行。
def increasingTriplet(nums):
DP = [1] * len(nums)
for i in range(1, len(nums)):
max_num = 0
for j in range(i):
if DP[j] > max_num and nums[j] < nums[i]:
max_num = DP[j]
DP[i] = max_num + 1
if DP[i] == 3:
return True
return False
其余题就自己去leetcode搜搜把,这里就不再赘述了。
零钱兑换系列
1. 零钱兑换I
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
你可以认为每种硬币的数量是无限的。
链接:https://leetcode-cn.com/problems/coin-change
当我第一次拿到题的时候,想都不想,直觉是贪心即最大面值的硬币肯定需要最多,然后第二大的面值来填满剩下的空间直到最小的面值。但是天不随人愿,理想太美好,现实太残酷了!!!啪啪啪打脸。
贪心没了我们就继续dp,其实零钱兑换的题目你可以换成走楼梯的题目,原本你只能每次走1级或者2级,只不过现在你可以走coins级。
如果我们使用dp[i]
来进行状态的定义,那么他的意思就是到i元的时候,最少需要多少硬币。换成楼梯就是走到第i阶台阶需要多少步。熟悉么?那我们的状态转移就是取当前台阶前coins的台阶他的步数与我们自身的步数进行比较,取小的即可。
那接下来我们的状态转移方程就是这样:
for j in coins:
dp[i] = min(dp[i - coins[j]] + 1, dp[i])
得出状态转移方程之后,就是编写代码了,只不过还是需要注意边界问题。那下面就是我们的代码了
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for i in range(1, amount + 1):
for j in coins:
if j <= i:
dp[i] = min(dp[i], dp[i - j] + 1)
if dp[-1] > amount:
return-1
return dp[-1]
对于这里的j<i是应为你要确保(i - j)是大于0的,不然就会超范围,会有意想不到的错误。然后初始状态是amount+1,只要你在最后能确定把-1输出啥值都行。
2.兑换零钱II
给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。
链接:https://leetcode-cn.com/problems/coin-change-2/
拿到题目,先看看状态如何定义:
一维数组可以么?首先如果是一维数组,那第i个数就是表示i元钱可以兑换的硬币数。那说明一维数组还是可行的。那我们继续思考,状态转移是如何完成的?状态转移的在我们知道是一维之后就有大概思路了。那就是根据coins的值来,如果coins值为1,那dp[i]
就是自身加上dp[i - 1]
。
所以我们的状态转移方程就是下面这样:
for coin in coins:
dp[i] += dp[i - coin]
这里需要注意一点,就是先进行硬币的循环还是先进行总金额的循环。如果先进行总金额的循环,你就会发现始终是比我们所预想的要高,我们简单的拿amount = 4, coins = [1, 2, 5]为例
dp[0] = 1
dp[1] = dp[0] = 1 ==>(1)
dp[2] = dp[0] + dp[1] = 2 ==> (1+1, 2)
dp[3] = dp[2] + dp[1] = 3 ==>(1+1+1, 1+2, 2+1)
dp[4] = dp[3] + dp[2] = 5 == >(1+1+1+1, 1+2+1,2+1+1,1+1+2,2+2)
你会发现是会有重复项的,我们又无法主动的去消除这些重复项,因此就把硬币先放在外层进行循环,这样每个硬币都只循环一次,这样就可以达到我们的预期。
代码就是下面的样子:
def change(amount, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for j in range(1, amount + 1):
if j >= coin:
dp[j] += dp[j - coin]
return dp[-1]
同时还可以发现,coin在小于j的时候是一直不进入的,那我们完全可以从coin开始循环,所以代码也可以升级为这样:
def change(amountt, coins):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for j in range(coin, amount + 1):
dp[j] += dp[j - coin]
return dp[-1]
3. 最低票价
在一个火车旅行很受欢迎的国度,你提前一年计划了一些火车旅行。在接下来的一年里,你要旅行的日子将以一个名为 days 的数组给出。每一项是一个从 1 到 365 的整数。
火车票有三种不同的销售方式:
一张为期一天的通行证售价为 costs[0] 美元;
一张为期七天的通行证售价为 costs[1] 美元;
一张为期三十天的通行证售价为 costs[2] 美元。
通行证允许数天无限制的旅行。 例如,如果我们在第 2 天获得一张为期 7 天的通行证,那么我们可以连着旅行 7 天:第 2 天、第 3 天、第 4 天、第 5 天、第 6 天、第 7 天和第 8 天。
返回你想要完成在给定的列表 days 中列出的每一天的旅行所需要的最低消费。
链接:https://leetcode-cn.com/problems/minimum-cost-for-tickets
该题与零钱兑换是同个思路,我们取给定时间的最大值当作台阶数,而1,7,30就是我们走的步数。那我们的状态定义就可以是dp[i]
。如果需要注意一点的是,如果今天没去旅行,那就直接获取前一天的消费即可。
当我们做完这些工作之后,就是来写一下状态转移方程:
for i in range(1, days[-1] + 1):
if i in days:
dp[i] = min(dp[i - 1] + costs[0], dp[i - 7] + costs[1], dp[i - 30] + costs[2])
else:
dp[i] = dp[i - 1]
当然,还是需要注意这里的i与1,7,30之间的值的关系,如果相减是负数,那就直接dp变成对应的价格即可。还有就是dp的初始化的时候,值要稍微选的大一些,如果只是选了day[-1],很可能最后的价格是大于天数的,那样就会一直报错,所以你可以选怎天数乘上最大的价格或者直接变成最大值。那下面就是整个代码了。
def mincostTickets(days, costs):
mid = [1, 7, 30]
all_day = days[-1] + 1
dp = [float("inf")] * all_day
dp[0] = 0
for i in range(1, all_day):
if i not in days:
dp[i] = dp[i - 1]
else:
a = dp[i - 1] + costs[0]
if i - 7 > 0:
b = dp[i - 7] + costs[1]
else:
b = costs[1]
if i - 30 > 0:
c = dp[i - 30] + costs[2]
else:
c = costs[2]
dp[i] = min(a, b)
dp[i] = min(c, dp[i])
return dp[-1]
编辑距离系列
https://leetcode-cn.com/problems/edit-distance/
编辑距离是dp的入门题,与斐波那契差不多。题目也比较相似,你只能通过增加、删除或者修改某个字母来达到新的字符串。假设想从horse
到ors
,你只需要删除h和e就可以达到,也就是两步。
该dp可能会有点绕,首先我们还是进行状态的定义。
我们先想,一维可行么?假设可行,那么第i个数表示什么?如果可行,又要符合我们的增加、删除、修改,那么就只能表示从0到第i个元素最小的次数。但是并不能表示增删改呀,所以一维是不行的。那就继续升维。我们把dp变成二维,那么如何定义状态呢?如果我们把行变成word1的长度,把列变成word2的长度。那么第i,j个元素就表示word1之前的i元素到word2的j个元素经过的最小的步骤。那如何定义增加呢?那就是dp[i - 1][j]
,删除就是dp[i][j - 1]
替换就变成了dp[i - 1][j - 1]
。那我们就可以进行转移方程的定义了。
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j],dp[i][j - 1],dp[i - 1][j - 1]) + 1
为什么要提前加个判断?这是因为如果前面的元素都相等,我们就不需要进行任何操作。只要复制前面元素的步骤次数即可。
那么,下面就是整个代码了
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0 for _ in range(n + 1)] for _ 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(m + 1):
for j in range(n + 1):
dp[i][j] = min(dp[i - 1][j - 1] + (0 if word[i - 1] == word[j - 1] else 1), dp[i - 1][j] + 1, dp[i][j - 1] + 1)
return dp[m][n]