进死胡同一个月,终于捋清背包问题了

1,057 阅读13分钟

算法背包问题动态规划思路

本文重度关联掘金小册 前端算法与数据结构面试:底层逻辑解读与大厂真题训练第 23 节:背包问题。

顺便做下广告,小册不错,大家可以去看看~

小册中关于背包问题的解答乍一看很简单,不过回过味后,自己细细的去想,有一些问题:

  • 有一些语言表达在文字本身、文字顺序上让人有思路走不通的地方。
  • 最后给出的代码有问题,i 应该从 0 到 n-1,否则给其他的入参的话,就得到错误的答案。

本文从本能开始,思维顺序层层递进,既给自己留下思维印记,也供其他有类似疑惑的同学跳出死胡同提供思路。

下面的代码实现是本人自己捋代码的过程,在注释中,有些啰嗦,希望可以耐心按照顺序去看,不要跳着看。

代码实现


/**
 * 题目描述:0-1背包问题
 * 有 n 件物品,物品体积用一个名为 w 的数组存起来,物品的价值用一个名为 value 的数组存起来;
 * 每件物品的体积用 w[i] 来表示,每件物品的价值用 value[i] 来表示。现在有一个容量为 c 的
 * 背包,问你如何选取物品放入背包,才能使得背包内的物品总价值最大?
 *
 * 注意:每种物品都只有1件
 *
 * ==================   上面是题目,下面是注释 ==================
 *
 * 同样体积和价值的物品只有一件
 * 如何才能使背包内的物品总价值最大?
 *
 * 小册中这段话需要改一下:
 * 现在,假设背包已满,容量已经达到了 c。站在c这个容量终点往后退,考虑从中取出一样物品,那么可能被取出的物品就有 i 种可能性。我们现在尝试表达“取出一件”这个动作对应的变化,我用 f(i, c) 来表示前 i 件物品恰好装入容量为 c 的背包中所能获得的最大价值。现在假设我试图取出的物品是 i,那么只有两种可能:
 * 应该改为
 * 现在,假设背包已达到最优解,容量已经达到了 c。我用 f(i, c) 来表示前 i 件物品恰好装入容量为 c 的背包中所能获得的最大价值。站在c这个容量终点往后退,考虑从中取出一样物品,那么可能被取出的物品有 i 种可能性。我们现在尝试表达“取出一件”这个动作对应的变化。现在假设我试图取出的物品是 i,那么只有两种可能:
 *
 * 1.不一定满,有可能是个超大背包吧,所以不应该说"背包已满"。
 * 2.语句顺序要变一下,否则就应该在那个地方说 "有 n 中可能性"。因为该处还没有引入概念 i 呢。
 * 3.i 就是物品数组中前 i 个中的最后一个物品(非代码语言,从 1 开始数)。
 * 4.在讨论 f(i, c) 的时候,就是假定物品数组中的 i+1 及后面的物品都还不在可选物品堆里。
 * 5.讨论 f(i, c) 时,就是假设取出的物品是 i(当前可选物品中的最后一个)。前面的那些物品的讨论顺序就不能再变。
 * 5.1.讨论 f(i, c) 时,前面的那些物品的讨论顺序就不能再变,虽然可以有不同的遍历顺序,不过对于同一个遍历顺序,之前的结果不能乱动。
 * 6.如果讨论物品 i-1 的话,讨论的是 f(i-1) 和 f(i-2) 的关系。而且物品 i 也还不在可选物品堆里。
 * 7.对于遍历来说,i 本身表示的意思,就是遍历讨论物品数组的时候,讨论任何一个物品的时候,该物品的序号。
 * 8.遍历的时候,通过不同体积下的解进行数值的衔接。
 *
 *
 *【这里要知道,对于给定的一堆物品,什么都定下了,在不同的背包体积下的最优解都有固定答案了】
 *【不要在思考的过程中考虑有别的规格的物品怎么办】
 *【倒推法,能推导出 f(n) 和 f(n-1) 之间的关系,能得到状态转移方程】
 *【再找到边界 f(0) f(1) 等。就可以任意决定用正向的动态规划还是倒着来的递归了。】
 *
 *
 * 讨论物品 i 时,有如下 2 种情况:
 * 1.假如最优解中就不包含 i 物品的话
 *  那么假如物品堆里本就没有这个物品(比如一个超大气球),也不影响最优解。
 *  f(可选物品堆, c) 等于 f(可选物品堆不包含 i 物品, c), 是背包体积 c 下的最大的值。
 *  f(i-2, c) 也不包含 i 物品,不过连 i-1 物品也不包含,哈哈,所以是等于 f(i-1, c)。
 *  f(i, c) == f(i-1, c)
 *
 *  2.假如最优解中包含 i 物品的话
 *  用倒推法的时候,从最优解包里面拿出来 i 物品后,
 *  f(可选物品堆, c) - value[i] 是去掉从 i+1 到 n 的物品后的最优解方案的价值减去物品 i 的价值,
 *  和
 *  f(可选物品堆不包含 i 物品, c - w[i]), 是可选物品堆再刨除物品 i(即从 1 到 i -1 的物品)在背包体积 c - w[i] 下的最大的价值(该情况下的最优解)
 *  两者相等。即:
 *  f(i, c) - value[i] == f(i-1, c - w[i])
 *  进而:f(i, c) == f(i-1, c-w[i]) + value[i]
 *
 *  !!!为什么前者等于后者(后者就是按照表示方式进行的表示,或者说该情况下的最优解就是用它表示。前者用了减号,就是从之前的最优解算出来的一个值)
 *  !!!!原因:
 *  "那么取出这个动作就会带来价值量和体积量的减少" 这句话容易扰乱思路。
 *  就记住现在是包里有物品 i,即最优解中 包含 i 物品,即终点体积 c 下的最优解包含 i 物品。
 *  最优解没有 i 物品的情况已经在上面是另外一种情况。
 *
 *
 * 讨论 i 物品的时候,从 i+1 到 n 的物品都还不在可选物品堆,在动态规划的过程中,还没有讨论到它们。
 *  可选物品堆的当前一步只跟前一步有关系。对于最终的最优解来说,过程不重要,不同的遍历顺序的过程可以不一样,
 *  不过最终的最优解一样。因为最终的可选物品都是全部物品,中间用 Math.max 的状态转移方程,
 *  就是在尝试各种方案。
 *  可以试试不同的遍历顺序,去查看打印过程和最终最优解。
 *  尝试后可以看到,不同物品遍历顺序时,过程中打印是不一样的,不过最终最优解是一样的。
 *
 *
 *
 *  把 i 物品拿走之后,背包 A 减少 i 的体积后剩下的体积 B 中,
 *  如果当前的最优解剩下的物品的组合不是价值最大的,而是有别能让价值更大的方案,
 *  那么在那个基础上把体积恢复背包 A 后,包中最沉的方案肯定不是原来的方案,
 *  就是那个更大的方案基础上再加上 i 物品了(虽然这个别的方案和原来的方案一样都包含 i 物品)。【记住现在一直在讨论的是情况 2,最终最优解中一定包含 i 物品】
 *  (有别的价值同等的方案倒是没关系,因为不影响等式本身,都是值一样的最优解的话,等式依然成立)
 *  可以想象家里有个塑料袋,最优解有洗发水,香皂,苹果等,把洗发水拿出来后,再把塑料袋压住,减少洗发水的体积,
 *  剩下的肯定是剩下体积下最重的,一个是别的物品不管大小,依然放不进去,
 *  而且也如上所说,不能换方案,否则加上物品i后最重的就不是原来的方案了。
 *
 *
 *  f(i, c) 在这 2 中情况下有 2 个可能的值,那么结合 2 种情况,得到了状态转移方程:
 *  f(i, c) = Math.max(f(i-1, c), f(i-1, c-w[i]) + value[i])
 *
 *
 * 然后就是不断把物品数组中的物品加入可选物品堆,每次对那个加入物体都运用状态转移方程,即对可选物品堆的这个最后加入的物品讨论上面 2 种情况。
 * 这么说,i的遍历顺序可以正序、倒序、甚至随机跳着来,for循环只是方便遍历而已。
 * 不过不管怎么遍历,已经加入可选物品堆的东西不能再拿出来,知道最后加入了物品数组中的所有物品。
 * 小册的解法里面就是让 i 从 0 开始计数的。
 *
 *
 * 动态规划的关键:最优子结构+自底向上从已知推导找出未知。
 * 还是爬楼梯的结论,难点在于状态转移方程不好确定,已知状态不明显(这里是需要自己设置初始值,以及求出各个体积下的解,虽然题目只是要求求出某个体积下的解)。
 *
 * 在上面的爬楼梯问题 climbStairs 已经总结了:
 * 基于树形思维思考是自顶向下,从未知最终拆回到已知的过程
 * 动态规划是自底向上,从已知一步步向前推导找出未知的过程
 * 小册代码走的是动态规划的路线。
 *
 * 这里进行动态规划的代码是这样的:
 * 遍历物品数组相当于逐步增加可以选择放入背包中的物品,
 * 在其中的一个步骤中,遍历体积 v 从该步骤物品体积开始(也可以从0开始,不过小于
 * 该物品体积时,该物品肯定放不进去,没有意义,直接保持原来的值)到 v 最终到达背包总体积最优解的值,
 * 直到最后得到了可选物品包含了所有物品的情况下的最大值。该最大值就是答案。
 *
 * !!!关键点:讨论i的时候,i+1到n的物品还不在可选物品堆中。
 *
 * 物品数组中物品的顺序变化的话,只会影响遍历过程(逐步增加可能的物品的过程)中的值,
 * 不影响最终的最优解,最终得到的最优解都是一样的。
 *
 *
 *
 * 小册中:考虑到这道题中存在两个自变量,我们需要开辟的是一个二维数组【存二维数组,这个思路要记住】
 * 进而,说二维数组的时候,用 i-1 表示已经把 i 物品拿出来的情况的那一行。表示物品堆里没有 i 的情况那一行。
 * 不这样理解的话,难以往对物品数组的遍历上去转。
 * f(i, c) 不是 js 代码里的函数,而是表示该条件下的最优解的值,就是个值而已,也就是数组的一个值,dp[i][c]。
 * 这样,上面的状态转移方程就可以通过有初始值的数组进行遍历了。
 * 就这样,思维转到了数组上。
 *
 * !!!注意是这样转的:求对于每个物品在不同背包体积下的最优解,才能进行最优解之间的衔接。
 *                 题目本身只要求求某个体积下的最优解,而我们自己逐
 *                 个(自定为每次变化 1,这是假定物品体积都是整数没有小数 )求不同体积下的最优解,
 *                 达到了衔接的目的。
 *
 * 遍历每一个物品,在该循环内,倒推对于这个物体来说,背包体积的变化对应的最优解的值。
 *
 * 2 个变量,v是依赖 i 变化的,所以 v 放内层循环。
 *
 *
 * !!!在本题基础上进一步考虑优化下函数入参(这样便于理解 下面的 knapsack3 函数用这样的方式):
 * 对于一堆给定的物体来说,不同体积的背包的解,已经定死了结果。
 * 所以更合适的理解方式下,可能入参应该更语义化成 items 这种格式,连 n 也不要
 * 而且对于给定的入参来说,当次执行函数时,背包体积 c 的值也是定死的。
 *
 *
 * 变量 v 从 item.volume 到 vTotal, 而不是从 0 开始,
 * 是因为背包体积比 item.volume 都小的话,本就容不下item,没有意义(就用初始值 0)。
 * 后面之所以是从 vTotal 到 item.volume 递减的方式,是因为可以用滚动数组的方式,就是要倒着来,因为是覆盖性的。
 *
 *
 * 增量思维陷阱:
 * 假设背包中有 i,那么拿走i后,把体积减小 w[i],能拿走的价值就是 value[i],
 * 也能是体积相同但是价值比vaule[i]小的物体,
 * 如果是往包里塞东西的时候,在包里已经有 i 的基础上,包的体积又增加了 w[i], 而w[j] 等于 w[i],
 * 但是 value[j] < value[i], 那么假设这时候把包的总体积减小 w[j],拿出来的就是价值比 i 小的 j 了,
 *
 * 倒推法比较适合 人类 的思维模式。
 * !!!注意:要预防上面的"增量思维陷阱":即从开始向终点去思考。既不要从起点向终点思考,也不要从半截腰向终点思考。
 * !!!不要用上面的角度从增量去考虑,需要从终点的容积 c 值全塞满去倒着考虑。
 * 注意 c 可能是绰绰有余包含所有的物品的一个值,也可能是刚好包含所有物品的一个值,也可能是包含不了所有物品的一个值
 *
 *
 * 入参是物品的个数n 和背包的容量上限c,以及物品的体积w和价值value数组
 * 代码的写法和 knapsack1 一样,就是注释不一样。
 * 最优解 初始值给 0
 * res 初始值给 -Infinity
 */
funMap.knapsack2 = () => {
    let n = 3
    let vTotal = 23
    let vList = [7, 15, 7]
    let valueList = [4, 2, 3]

    function knapsack2(n, vTotal, vList, valueList) {
        // 这里数组的长度设置的 vTotal+1,因为下面初始化的 v 的值为 vTotal,
        // dp[vTotal] 想要为 0 而不是 undefined 的话,需要 dp 的 length 为 vTotal + 1
        const dp = (new Array(vTotal+1)).fill(0)
        // res 用来记录所有组合方案中的最大值
        let res = -Infinity

        // i 不能从 1 开始,否则会把 w[0] 漏掉
        for(let i=0; i<n; i++) {
        // 测试更换遍历物品的顺序,过程中的打印值会不一样,不过最终解一样
        // for(let i=n-1; i>=0; i--) {
            console.log(`======vList[${i}]: ${vList[i]}`)
            for(let v = vTotal; v >= vList[i]; v--) {
                // 写出状态转移方程
                console.log(`开始 v: ${v}, v-vList[i]: ${v-vList[i]}, dp[v]: ${dp[v]}  dp[v-vList[i]] + valueList[i]:${dp[v-vList[i]] + valueList[i]}`)
                dp[v] = Math.max(dp[v], dp[v-vList[i]] + valueList[i])
                console.log(`结束 dp[${v}]`, dp[v])

                // 即时更新最大值
                if(dp[v] > res) {
                    res = dp[v]
                }
            }
        }
        return res

    }

    console.log(knapsack2(n, vTotal, vList, valueList))
}

// funMap.knapsack2()

/**
 * 背包问题考虑优化下函数入参后的写法
 */
funMap.knapsack3 = () => {
    let vTotal = 23

    let items = [
        {
            volume: 7,
            value: 4,
        },
        {
            volume: 15,
            value: 2,
        },
        {
            volume: 7,
            value: 3,
        },
    ]

    function knapsack3(vTotal, items) {
        let len = items.length
        // 这里数组的长度设置的 vTotal+1,因为下面初始化的 v 的值为 vTotal,
        // dp[vTotal] 想要为 0 而不是 undefined 的话,需要 dp 的 length 为 vTotal + 1
        const dp = (new Array(vTotal+1)).fill(0)
        // res 用来记录所有组合方案中的最大值
        let res = -Infinity

        // i 不能从 1 开始,否则会把 w[0] 漏掉
        for(let i = 0; i < len; i++) {
        // 测试更换遍历物品的顺序,过程中的打印值会不一样,不过最终解一样
        // for(let i = len-1; i >=0 ; i--) {
            let item = items[i]
            console.log(`====== ${i} item.volume: ${item.volume}`)
            for(let v = vTotal; v >= item.volume; v--) {
                // 写出状态转移方程
                console.log(`开始 v: ${v}, v-item.volume: ${v-item.volume}, dp[v]: ${dp[v]}  dp[v-item.volume] + item.value:${dp[v-item.volume] + item.value}`)
                dp[v] = Math.max(dp[v], dp[v-item.volume] + item.value)
                console.log(`结束 dp[${v}]`, dp[v])

                // 即时更新最大值
                if(dp[v] > res) {
                    res = dp[v]
                }
            }
        }
        return res

    }

    console.log(knapsack3(vTotal, items))
}

// funMap.knapsack3()



/**
 * 背包问题留下怎么选择物品的足迹
 */
funMap.knapsack4 = () => {
    let vTotal = 23

    let items = [
        {
            volume: 7,
            value: 4,
        },
        {
            volume: 15,
            value: 2,
        },
        {
            volume: 7,
            value: 3,
        },
    ]

    function knapsack4(vTotal, items) {
        let len = items.length
        // 这里数组的长度设置的 vTotal+1,因为下面初始化的 v 的值为 vTotal,
        // dp[vTotal] 想要为 0 而不是 undefined 的话,需要 dp 的 length 为 vTotal + 1
        const dp = []

        for (let i = 0; i < vTotal+1; i++) {
            dp[i] = {
                value: 0,
                bagItems: [],
            }
        }

        // res 用来记录所有组合方案中的最大值
        let res = {
            value: -Infinity,
        }

        // i 不能从 1 开始,否则会把 w[0] 漏掉
        for(let i = 0; i < len; i++) {
            // 测试更换遍历物品的顺序,过程中的打印值会不一样,不过最终解一样
            // for(let i = len-1; i >=0 ; i--) {
            let item = items[i]
            console.log(`====== ${i} item.volume: ${item.volume}`)
            for(let v = vTotal; v >= item.volume; v--) {
                // 写出状态转移方程
                console.log(`开始 v: ${v}, v-item.volume: ${v-item.volume}, dp[v].value: ${dp[v].value}  dp[v-item.volume].value + item.value:${dp[v-item.volume].value + item.value}`)
                // 留下足迹的话,这里就不能用 Math.max 了,而是要对比下
                // dp[v] = Math.max(dp[v], dp[v-item.volume] + item.value)

                let valAfterUseItem = dp[v-item.volume].value + item.value
                if (dp[v].value < valAfterUseItem) {
                    dp[v].value = valAfterUseItem
                    // i 物品对这个体积 v 可用的话,就覆盖原来的取物痕迹
                    dp[v].bagItems = dp[v-item.volume].bagItems.concat([i])
                }

                console.log(`结束 dp[${v}]`, dp[v])

                // 即时更新最大值
                if(dp[v].value > res.value) {
                    res = dp[v]
                }
            }
        }
        return res

    }

    console.log(knapsack4(vTotal, items))
}

funMap.knapsack4()

总结

进入死胡同的思路没有贴在这里,感觉没有必要。有的人觉得这个简单,有的人觉得那个简单,思维卡壳不一定是在同一个地方。 不过也不是坏事,对动态规划的感受更深、更直观了。