动态规划之0/1背包问题原理详解: 简明、细致、深入理解

前言

背包问题是一类经典的动态规划问题,但在具体的算法考察过程中几乎不会直接问你背包问题原型,往往都是给出一个具体情景,需要你通过分析判定出问题是否符合背包问题的特征,从而是否能够使用动态规划去解决,所以对背包问题原型的熟悉程度很关键,今天我们就先来看看背包问题的“万恶之源”——0/1背包问题。

注:本文记录时候参考微信公共号代码随想录Carl大佬的分析和学习思路,感兴趣的小伙伴可以自行搜索相关内容进行进一步学习,附一个Carl哥的知乎链接:

咱就把0-1背包问题讲个通透! - 知乎 (zhihu.com)

正题

经典0/1背包问题

先给出最经典的0/1背包问题的大致原型描述:

给你一个背包,容量为W

你面前有一组物品(n_1, n_2, … n_i),共计n个物品

每个物品ni具有二维属性(w_i, v_i), 分别表示当前物品的容量和物品本身的价值

每当把一个物品n_i放入背包中,会占用掉w_i的背包容量并获得v_i的价值

问题:求解该背包装入这些物品能获得的最大价值V_max

举例说明

假设当前有一个背包,容量W为3,现有3个物品,描述如下表:

重量(容量)价值
物品128
物品214
物品3310

那么根据问题要求:选择装入物品1和物品2是最佳方案,能够获得8+4=12的最大价值,且所耗容量为2 + 1 = 3 <= W

小结

以上便是0/1背包问题的原型,那么其他多种多样的背包问题是如何描述的呢?差别在哪里?

其实在0/1背包问题中,物品有一个属性被隐藏掉了,其实物品应该具备三个属性**(c_i, w_i, v_i)**,其中多出来的c_i代表物品的数量,显然在0/1背包中,物品数量永远为1件,而如果物品数量无限制,则为完全背包问题,如果各个物品数量不相同,这又叫多重背包问题。

这里参考Carl哥的总结,给出背包问题的基本分类:

在这里插入图片描述

各种背包问题的根本都是0/1背包问题,所以今天我们只看0/1背包问题的解决方法!

暴力法如何解决?

很简单,穷举所有的物品组合情况,回溯求解,在求解过程中不断更新全局最大值

时间复杂度分析:很简单,假设有n个物品,每个物品要么选择放入背包,要么不放,方案数有2*n个,时间复杂度为O(2 * n),指数级别

根据个人经验,leetcode还好基本属于最慢的情况,排名后10%差不多,但是涉及到应试几乎无一例外都会超时,ac情况惨不忍睹,建议如果不是时间完全不够或者动态规划方法不会的情况下,千万别暴力做!!!

给出本人的一个回溯代码吧,以Java为例,思想很简单:

public class Solution {

    /**
	* 全局最优解
	*/
    static int res = 0;
    public static void main(String[] args) {
        int[][] nums = new int[3][2];
        nums[0][0] = 2;
        nums[0][1] = 8;
        nums[1][0] = 1;
        nums[1][1] = 4;
        nums[2][0] = 3;
        nums[2][1] = 10;
        process(nums, 0, 0, 3, 0);
        System.out.println(res);
    }

    /**
     * @param nums  二维数组 序号代表第i个物品 nums[i][0] nums[i][1] 分别代表第i个物品的容量和价值
     * @param curW  当前已占用的背包容量
     * @param curV  当前已获得的累计价值
     * @param W     背包的最大容量
     * @param index 控制物品编号选择的下标 从0开始往后走
     */
    public static void process(int[][] nums, int curW, int curV, int W, int index) {
        //nums.length个物品求子集
        for (int i = index; i < nums.length; i++) {
            //选择第i个物品
            //不超重,可选择
            if (curW + nums[i][0] <= W) {
                curW += nums[i][0];
                curV += nums[i][1];
                //更新全局最优值
                res = Math.max(res, curV);
                //dfs 继续选择
                process(nums, curW, curV, W, i + 1);
                //回溯
                curV -= nums[i][1];
                curW -= nums[i][0];
            }
        }
    }
}

暴力法下如何得到具体的方案:

很简单,因为是暴力回溯,只需要用一个路径记录一下做出的选择即可,比如用一个list对每次选择的物品号做记录,同时维持一个全局最佳方案,一旦发现curV > res时,我们把全局最优方案更新成当前这个list的内容即可,同时也要注意对这个list进行回溯!

动态规划

状态定义

状态

二维,一个是物品,一个是容量

状态解释

dp[i ] [j ] 表示,当前已经考虑了前i个物品的选择情况了,并且当前背包容量为j 情况下所获得的最大价值

这里一定要明确这二维状态的定义方式!!!

动态规划的本质就是:求解一个问题的最优解,我们通过求它子问题的最优解,然后一步步构造出整个问题的最优解

所以核心一定是明确子问题是什么?

这里有两个状态终点分别是物品数n 和容量W,那么子问题的个数就是 n * W个!

即第一维表示我当前先缩小问题的规模,先看前1个物品的选择情况,再看前2个物品的选择情况,再看前3个物品的选择情况,再看前i个物品的选择情况,一直处理到前n个物品的选择情况;而针对每一种上述情况,我们考虑背包的容量从0开始递增到W,即对于先看前1个物品的选择情况时,背包容量为0时最大价值是多少? 为1时最大价值是多少?一直到为w时最大价值为多少?

那么这个问题要求解的结果很容易理解为是 dp[n ] [W ] , 即已经考虑了前n个物品的选择情况,并且当前背包容量为W情况下的最大价值

选择

针对一个物品,如果当前背包的容量不能够容纳放入它,则当前无法进行任何选择;如果容量够,能放下它 ,那么可以有两种选择:要么放入背包,要么不放入,非常容易找到选择。

对每个状态做选择

有了状态和选择,动态转移就来了! 即对于每一个子问题状态,做全部的选择尝试,然后从中选结果最优的那个选择更新出的状态作为新状态! 这就是状态转移的推导!

状态转移

假如当前正要求 dp[i ] [j ] ,即 目前要考虑第 i 个物品的选择情况了 (即前i - 1个物品的选择情况,选了谁,不选谁已经确定好了!)

详细说一下状态的转移:

不放入第i个物品: dp[i ] [j ] = dp[i - 1] [j ]

当前第i个物品不放,必然第二个状态j根本不会变,第一个状态只需要继承一个i - 1时候的状态即可,同时最大价值不会有任何变化

放入第i 个物品 : dp[i ] [j ] = dp[i - 1] [j - w[i] ] + v[i]

首先第一个状态肯定还是继承 i - 1 时候的状态,当第二个状态是谁转移过来的?

由于对第i个物品我们选择是放入,则当前容量j 肯定是加上了物品i的容量了!所以把它减掉不就找到之前的状态,即 j - w[i]

同时最大价值因为放入了第i个物品,所以要加 v[i]

状态转移方程如下

在这里插入图片描述

状态初始化

状态初始化非常非常重要,一定要明白状态的是怎么更新的,否则得不到正确的结果,甚至是对于动态规划的任何优化,比如空间优化,遍历顺序的优化等都是基于对状态更新过程的完全掌握!

以上述例子说明一下dp矩阵的状态初始化,数组下标从0开始,所以物品编号从0开始!

在这里插入图片描述

边界状态初始化值的确定

熟悉动态规划的小伙伴应该有感觉:往往先求一下第0行和第0列的初始结果作为边界,然后动态规划,这是为什么呢?

其实这个并不固定,是根据你状态的转化过程决定的!!

什么是转化过程? 简单地理解就是要求**dp[i ] [j ]**这个子问题的结果 ,我必须要知道哪些其他子问题的结果!!而如何得知呢?要紧抓状态转移方程!!

例如:求解dp[1 ] [2 ] 根据我们的状态转移方程,dp[0 ] [2]是不是需要知道? 是不是 dp[0 ] [2 - w[i]] 需要知道?

dp[0 ] [2] 很具体了,那dp[0 ] [2 - w[i]]是谁? 是不是就是dp[0 ] [0 ]或者dp[0 ] [1 ] 中的一个,即dp[i ] [j ] 的上一行从0开始一直到dp[i ] [j ]左上角的结果为止,任意一个结果都有可能成为求解dp[i ] [j ]所需要的状态,所以我们要知道它们每一个的结果!!

因此状态转化过程可以理解为,从dp[i - 1] [0] ,dp[i - 1] [1] , dp[i - 1] [2]…dp[i - 1] [j ] 出发,能够确定出 dp[i ] [j ]的值

体现在矩阵中如下:
在这里插入图片描述
因此,对于次问题的dp矩阵,我们将第0行和第0列作为边界,就可以推导出其他任意处的结果!

边界计算较为简单,直接给出:
在这里插入图片描述

其他位置初始化值的确定

除了边界,其他位置怎么确定初始化值?

还是回归到状态转移方程中去:
在这里插入图片描述
很明显,可以看到某一个位置的结果求解跟自己的初始值没有任何关系,所有其他位置可以初始化成任意值,那这里我们就默认为初始化0值即可!

遍历顺序

显然我们在求解这n * W个子问题的结果时需要对dp矩阵进行遍历,遍历是双重循环:可以先对物品序号1 - n进行遍历,再对容量从0 - W进行遍历,也可以反过来遍历,这两个遍历顺序都是正确的,因为都符合我们的状态转化过程!! 具体可以自行分析一下比较容易。

给出迭代结果的变化

以先遍历物品编号,后遍历容量的顺序给出迭代结果的变化
在这里插入图片描述
在这里插入图片描述在这里插入图片描述
具体的推导过程不熟悉的小伙伴可以手动推导,这里不再给出,紧抓状态转移方程即可很容易的求解出dp矩阵的全貌!

伪代码

//dp矩阵构建
int[][] dp = new int[n][W + 1];
//边界初始化  w数组保存物品的容量 v数组保存物品的价值,假设编号都是相互对应的
for (int i = w[0]; i <= W; i++) {
    dp[0][i] = v[0];
}
//计算dp矩阵 因为边界已经计算过了,这里两重循环都从1开始进行即可
for (int i = 1; i < n; i++) {
    for (int j = 1; j <= W; j++) {
        if (j - w[i] < 0) {
            //容量不够 不可能放入
            dp[i][j] = dp[i - 1][j];
        } else {
            //容量够 可以放入第i个物品 考虑放不放
            dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
        }
    }
}
return dp[n - 1][W];

时间复杂度

显然为O(n * W),即子问题的规模 乘以 求解每个子问题的开销,而求解每个子问题的时间复杂度仅为O(1) (仅包含比较,求一次和以及赋值这样的常数级别的运算)

空间复杂度

显然为dp矩阵的空间大小 为O(n * W) , 我们后续再考虑对问题的空间复杂度进行优化!!!本文暂不予讨论

总结

个人认为:动态规划的每一步都很重要,缺一不可,

只有能确定好状态和选择,才有可能找到正确可行的状态转移方程

只有确定了状态转移方程,才能找到正确的状态转移的具体过程和dp矩阵的初始化值

只有知道状态转移的具体过程,才能知道哪里是边界,以及什么样的遍历顺序是正确的?什么样是错的?

只有上述流程都非常熟悉,优化起来才信手捏来!

初识动规时,其实每一步都是难点和重点,后续将对背包问题的其他类型,完全背包问题以及多重背包问题进行解析并进行空间复杂度的优化,同时以实际场景题目为例进行编程练习! 小伙伴们后续见!

  • 12
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值