动态规划算法

动态规划算法

关键字:子结构 状态转移方程 数塔 背包问题

 

目录

 

一、前言

二、动态规划概述

      1. 定义

      2. 概念

      3. 求解基本步骤

      4. 谈谈动态规划与其他算法之间的联系和区别

      5. 动态规划迷思

      6. 动态规划解决方法辨析

      7. 对动态规划总结性理解

三、基础动态规划

      1. 数塔问题

      2. DAG

      3. 多阶段决策问题

      4. LIS

      5. LCS

      6. LCIS

四、背包问题

      1.  01背包

      2.  完全背包

      3.  多重背包

      4.  二进制优化

      5.  混合背包

      6.  多条件分类01背包

      7.  01背包+第k优解

 

一、前言

 

猿爸爸把 1+1+1+1+1+1+1+1 = 写在纸上,问小猿(咦): 
「它们加起来是多少哇?」

(数了一会…)「8 !」

猿爸爸在左边又加了个 1+,再问一次小猿: 
「现在呢?」

(迅速地)「9 !」

「为什么小猿这么快就知道了呢?」

「因为你刚刚加了 1 啊~」

「所以只要记得之前的结果,就不用再计一次数啦。」

嗯,动态规划就是一种「先记住些事,方便后面节约时间」的神奇方法

 

二、动态规划概述

 

1.  定义

 

      原问题的解如何由子问题的解组合而成。

 

2.  概念

 

    1)动态规划是运筹学中用于求解决策过程中的最优化数学方法。 当然,我们在这里关注的是作为一种算法设计技术,作为一种使用多阶段决策过程最优的通用方法。

它是应用数学中用于解决某类最优化问题的重要工具。

    

动态规划(dynamic programming)是解决问题的一种方法。

 

是的,它只是问题的一种解决方法。一个问题完全可能有多种解法。

 

2)如果问题是由交叠的子问题所构成,我们就可以用动态规划技术来解决它,一般来说,这样的子问题出现在对给定问题求解的递推关系中,这个递推关系包含了相同问题的更小子问题的解。动态规划法建议,与其对交叠子问题一次又一次的求解,不如把每个较小子问题只求解一次并把结果记录在表中(动态规划也是空间换时间的),这样就可以从表中得到原始问题的解。

对于有经验的人来说,摸索出原问题的解如何由子问题组合而成,就能把状态转移方程写出来,该问题就已经解决了。 
    因为动态规划的实现有很成熟的两类方法:

①“自顶向下”(top-down dynamic programming) 的实现方式;

②“自底向上” (bottom-up dynamic programming)的实现方式;

 

有经验的人由状态转移方程随便选一种实现方式,就跟大部分高中试题一样遵从套路即可。 
    所以,解动态规划问题本质还是找出“原问题的解如何由子问题组合而成”,这是动态规划的本质

而这种本质,实现的真正方法来自两种:

①对问题状态的定义;

②对状态转移方程的定义;

 

动态规划是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。而拆分问题,靠的就是状态的定义状态转移方程的定义

 

3.  求解基本步骤

 

动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。

 

初始状态→│决策1│→│决策2│→…→│决策n│→结束状态

 

动态规划决策过程示意图

 

1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解

2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性

3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。

4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件边界条件 


一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。实际应用中可以按以下几个简化的步骤进行设计:

①分析最优解的性质,并刻画其结构特征;

②递归的定义最优解;

③以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值;

④根据计算最优值时得到的信息,构造问题的最优解。

 

4.  谈谈动态规划与其他算法之间的联系和区别(要求较高,初学者可跳过)

 

下面截取一段来自知乎王勐对动态规划的理解:(递推、贪心、搜索、动态规划四者之间的关系)

动态规划的本质不在于是递推或是递归,也不需要纠结是不是内存换时间。

 

首先需要明白哪些问题不是动态规划可以解决的,才能明白为神马需要动态规划。不过好处时顺便也就搞明白了递推贪心搜索和动规之间有什么关系,以及帮助那些总是把动规当成搜索解的同学建立动规的思路。当然熟悉了之后可以直接根据问题的描述得到思路。

动态规划是对于某一类问题的解决方法!!重点在于如何鉴定“某一类问题”是动态规划可解的而不是纠结解决方法是递归还是递推!

怎么鉴定dp可解的一类问题需要从计算机是怎么工作的说...计算机的本质是一个状态机,内存里存储的所有数据构成了当前的状态,CPU只能利用当前的状态计算出下一个状态(不要纠结硬盘之类的外部存储,就算考虑他们也只是扩大了状态的存储容量而已,并不能改变下一个状态只能从当前状态计算出来这一条铁律)

当你企图使用计算机解决一个问题是,其实就是在思考如何将这个问题表达成状态(用哪些变量存储哪些数据)以及如何在状态中转移(怎样根据一些变量计算出另一些变量)。所以所谓的空间复杂度就是为了支持你的计算所必需存储的状态最多有多少,所谓时间复杂度就是从初始状态到达最终状态中间需要多少步!

 

太抽象了还是举个例子吧:

 

比如说我想计算第100个非波那契数,每一个非波那契数就是这个问题的一个状态,每求一个新数字只需要之前的两个状态。所以同一个时刻,最多只需要保存两个状态,空间复杂度就是常数;每计算一个新状态所需要的时间也是常数且状态是线性递增的,所以时间复杂度也是线性的。

 

上面这种状态计算很直接,只需要依照固定的模式从旧状态计算出新状态就行(a[i]=a[i-1]+a[i-2]),不需要考虑是不是需要更多的状态,也不需要选择哪些旧状态来计算新状态。对于这样的解法,我们叫递推

非波那契那个例子过于简单,以至于让人忽视了阶段的概念,所谓阶段是指随着问题的解决,在同一个时刻可能会得到的不同状态的集合。非波那契数列中,每一步会计算得到一个新数字,所以每个阶段只有一个状态。想象另外一个问题情景,假如把你放在一个围棋棋盘上的某一点,你每一步只能走一格,因为你可以东南西北随便走,所以你当你同样走四步可能会处于很多个不同的位置。从头开始走了几步就是第几个阶段,走了n步可能处于的位置称为一个状态,走了这n步所有可能到达的位置的集合就是这个阶段下所有可能的状态。

 

现在问题来了,有了阶段之后,计算新状态可能会遇到各种奇葩的情况,针对不同的情况,就需要不同的算法,下面就分情况来说明一下:

假如问题有n个阶段,每个阶段都有多个状态,不同阶段的状态数不必相同,一个阶段的一个状态可以得到下个阶段的所有状态中的几个。那我们要计算出最终阶段的状态数自然要经历之前每个阶段的某些状态。

好消息是,有时候我们并不需要真的计算所有状态,比如这样一个弱智的棋盘问题:从棋盘的左上角到达右下角最短需要几步。答案很显然,用这样一个弱智的问题是为了帮助我们理解阶段和状态。某个阶段确实可以有多个状态,正如这个问题中走n步可以走到很多位置一样。但是同样n步中,有哪些位置可以让我们在第n+1步中走的最远呢?没错,正是第n步中走的最远的位置。换成一句熟悉话叫做“下一步最优是从当前最优得到的”。所以为了计算最终的最优值,只需要存储每一步的最优值即可,解决符合这种性质的问题的算法就叫贪心。如果只看最优状态之间的计算过程是不是和非波那契数列的计算很像?所以计算的方法是递推。

 

既然问题都是可以划分成阶段和状态的。这样一来我们一下子解决了一大类问题:一个阶段的最优可以由前一个阶段的最优得到。

如果一个阶段的最优无法用前一个阶段的最优得到呢?

什么你说只需要之前两个阶段就可以得到当前最优?那跟只用之前一个阶段并没有本质区别。最麻烦的情况在于你需要之前所有的情况才行。

 

再来一个迷宫的例子。在计算从起点到终点的最短路线时,你不能只保存当前阶段的状态,因为题目要求你最短,所以你必须知道之前走过的所有位置。因为即便你当前再的位置不变,之前的路线不同会影响你的之后走的路线。这时你需要保存的是之前每个阶段所经历的那个状态,根据这些信息才能计算出下一个状态!

每个阶段的状态或许不多,但是每个状态都可以转移到下一阶段的多个状态,所以解的复杂度就是指数的,因此时间复杂度也是指数的。哦哦,刚刚提到的之前的路线会影响到下一步的选择,这个令人不开心的情况就叫做有后效性

 

刚刚的情况实在太普遍,解决方法实在太暴力,有没有哪些情况可以避免如此的暴力呢?

契机就在于后效性。

有一类问题,看似需要之前所有的状态,其实不用。不妨也是拿最长上升子序列的例子来说明为什么他不必需要暴力搜索,进而引出动态规划的思路。

假装我们年幼无知想用搜索去寻找最长上升子序列。怎么搜索呢?需要从头到尾依次枚举是否选择当前的数字,每选定一个数字就要去看看是不是满足“上升”的性质,这里第i个阶段就是去思考是否要选择第i个数,第i个阶段有两个状态,分别是选和不选。哈哈,依稀出现了刚刚迷宫找路的影子!咦慢着,每次当我决定要选择当前数字的时候,只需要和之前选定的一个数字比较就行了!这是和之前迷宫问题的本质不同!这就可以纵容我们不需要记录之前所有的状态啊!既然我们的选择已经不受之前状态的组合的影响了,那时间复杂度自然也不是指数的了啊!虽然我们不在乎某序列之前都是什么元素,但我们还是需要这个序列的长度的。所以我们只需要记录以某个元素结尾的LIS长度就好!因此第i个阶段的最优解只是由前i-1个阶段的最优解得到的,然后就得到了DP方程(感谢 @韩曦 指正)

 

LIS(i)=max{LIS(j)+1}       j<i and a[j]<a[i]

 

所以一个问题是该用递推、贪心、搜索还是动态规划,完全是由这个问题本身阶段间状态的转移方式决定的!

每个阶段只有一个状态->递推

每个阶段的最优状态都是由上一个阶段的最优状态得到的->贪心

每个阶段的最优状态是由之前所有阶段的状态的组合得到的->搜索

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到而不管之前这个状态是如何得到的->动态规划

 

每个阶段的最优状态可以从之前某个阶段的某个或某些状态直接得到这个性质叫做最优子结构;而不管之前这个状态是如何得到这个性质叫做无后效性。

 

另:其实动态规划中的最优状态的说法容易产生误导,以为只需要计算最优状态就好,LIS问题确实如此,转移时只用到了每个阶段“选”的状态。但实际上有的问题往往需要对每个阶段的所有状态都算出一个最优值,然后根据这些最优值再来找最优状态。比如背包问题就需要对前i个包(阶段)容量为j时(状态)计算出最大价值。然后在最后一个阶段中的所有状态种找到最优值。

 

5.  动态规划迷思

 

a. “缓存”,“重叠子问题”,“记忆化”:

 

这三个名词,都是在阐述递推式求解的技巧。以Fibonacci数列为例,计算第100项的时候,需要计算第99项和98项;在计算第101项的时候,需要第100项和第99项,这时候你还需要重新计算第99项吗?不需要,你只需要在第一次计算的时候把它记下来就可以了。 
    上述的需要再次计算的“第99项”,就叫“重叠子问题”。如果没有计算过,就按照递推式计算,如果计算过,直接使用,就像“缓存”一样,这种方法,叫做“记忆化”,这是递推式求解的技巧。这种技巧,通俗的说叫“花费空间来节省时间”。都不是动态规划的本质,不是动态规划的核心。

 

b. “递归”:

 

递归是递推式求解的方法,连技巧都算不上。(自顶向下的方式)

 

c. “无后效性“,“最优子结构”:

 

上述的状态转移方程中,等式右边不会用到下标大于左边i或者k的值,这是“无后效性的通俗上的数学定义,符合这种定义的状态定义,我们可以说它具有“最优子结构”的性质,在动态规划中我们要做的,就是找到这种“最优子结构”。 
    在对状态和状态转移方程的定义过程中,满足“最优子结构”是一个隐含的条件(否则根本定义不出来)。

 

6.  动态规划解决方法辨析

 

“自顶向下”的实现方式 和 “自低向上”的实现方式各有什么优缺点,我的理解如下。

两种方法的取舍我个人的喜好是——优先选择Top-Down dynamic programming,除非不容易得到递归公式或空间复杂度无法接受。

 

“自顶向下”(top-down dynamic programming):

①能方便的得到递归公式,并用递归函数实现;

②保持了递归实现的代码结构,逻辑上容易理解;

③过程中只计算需要计算的子结果;

当采用了caching技术时多次被调用时天然的复用之前被调用时的子结果。(比如连续两次计算fibonacci数F(4), F(5),则计算F(5)时已知F(3)和F(4),直接相加即可);

 

“自底向上”(bottom-up dynamic programming):

①需要设计数据结构来完成自底向上的计算过程。逻辑上相对不那么直观;

常常可以进行空间复杂度的优化。比如Fibonacci数列可以优化为只使用两个变量的额外存储空间,0-1背包问题可以优化为O(n)的空间复杂度;

 

7.  对动态规划总结性理解

 

(1)“动态规划是什么”?

 

动态规划是递归(很多时候这种说法并不对,但是对于入门,可以暂时这么理解),是缓存,是用空间来换取时间;

但是,如果仅仅知道这些,你还是发现无法设计动态规划的算法。

因为你慢慢会发现,有些问题用动态规划和递归都能求解,但是动态规划的速度会更慢。

于是有人说了,动态规划题目的特征在于最优子结构重叠子问题——这就涉及到下一个问题(其实对于初学者来书,判断重叠子问题和最优子结构是不容易的,这部分就不是用文字能够使人明白的了。需要结合实例,分析才行)

 

(2)为什么需要使用动态规划?

 

在初等算法中,算法设计的思路一般如下,首先尝试穷举法;然而如何穷举?

此时往往要用到分治法——而归递,在绝大多数时候仅仅是分治法的一种表现形式而已;

在递归和分治法的基础上,往往会用动态规划来优化——动态规划,实际上是一种升级版的分治法。

当然,不是所有的穷举都能使用分治法;不是所有的分治法都能优化成动态规划。此时,就是上文提到的:只有一个问题是可分的,才可以使用分治法;只有分治出来的子问题有重叠,才可以使用DP;只有子问题具有最优子结构DP才具有意义

另外dp主要解决重叠子问题的问题,遇到重叠的子问题不需要再次计算。分治是把一个问题分解成若干个互不干扰的子问题,解决了子问题就解决了知道大的问题。所以才有了dp比较消耗空间的说法,因为要记录子问题的解。所以才出现了各种压缩空间的做法。如果不记录子问题的话dp就变成了搜索。 (在我的理解里,搜索其实就是一种特殊的动态规划,虽然效率很怎么样。但是暴搜一定能得到解,并且思路来的要比动态规划痛快一些。)

 

三、基础动态规划

 

1.  数塔问题

 

(1)练手

有一段楼梯有10级台阶,规定每一步只能跨一级或两级,要登上第10级台阶有几种不同的走法?

分析:很显然,这道题的对应的数学表达式是

F(n)=F(n-1) + F(n-2);      其中F(1)=1, F(2)=2

很自然的状况是,采用递归函数来求解:

  int  solution(int n)
  {  
     if(n>0 && n<2)
       return n;  
     return solution(n-1) + solution(n-2);  
  }  

如果我们计算F(10), 先需要计算F(9) F(8); 但是我们计算F(9)的时候,又需要计算F(8),很明显,F(8)被计算了多次,存在重复计算;同理F(3)被重复计算的次数就更多了。算法分析与设计的核心在于 根据题目特点,减少重复计算。 在不改变算法结构的情况下,我们可以做如下改进:

  int dp[11];  
  int  solution(int n)
  {  
      if(n>0 && n<2)
        return n;  
      if(dp[n]!=0)
        return dp[n];  
      dp[n] = solution(n-1) + solution(n-2);  
      return  dp[n];  
  }  

这是一种递归形似的写法,进一步,我们可以将递归去掉:

  int  solution(int n)
  {  
      int dp[n+1];  
      dp[1]=1;dp[2]=2;  
      for (i = 3; i <= n; ++i)
        dp[n] = dp[n-1] + dp[n-2];   
      return  dp[n];  
  }  

当然,我们还可以进一步精简,仅仅用两个变量来保存前两次的计算结果; 这个算法留待读者自己去实现

(2)实践

如下图(图片来自百度图片)是一个数塔,从顶部出发在每一个节点可以选择向左或者向右走,一直走到底层,要求找出一条路径,使得路径上的数字之和最大.

 

数塔问题

 

思路分析: 

 

这道题目如果使用贪婪算法不能保证找到真正的最大和。 在用动态规划考虑数塔问题时可以自顶向下的分析,自底向上的计算。

 
从顶点出发时到底向左走还是向右走应取决于是从左走能取到最大值还是从右走能取到最大值,只要左右两道路径上的最大值求出来了才能作出决策。同样的道理下一层的走向又要取决于再下一层上的最大值是否已经求出才能决策。这样一层一层推下去,直到倒数第二层时就非常明了。 


所以第一步对第五层的8个数据,做如下四次决策:

 

如果经过第四层2,则在第五层的19和7中肯定是19;

 

如果经过第四层18,则在第五层的7和10中肯定是10;

 

如果经过第四层9,则在第五层的10和4中肯定是10;

 

如果经过第四层5,则在第五层的4和16中肯定是16;

 

经过一次决策,问题降了一阶。5层数塔问题转换成4层数塔问题,如此循环决策…… 最后得到1阶的数塔问题。

算法实现:首先利用一个二维数组data存储数塔的原始数据(其实我们只使用数组data一半的空间,一个下三角矩阵),然后利用一个中间数组dp存储每一次决策过程中的结果(也是一个下三角矩阵)。初始化dp,将data的最后一层拷贝到dp中。dp[n][j] = data[n][j] (j = 1, 2, …, n) 其中,n为数塔的层数。再动态规划过程汇总,我们有

  dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + data[i][j]) 

最后的结果保存在dp[0][0]中。

 
对于上面的数塔,我们的data数组如下:

9

12    15

10    6     8

2     18    9     5

19    7     10    4   16

而我们的dp数组如下:

 59

 50   49

 38   34    29

 21   28    19    21

 19   7     10    4    16

 

2.  DAG

 

DAG也叫有向无环图,是学习动态规划的基础。很多问题都可以转化为DAG上的最长路、最短路或路径计数问题。

根据紫书上面的总结,DAG的最长路和最短路都可以用记忆化搜索和递推两种实现方式。

由于DAG最长(短)路的特殊性,有两种“对称”的状态定义方式:

 

状态1: 设d[i]为从i出发的最长路,则d[i]=max(d[j]+1|(i,j)∈E)

状态2: 设d[i]为从i结束的最长路,则d[i]=max(d[j]+1|(i,j)∈E)

 

3.  多阶段决策问题

 

多段图是一个特殊的DAG,其结点可以划分成若干个阶段,每个阶段只由上一个阶段所决定。它的最优化问题往往可以用动态规划解决,其中,状态及其转移类似于回溯法中的解答树。解答树中的“层数”,也就是递归函数中的“当前填充位置”cur,描述的是即将完成的决策序号,在动态规划中被称为阶段。

 

d[i][j]为到达第i行第j列的最小(最大)花费

 

每做一次决策就可以得到解得一部分,当所有决策做完之后,完整的解就“浮出水面”了。

 

 

4.  LIS

LIS是最长上升子序列的缩写。给定n个整数A1,A2,…,An,按从左到右的顺序选出尽量多的整数,组成一个上升的子序列(子序列可以理解为:删除0个或者多个数,其他数的顺序不变),例如序列:1,6,2,3,7,5,可以选出上升子序列1,2,3,5,也可以选出1,6,7,但前者更长。注意,选出的上升子序列中相邻元素不能相等。

 

d[i]为以i结尾的最长上升子序列的长度

 

可以得到状态转移方程:

d[i]=max(0,d[j]|j<i,Aj<Ai)+1

最后遍历一次,答案为

max(d[i])

如果LIS中的元素可以相等,把小于号改成小于等于号即可。

 

5.  LCS

 

LCS全称叫做最长公共子序列问题。给定两个子序列A和B,求长度最大的公共子序列。

 

d[i][j]为A1,A2,...,Ai和B1,B2,...Bj的LCS长度

 

则分成两种情况来讨论:

 

(1)a[i] == b[j]时     

            d[i][j] = d[i-1][j-1]+1;

(2)a[i] != b[j]时     

           d[i][j] = max(d[i-1][j],d[i][j-1]);

 

6.  LCIS

 

LCS是增加的一个内容,叫做最长递增公共子序列

 

d[i][j]为以a串的前i项和b串的前j项且以b[j]为结尾构成的LCIS的长度

 

同时也可以分成两种情况来讨论:

 

(1)a[i] == b[j]时    

             d[i][j] = max+1;

(2)a[i] >  b[j]时    

             max = max(max,d[i-1][j]);

 

如果想要更简单一点,可以在维度上进行优化:

 

(1)a[i] == b[j]时    

           d[j]  max+1;

(2)a[i] >  b[j]时     

           max = max(max,d[j]+1);

 

四、背包问题

 

1.  01背包

 

问题描述: 
给定n种物品和一个背包。物品i的价值是Wi,其体积为Vi,背包的容量为C。可以选择任意装入背包中的物品,求装入背包中物品的最大总价值。

 

对于一种物品,要么装入背包,要么不装。所以对于一种物品的装入状态可以取0和1。我们设物品i的装入状态为xi,xi∈ (0,1),此问题称为0-1背包问题。

 

dp[i][j]表示把前i个物品装入容量为j的背包的最大总价值,则状态转移方程可以表示为:

dp[i][j]=min(dp[i-1][j],dp[i-1][j-w[j]]+v[j]);

另外,还可以优化空间,降为一维(用到滚动数组),只不过需要逆序:

dp[j]=min(dp[j],dp[j-w[i]]+v[i]);

2.  完全背包

 

问题描述: 
给定n种物品和一个背包。第i种物品的价值是Wi,其体积为Vi,背包容量为C,同一种物品的数量无限多。可以选择任意装入背包中的物品,求装入背包中物品的最大总价值。

 

转化为01背包问题求解:

考虑到第i种物品最多选V/c[i]件,于是可以把第i种物品转化为V/c[i]件费用及价值均不变的物品,然后求解这个01背包问题。这样完全没有改进基本思路的时间复杂度,但这毕竟给了我们将完全背包问题转化为01背包问题的思路:将一种物品拆成多件物品

 

更高效的转化方法是:

把第 i 种物品拆成费用为c[i]*2^k、价值为w[i]*2^k的若干件物品,其中k满足c[i]*2^k<=V

 

这是二进制的思想,因为不管最优策略选几件第i种物品,总可以表示成若干个2^k件物品的和。

这样把每种物品拆成O(log(V/c[i]))件物品,是一个很大的改进。

但我们有更优的O(VN)的算法:

for(int i=0;i<n;i++)
    for(int j=0;j<=V;j++)    //将01背包降一维的逆序改为顺序即可
        d[j]=max(d[j],d[j-v[i]]+w[i]);

(想想为什么可以这样做?试着从贪心的角度去分析)

 

3.  多重背包

 

问题描述: 
给定n中物品和一个背包。第i种物品的价值是Wi,其体积为Vi,数量是Ki件,背包的容量为C,可以任意选择装入背包中的物品,求装入背包中物品的最大总价值。

 

基本方法是转化为01背包求解:

 

把第i种物品换成n[i]件01背包中的物品,则得到了物品数为Σn[i]的01背包问题,直接求解.

 for(int i=0;i<n;i++)
    for(int j=1;j<=k[i];j++)      //多重背包,最简单的做法就是插入一个for循环
       for(int p=C;p>=v[i];p--)      //多重背包变01背包,降一维要逆序               
           dp[p]=max(dp[p],dp[p-v[i]]+w[i]); 

还可以优化吗?

 

优化思路: 


因为考虑到我们需要凑出0~num[i]个数目,可以证明,用二进制的拆分可以是件数达到最小。

 

4.  二进制优化

 

将第 i 种物品分成若干件物品,每件物品有一个系数,这件物品的体积和价值均是原来的体积和价值乘以这个系数,使这些系数分别为

 

  1,2,4,...,2^(k-1),num[i]-2^k+1

 

k是满足num[i]-2^k+1的最大整数。

 

例如num[i]=13,则将这种物品分成系数分别是1,2,4,6的四件物品。

 

下面给出部分代码

void ZeroOne_Pack(int v,int w,int m)  
{  
    for(int i=C; i>=v; i--)  
        dp[i] = max(dp[i],dp[i-v] + w);  
}  

void Complete_Pack(int v,int w,int C)  
{  
    for(int i=v; i<=C; i++)  
        dp[i] = max(dp[i],dp[i-v] + w);  
}

memset(dp,0,sizeof(dp));  
for(int i=1; i<=n; i++)    
{  
    if(num[i]*c[i] > C)  
        Complete_Pack(c[i],w[i],C);  
    else  
    {  
        int k = 1;  
        while(k < num[i])  
        {  
            ZeroOne_Pack(k*c[i],k*w[i],C);  
            num[i] -= k;  
            k *= 2;  
        }  
        ZeroOne_Pack(num[i]*c[i],num[i]*w[i],C);  
    }  
}  
return dp[m];

5.  混合背包

 

问题描述:

一个旅行者有一个最多能用V公斤的背包,现在有n件物品,它们的重量分别是W1,W2,…,Wn,它们的价值分别为C1,C2,…,Cn。有的物品只可以取一次(01背包),有的物品可以取无限次(完全背包),有的物品可以取的次数有一个上限(多重背包)。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。

数据说明: 
第一行:V(V<=200),N(N<=30); 
2..N+1行:Wi,Ci,Pi,前两个整数分别表示每个物品的重量,价值,第三个整数若为0,则说明此物品可以购买无数件,若为其他数字,则为此物品可购买的最多件数(Pi)。

 

把每一样物品分成三种情况讨论即可:

for(int i=0;i<n;i++)
{
    if(num[i]==0)
        for(int  j=0;j<V;j++)        //完全背包
            d[j]=max(d[j],d[j-v[i]]+w[i]);
    else  if(num[i]==1)
        for(int j=V;j>=0;j--)       //01背包
            d[j]=max(d[j],d[j-v[i]]+w[i]);
    else
        多重背包(略);
}

 

6.  多条件分类01背包

 

题目大意: 
给你n个工作集合,你有T的时间去做它们。其中每个工作集合有m件事可以做,它们是s类的工作集合(s=0,1,2) 
s=0说明这m件事中最少得做一件; 
s=1说明这m件事中最多只能做一件; 
s=2说明这m件事你可以做也可以不做; 
并且每件事情需要用ci的时间,但能获得gi的快乐值。求在T的时间内你能获得的最大快乐值。

 

解题思路: 
        dp[i][j] 表示处理完前面i个工作集所花时间不超过j的最大快乐值 


则分类讨论:

 

①当s==0 的时候,表示这个集合内的m件事情至少做一件,也就分两种情况比较:

1)这个集合中m件事情已经选择其中k件做了(k>=1);

      dp[i][j]=max(dp[i][j],dp[i][j-cost]+val);

2)这个集合中m件事情还没有做过一件;

      dp[i][j]=max(dp[i][j],dp[i-1][j-cost]+val);

②当s==1的时候,表示这个集合内的m件事情最多做一件,如果现在还有挑选的余地,就已经说明这个集合中的m件事情还没有做过一件;

dp[i][j]=max(d[i][j],dp[i-1][j-cost]+val);

③当s==2的时候,表示这个集合内的m件事情可以不做任何一件,也可以做任何多件;

dp[i][j]=max(dp[i][j],dp[i][j-cost]+val);

 

另外还有一个疑问需要解答: 
在情况①中的1),2)的先后顺序不可以改变,一定要先更新这个集合中m件事情已经选择其中k件做过了(k>=1)dp[i][j],后更新这个集合中m件事情还没有做过一件的dp[i][j];

 

为什么?

 

因为如果情况①先更新2),后更新1)的话,2)的值会被覆盖,也就得不到特殊情况的结果(也就是若剩下的时间已经不够了 那么就需要输出-1的情况)

 

部分代码:

memset(dp,0,sizeof(dp));
for(int i=1;i<=n;i++)
{
    cin>>m>>s;
    for(int j=0;j<=t;j++)
        dp[i][j]=s==0?-INF:dp[i-1][j]; //如果s==0 则至少需要选择一个的01背包 若剩下的时间已经不够了 那么就需要输出-1 因此初始化-INF
    for(int k=0;k<m;k++)
    {
        cin>>cost>>val;
        for(int j=t;j>=cost;j--)         //都选择逆序枚举
        {
            if(s==0)          //至少选择一个的01背包
              dp[i][j]=max(max(dp[i][j],dp[i][j-cost]+val),dp[i-1][j-cost]+val);
            else if(s==1)     //最多选择一个的01背包
              dp[i][j]=max(dp[i][j],dp[i-1][j-cost]+val);
            else               //选择任意多个的01背包
                dp[i][j]=max(dp[i][j],dp[i][j-cost]+val);
            }
        }
    }
 int ans=max(dp[n][t],-1);

 

7.  01背包+第k优解

 

首先分析如何求第k优解? 

 

分析分析思路,因为每次遇到一个物品,都只有两种决策:要么取,要么不取。 
    也就是说,相当于全年级有两个班,要求年级排名第k,那么只要知道这两个班的班排名前k就行了。

 

因此用A[]表示选择第i个物品的从大到小的排列,B[]表示不选择第i个物品的从大到小的排列。

然后每次循环找出两个数组里前k个,也就是在2*k个数中不重复地找出前k个数,记录到dp[i][j]中。

其中dp[i][j]表示体积为i的时候第j优解。

 

部分代码:

memset(dp,0,sizeof(dp));
for(int i=0;i<n;i++)
{
    for(int j=v;j>=vol[i];j--)
    {
        for(kk=1;kk<=k;kk++)
        {
            A[kk]=dp[j-vol[i]][kk]+val[i];   //A[] 记录选择第i个物品的从大到小的排列
            B[kk]=dp[j][kk];                 //B[] 记录不选择第i个物品的从大到小的排列
         }
         A[kk]=B[kk]=-1;
         a=b=c=1;
         while(c<=k&&(A[a]!=-1||B[b]!=-1))
         {
             dp[j][c]=A[a]>B[b]?A[a++]:B[b++];        //d[i][j] 记录体积为 i 的时候的第 j 优解
             if(dp[j][c]!=dp[j][c-1])          //去重操作
               c++;
         }
     }
}

相关推荐
©️2020 CSDN 皮肤主题: 精致技术 设计师:CSDN官方博客 返回首页