【动态规划】01背包问题

说明

前面用动态规划解决了正则表达式的问题,感受仍是不过瘾,总以为对于动态规划的理解尚未到位,因此趁热打铁,继续研究几个动态规划的经典问题,但愿可以借此加深对动态规划的理解。在此以前,还须要说两个跟动态规划有关的理论知识。java

最优化原理

最优化原理指的最优策略具备这样的性质:不论过去状态和决策如何,对前面的决策所造成的状态而言,余下的诸决策必须构成最优策略。简单来讲就是一个最优策略的子策略也是必须是最优的,而全部子问题的局部最优解将致使整个问题的全局最优。若是一个问题能知足最优化原理,就称其具备最优子结构性质正则表达式

这是判断问题可否使用动态规划解决的先决条件,若是一个问题不能知足最优化原理,那么这个问题就不适合用动态规划来求解。算法

这样说可能比较模糊,来举个栗子吧:数组

如上图,求从A点到E点的最短距离,那么子问题就是求从A点到E点之间的中间点到E点的最短距离,好比这里的B点函数

那么这个问题里,怎么证实最优化原理呢?优化

咱们假设从A点到E点的最短距离为d,其最优策略的子策略假设通过B点,记该策略中B点到E点的距离为d1,A点到B点的距离为d2。咱们可使用反证法,假设存在B点到E点的最短距离d3,而且d3 < d1,那么 d3 + d2 < d1 + d2 = d,这与d是最短距离相矛盾,因此,d1是B点到E点的最短距离。3d

为了增长理解,这里再举一个反例:code

图中有四个点,A、B、C、D,相邻两点有两条连线,表明两条通道,d1,d2,d3,d4,d5,d6表明的是道路的长度,求A到D的全部通道中,总长度除以4获得的余数最小的路径为最优路径,求一条最优路径blog

这里若是仍是按照上面的思路去求解,就会误入歧途了。按照以前的思路,A的最优取值应该能够由B的最优取值来肯定,而B的最优取值为(3+5)mod 4 = 0。因此应该选d2d6这两条道路,而实际上,全局最优解是d4+d5+d6或者d1+d5+d3。因此这里子问题的最优解并非原问题的最优解,即不知足最优化原理。因此就不适合使用动态规划来求解了。递归

无后效性

无后效性指的是某状态下决策的收益,只与状态和决策相关,与到达该状态的方式无关。某个阶段的状态一旦肯定,则此后过程的演变再也不受此前各类状态及决策的影响。换句话说,将来与过去无关,当前状态是此前历史状态的完整总结,此前历史决策只能经过影响当前的状态来影响将来的演变。再换句话说,过去作的选择不会影响如今能作的最优选择,如今能作的最优选择只与当前的状态有关,与通过如何复杂的决策到达该状态的方式无关。

这也是用来验证问题是否可使用动态规划来解答的重要方法。

咱们再回头看看上面的最短路径问题,若是在原来的基础上加上一个限制条件:同一个格子只能经过一次。那么, 这个题就不符合无后效性了,由于前一个子问题的解会对后面子问题的选择策略有影响,好比说,若是从A到B选择了一条以下图中绿色表示的路线,那么从B点出发到达E点的路线就只有一条了。也就是说从A点到B点的路径选择会影响B点到E点的路径选择。

理论部分就此打住,接下来咱们实战一下。

01背包问题

假设你是一名经验丰富的探险家,背着背包来到野外进行平常探险。天气晴朗而不燥热,山间的风夹杂着花香,正当你欣赏这世外桃源般的美景时,忽然,你发现了一个洞穴,这个洞穴外表看起来其貌不扬,但凭借着惊为天人的直觉,这个洞穴不简单。

因而,你开始往洞穴内探索,但愿能发现一些有意思的东西。终于,皇天不负有心人,你在洞穴的尽头,发现了一堆不世出的珠宝,凭借你惊人的阅历,一眼便看出了它们各自的价值,心想着下下下下下下下下半辈子都有着落了。

然而,天有不测风云,正准备将它们收入囊中,却不当心触碰到一个防护机关,洞穴立刻就要崩塌了。在此危机时刻,你只有一个背包,你必须尽快作出抉择,从中选择最值钱的珠宝塞到你的背包,让背包中珠宝的总价值最大。

好了好了,啰里啰嗦了大半天,我仍是来精简一下问题吧。简而言之,你只有一个容量有限的背包,总容量为c,有n个可待选择的物品,每一个物品只有一件,它们都有各自的重量和价值,你须要从中选择合适的组合来使得你背包中的物品总价值最大。

问题分析

那还不简单,无论是什么,先往背包里塞,塞满赶忙走,狗命要紧,狗命要紧。。。

20190310214935.png

好了好了,开个玩笑,言归正传。

简单起见,咱们来将上面的问题具体化,举一个更具体的栗子:

假设有5个物品,它们的价值(v)和重量(w)以下图:

背包总容量为10,如今要从中选择物品装入背包中,要求物品的重量不能超过背包的容量,而且最后放在背包中物品的总价值最大。

emmm,等等,为何叫作0/1背包呢?为何不叫1/2背包2/3背包???

仔细想一想,这里每一个物品只有一个,对于每一个物品而言,只有两种选择,盘它或者不盘,盘它记为1,不盘记为0,咱们不能将物品进行分割,好比只拿半个是不容许的。这就是这个问题被称为0/1背包问题的缘由。

因此究竟选仍是不选,这是个问题。

让咱们先来体验一下将珠宝装入背包的感受,为了方便起见,用xi表明第i个珠宝的选择(xi = 1 表明选择该珠宝,0则表明不选),vi表明第i个珠宝的价值,wi表明第i个珠宝的重量。因而咱们就有了这样的限制条件:

咱们的初始状态是背包容量为10,背包内物品总价值为0,接下来,咱们就要开始作选择了。对于1号珠宝,当前容量为10,容纳它的重量2绰绰有余,所以有两种选择,选它或者不选。咱们选择一个珠宝的时候,背包的容量会减小,可是里面的物品总价值会增长。就像下面这样:

这样就分出了两种状况,咱们继续进行选择,若是咱们选择了珠宝1,那么对于珠宝2,当前剩余容量为8,大于珠宝2的容量3,所以也有两种选择,选或者不选。

如今,咱们获得了四个可能结果,咱们每作出一个选择,就会将上面的每一种可能分裂成两种可能,后续的选择也是如此,最终,咱们会获得以下的一张决策图:

这里被涂上色的方框表明咱们的最终待选结果,原本应该有16个待选结果,但有三个结果因为容量不足以容纳下最后一个珠宝,因此就没有继续进行裂变。

而后,咱们从这些结果中,找出价值最大的那个,也就是13,这就是咱们的最优选择,根据这个选择,依次找到它的全部路径,即可以知道该选哪几个珠宝,最终结果是:珠宝4,珠宝2,珠宝1。

分治法

接下来,咱们就来分析一下,如何将它扩展到通常状况。为了实现这个目的,咱们须要将问题进行抽象并建模,而后将其划分为更小的子问题,找出递推关系式,这是分治思想中很重要的一步。

  1. 抽象问题,背包问题抽象为寻找组合(x1,x2,x3...xn,其中xi取0或1,表示第i个物品取或者不取),vi表明第i个物品的价值,wi表明第i个物品的重量,总物品数为n,背包容量为c。
  2. 建模,问题即求max(x1v1 + x2v2 + x3v3 + ... + xnvn)。
  3. 约束条件,x1w1 + x2w2 + x3w3 + ... + xnwn < c
  4. 定义函数KS(i,j):表明当前背包剩余容量为j时,前i个物品最佳组合所对应的价值;

那这里的递推关系式是怎样的呢?对于第i个物品,有两种可能:

  1. 背包剩余容量不足以容纳该物品,此时背包的价值与前i-1个物品的价值是同样的,KS(i,j) = KS(i-1,j)
  2. 背包剩余容量能够装下该商品,此时须要进行判断,由于装了该商品不必定能使最终组合达到最大价值,若是不装该商品,则价值为:KS(i-1,j),若是装了该商品,则价值为KS(i-1,j-wi) + vi,从二者中选择较大的那个,因此就得出了递推关系式:

对于这个问题的子问题,这里有必要详细说明一下。原问题是,将n件物品放入容量为c的背包,子问题则是,将前i件物品放入容量为j的背包,所获得的最优价值为KS(i,j),若是只考虑第i件物品放仍是不放,那么就能够转化为一个只涉及到前i-1个物品的问题。若是不放第i个物品,那么问题就转化为“前i-1件物品放入容量为j的背包中的最优价值组合”,对应的值为KS(i-1,j)。若是放第i个物品,那么问题就转化成了“前i-1件物品放入容量为j-wi的背包中的最优价值组合”,此时对应的值为KS(i-1,j-wi)+vi。

因此,就能够很容易的写出递归解法了:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};

    @Test
    public void testKnapsack1() {
        int result = ks(4,10);
        System.out.println(result);
    }

    private int ks(int i, int c){
        int result = 0;
        if (i == 0 || c == 0){
            // 初始条件
            result = 0;
        } else if(ws[i] > c){
            // 装不下该珠宝
            result = ks(i-1, c);
        } else {
            // 能够装下
            int tmp1 = ks(i-1, c);
            int tmp2 = ks(i-1, c-ws[i]) + vs[i];
            result = Math.max(tmp1, tmp2);
        }
        return result;
    }
}

这里为了方便处理,将数组ws和vs都增长了一个补位数0,防止数组越界,输出结果:

13

这样,咱们就轻松加愉快的解决了这个问题。

动态规划解法

验证可行性

既然开头已经说了两个验证问题是否可使用动态规划求解的方法,那么为什么不试一试呢?

先来看看最优化原理。一样,咱们使用反证法:

假设(x1,x2,…,xn)是01背包问题的最优解,则有(x2,x3,…,xn)是其子问题的最优解,假设(y2,y3,…,yn)是上述问题的子问题最优解,则有(v2y2+v3y3+…+vnyn)+v1x1 > (v2x2+v3x3+…+vnxn)+v1x1。说明(X1,Y2,Y3,…,Yn)才是该01背包问题的最优解,这与最开始的假设(X1,X2,…,Xn)是01背包问题的最优解相矛盾,故01背包问题知足最优性原理

至于无后效性,其实比较好理解。对于任意一个阶段,只要背包剩余容量和可选物品是同样的,那么咱们能作出的现阶段的最优选择一定是同样的,是不受以前选择了什么物品所影响的。即知足无后效性

自上而下记忆法

就像上一篇里的解法同样,自上而下的解法与分治法的区别就是增长了一个数组用来存储计算的中间结果来减小重复计算。这里,咱们只须要多定义一个二维数组。

表格中,每个格子都表明着一个子问题,咱们最终的问题是求最右下角的格子的值,也就是i=4,j=10时的值。这里,咱们的初始条件即是i=0或者j=0时对应的ks值为0,这很好理解,若是可选物品为0,或者剩余容量为0,那么最大价值天然也是0。代码以下:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};
    Integer[][] results = new Integer[5][11];

    @Test
    public void testKnapsack2() {
        int result = ks2(4,10);
        System.out.println(result);
    }

    private int ks2(int i, int c){
        int result = 0;
        // 若是该结果已经被计算,那么直接返回
        if (results[i][c] != null) return results[i][c];
        if (i == 0 || c == 0){
            // 初始条件
            result = 0;
        } else if(ws[i] > c){
            // 装不下该珠宝
            result = ks(i-1, c);
        } else {
            // 能够装下
            int tmp1 = ks(i-1, c);
            int tmp2 = ks(i-1, c-ws[i]) + vs[i];
            result = Math.max(tmp1, tmp2);
            results[i][c] = result;
        }
        return result;
    }
}

能够看到,其实只比分治多了三行代码。

自下而上填表法

接下来,咱们用自下而上的方法来解一下这道题,思路很简单,就是不断的填表,回想一下上一篇中的斐波拉契数列的自下而上解法,这里将使用一样的方式来解决。仍是使用上面的表格,咱们开始一行行填表。

当i=1时,即只有珠宝1可供选择,那么若是容量足够的话,最大价值天然就是珠宝1的价值了。

当i=2时,有两个物品可供选择,此时应用上面的递推关系式进行判断便可。这里以i=2,j=3为例进行分析:

剩下的格子使用相同的方法进行填充便可:

这样,咱们就获得了最后的结果:13。根据结果,咱们能够反向找出各个物品的选择,寻找的方法很简单,就是从i=4,j=10开始寻找,若是ks(i-1,j)=ks(i,j),说明第i个物品没有被选中,从ks(i-1,j)继续寻找。不然,表示第i个物品已被选中,则从ks(i-1,j-wi)开始寻找。

转化成代码:

public class Solution{
    int[] vs = {0,2,4,3,7};
    int[] ws = {0,2,3,5,5};
    Integer[][] results = new Integer[5][11];
    
    @Test
    public void testKnapsack3() {
        int result = ks3(4,10);
        System.out.println(result);
    }

    private int ks3(int i, int j){
        // 初始化
        for (int m = 0; m <= i; m++){
            results[m][0] = 0;
        }
        for (int m = 0; m <= j; m++){
            results[0][m] = 0;
        }
        // 开始填表
        for (int m = 1; m <= i; m++){
            for (int n = 1; n <= j; n++){
                if (n < ws[m]){
                    // 装不进去
                    results[m][n] = results[m-1][n];
                } else {
                    // 容量足够
                    if (results[m-1][n] > results[m-1][n-ws[m]] + vs[m]){
                        // 不装该珠宝,最优价值更大
                        results[m][n] = results[m-1][n];
                    } else {
                        results[m][n] = results[m-1][n-ws[m]] + vs[m];
                    }
                }
            }
        }
        return results[i][j];
    }
}

嗯,完美解决。时间复杂度即填表耗时O(n * c),这里用了一个二维数组来存储子问题的解,因此空间复杂度为O(n * c);

总结

回过头再看看上面的分析,会发现动态规划里最关键的问题实际上是寻找原问题的子问题,并写出递推表达式,只要完成了这一步,代码部分都是水到渠成的事情了。

那么问题来了,怎样把问题拆分红子问题呢?

emmm,这个问题有点超纲了,说实话,我也没有掌握到诀窍,仍是得具体状况具体分析,可是不少经典的问题都有其经典的套路,其它问题均可以归结到这些问题上面来,能够看作是它们的变种和延伸,把这些经典的问题吃透的话,天然能触类旁通。好比采药问题,本质上就是01背包问题,而硬币问题,本质上就是咱们以后要介绍的彻底背包问题。

我的认为,算法不在于刷多少个,而在于概括总结,就跟作数学题同样,总有一些范式和套路,无论形式如何变化,其本质是同样的,万变不离其宗,说的就是这么回事。

本篇到此就告一段落了,若是以为有收获,不要吝啬你的赞哦,也欢迎关注个人公众号留言交流。

相关文章
相关标签/搜索