学习笔记 - 动态规划做题思路

目录

一 . 什么是动态规划?

二.什么问题能用动态规划?

三.动态规划思路


一 . 什么是动态规划?

上一篇文章已经讲了动态规划是什么,按我个人的理解来说

动态规划就是一种 将一个大问题分解为各个独立的小问题,建立一个可保存数据的结构(通常是数组)来缓存小问题已经得出的结果,并且在后面通过一个归纳的方程(即状态转移方程)复用这个结果,得出大问题的结果 的一种方法。

如果觉得这段话很绕也没事,注意加粗的字体,这些就是动态规划的要义所在

缓存数据、复用数据、状态、转移方程、方法

反正与我对“动态规划”这个名字的第一印象半杆子打不着。

举个例子,很好的解释了什么是动态规划:

Q:(1+1+1+1+1+1+1+1+1+1+1) = ?

A:(你一个一个数了,很慢的回答)等于11

Q:(1+1+1+1+1+1+1+1+1+1+1)+1=?

A:(你只看到右边多了个+1,快速回答了)等于12

为什么第二次你不需要一个一个数到底有多少个“1”在等式左边呢?因为第一次问你的时候,你数了,知道等于11。第二次在左边加了个1,对于你来讲,就是问你:11+1=? 于是你几乎不需要停留,脱口而出:“12”

(恭喜你,达到了小学一年级的水平)

回到正题,这个例子就是在阐述缓存数据、复用数据、转移方程(在这个例子里就是呈现的算术)这些动态规划的核心。

二.什么问题能用动态规划?

在这里,先不仔细解释满足最优子结构无后效性这些术语,因为第一次学我也对这些术语很懵。

浅显的说,一个问题如果大问题(原问题)能够分解为一个个小问题,小问题可以拆分为更小的问题,同时,大问题的最优解能够通过小问题的最优解递推得到、小问题的答案可以由更小的问题的最优解递推得到(这个大与小指规模的大小),这就是满足“最优子结构”的问题。

在这里大问题的结果,我们只看小问题的答案,而不用考虑小问题的答案是如何得到的,这就是满足“无后效性”的问题。即

“现在就是过去的总结,现在决定未来,未来与过去无关。”

那这个问题就可以通过动态规划解决。

“什么问题能用动态规划?”这个问题实际上有些泛化。

可以这样问:“什么问题适合用动态规划?”那就是

小问题的答案长期被复用。

长期被复用,这才是关键。

如果不被长期复用,那么除去“缓存、复用”的步骤,这个“定义”依然是可以适用于其他情况的。

实际上大问题都是可以转化为小问题的,那为什么我们一定要用动态规划呢?我们用递归从上到下暴力求解不也可以吗?还减少了“找状态转移方程”这个麻烦的过程,我们直接把数据交给计算机,叫计算机一个一个遍历然后从中筛选取得我们想要的值就行了,要知道计算机最擅长的就是计算,计算机最不怕的就是大量的数据。

一切的一切,在于长期被复用。

多次被复用,那么用一个结构储存当前的结果,以便于后续使用,这才是聪明人的做法。

再举一次Fibonacci数列的例子,更好的理解长期被复用带来的影响

F(n)=F(n-1)+F(n-2)

你要求F(100),自然要知道F(99),F(98)。

你要求F(99),自然要知道F(98),F(97).

你要求F(98),自然要知道F(97),F(96).

...

递归方法,你要从F(99)求到F(1),然后再从F(98)求到F(1),你才能得到F(100)

动态规划,我们第一次求到F(99)的时候直接保存值(很明显,每一个值在Fibonacci数列里面都会调用多次),后期用到直接拿来用,省下的时间是指数级别,不是一点半点。

三.动态规划思路

1.明确题目里的状态

2.明确DP数组的定义

3.做出正确的状态转移方程。

Fibonacci数列问题里面,我们要求的是指定数字的Fibonacci值

状态自然就是数字n

我们把F(n)定义为:数字为n时的Fibonacci值

那么根据我们的状态定义,该题正确的状态转移方程是 F(n)=F(n-1)+F(n-2)

每一次求出F(n),我们就把这个值保存起来,实现了缓存数据并复用。

以上任何一个环节定义错误,都会影响到下一环节的进行,这就要求我们明确状态、明确DP数组的定义,不然转移方程有可能会不再适用(脱离了“最优子结构”或脱离了“无后效性”),也就得出了错误的最终结果。

这种情况下,我们要重新考虑状态、重新考虑DP数组的定义。确保DP状态的定义满足:

1.最优子结构

2.无后效性

通过几个题目,我的一般处理思路是

1.思考状态是否找对、找全?下一结果不能由当前结果递推得到(也就是说,当前的DP状态定义不满足“最优子结构”),有可能是状态不够,也许这个DP数组可以升为二维、三维甚至更多维来解决(参考 Leetcode.买卖股票问题)、或者可能要用到两个、多个状态定义来做(参考 Leetcode.乘积最大子数组)用到来储存更多状态

状态越明确,范围越明确,从而可以从更多的状态(更为明确的范围)里面做出正确的选择,找出正确的状态转移方程。

2.思考DP数组的定义是否正确?DP[i]储存的东西是什么?变换一下思路,定义正确的DP数组,充分仔细考虑问题条件与所求,确保储存的东西一经确定,不会受到未来(过去)的影响。(参考最长子数组和问题)

为什么一定要保证状态的定义满足最优子结构、无后效性?

上面已经写了,DP数组定义不适当的话,不仅找不到合适的状态转移方程,而且在做题过程中可能会混乱状态,不知所然。

最长子数组的问题来举例

给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

子数组 是数组中的一个连续部分。

示例 1:

输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。

如果你做了一些其他求最值的动态规划的题目,不按步骤一一考虑,按所谓“经验”,胡乱的可能你(我)直接就这样定义F[i]了:

前i个数字里面的最大子数组和,结果输出F[len(nums)-1]就行了。

看起来似乎没毛病,但当你按这个定义时,状态转移方程怎么找?

举个例子,nums = [4,-1,5] 这个数组,当你一开始选了4的时候,max更新为4,由于下一个是-1,4-1=3,比之前的小了,于是你放弃了选择-1,放弃了继续连续,于是你重新开始,选了[3],max = 5。这就是鼠目寸光,如果你继续选,max可以达到4-1+5=8。

于是,我们应该这样定义F(n):以n为右端点的子数组的最大和。

于是以每一个数字为右端点的子数组和就都有了,然后我们再考虑后面的结果加不加这个值。

状态转移方程为:

F(n) =max(nums[i],nums[i]+F(n-1)) | i\leqslant n

关键就在于,问题问的是数组和。要么连续,要么重新开始、割舍一切。所以,最大值不一定是F(end),我们应该从F(1..end)里面寻找最大值,即为我们要求的最大子数组和。

代码如下 (Python):


nums = [0]+[-2,1,-3,4,-1,2,1,-5,4]
n = len(nums)
dp=[0]*n
for i in range(1,n):
    dp[i] = max(nums[i],nums[i]+dp[i-1])
res = 0
for i in range(n):
    res = max(res,dp[i]);

print(res)

再举一个乘积最大子数组的例子,和上面最大子数组和有些类似。这道题讲了在发现我们的dp状态不满足最优子结构的情况下如何思考优化,实际上就是上面我总结的我个人的思路。

给你一个整数数组 nums ,请你找出数组中乘积最大的非空连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

测试用例的答案是一个 32-位 整数。

子数组 是数组的连续子序列。

示例 1:

输入: nums = [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
示例 2:

输入: nums = [-2,0,-1]
输出: 0
解释: 结果不能为 2, 因为 [-2,-1] 不是子数组。

此时,把F(n)定义为以n为右端点的子数组最大乘积。

这里情况有所不同了,这里要求的是子数组的最大乘积,不是和。你可能会想,改一下转移方程就行了,把加改为乘。

F(n) =max(nums[i],nums[i]*F(n-1)) | i\leqslant n

求最大和,数字相加看看哪个更大就行,但求乘积,就是单纯的两数相乘看结果吗?如果有三个元素,前两个元素的乘积是负数,第三个数也是负数,再继续乘,负负得正,这样的情况下反而前面两个元素的值越低,三个一起的乘积就越大。比如nums = [3,-2,-3] ,按照上面的转移方程,得到的F(3) = 6,但实际上应该得到的F(3)=18

F(3 )\neq nums[3]] \neq F(2)*nums[3]

即F的最优解不能由更小的规模的F解得出,即这里我们的DP状态F(n)不能满足最优子结构 。

于是我们不难发现,这里应该要再引入一个新的DP状态处理存在负数乘积的情况。我们令minn(n)表示以n为右端点的数字最小乘积,maxn(n)表示以n为右端点的数字最大乘积

分类讨论:

nums[n]\leqslant 0:

        maxn(n) = max(nums[n],minn(n-1)*nums[n])

        minn(n) = min(nums[n],maxn(n-1)*nums[n])

nums[n]>0:

        maxn(n) = max(nums[n],maxn(n-1)*nums[n])

        minn(n) = min(nums[n],minn(n-1)*nums[n])

当前位置如果是一个负数的话,它的最大值来源于前一个位置的最小数*当前数,前一个位置的最小数是负数的话自然负负得正,越负越大。前一个位置的最小数是正数的话也不矛盾,我们希望正数尽可能小,以使得当前位置的最大数取得最小。正数同理。(这里可能有点绕,可以自己写一写理解一下)

此时,就能得出以n-1为右端点的最大值与最小值,然后根据nums[n]是正数还是负数来分类进行状态转移。从而得出(1...end)的所有maxn值和minn值,最后我们输出maxn(1...end)里面最大的值即为最大数组乘积。

代码如下:

class Solution:
    def maxProduct(self, nums: List[int]) -> int:
        n = len(nums)
        maxn = [0]*n
        minn = [0]*n
        maxn[0],minn[0] = [nums[0],nums[0]]
        for i in range(1,n):
            if nums[i]>0:
                maxn[i] = max(nums[i],maxn[i-1]*nums[i])
                minn[i] = min(nums[i],minn[i-1]*nums[i])
            else:
                maxn[i] = max(nums[i],minn[i-1]*nums[i])
                minn[i] = min(nums[i],maxn[i-1]*nums[i])

        return max(maxn)

接下来通过和 买卖股票的最佳时机 III122. 买卖股票的最佳时机 II来讲述什么是满足无后效性,以及怎么处理让其满足无后效性。

1.买卖股票最佳时机II:

给定一个数组 prices ,其中 prices[i] 表示股票第 i 天的价格。

在每一天,你可能会决定购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。你也可以购买它,然后在 同一天 出售。
返回 你能获得的 最大 利润 。

示例 1:

输入: prices = [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。
示例 2:

输入: prices = [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
示例 3:

输入: prices = [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。

2.买卖股票最佳时机III :

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

示例 1:

输入:prices = [3,3,5,0,0,3,1,4]
输出:6
解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
     随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。
示例 2:

输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。   
     注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。   
     因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。

这两道题的区别在于一个能进行不限制次数的交易,一个只能进行两次交易。

我们先来看一下第一题,能进行不限次数的交易。

由于该题卖出的前提是要持有,容易知道这道题的状态就是天数n、是否持有股票(0 or 1)。

我们将dp[天数][是否持有股票] 

(dp[n][0 or 1])

定义为 到第n天,卖出/持有股票的最大利润,我们规定0为不持有股票,1为持有股票,那么我们要求的最大利润自然就是

dp[end][0]

很容易理解,最后一天的时候,不持有股票一定要比持有股票的利润要多。(最后一天了,手中还搁置着股票不卖,这也太傻了)

状态转移方程如下:

dp[i][0] = max(dp[i-1][0],dp[i-1][1]+prices[i])

dp[i][1] = max(dp[i-1][1],-prices[i])

很容易理解,当你第n天持有股票时,有两种情况:

1.第n-1天你就已经持有股票,继承n-1的情况。

2.第n-1天你没有持有股票,你要花费第n天股票的价值将其股票收购。

当你第n天没有持有股票时,也是有两种情况:

1.第n-1天你就没有持有股票,继承n-1的情况

2.第n-1天时你持有股票,第n天你把股票出售了。

在平常做题的过程中,只要问题满足最优子结构,我们就可以按照这个思路来思考状态转移方程:

题目要求F(n)的情况,那么我们默认已经知道了F(0)~F(n-1)的值,现在就只需要去思考:怎么用F(0)~F(n-1)的元素递推出F(n)的值,然后将这个过程转化为数字语言表示出来。

完整代码如下:

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        m = len(prices)
        dp = [[-float('inf')]*2]*m
        dp[0][0] = 0
        dp[0][1] = -prices[0]
        for i in range(1,m):
            dp[i][0] = max(dp[i-1][0],dp[i-1][1]+prices[i])
            dp[i][1] = max(dp[i-1][1],dp[i-1][0]-prices[i])

        return dp[m-1][0]

我们来讲讲第二题,它给了一个限定条件K:你只能出售2次。

我们还是按第一题的定义DP状态吗?很明显不行了,因为我们有一个更多的限制条件K,必须按照这个规矩行事。

现在你应该有些理解无后效性了吧?

大问题的结果,我们只看小问题的答案,而不用考虑小问题的答案是如何得到的,这就是满足“无后效性”的问题

“现在就是过去的总结,现在决定未来,未来与过去无关。”

如果我们还是按照第一题的定义来定义第二题的DP状态,很明显就不能满足“无后效性”,因为下一个阶段的结果不仅仅取决于上一天持有与否的最大利润,还取决于上一条是如何持有的,即K还剩几次。

如果K已经满足了2,下一个阶段K就将等于3,这时就不能按照第一题的转移方程递推得到下一阶段的答案。

这时我们就要多设一个状态K,将DP数组转为三维数组来考虑,这样K就会跟着状态改变,从而进一步缩小了问题的规模,可以从更多的状态(更为明确的范围)里面做出正确的选择,找出正确的状态转移方程。

我们将dp状态定义为

(dp[n][0 or 1][k])

dp[天数][是否持有股票][最大交易次数](注意:此处k为最大交易次数,而不是已交易次数,因为这样定义才可以满足我们的状态转移方程,因为不保证取得最大利润时已经交易了K次)

状态转移方程如下:

dp[i][0][k] = max(dp[i-1][0][k],dp[i-1][1][k]+prices[i])

dp[i][1][k] = max(dp[i-1][1][k],dp[i-1][0][k-1]-prices[i])

(待更)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值