草庐IT

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

yw906002599 2023-04-04 原文

前言

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

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

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

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

有关动态规划之0/1背包问题原理详解: 简明、细致、深入理解的更多相关文章

  1. ruby - 在 Ruby 中动态创建数组 - 2

    有没有办法在Ruby中动态创建数组?例如,假设我想遍历用户输入的书籍数组:books=gets.chomp用户输入:"TheGreatGatsby,CrimeandPunishment,Dracula,Fahrenheit451,PrideandPrejudice,SenseandSensibility,Slaughterhouse-Five,TheAdventuresofHuckleberryFinn"我把它变成一个数组:books_array=books.split(",")现在,对于用户输入的每一本书,我想用Ruby创建一个数组。伪代码来做到这一点:x=0books_array.

  2. ruby - 是否可以将 IRB 提示配置为动态更改? - 2

    我想在IRB中浏览文件系统并让提示更改以反射(reflect)当前工作目录,但我不知道如何在每个命令后进行提示更新。最终,我想在日常工作中更多地使用IRB,让bash溜走。我在我的.irbrc中试过这个:require'fileutils'includeFileUtilsIRB.conf[:PROMPT][:CUSTOM]={:PROMPT_N=>"\e[1m:\e[m",:PROMPT_I=>"\e[1m#{pwd}>\e[m",:PROMPT_S=>"FOO",:PROMPT_C=>"\e[1m#{pwd}>\e[m",:RETURN=>""}IRB.conf[:PROMPT_MO

  3. ruby-on-rails - carrierwave:在序列化动态属性上安装 uploader - 2

    首先,我使用的是rails3.1.3和来自master的carrierwavegithub仓库的分支。我使用after_init钩子(Hook)来确定基于属性的字段页面模型实例并为这些字段定义属性访问器将值存储在序列化哈希中(希望它清楚我是什么谈论)。这是我正在做的事情的精简版:classPage省略mount_uploader命令让我可以访问我想要的属性。但是当我安装uploader时出现错误消息说“nil类的未定义新方法”我在源代码中读到有方法read_uploader和扩展模块中的write_uploader。我如何必须覆盖这些来制作mount_uploader命令使用我的“虚拟

  4. ruby - 在 Ruby 中动态生成多维数组 - 2

    我正在尝试动态构建一个多维数组。我想要的基本上是这样的(为简单起见写出来):b=0test=[[]]test[b]这给了我错误:NoMethodError:undefinedmethod`test=[[],[],[]]而且它工作正常,但在我的实际使用中,我不会事先知道需要多少个数组。有一个更好的方法吗?谢谢 最佳答案 不需要像您正在使用的索引变量。只需将每个数组附加到您的test数组:irb>test=[]=>[]irb>test[["a","b","c"]]irb>test[["a","b","c"],["d","e","f"]]

  5. ruby-on-rails - 使用 gmaps4rails 动态加载谷歌地图标记 - 2

    如何只加载map边界内的标记gmaps4rails?当然,在平移和/或缩放后加载新的。与此直接相关的是,如何获取map的当前边界和缩放级别? 最佳答案 我是这样做的,我只在用户完成平移或缩放后替换标记,如果您需要不同的行为,请使用不同的事件监听器:在你看来(index.html.erb):{"zoom"=>15,"auto_adjust"=>false,"detect_location"=>true,"center_on_user"=>true}},false,true)%>在View的底部添加:functiongmaps4rail

  6. ruby - 动态方法链? - 2

    如何在对象上调用方法名称的嵌套哈希?例如,给定以下哈希:hash={:a=>{:b=>{:c=>:d}}}我想创建一个方法,给定上面的散列,执行以下操作:object.send(:a).send(:b).send(:c).send(:d)我的想法是我需要从一个未知的关联中获取一个特定的属性(这个方法不知道,但程序员知道)。我希望能够指定一个方法链来以嵌套哈希的形式检索该属性。例如:hash={:manufacturer=>{:addresses=>{:first=>:postal_code}}}car.execute_method_hash(hash)=>90210

  7. ruby - 如何使用 method_missing 动态声明方法? - 2

    我有一个ruby​​程序,我想接受用户创建的方法,并使用该名称创建一个新方法。我试过这个:defmethod_missing(meth,*args,&block)name=meth.to_sclass我收到以下错误:`define_method':interningemptystring(ArgumentError)in'method_missing'有什么想法吗?谢谢。编辑:我以不同的方式让它工作,但我仍然很好奇如何以这种方式做到这一点。这是我的代码:defmethod_missing(meth,*args,&block)Adder.class_evaldodefine_method

  8. ruby - 动态扩展现有方法或覆盖 ruby​​ 中的发送方法 - 2

    假设我们有A、B、C类。Adefself.inherited(sub)#metaprogramminggoeshere#takeclassthathasjustinheritedclassA#andforfooclassesinjectprepare_foo()as#firstlineofmethodthenrunrestofthecodeenddefprepare_foo#=>prepare_foo()neededhere#somecodeendendBprepare_foo()neededhere#somecodeendend如您所见,我正在尝试将foo_prepare()调用注入

  9. ruby - 使用 jbuilder 创建具有动态哈希键的 JSON - 2

    这里我想输出带有动态组名的json而不是单词组@tickets.eachdo|group,v|json.group{json.array!vdo|ticket|json.partial!'tickets/ticket',ticket:ticketend}end@ticket是这样的散列{a:[....],b:[.....]}我想要这样的输出{a:[.....],b:[....]} 最佳答案 感谢@AntarrByrd,这个问题有类似的答案:JBuilderdynamickeysformodelattributes使用上面的逻辑我已经

  10. ruby - 在 Rakefile 中动态生成 Rake 测试任务(基于现有的测试文件) - 2

    我正在根据Rakefile中的现有测试文件动态生成测试任务。假设您有各种以模式命名的单元测试文件test_.rb.所以我正在做的是创建一个以“测试”命名空间内的文件名命名的任务。使用下面的代码,我可以用raketest:调用所有测试require'rake/testtask'task:default=>'test:all'namespace:testdodesc"Runalltests"Rake::TestTask.new(:all)do|t|t.test_files=FileList['test_*.rb']endFileList['test_*.rb'].eachdo|task|n

随机推荐