动态规划理解

DP学习记录

想法

动态规划实现方法:带备忘录的自顶向下法(带备忘录的递归)、自底向上法
(看成一个在填表且从表中取值的过程)


步骤:

  1. 刻画一个最优解的结构特征
  2. 递归地定义最优解的值
  3. 计算最优解的值,通常采用自底向上的方法
  4. 利用计算出的信息构造一个最优解

原来的问题可以拆成很多不同的子问题组合,每一种都可以达到最终情况,都是可能情况。dp和递归其实都是在算更大规模的问题的时候,遍历当前规模问题的各种可能组成情况,用各种规模子问题的最优解来进行计算,找到当前规模问题的最优解。

要理解其实各种可能情况都有算到,也就是各种规模的情况都是有算到的,只是各种规模下都只取了最优解,由最优解来构成最终的结果。


递归还有一种是所有可能情况从头到尾走了,然后等所有的都做完了才能比较出最优解是什么。而这种情况下的dp也只是记录了每种情况的最优解,非最优的就不管了。(看一下有没有这种问题的例子,就是感觉用递归特别耗时,要到最后都走完了才能判断的那种)


判断最优子结构的时候,就假设最优解里的子问题不是最优解,那如果把子问题换成最优解,会是什么情况。

问题规模/子问题计算和状态转移关系(任何子问题的求解只依赖于更小的子问题的求解)类比递归(如果dp想不出来怎么设置的话,可以试一下用递归会怎么写,也就是想一下用递归怎样处理各种情况,怎样递归调用。递归函数就是一个封装好的求解该问题的函数,就可以单独拎出来看一个问题是怎样的,只是进入函数时的问题规模是不一样的。)
明确问题中的规模指的是什么,然后由规模来分析子问题是什么,即要找形式完全一样,但规模更小的问题,可以看成独立问题的那种。就是单独拎出来,然后把规模的部分用参数来写的话,看起来就是跟原问题是一样的。要由子问题刻画最优解的结构特征。明确每次计算会依赖哪些值,要保证在算之前依赖的值都已经算好了。明确原问题的最优解中涉及多少个子问题,在确定最优解使用哪些子问题的时候,需要考察多少种可能的子问题情况,即多少种选择。函数的函数参数中就有当前问题的规模。函数刚进入时判断的边界情况就是要在dp数组里设置的边界情况。之后遍历各种可能情况,从各种可能情况进行递归,再由返回值选出最优的情况,进行当前问题的计算,然后返回,就对应了dp中在计算状态转移时用到的各种子问题和状态转移方程,计算都是一样的。只是递归是由子问题递归回来的结果计算当前问题的值,其实也是在算最优的,但是每个子问题的情况其实都遍历过了,dp里是原先就把子问题的结果算好的,就直接用来算当前值最优的,每个子问题的最优情况其实也都算过了。
钢条切割规模就是长度,因为值只与长度有关,与起始位置无关,无论在哪个位置,那么长的钢条都是一样的。dp数组就是一个一维数组,每个下标对应不同长度的钢条,子问题也就是长度更短的钢条。每个子问题单拎出来就是长度为i的钢条的最大切割收益。在递归函数里传入的规模的参数也就是在记当前钢条的长度情况,然后在递归函数中会遍历所有可能的切割,每次切割后都会递归进入子问题,传入的子问题的规模就是这次切后的长度,而递归返回会返回从该种切割切下去的各种情况的值里最优的那一个,然后就会在该层递归中再进行计算。
矩阵链乘法分析一下当前某个链切分后的子问题是什么样的,就会发现子问题在两端都是变化的,所以dp得是二维的,也就是从 i 到 j 的子矩阵链。这里不同位置上的就不一样了,有分起始和末尾,所以得算二维的,一个下标代表起始,一个下标代表末尾。每个子问题单拎出来就是从i到j的矩阵链的最小计算次数。这里算一个子矩阵链的时候,遍历的所有情况就是所有可能的划分点,找出其中最优的。要用到的子问题就是长度更短的子矩阵链,就比如在k处切,用到的子问题就是从i到k的和从k+1到j的。在递归函数里传入规模的参数就是在这个链中现在算到从i到j了,然后在函数里就是要遍历从i到j的各种划分情况,再递归进入划分后的各种子链下计算,把计算值返回。然后就是在子链的各种返回值中选出最优的来算当前的最优值来返回。
最长公共子序列因为是两个序列在比较到某个长度时的匹配关系,所以子问题就是两个序列的前缀对,dp数组就得是二维的,两个下标分别对应两个序列。每个子问题单拎出来就是一个长度为i的序列和一个长度为j的序列的对应最长公共子序列。序列的对应关系就跟前面那张要划分的不一样,一般就只与两个下标的前一位在两个序列中得到的前序有关,就相当于通过限制条件限制了需要求解哪些子问题,只与某几个子问题有关(也就是首先要明确是怎样由子问题组成的,有哪些情况,由子问题到当前问题,之间值的关系是什么。)是一个根据条件排除子问题的动态规划算法,这种序列间的比较和匹配是有限定的,不是所有子问题都需要用到,就不要类别递归了
最优二叉搜索树有树的,子问题一般就是左右子树。每个子问题单拎出来就是一个从i到j的序列要组成一棵树的问题。处理就是在子树里找根结点,再把左右两边分成左右子树,就相当于选定根结点来划分。这样的问题就可以类比递归算法了,递归的每一层就是一个从i到j的序列,要从中间选出一个最优的作为根结点来划分,得到一棵子树后返回。

把 0 - 1 背包问题用递归来改和分析:

从递归的算法来看,函数的参数就是当前要判断的物品的下标 i 还有剩余容量 c,这时候相当于是从 0 到 i - 1 都判断完了,当前函数里是要判断 i 要不要选。

无论是否选,都要调用自身函数递归进入下一个物品的判断,只是如果有选的话剩余容量要减掉当前物品的容量。即如果选的话,进入递归时的函数参数是 i + 1 和 c - w[i];不选的话,进入递归时的函数参数是 i + 1 和 c 。这时候进入递归的问题就相当于是规模更小的子问题,然后递归函数会返回这个子问题的解的值,然后就可以用返回的这个值来算当前问题解的值。然后等往下的一串递归到底的时候就回溯,回溯后进入另一种选择的递归来判断。

所以改成动态规划的话,函数的参数:当前要判断的物品下标 i 和剩余容量 c 就相当于是状态,对应问题规模,对应设定的 dp 数组上两个维度的含义。而递归函数中调用自身时的问题就对应子问题,也就是在动态规划里要用到的子问题,也就是对应 i + 1 和是否选择下的 c,也就是对应这两种子问题的选择。递归里是调用自身函数往下求,在动态规划里这两个子问题就应该是已经算好了的,也就是在动态规划里要先从 i 更大的开始算。而递归里用子问题返回的子问题的解的值来算当前问题的解的值,也就对应了动态规划里的转移方程。

所以就是先按递归来想,然后再对应转换成动态规划,想一下是要怎样转,要记录的值是什么,要从哪里开始算。


整理

具体来说,动态规划的一般流程就是三步:暴力的递归解法 -> 带备忘录的递归解法 -> 迭代的动态规划解法

就思考流程来说,就分为一下几步:找到状态和选择 -> 明确 dp 数组/函数的定义 -> 寻找状态之间的关系


明确是有哪些状态,其实也就相当于对子问题的解释,理解成子问题的一种规模?

这种题目要怎么用规模来看?之前的题目要怎样用状态来看?


应该说规模和状态算是分开的。规模用来分子问题,状态就是每个问题下的状态,用来计算该规模下的最优值用的,不同的状态对应不同的计算和值,要从几个状态中找到值最优的那个状态。计算当前值的时候,其实也是要分从子问题的哪个状态转的。

像下面股票的问题,规模其实就是哪一天,子问题就是到了其中的某一天。主要这题状态比较多,所以要开三维数组,每一维度表示不同的状态,第一维表示的就是子问题规模,数组中记录的就还是计算值。

前面的问题的就没有什么状态,感觉处理都是一样的,就不需要分不同的状态下有什么不同的计算、不同的值。

所以就是规模是一定有的,状态是不一定要分的。

有些比较麻烦的,感觉有很多种可能情况的,这些情况多半就对应着不同的状态,需要把数组多开几维来分出来。

从股票问题入手的解释

多重循环穷举所有状态 -> 在对应状态下进行选择

第一步:穷举框架

不用递归思想进行穷举,而是利用「状态」进行穷举。我们具体到每一天,看看总共有几种可能的「状态」,再找出每个「状态」对应的「选择」。我们要穷举所有「状态」,穷举的目的是根据对应的「选择」更新状态

for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 择优(选择1,选择2...)

比如说这个问题,每天都有三种「选择」:买入、卖出、无操作,我们用 buy, sell, rest 表示这三种选择。但问题是,并不是每天都可以任意选择这三种选择的,因为 sell 必须在 buy 之后,buy 必须在 sell 之后。那么 rest 操作还应该分两种状态,一种是 buy 之后的 rest(持有了股票),一种是 sell 之后的 rest(没有持有股票)。而且别忘了,我们还有交易次数 k 的限制,就是说你 buy 还只能在 k > 0 的前提下操作。


要分清楚哪些是选择,哪些是状态。


现在的目的只是穷举,你有再多的状态,要做的就是一把全部列举出来。这个问题的「状态」有三个,第一个是天数(到了第几天了),第二个是允许交易的最大次数,第三个是当前的持有状态(即之前说的 rest 的状态,我们不妨用 1 表示持有,0 表示没有持有)。然后我们用一个三维数组就可以装下这几种状态的全部组合:

dp[i][k][0 or 1]
0 <= i <= n-1, 1 <= k <= K
n 为天数,大 K 为最多交易数
此问题共 n × K × 2 种状态,全部穷举就能搞定。

for 0 <= i < n:
    for 1 <= k <= K:
        for s in {0, 1}:
            dp[i][k][s] = max(buy, sell, rest)

而且我们可以用自然语言描述出每一个状态的含义,比如说 dp[3][2][1] 的含义就是:今天是第三天,我现在手上持有着股票,至今最多进行 2 次交易。再比如 dp[2][3][0] 的含义:今天是第二天,我现在手上没有持有股票,至今最多进行 3 次交易。想求的最终答案是 dp[n - 1] [K] [0],即最后一天,最多允许 K 次交易,最多获得多少利润。

记住如何解释「状态」,一旦你觉得哪里不好理解,把它翻译成自然语言就容易理解了。


第二步:状态转移框架

我们完成了「状态」的穷举,我们开始思考每种「状态」有哪些「选择」,应该如何更新「状态」

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
              max(   选择 rest  ,             选择 sell      )

解释:今天我没有持有股票,有两种可能:
要么是我昨天就没有持有,然后今天选择 rest,所以我今天还是没有持有;
要么是我昨天持有股票,但是今天我 sell 了,所以我今天没有持有股票了。

dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])
              max(   选择 rest  ,           选择 buy         )

解释:今天我持有着股票,有两种可能:
要么我昨天就持有着股票,然后今天选择 rest,所以我今天还持有着股票;
要么我昨天本没有持有,但今天我选择 buy,所以今天我就持有股票了。
    注意 k 的限制,我们在选择 buy 的时候,把 k 减小了 1

定义 base case,即最简单的情况,也就是起始时的情况

要注意对各个状态都要判断起始的时候、边界的时候是什么情况。

dp[-1][k][0] = 0
解释:因为 i 是从 0 开始的,所以 i = -1 意味着还没有开始,这时候的利润当然是 0 。
dp[-1][k][1] = -infinity
解释:还没开始的时候,是不可能持有股票的,用负无穷表示这种不可能。
dp[i][0][0] = 0
解释:因为 k 是从 1 开始的,所以 k = 0 意味着根本不允许交易,这时候利润当然是 0 。
dp[i][0][1] = -infinity
解释:不允许交易的情况下,是不可能持有股票的,用负无穷表示这种不可能。

整个总结一下就是:

base case:
dp[-1][k][0] = dp[i][0][0] = 0
dp[-1][k][1] = dp[i][0][1] = -infinity

状态转移方程:
dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

在面对具体的题目的时候,就根据各题的要求和条件限制,改变几个状态和选择。

​关键就在于列举出所有可能的「状态」,然后想想怎么穷举更新这些「状态」。一般用一个多维 dp 数组储存这些状态从 base case 开始向后推进,推进到最后的状态,就是我们想要的答案。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值