背包九讲个人整理(施工中

动态规划理论基础

只有当问题符合最优化原理和无后效原理,才适合使用动态规划

最优化原理

最优化原理定义的最优策略:不论过去状态和决策如何,对前面的决策所形成的状态而言,余下的决策必须构成最优策略

简单来说就是一个最优策略的子策略(之后产生的策略)也是必须是最优的,而所有子问题的局部最优解将导致整个问题的全局最优

如果一个问题能满足最优化原理,就称其具有最优子结构性质,这是判断问题能否使用动态规划解决的先决条件,如果一个问题不能满足最优化原理,那么这个问题就不适合用动态规划来求解

举个例子:

棋盘上A点到B点的最短距离,那么子问题就是求从A点到B点之间的 中间点 到B点的最短距离

怎么证明最优化原理呢?

我们假设从A点到C点的最短距离为d,假设其最优策略的子策略经过B点,记该策略中B点到C点的距离为d1,A点到B点的距离为d2

用反证法,假设存在B点到C点的最短距离d3,并且d3 < d1,那么 d3 + d2 < d1 + d2 = d,这与d是最短距离相矛盾,所以,d1是B点到C点的最短距离

再举一个反例:

扫描二维码关注公众号,回复: 6716562 查看本文章

求A到D的所有通道中,总长度除以4得到的余数最小的路径为 最优路径,求一条最优路径

按照之前的思路,A的最优取值应该可以由B的最优取值来确定,而B的最优取值为(3+5)mod 4 = 0,所以应该选d2d6这两条道路,而实际上,全局最优解是d4+d5+d6或者d1+d5+d3,所以这里子问题的最优解并不是原问题的最优解,即不满足最优化原理,所以就不适合使用动态规划来求解了

无后效性

某状态下决策的收益,只与状态和决策相关,与到达该状态的方式无关

某个阶段的状态一旦确定,则此后过程的演变不再受此前各种状态及决策的影响。换句话说,未来与过去无关,当前状态是此前历史状态的完整总结,此前历史决策只能通过影响当前的状态来影响未来的演变。再换句话说,过去做的选择不会影响现在能做的最优选择,现在能做的最优选择只与当前的状态有关,与经过如何复杂的决策到达该状态的方式无关。

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

我们再回头看看上面的最短路径问题,如果在原来的基础上加上一个限制条件:同一个格子只能通过一次。那么, 这个题就不符合无后效性了,因为前一个子问题的解会对后面子问题的选择策略有影响

01背包

有N件物品和一个容量为C的背包,第i件物品的费用(占空间/重量)是w[i] ,价值是v[i]每种物品仅有一件,可以选择放或不放,求将哪些物品装入背包可使价值总和最大

假设背包总容量为10,有5个物品,它们的价值(v)和重量(w)如下表:

编号 1 2 3 4
价值v 2 4 3 7
重量w 2 3 5 5

这里每个物品只有一个,对于每个物品而言,只有两种选择,要或不要,记为1和0,所以叫01背包

xi代表第i个物品的选择(xi = 1 要,0则代表不要),vi代表第i个物品的价值,wi代表第i个物品的重量,我们背包的初始状态是容量为10,包内物品总价值为0,接下来,我们就要开始做选择了。对于1号物品,当前容量为10,容纳它的重量2绰绰有余,因此有两种选择,选它或者不选。我们选择一个物品的时候,背包的容量会减少,但是里面的物品总价值会增加

那么对于物品2,当前剩余容量为8,大于物品2的容量3,因此也有两种选择,选或者不选

现在,我们得到了四个可能结果,我们每做出一个选择,就会将上面的每一种可能分裂成两种可能,后续的选择也是如此,最终,我们会得到如下的一张决策图

红色方框代表我们的最终待选结果,本来应该有16个待选结果,但有三个结果由于容量不足以容纳下最后一个物品,所以就没有继续进行裂变。然后,从这些结果中找出价值最大的,也就是13,这就是我们的最优选择,根据这个选择,依次找到它的所有路径,便可以知道该选哪几个物品

分治法

接下来,我们就来分析一下,如何将它扩展到一般情况。为了实现这个目的,我们需要将问题进行抽象并建模,然后将其划分为更小的子问题,找出递推关系式

  1. 抽象问题,背包问题抽象为寻找组合(x1,x2,x3…xn,其中xi = 0或1,表示第i个物品取或者不取),vi代表第i个物品的价值,wi代表第i个物品的重量,总物品数为n,背包容量为c
  2. 建模,问题即为max(x1×v1 + x2×v2 + x3×v3 + … + xn×vn)
  3. 约束条件,x1×w1 + x2×w2 + x3×w3 + … + xn×wn < 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,从两者中选择较大的那个,

所以可以得到递推关系式

KS(i,j)=KS(i-1,j)  //j<wi
KS(i,j)=max[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;
    }
}

动态规划法

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

假设(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

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];
    }
}

动态规划里最关键的问题其实是寻找原问题的子问题,并写出递推表达式,只要完成了这一步,代码部分都是水到渠成的事情了

用子问题定义状态:用 f [i] [j] 表示已经处理到第 i 个物品,前 i 件物品放入一个剩余容量为 j 的背包可以获得的最大价值,那么会先出现两种情况

  1. 背包体积 j < i 的体积 w[i],这时候背包容量不足以放下第 i 件物品,只能选择不拿:f [i] [j] = f [i-1] [j]
  2. 背包体积 j >= i 的体积 w[i],这时候背包容量可以放下第 i 件物品,我们就要考虑是拿还是不拿:
    1. 拿: f [i] [j] = f [i-1] [j-w[i]]+v[i]; f [i-1] [j-w[i]] 指的是前 i-1 件物品,背包容量为j-w[i]时的最大价值(相当于为第i件物品腾出了w[i]的空间)
    2. 不拿:f [i] [j] = f [i-1] [j] 同1

所以可以得到状态转移方程

if (j<w[i]) //背包剩余容量j小于物品i的体积
    f[i][j] = f[i-1][j] //装不下第i个物体,目前只能靠前i-1个物体装包
else
    f[i][j] = max(f[i-1][j], f[i-1][j-w[i]] + v[i])

$$
f[i][j]=max(f[i−1][j],f[i−1][j−w[i]]+v[i])
$$
i 件物品放入一个容量恰为 j 的背包可以获得的最大价值

价值数组v = {8, 10, 6, 3, 7, 2},

重量数组w ={4, 6, 2, 2, 5, 1},

背包容量C = 12时对应的m[i][j]数组

i\j 1 2 3 4 5 6 7 8 9 10 11 12
1 0 0 0 8 8 8 8 8 8 8 8 8
2 0 0 0 8 8 10 10 10 10 18 18 18
3 0 6 6 8 8 14 14 16 16 18 18 24
4 0 6 6 9 9 14 14 17 17 19 19 24
5 0 6 6 9 9 14 14 17 17 19 21 24
6 2 6 8 9 11 14 16 17 19 19 21 24

左上角按行求解,一直求解到右下角

for (int i = 1;i <= N;i++) //枚举物品
    for (int j = 0;j <= W;j++) { //枚举背包容量
        f[i][j] = f[i - 1][j];
        if (j >= w[i])
                f[i][j] = max(f[i-1][j],f[i-1][j-w[i]] + v[i]);
    }

空间优化(滚动数组)

递推本来就是用空间换时间,消耗的空间比较大

可以发现,每次求解 KS(i,j)只与KS(i-1,m) {m:1...j} 有关。也就是说,如果我们知道了K(i-1,1...j)就肯定能求出KS(i,j),如图

下一层只需要根据上一层的结果即可推出答案,举个栗子,看i=3,j=5时,在求这个子问题的最优解时,根据上述推导公式,KS(3,5) = max{KS(2,5),KS(2,0) + 3} = max{6,3} = 6;如果我们得到了i=2时所有子问题的解,那么就很容易求出i=3时所有子问题的解。

因此,我们可以将求解空间进行优化,将二维数组压缩成一维数组,此时,装填转移方程变为:

KS(j) = max{KS(j),KS(j - wi) + vi}
这里KS(j - wi)就相当于原来的KS(i-1, j - wi)。需要注意的是,由于KS(j)是由它前面的KS(m){m:1..j}推导出来的,所以在第二轮循环扫描的时候应该由后往前进行计算,因为如果由前往后推导的话,前一次循环保存下来的值可能会被修改,从而造成错误。

这么说也许还是不太清楚,回头看上面的图,我们从i=2推算i=3的子问题的解时,此时一维数组中存放的是{0,0,2,4,4,6,6,6,6,6,6},这是i=2时所有子问题的解,如果我们从前往后推算i=3时的解,比如,我们计算KS(0) = 0,KS(1) = KS(1) = 0 (因为j=1时,装不下三个物品,第三个物品的重量为5),KS(2)=2,KS(3)=4,KS(4)=4, KS(5) = max{KS(5), KS(5-5) + 3} = 6,....,KS(8) = max{KS(8),KS(8 - 5) + 3} = 7。在这里计算KS(8)的时候,我们就把原来KS(8)的内容修改掉了,这样,我们后续计算就无法找到这个位置的原值,也就是上一轮循环中计算出来的值了,所以在遍历的时候,需要从后往前进行倒序遍历

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

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

    private int ksp(int i, int c){
        // 开始填表
        for (int m = 0; m < vs.length; m++){
            int w = ws[m];
            int v = vs[m];
            for (int n = c; n >= w; n--){
                newResults[n] = Math.max(newResults[n] , newResults[n - w] + v);
            }
            // 可以在这里输出中间结果
            System.out.println(JSON.toJSONString(newResults));
        }
        return newResults[newResults.length - 1];
    }
}
[0,0,0,0,0,0,0,0,0,0,0]
[0,0,2,2,2,2,2,2,2,2,2]
[0,0,2,4,4,6,6,6,6,6,6]
[0,0,2,4,4,6,6,6,7,7,9]
[0,0,2,4,4,7,7,9,11,11,13]
13

对于将来肯定用不到的数据,直接覆盖,所以这个方法叫滚动数组

缺点是牺牲了抹除了大量数据,不是每道题都可以用,但是在这里我们要的答案刚好是递推的最后一步,所以直接输出即可


先回顾我们之前的状态转移方程

f[i][j] = max(f[i-1][j], f[i-1][j-W[i]] + [i]])

想知道f [i] [j] ,需要 f [i-1] [j] 和 f [i-1] [j-w[i]] ,我们之前是使用二维数组保存中间状态,所以可以直接取出这两个状态的值

我们可以直接使用一维数组 f [j] 表示:在执行 i 次循环后(已经处理 i 个物品),前 i 个物体放到剩余容量 j 时的最大价值,即之前的 f [i] [v]

与二维相比较,它把第一维隐去了,但是二者表达的含义还是相同的,只不过针对不同的i,f[j] 一直在重复使用,所以,也会出现第i次循环可能会覆盖第 i - 1 次循环的结果

    for (int i = 1;i <= N;i++) //枚举物品  
        for (int j = W; j >= w[i]; j--) //枚举背包容量
            f[j] = max(f[j],f[j] - w[i]] + v[i]);

初始值

01背包问题一般有两种不同的问法,一种是“恰好装满背包”的最优解,要求背包必须装满,那么在初始化的时候,除了KS(0)0,其他的KS(j)都应该设置为负无穷大,这样就可以保证最终得到的KS(c)是恰好装满背包的最优解。另一种问法不要求装满,而是只希望最终得到的价值尽可能大,那么初始化的时候,应该将KS(0...c)全部设置为0

为什么呢?因为初始化的数组,实际上是在没有任何物品可以放入背包的情况下的合法状态。如果要求背包恰好装满,那么此时只有容量为0的背包可以在什么都不装且价值为0的情况下被“恰好装满”,其他容量的背包均没有合法的解,因此属于未定义的状态,应该设置为负无穷大。如果背包不需要被装满,那么任何容量的背包都有合法解,那就是“什么都不装”。这个解的价值为0,所以初始状态的值都是0

完全背包

有N件物品和一个容量为T的背包,第i件物品的费用(占空间/重量)是w[i] ,价值是v[i]每种物品都有无限件可用,求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大

贪心?把每种物品的价格除以体积来算出它们各自的性价比,然后只选择性价比最高的物品放入背包中

但这种做法还有一个问题,单个物品是无法拆分,不能选择半件,这样往往无法用性价比最高的物品来装满整个背包,比如背包空间为10,性价比最高的物品占用空间为7,那么剩下的空间该如何填充呢?

你可能还会想到用性价比第二高的物品填充,如果仍旧无法填满,那就依次用第三、第四性价比物品来填充,看似可行,但只需要举一个反例便能证明这个策略不可用:

  • 只有两个物品:A价值5,体积5;B价值8,体积7,背包容量为10

递归法

由01背包的
$$
f[i][j]=max(f[i−1][j],f[i−1][j−w[i]]+v[i])
$$
可以得到
$$
f[i][j]=max [ (f[i−1][j−k*w[i]]+kv[i]) ,0\leq kw[i] \leq j ]
$$
f [i] [j] 依然表示已经处理到第 i 个物品,前 i 件物品放入一个剩余容量为 j 的背包可以获得的最大价值

f [i−1] [j−k × w[i]] + k × v[i] 表示:i-1 种物品中选取若干件物品放入剩余空间为 j-k × w[i] 的背包中所能得到的最大价值 加上 k 件第 i 种物品的总价值

public static class Knapsack {
    private static int[] P={0,5,8};
    private static int[] V={0,5,7};
    private static int T = 10;

    @Test
    public void soleve1() {
        int result = ks(P.length - 1,10);
        System.out.println("最大价值为:" + result);
    }

    private int ks(int i, int t){
        int result = 0;
        if (i == 0 || t == 0){
            // 初始条件
            result = 0;
        } else if(V[i] > t){
            // 装不下该珠宝
            result = ks(i-1, t);
        } else {
            // 可以装下
            // 取k个物品i,取其中使得总价值最大的k
            for (int k = 0; k * V[i] <= t; k++){
                int tmp2 = ks(i-1, t - V[i] * k) + P[i] * k;
                if (tmp2 > result){
                    result = tmp2;
                }
            }
        }
        return result;
    }
}

对比一下01背包问题中的递归解法,就会发现唯一的区别便是这里多了一层循环,因为01背包中,对于第i个物品只有选和不选两种情况,只需要从这两种选择中选出最优的即可,而完全背包问题则需要在k种选择中选出最优解,这便是最内层循环在做的事情

for (int k = 0; k * V[i] <= t; k++){
    // 选取k个第i件商品的最优价值为tmp2
    int tmp2 = ks(i-1, t - V[i] * k) + P[i] * k;
    if (tmp2 > result){
        // 从中拿出最大的值即为最优解
        result = tmp2;
    }
}

最优化原理和无后效性

那这个问题可以不可以像01背包问题一样使用动态规划来求解呢?来证明一下即可。

首先,先用反证法证明最优化原理:假设完全背包的最优解为F(n1,n2,…,nN)(n1,n2 分别代表第1、第2件物品的选取数量),完全背包的子问题为,将前i种物品放入容量为t的背包并取得最大价值,其对应的解为:F(n1,n2,…,ni),假设该解不是子问题的最优解,即存在另一组解F(m1,m2,…,mi),使得F(m1,m2,…,mi) > F(n1,n2,…,ni),那么F(m1,m2,…,mi,…,nN) 必然大于 F(n1,n2,…,nN),因此 F(n1,n2,…,nN) 不是原问题的最优解,与原假设不符,所以F(n1,n2,…,ni)必然是子问题的最优解

再来看看无后效性:前i种物品如何选择,只要最终的剩余背包空间不变,就不会影响后面物品的选择,满足无后效性

因此,完全背包问题也可以使用动态规划来解决。

动态规划

ks(i,t) = max{ks(i-1, t - V[i] * k) + P[i] * k}; (0 <= k * V[i] <= t)

递推法中,已经找到了递推关系式,就是状态转移方程

自上而下记忆法

public static class Knapsack {
    private static int[] P={0,5,8};
    private static int[] V={0,5,7};
    private static int T = 10;

    private Integer[][] results = new Integer[P.length + 1][T + 1];

    @Test
    public void solve2() {
        int result = ks2(P.length - 1,10);
        System.out.println("最大价值为:" + result);
    }

    private int ks2(int i, int t){
        // 如果该结果已经被计算,那么直接返回
        if (results[i][t] != null) return results[i][t];
        int result = 0;
        if (i == 0 || t == 0){
            // 初始条件
            result = 0;
        } else if(V[i] > t){
            // 装不下该珠宝
            result = ks2(i-1, t);
        } else {
            // 可以装下
            // 取k个物品,取其中使得价值最大的
            for (int k = 0; k * V[i] <= t; k++){
                int tmp2 = ks2(i-1, t - V[i] * k) + P[i] * k;
                if (tmp2 > result){
                    result = tmp2;
                }
            }
        }
        results[i][t] = result;
        return result;
    }
}

自下而上填表法

完全背包可以转化为01背包

多重背包

有N件物品和一个容量为的背包,第i件物品的费用(占空间/重量)是w[i] ,价值是v[i]每种物品只有 n[i] 件可用,求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大

二维费用背包

猜你喜欢

转载自www.cnblogs.com/zhxmdefj/p/11128193.html