动态规划基础机械解题套路 I: 子集和连续序列

本文讲解的力扣习题(大体分为两类问题):

1137. 第 N 个泰波那契数 斐波那契 198. 打家劫舍 70. 爬楼梯 746. 使用最小花费爬楼梯 740. 删除并获得点数  121. 买卖股票的最佳时机 53. 最大子序和  152. 乘积最大子数组 1567. 乘积为正数的最长子数组长度 122. 买卖股票的最佳时机 II 123. 买卖股票的最佳时机 III 188. 买卖股票的最佳时机 IV

主要内容

重新看的时候发现这个文章跳步了很多内容,没有在连续做题的上下文上可能很难看懂讲了什么问题,这里把后面的总结提前到这里方便自己复习。主要是分析上面这一堆一维 DP 问题里的异同点,将他们归为两大类,从而理解什么情况下采用哪种思路。复习的时候可能要把上面的题都复习一遍才能有这个上下文的感觉。然后解释了股票问题的疑问为什么有的用 have 和 sell 有的用 buy sell 来理解那个状态变量,因为他们本质是两种问题。

这里讲解了一维 DP 的两种基本分类,一种是求子序列问题(子集),一种是求连续子序列问题。他们的二维形式分别是 LCS 最长公共子序列 问题和 LIS 最长上升连续子序列问题。

其中,求子集的问题一般状态变量是持续到当前时刻的某个决策权值(最大值,最小值,和,长度等),当前的状态是否在最终最优化选取的集合中并不知道。求连续子序列的问题一般状态变量是表示当前的状态必须在连续子序列中,因此为了求决策值必须另外使用一个累计变量(max,min 等)。

对于多种状态的一维 DP,同样也有 LCS 模型和  LIS 模型之分,对于多种状态的关键性区别是连续子序列问题的额外状态变量常常没有交叉关系,或者多个跟随变量间有交叉关系但是 DP 的状态变量不会依赖于跟随变量,处于跟随变量单向依赖于 DP 状态变量的关系,但是多个跟随变量间可能会会有交叉依赖关系,而子集问题的多个状态肯定是相互交叉的。
 

基于 MIT CLRS Spring 2021 讲 DP 前两节视频的分类,总结最基本的几个经典题型(后续的视频之后再说)。机械思维的好处是不用思想实验,输出稳定可靠,理想的思路是用自然思维把不够简化的模型转化为简化的模型,然后应用机械思维解决他。

这一篇只是简单的机械DP思路,下一篇要具体理解什么情况能进行动态规划(无后效性),以及各种非一维的 DP,LCS,LIS,回文,MED,背包,区间(后面就太难了,我打算就截至这两个。。。)。

动态规划一般思路

第一个例子是保龄球(没有看视频上下文的时候可以把保龄球认为是打家劫舍问题,谁是和斐波那契同构的)最经典的问题就是分治的边缘扩充,可以通过 k 个状态进行 local brute force 来转移。

基本思路是 SRTBOT,实际写题只需要 SRT 就行了。

机械化寻找子问题,一般 prefixessuffixex 或者 substrings下面的例子中,这三个肯定有一个能行。

相关子问题即由子问题得到新的子问题 while 整个问题是最大的一个子问题。

这种问题,等价为 k阶 fib(如泰波就算3阶斐波 while 斐波本身是 2阶的) ,空间优化套路是迭代次数为跳过多少个开始,一直到等于 n(或者从 k-1 到开区间 n)。原因很简单就是因为 base case 不进行迭代。

子集问题空间优化模板

比如保龄球和下列一阶 DP 问题,全部与斐波那契同构。

1137. 第 N 个泰波那契数

和斐波那契:

198. 打家劫舍

他们的共同点是,某个节点的结果是由前面 k 个状态经过某种运算(前两道是求和,保龄球打家劫舍是一些 max ,保龄球其实就是打家劫舍)。

子集问题总结(只是决策的函数不一样)

同样的模板解决下面问题,只需要判断出他们是某种累计的状态就能等效为保龄球/斐波那契问题,而累计的状态不需要管是求累计的值(和,多少种方法)还是某种决策(最大值,最小值),其中最大值和最小值这个东西和viterbi算法的思路一样,不过一个是 DAG 的路径,一个是一条线。

70. 爬楼梯

746. 使用最小花费爬楼梯

这些问题的基本特征是,当前位置取或者不取(或者肯定取,即累积),可以从相邻第1个到相邻第k个状态转移,而且这些转移都是局部贪心,即当前状态最优解肯定是贪心得到的(取或不取选最优一个,累计和全都要)。

将问题转化为子集问题

然后下面这道题被称作打家劫舍3,他有个隐含点,现在分析他学习怎么把问题中是上诉模板的点找出来。

740. 删除并获得点数

给你一个整数数组 nums ,你可以对它进行一些操作。

每次操作中,选择任意一个 nums[i] ,删除它并获得 nums[i] 的点数。之后,你必须删除 所有 等于 nums[i] - 1 nums[i] + 1 的元素。

开始你拥有 0 个点数。返回你能通过这些操作获得的最大点数。

首先最看到删除动作本身可以实现为局部贪心,利用 nums[i] -1 nums[i] + 1 应该提取出近邻关键字,如果要出现这个信息,必须让问题变成以 nums[i] 排序,所以直接开一个大数组即可。

于是问题转换成打家劫舍,对于近邻要么选要么不选,反正就 local brute force 了。

连续子序列问题

第二种我们以股票7部曲为例讲解。

首先是 121. 买卖股票的最佳时机

这个问题很容易理解,只需要最低点买进,最高点卖出就行了,但是要添加一个限制条件(即上面DP套路三部曲的拓扑序)就是卖出必须在买进之后。这种情况基本做法是使用两个状态变量,二者一起迭代。也就是说除了维护状态,还要维护一个历史利润最大值(等于另一个状态,下文我会把它叫做跟随状态)。

这里给定这种问题模板的关键字,前面的那种问题 local bf 做的是继承自不同的历史状态然后做某种运算。这里是当前状态要么继承之前的状态,要么全部不要了,生成新的状态

保龄球问题本质是当前位置要不要纳入结果的问题,斐波那契为其特殊情况,即累积的情况,而这种变种问题本质是继承与重新开始的问题。一言以蔽之,就是子序列问题和连续子序列问题

很容易理解为什么需要第二个变量维护最大值,因为历史的状态可能不会累积下来。总之,如果需要全部抛弃历史状态的话,很有可能都需要第二个变量(下文会被叫跟随变量,因为他本身不影响 dp 状态)。

连续子序列问题模板

根据这个套路,直接看下列同构问题:

53. 最大子序和 (连续子数组),马上从题目中就知道是连续的问题,就需要两个变量模板(其中一个是跟随变量)解决。

152. 乘积最大子数组,这个有点复杂,原因是正数和负数做的决策不一样,所以要维护两个状态外加一个维护最大值的跟随变量。(这种问题是多状态问题)

多状态问题

上面这道题是前面说的连续子序列问题加上多状态的问题,下面讲解多个状态的一维 DP,多个状态的 DP,这种问题本质上是有多个相互依赖的状态,而决策本身是对这两个变量交叉取值的,比如当前 x1 变量和 x2 上个状态有关,x2 状态和 x1 上个状态有关。

注意这个和上面买卖股票时机中两个状态的区别,他们是没有交叉的(下图为股票1题的代码):

这样的 profit 只不过是个记录全局最优解的累积变量。只有 have 是状态变量。

为了深入理解这个内容,先不做第二道股票题,而是看这道明显交叉的题目:

1567. 乘积为正数的最长子数组长度

这种题目必须分类讨论,很快就得知,一个变量无法进行决策,因为负负得正,所以很简单的思路是维护两个状态,一个是负数连续子数组,一个是正数的。这样一来马上就能完成编写:

当然还要维护一个最大值变量。(其实这道题也是连续子序列问题加上多状态的问题,他的打断子序列导致重新开始(完全不继承)的条件是数组当前位置为 0)。

交叉依赖的多状态 DP

熟悉了这个再看122. 买卖股票的最佳时机 II:多次买卖股票必然是交叉的状态变量,买必须从卖转移过来,卖必须从买转移过来。这个只需要问题里出现的某种限制就能找到关键点,在这里是买股票必须不持有股票,卖股票必须持有股票。区分股票 I 的点在于,股票 I 只要买了股票,就必须在后续某个最高点卖出以谋取最大利润,而这里多次买卖不存在这个限制,所以这里的卖出的状态变量必然是规划的一个部分,而不是跟随

插播后面讲 LIS (最长上升子序列)问题中归纳出来的一个状态变种,前面说的状态变量定义基本有两类,一类是到目前为止累积的(结果中不一定包含当前位置),以及到目前为止一定包含当前位置的。很容易可以分析出来,前者不需要一个累计变量,因为他本身就是累计的,而后者一般都要一个累计变量计算全程的 max min。我们直接他们联系起来,一般如果涉及选择问题的,就首先尝试累计的状态变量,定义为到目前位置 xxx 的,如果涉及范围的,比如连续子序列问题,就首先尝试包含当前位置的!

对于多个变量的题目,需要区分他到底是规划的变量还是跟随的变量,跟随的变量就是全局累计变量(即 max min 等)!

梳理两种问题的特点

梳理一下上面的题目就清楚了:打家劫舍,保龄球,爬楼梯,斐波那契全部属于选择的子序列(子集)问题,炒股第一题,最大子序和,最大乘积数组 都是连续子序列问题,需要全程累计的一个min/max 变量(跟随状态变量)。

炒股第二题买卖多次股票,是多状态交叉的子集问题!本质上等于两个子集规划,一个选择一堆买的日子,一个选择一堆卖的日子,只不过他们有交叉关系。一定要和炒股第一题区分,炒股第一题是选取一个区间,在区间内持有股票!只要弄懂这一部分,之后讲到二维 DP 的时候,理解完 LCS LIS 问题,就会更加恍然大悟,因为目前做的所有 DP 练习都能归入 LCS LIS 两种问题里。(但是后面其实还有)

两次购买股票题(连续序列)

下面这个题目为例,再次区分上一段说的内容

123. 买卖股票的最佳时机 III

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

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

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

我们必须区分好子序列(子集)问题和连续子序列问题!这个必须是连续子序列问题!听懂掌声!

连续子序列问题(选一个区间持有股票!) -》状态变量必须包括当天的情况!需要一些全程累积变量。

但是实际上,这个问题和股票 1 是一致的,因为如果第一天买股票的日子决定了,那么最大利润的第一笔卖以及第二笔买卖其实都是确定的了!这一点如果没理解,就无法理解怎么写出来转移方程的了!

根据这个理解,就能明白,这里是dp状态是没有交叉的,只有跟随!本题目不是直观理解的,必须给出完整代码了。

(下面为股票3代码,命名不好,buy 不好理解,应该用 have 才能区分 LCS LIS 的区别!!)

class Solution {

public:

    int maxProfit(vector<int>& prices) {

      // 用净利润解释,变量为统计的最大利润值。

      // 第一天如果买了第一笔,说明花掉股票钱。

      // 第一天如果卖了,说明第一天买了马上卖,利润为0。

      // 第一天如果买入第二笔交易,说明第一天肯定买卖了第一笔,所以第一笔不赚钱,再花股票钱。

      // 第一天如果卖了第二笔,说明两买两卖,不赚钱。

      int buy1 = -prices[0], sell1 = 0, buy2 = -prices[0], sell2 = 0;

      for(int i = 1;i<prices.size();i++){

        // 统计目前为止如果今天买第一笔最多赚多少,要么是之前就买了第一笔,要么是今天买第一笔,肯定花钱,选花最少钱的。

        buy1 = max(buy1, -prices[i]);

        // 统计到目前如果今天卖第一笔最多赚多少,要么是之前就卖了赚的,要么是之前(包括今天)买的第一笔里花最少钱买入,今天卖出。

        sell1 = max(sell1, buy1 + prices[i]);

        // 要么是之前就买了第二笔,要么是今天买第二笔,今天买第二笔赚的钱要累计第一笔赚的钱。

        buy2 = max(buy2, sell1 - prices[i]);

        // 要么是之前卖的时候最赚,要么是今天卖第二笔最赚。

        sell2 = max(sell2, buy2 + prices[i]);

      }

      // 最后最赚是累计的。

      return sell2;

    }

};

 

我们必须要明白,这里的 sell1,buy2,sell2 都是跟随变量,只不过他们具备一个状态标识而已。之前说的那种跟随变量记录的是全局的最大值,这里的跟随变量记录的是最后一天是某个情况下的最大值。然而根据我们的理论,是可以改写成直接让跟随变量记录的是整个过程的最大值的,但是本身求这个最大值(代码中的 sell 2)并不简单,甚至复杂度会很高,所以这里实际上是用动态规划来求解这个跟随变量了(所以其实股票1 的 profit 也是这样理解的)!这是为什么这样反而直接用4个状态的理解容易理解他而不是用机械套路来做。。。。

 

不过用套路+思想实验理解就行了,只需要判断出他是求连续子序列的问题 -》定义状态变量标识当天的状态即可!不过这里和股票2一样,他的继承不需要算上今天的价钱,是因为他的子序列只是说明持有股票的时间而已。这个不能理解为他是累积状态变量。(把我给整绕晕了)。

 

基于这个题目的分析,可以把 188. 买卖股票的最佳时机 IV 这也做了。这里就不赘述了。

冷冻股票(子集交叉)

然后我们再看冷冻期 309. 最佳买卖股票时机含冷冻期 这道题又不是求连续子序列,而是求子集问题。然后这里又三个子集求解,一个是一堆卖股票的,一堆卖股票的日子,由于存在冷冻期,冷冻期当天本身是跟随卖股票的变量,所以实际是两个状态变量交叉,然而这里买股票和卖股票的交叉又通过跟随变量来实现的。。。。这里存在的交叉是卖股票前必须持有股票,买股票前必须结束冷冻期。由于是子集问题,变量的含义是是所有状态中的利润,而且他是交叉的。。这里是子集,因为这里的买意思是不知道今天是否持有股票,但是知道上一次持有股票时最多的利润。

(下面为冷冻期代码)

class Solution {

public:

    int maxProfit(vector<int>& prices) {

      int have = -prices[0], sell = 0, nothing = 0;

      for(int i = 1;i<prices.size();i++){

        auto nhave = max(have, nothing - prices[i]);

        auto nsell = max(have + prices[i], sell);

        auto nnothing = max(nothing, sell);

        have = nhave; sell = nsell; nothing = nnothing;

      }

      return max(sell, nothing);

    }

};

 

所以这个代码写的不好==应该是股票 3 用 have 关键字,表示当天肯定要持有股票的,要么是之前一直持有股票,要么是今天新买的股票。而冷冻期应该用 buy,表示持续到今天上一次买股票赚到的钱。(表示刚才我还蒙圈了为什么两个代码这么像,其实就是这个命名的解释问题。但是本质 LCS 问题和 LIS 问题的确是只有微小区别?)

 

梳理总结

本文到此就告一段落了。总结一下概念:

 

这里讲解了一维 DP 的两种基本分类,一种是求子序列问题(子集),一种是求连续子序列问题。他们的二维形式分别是 LCS 最长公共子序列 问题和 LIS 最长上升连续子序列问题。

其中,求子集的问题一般状态变量是持续到当前时刻的某个决策权值(最大值,最小值,和,长度等),当前的状态是否在最终最优化选取的集合中并不知道。求连续子序列的问题一般状态变量是表示当前的状态必须在连续子序列中,因此为了求决策值必须另外使用一个累计变量(max,min 等)。

对于多种状态的一维 DP,同样也有 LCS 模型和  LIS 模型之分,对于多种状态的关键性区别是连续子序列问题的额外状态变量常常没有交叉关系,或者多个跟随变量间有交叉关系但是 DP 的状态变量不会依赖于跟随变量,处于跟随变量单向依赖于 DP 状态变量的关系,但是多个跟随变量间可能会会有交叉依赖关系,而子集问题的多个状态肯定是相互交叉的。

问题

状态变量含义

多个状态时常常

跟随变量

子集问题(LCS)

不知道当前时刻的状态

相互交叉依赖

不需要

子序列问题(LIS)

当前时刻状态确定

跟随变量相互依赖(状态变量也可能相互依赖)

需要

 

题目分类:

子集问题:

斐波那契,1137. 第 N 个泰波那契数 198. 打家劫舍 70. 爬楼梯 746. 使用最小花费爬楼梯740. 删除并获得点数

122. 买卖股票的最佳时机 II(多次买卖),

连续子序列问题121. 买卖股票的最佳时机53. 最大子序和(连续),152. 乘积最大子数组(连续),

1567. 乘积为正数的最长子数组长度(两个DP状态),123. 买卖股票的最佳时机 III(两笔买卖,多状态),

188. 买卖股票的最佳时机 IV (同123,k笔)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值