动态规划(一)详解揹包问题

1 引言

1.1 背景

在许多算法中都有子程序重复计算的问题。在 Fibi 计算中采用的存储前面几个结果数值的方法并不是很通用。这样, 在很多情况下存储中间结果全列表的方法就非常有用了。
这种存储子程序结果列表的算法设计方法就称为动态规划(dynamic programming)。
——《数据结构与算法分析(C++版)(第三版)》

Fibi 指的是书中使用循环实现的斐波那契数列代码,如下:

long Fibi(int n){
	// Fibi(46) is largest value that fits in a long
	Assert((n > 0) && (n < 47), "Input out of range");
	long past, prev, curr;
	past = prev = curr = 1;
	for(int i = 3; i <= n; i++){
		past = prev;  			// past holds Fibi(n-2)
		prev = curr;  			// prev holds Fibi(n-1)
		curr = past + prev;  	// curr holds Fibi(n)
	} 
	return curr;
}

友情链接:(转)《剑指offer》中的斐波那契数列系列问题

2.2 动态规划问题的性质

任何思想方法都有一定的局限性,超出了特定条件,它就失去了作用。同样,动态规划也并不是万能的。适用动态规划的问题必须满足最优化原理和无后效性。
—— 百度百科词条“动态规划”

动态规划解决的是优化问题,企图通过将问题划分成一个个子问题进而求取最优解,所以要求子问题的解是必须是最优的。同时,由于子问题一般带有重叠性,也就是带有重复计算的特点,所以我们希望保存中间结果来消除冗余计算。本质上,动态优化是一种空间换取时间的算法。

使用暴力解法求解优化问题的过程中,我们需要遍历问题的状态空间,开销很大;使用进化算法,如遗传算法时,种群的进化带有随机性,尽管理论上我们最终会获得最优解,但是解决诸如陷入局部最优、调参等问题时,我们耗费的精力也是很可观的。

2 揹包问题

揹包问题(Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。
也可以将揹包问题描述为决定性问题,即在总重量不超过W的前提下,总价值是否能达到V?它是在1978年由Merkel和Hellman提出的。
——百度百科词条“揹包问题”

2.1 01揹包

01揹包问题中每种物品只有1个,物品只有两种状态,要么在揹包内,要么不在揹包内。

先给出状态转移方程,之后进行分析:
P[i][j]=max(P[i1][j], P[i1][jVol[i]]+Value[i])P[i][j] = max(P[i-1][j], \ P[i-1][j-Vol[i]] + Value[i])

其中,ii 表示第 ii 个物体,jj 表示揹包的容量,Vol[i]Vol[i]表示第 ii 个物体的体积, Value[i]Value[i] 表示第 ii 个物体的价值。

P[i][j]P[i][j] 表示对于容量为 jj 的揹包来说,前 ii 件物体放置可以得到的最大价值。(重要)

2.1.1 分析

正如我们在引言中提到动态规划的概念,在搜索空间中搜索最优解的过程中,我们将问题划分成一个个子问题,我们会不断地尝试、计算、回退,重复计算的开销很大,所以我们需要保存中间结果,这就是我们使用矩阵 P[i][j]P[i][j] 的原因。

那么怎么决定每一个子问题最优解 P[i][j]P[i][j] 的取值呢?状态转移方程是怎么得到的呢?

举个例子,求容量为 10 的揹包的最佳放置方案。

假如我们已经决定了前 i1i-1 件物品的最优方案,得到了 P[i1][10]P[i-1][10] 的取值,这个值可能是 100(随便假设的),接下来我们要考虑第 ii 件物品了,第 ii 件物品的价值是 2(也是随便假设的)。

若第 ii 个物体在揹包里,P[i][10]P[i][10] 的取值肯定不小于 P[i1][10]P[i-1][10],为什么?因为如果将第 ii 件物品放进入揹包,但取值反而减小了,说明有一些物品为了给物品 ii 腾位置被拿出来了。这才会导致取值不增反减,也会使得我们的假设不成立。

所以,我们需要考察放入物品 ii 后的取值能否大于 P[i1][10]P[i-1][10],否则就没有更新的必要了。

我们希望放入物品 ii 后的方案是最优的,不妨这样考虑:ii 个物体我们已经假设在揹包里了,那么我们留下的空间只有 10Vol[i]10-Vol[i],若能找到这 10Vol[i]10-Vol[i] 的空间的最优值,在这个最优值的基础上加上物品 ii 的价值,不就是我们想要考察的P[i][10]P[i][10] 的最优取值吗?(当然,是不是最优还要和 P[i1][10]P[i-1][10] 比较一番~)

而我们的矩阵正是保存着这样的最优值,注意到这 10Vol[i]10-Vol[i] 的空间只能留给前 i1i-1 个物体,也就是说我们实际上考察的是 (P[i1][10Vol[i]]+Value[i])(P[i-1][10-Vol[i]] + Value[i]) 的取值能不能大于 P[i1][10]P[i-1][10]

假如我们运气好,找到的 P[i1][10Vol[i]]P[i-1][10-Vol[i]] 为99,那么毫无疑问,P[i][10]P[i][10] 可以更新为 99+2=10199+2 =101 了。

将我们的例子泛化为一般形式就能得到状态转移方程了。

2.1.2 解决方案

假设 n 表示物体个数,v 表示揹包容量。

常见的解决方案有两种,一种就是使用递归,状态转移方程实际上也是递归的递推公式,代码如下:

	private static int[] value;  // 价值数组
    private static int[] volume; // 体积数组
    
	private static int KnapsackCore(int n, int v) {
        if (n < 0 || v < volume[n]) return 0;

        int temp1 = KnapsackCore(n - 1, v);
        int temp2 = KnapsackCore(n - 1, v - volume[n]) + value[n];

        return temp1 >= temp2 ? temp1 : temp2;
    }

第二种就是使用中间矩阵 PP 自底向上求取最优解。

具体怎样更新矩阵,下面以体积分别为 (1,2,3,4,5),价值分别为 (5,4,3,2,1) 的五个物品为例,假设揹包的容量为 10。根据状态转移方程有下表:
在这里插入图片描述
从左到右,从上到下更新每一行即可,其中的蓝色箭头表示更新数值的计算对象,比如,P[1][3]=P[11][32]+4=P[0][1]+4=9P[1][3] = P[1-1][3-2] + 4 = P[0][1] + 4 = 9

注意边界条件,超出边界的数值直接取0。比如求P[1][1]P[1][1] 时,P[1][1]=P[11][12]+4=P[0][1]+4=4P[1][1] = P[1-1][1-2] + 4 = P[0][-1] + 4 = 4,很显然 P[0][1]P[0][-1] 已经超出范围了,直接令 P[0][1]=0P[0][-1] = 0 ,所以 P[1][1]=max(P[0][1],P[0][1]+4)=5P[1][1] = max(P[0][1], P[0][-1]+4) = 5。迭代结束后,右下角就是最终的答案。

为什么使用 v+1v+1 列,而不是使用 vv 列呢?
首先 vv 是可以取到0的,我们允许输入为0,其次,便于代码直接使用数组的列标作为容量进行计算。

为什么使用 nn 行,而不使用 n+1n+1 行?
很多文章中解决的问题的矩阵大小是 (n+1)(v+1)(n+1) * (v+1),其实使用 nn 还是 n+1n+1 都可以。使用 n+1n+1 行的矩阵就要求我们初始化的时候将第 0 行全部初始化为 0 ,然后从第 1 行开始更新,而使用 nn 行矩阵就需要注意边界条件,主要是第 0 行的更新需要做好判断。

2.1.3 代码


	private static int Knapsack(int v, int[] value, int[] volume)  throws Exception{
        int n = volume.length;
        if (n != value.length) // 注意代码的鲁棒性,面试时崩溃就凉了
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;  
        
        int P[][] = new int[n][v + 1];  // 构建n*(v+1)矩阵
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < v + 1; j++) {
                int temp1 =  i > 0 ? P[i - 1][j]:0;  	// 检查数组越界
                int temp2 =  i > 0 && j >= volume[i] ?  // 检查数组越界
                	P[i - 1][j - volume[i]] : 0;
                if(j >= volume[i])	temp2 += value[i];  // j能放下物品i
                
                P[i][j] = temp1 >= temp2 ? temp1 : temp2;
            }
        }
        show(n, v + 1, value, volume, P);
        return P[n-1][v];
    }
	// 格式化输出
    private static void show(int v, int[] value, int[] volume, int[][] P) throws Exception {
    	int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;
    	
        System.out.print("Volume Value    ");
        for (int i = 0; i < v; i++) {
            System.out.printf("%3d", i);
        }
        System.out.println();
        for (int i = 0; i < n; i++) {
			System.out.printf("%4d  %4d  %4d", volume[i], value[i], i);
            for (int j = 0; j < v; j++) {
                System.out.printf("%3d", P[i][j]);
            }
            System.out.println();
        }
    }
    // 主函数
    public static void main(String[] args) {
        int[] va = {5, 4, 3, 2, 1};
        int[] vo = {1, 2, 3, 4, 5}; 
        try {
            System.out.println("返回值:" + Knapsack(10, va, vo));
        } catch (Exception e){
            e.printStackTrace();
        }   	 	
    }

结果:
在这里插入图片描述

2.1.4 改进

观察发现,下一行的更新完全依赖于上一行,所以我们实际上可以使用一个 2*(v+1) 的矩阵改进这个算法,矩阵的两行数值交替更新。代码如下:

    private static int Knapsack(int n, int v, , int[] value, int[] volume) throws Exception {
        int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;

        int P[][] = new int[2][v + 1];
        int flag = 0;
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < v + 1; j++) {
                int temp1 = i > 0 ? P[1 - flag][j] : 0;  // i-1换成1-flag
                int temp2 = i > 0 && j >= volume[i] ?
                        P[1 - flag][j - volume[i]] : 0;  // i-1换成1-flag
                if (j >= volume[i]) temp2 += value[i];

                P[flag][j] = temp1 >= temp2 ? temp1 : temp2;  // i换成flag
            }
            flag = 1 - flag; 
        }
        show(2, v + 1, va, vo, P);
        return P[1 - flag][v];
    }

结果:
在这里插入图片描述

2.1.5 进一步改进

问题还可以进一步简化,继续观察发现,每一次更新的元素的列标都在上一行读取的元素之后,刚好是错开的,所以使用一个一维数组也能解决,如下图:
在这里插入图片描述
实际上,状态转移方程已经变成了:
P[j]=max(P[j], P[jVol[i]]+Value[i])P[j] = max(P[j], \ P[j-Vol[i]] + Value[i])

特别注意我们需要从数组的末端开始更新,这样才能准确读取到上一行的值。如果从前往后更新,后半部分元素读取的计算对象实际上已经被覆盖。代码如下:

private static int Knapsack(int v, int[] value, int[] volume) throws Exception {
		int n = volume.length;
        if (n != value.length)
            throw new Exception("Bad input!");
        if (n <= 0 || v <= 0) return 0;
		
        int P[] = new int[v + 1];  // v+1 一维数组
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) { 	// 从v往前更新
                int temp1 = P [j];
                int temp2 = j >= volume[i] ?  // 注意数组越界,同时判断j能否放下物品
                	P[j - volume[i]] + value[i] : 0;

                P[j] = temp1 >= temp2 ? temp1 : temp2;
            }
        }
        return P[v];
    }

结果:
在这里插入图片描述

2.1.6 其他测试用例

value = {6,3,5,4,6};
volume = {2,2,6,5,4};
n = 5, v =10;

结果:
在这里插入图片描述

2.2 完全揹包和多重揹包

完全揹包问题中物品的数量是无限的,而多重揹包中的物品数量是有限的。

注意到完全揹包问题的物品数量虽然是无限的,但是由于揹包的容量是有限的,实际上每种物品可以取的数量都是有限的,不多于 v/volume[i]v/volume[i],所以两者在一定程度上可以相互转化。假如多重揹包中的每一种物品的数量都超过 v/volume[i]v/volume[i],实际上就是完全揹包问题。

下面将两个问题统一解决。

2.2.1 分析

我们使用 num[i]num[i] 表示每种物品可以取的最大数量。对于多重揹包问题,这个数量来自用户的输入,对于完全揹包问题,需要我们通过 v/volume[i]v/volume[i] 进行初始化。

和 01 揹包问题相比,每一次决定的是不再是一个物体,而是 num[i]num[i] 个物体的状态。所以,状态转移方程改写为:
f[i][j]=max(f[i1][j],f[i1][jk×volume[i]]+k×value[i])f[i][j] = max(f[i-1][j],f[i-1][j-k \times volume[i]]+k \times value[i])

其中,0knum[i]0 \le k \le num[i]

和 01 揹包一样,对方程进行优化,使用一维数组解决,方程改写为:
f[j]=max(f[j],f[jk×volume[i]]+k×value[i])f[j] = max(f[j],f[j-k \times volume[i]]+k \times value[i])

2.2.2 代码

    private static int Knapsack(int v, int[] value, int[] volume, int[] num) throws Exception {
        int n = volume.length;
        if (n != value.length ||(num!=null && n != num.length))
            throw new Exception("Bad input");
        if (n <= 0 || v <= 0) return 0;
    
        if (num == null) {  // 没有传入数量限制,默认为完全揹包问题
            num = new int[n];
            for (int i = 0; i < n; i++) {
                num[i] = v / volume[i];
            }
        }
        
		int P[] = new int[v + 1];
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) {
                for (int k = 0; k <= num[i]; k++) {
                    int temp1 = P[j];
                    int temp2 = j >= k * volume[i] ? 
                    	P[j - k * volume[i]] + k * value[i] : 0;

                    P[j] = temp1 >= temp2 ? temp1 : temp2;
                }
            }
        }
        return P[v];
    }

2.2.3 测试

  1. 完全揹包测试用例:
    value = {9,5,3,1};
    volume = {7,4,3,2};
    num = null;
    n = 4, v =10;
    
    结果:
    在这里插入图片描述
  2. 多重揹包测试用例:
    value = {20, 10, 6};
    volume = {2, 2, 1};
    num = {2, 5, 10};
    n = 3, v =8;
    
    结果:
    在这里插入图片描述

2.3 揹包问题的其他优化

  1. 对完全揹包问题输入用例的优化。假如存在物体a,b,其中 a 的体积大于 b,但是 a 的价值低于 b,那么可以直接舍去 a。因为物体是无限的,选择“性价比”高的物体肯定更优。
  2. 虽然不能整体上对时间开销进行优化,但由于我们的循环次数较多,而且每一层循环都是完全循环,可以从这里入手,进行剪枝操作
  3. 规范用户输入,减少无用功。比如删除体积大于揹包容量的物体、检查 num[i]num[i]有没有超出揹包的最大容量等。

3 - 其他形式的揹包问题

假定有一个揹包,揹包中有一定的空间,空间的容积用一个整数 KK 来定义。有 nn 件物品,每一件物品都有一定的体积,第 ii 件物品的体积记为整数 KiK_i。揹包问题是,是否存在着 nn 件物品的一个子集,这个子集中的物品的体积之和正好为 KK
可以正式定义揹包问题如下:找出 S{1,2,...,n}S \subset \{ 1,2, ... , n\},使得
iSki=K\sum_{i \in S}k_i = K

——《数据结构与算法分析(C++版)(第三版)》第16章 算法模式

这个揹包问题和上面的揹包问题有什么区别吗?
这个揹包问题实际上已经不是一个优化问题,因为没有优化目标。但仍然可以应用动态规划的思想来处理,我们希望通过划分子问题、自底向上解决这个问题。

使用 P[i][j]P[i][j] 来表示前 i1i-1 个物体能否解决容量为 jj 的问题。由于本题考察的是存不存在这么一个子集满足条件,而不是最大价值,所以我们需要思考一下矩阵存的中间结果是什么?

3.1 第一种方法

矩阵保存一个boolean,若子问题解决,则该值为 true,反之为 false。

比如,有体积分别为{1,3,2,5}\{ 1,3,2,5\} 的 4 个物体,更新矩阵时,P[0][4]P[0][4] 为 false,因为只放第一个物体不能达到容量 4,前 1 个物体不能解决问题;P[1][4]P[1][4] 为 true,前 2 个物体都放进去刚好为 4;P[2][4]P[2][4] 为 true,虽然达到容量 4 并不需要放入第 3 个物体,但前 3 个物品能够解决问题,故应该为 true;同理,P[3][4]P[3][4] 也为 true,尽管第 4 个物品同样没用。

如下表,注意我们特别指定容积为 0 的时候恒存在满足条件的子集——空集

物品体积 序号/容积 0 1 2 3 4
1 0 true true false false false
3 1 true true false true true
2 2 true true true true true
5 3 true true true true true

按照我们的思路,似乎从上往下,再从左往右更新比较好。但是矩阵的更新方向其实影响不大,无非是 ii 作为外层循环还是 jj 作为外层循环的问题。

和 01 揹包问题一样,我们可以对这个算法进行优化,使用一维矩阵 P[j]P[j] 解决问题。考虑从一维矩阵的右边,也就是末端向前更新,对于 P[j]P[j],有:

  1. 假如 P[j]P[j] 为真,说明容量为 jj 的子问题已经被前面 i1i-1 个物体解决了,不需要更新。
  2. 假如 P[j]P[j] 为假,说明前面 i1i-1 个物体不能解决子问题,考察使用物体 ii 能否解决问题?若物体 ii 的体积等于 jj 或者 P[jvolume[i]]P[j - volume[i]] 为真,则 P[j]P[j] 为真,反之,不需要更新。

代码如下:

    private static boolean Knapsack(int v, int[] volume) {
        int n = volume.length;
        if (n == 0 || v < 0) return false;

        boolean P[] = new boolean[v + 1];
        P[0] = true;  // 容量0恒有解
        for (int i = 0; i < n; i++) {
            for (int j = v; j >= 0; j--) {
                if (!P[j] && j >= volume[i]) { // 注意数组越界,越界表示不存在子问题
                    if (j == volume[i] || P[j - volume[i]]) 
                    	P[j] = true;
                }
            }
        }
        return P[v];
    }

测试用例:

volume = {9, 2, 7, 4, 6};
v = 10;

结果:
在这里插入图片描述

3.2 第二种方法

第一种方法只记录了结果存不存在,但不能得到结果具体放入了哪些物体,无论是二维数组还是一维数组都不能。

所以,我们希望矩阵除了记录前 ii 个物体能否解决问题外,还希望矩阵记录每个物体对于解决这个子问题是否有帮助,以便可以找到子集的具体元素。

所以,P[i][j]P[i][j] 实际上对应了三种状态:

  1. ii 个物体没有解决问题 jj
  2. ii 个物体解决了问题 jj,但子集不需要物体 ii
  3. ii 个物体解决了问题 jj,子集包括物体 ii

据此,我们使用一些可标记三种状态的数据类型即可,比如字符数组、整型数组等。代码如下,使用了整型数组,其中 0,-1,1 分别对应三种状态

   private static void Knapsack(int v, int[] volume) {
        int n = volume.length;
        if (n == 0) return;

        int P[][] = new int[n][v + 1];
        // 默认容量0恒有解
        for (int i = 0; i < n; i++) {
            P[i][0] = -1;
        }
        for (int i = 0; i < n; i++) {
            for (int j = 1; j < v + 1; j++) {
            	// 先判断前i-1个物体有没有解决问题
            	// 注意,取值为1或者-1都代表问题被解决
                if (i > 0 && (P[i - 1][j] == -1 || P[i - 1][j] == 1)) {
                    P[i][j] = -1;
                }
                // 再判断物体i能否解决问题
                if (j == volume[i]) {
                    P[i][j] = 1;
                } else if (i > 0 && j >= volume[i]) {
                    if (P[i - 1][j - volume[i]] == -1 || P[i - 1][j - volume[i]] == 1) {
                        P[i][j] = 1;
                    }
                }
            }
        }
    }

测试用例:

volume = {9, 2, 7, 4, 1};
v = 10;

结果:
在这里插入图片描述
怎么获得子集中的元素?
观察 P[4][10]P[4][10] 有解,且子集包含物体 4。但物体 4 体积只有 1,必然需要依赖子问题 9;物体只能放一次,故指针往上走,观察 P[41][101]=P[3][9]P[4-1][10-1] = P[3][9] 有解,但是物体 3 不在子集内,指针继续往上走一步,观察到 P[31][9]=P[2][9]P[3-1][9] =P[2][9] 有解,且包含物体 2,但体积仍不够,还要依赖子问题 2,指针继续走到P[21][97]=P[1][2]P[2-1][9-7] = P[1][2],物体 1 也被包含到子集中。终于,总体积已经达到了 10,所以子集为{4,2,1}\{ 4,2,1\}
在这里插入图片描述
有些子问题有两个 1,代表什么意思?
这代表子集的形式不止一种。对于容量 10 的问题来说,子集就有{4,2,1}\{ 4,2,1\}{4,0}\{ 4,0\}

4 - 参考

  1. 揹包问题详解:01揹包、完全揹包、多重揹包
  2. 揹包问题详解
  3. 百度百科词条“揹包问题”
  4. 百度百科词条“动态规划”
  5. 《数据结构与算法分析(C++版)(第三版)》

正文结束。

部分代码多次修改,如有校对错误,请留言指出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章