背包问题套路

背包问题

背包问题是动态规划中一个子类。

01背包问题

问题描述:

有 n 个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?

问题分析:

定义一些变量:Vi表示第 i 个物品的价值,Wi表示第 i 个物品的体积,背包问题抽象化(X1,X2,…,Xn,其中 Xi 取0或1,表示第 i 个物品选或不选)。注意,每个物品只能最多拿一次,这也是01叫法的来源。

  1. 建立模型,即求max(V1X1+V2X2+…+VnXn);
  2. 寻找约束条件,W1X1+W2X2+…+WnXn < capacity;

举个例子:

n = 3, capacity = 4
weight = [2, 1, 3]
value = [4, 2, 3]

应该返回6,选择前两个物品。

二维DP

根据之前《动态规划套路》,先明确「状态」,比较浅显的一种描述是「状态」是二维的,分别对应「可选的物品」和「背包的容量」,即 dp[i][j] 表示前 i 个物品的组合在容量 j 的背包的最大价值。

再明确「选择」,很显然就是要不要第 i 个物品,根据选择情况,思考状态转移方程。对于第 i 个物品,如果不选择它,那么当前状态和前 i-1 个物品的状态一致:

dp[i][j] = dp[i-1][j]

如果要选择它,那么从当前状态倒推上一个状态是 dp[i-1][j-weight[i],它们的关系是:

dp[i][j] = dp[i-1][j-weight[i]]+value[i]

求最值:

dp[i][j] = max{dp[i-1][j], dp[i-1][j-weight[i]]+value[i]}

再考虑初始条件,对于容量大于等于第一个物品的情况,值都是第一个物品的价值。

func knapsackZeroOne2DP(weight []int, value []int, capacity int) int {
    length := len(weight)
    if length == 0 || length != len(value) {
        return 0
    }

    //普通背包解法
    //dp[i][j],表示前i个组合,其容量和为j的最大价值
    dp := make([][]int, length)
    for i := range dp {
        dp[i] = make([]int, capacity+1)
    }

    //init
    for j := range dp[0] {
        if weight[0] <= j {
            dp[0][j] = value[0]
        }
    }

    for i := 1; i < length; i++ {
        for j := 0; j <= capacity; j++ {
            //如果不选第i个物品
            dp[i][j] = dp[i-1][j]
            if j >= weight[i] {
                //如果要选的话
                dp[i][j] = int(math.Max(float64(dp[i-1][j]), float64(dp[i-1][j-weight[i]]+value[i])))
            }
        }
    }

    return dp[length-1][capacity]
}

一维DP

再回头看看前面的状态转移方程:

dp[i][j] = max{dp[i-1][j], dp[i-1][j-weight[i]]+value[i]}

可以考虑优化空间复杂度。从knapsackZeroOne2DP的代码也可以看出来,是经历了两重循环,每一轮的外层循环 i,算出二维 DP 的其中一行 dp[i][0..capacity],且 dp[i][j] 仅与 dp[i-1][j], dp[i-1][j-weight[i]] 相关,即该行数据仅与前面一行 i-1 相关。那么可以把二维数据缩减为一维,dp[j] 的定义为容量 j 的背包的最大价值,总结01背包的套路如下:

for i=1..N;
    for j=capacity..0; 
        dp[j]=max{dp[j],dp[j-weight[i]]+value[i]};

注意内部循环为降序,降序的理解是:为了保证 j-weight[i] 是「上一层」的状态,即 i 还在上一个循环的状态,即 j 在 j-weight[i] 的前面。画图加深理解:

image
//01背包问题,1维dp
func knapsackZeroOne1DP(weight []int, value []int, capacity int) int {
    length := len(weight)
    if length == 0 || length != len(value) {
        return 0
    }

    //高级背包解法
    //dp[j],表示容量和为i的最大价值
    dp := make([]int, capacity+1)

    //init,容量为0,价值为0
    dp[0] = 0

    for i := 0; i < length; i++ {
        for j := capacity; j >= weight[i]; j-- {
            //照搬公式
            dp[j] = int(math.Max(float64(dp[j]), float64(dp[j-weight[i]]+value[i])))
        }
    }

    return dp[capacity]
}

时间复杂度并不会变。

示例

来看看 LeetCode 的416题《分割等和子集》:

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

每个数组中的元素不会超过 100

数组的大小不会超过 200

示例 1:

输入: [1, 5, 11, 5]

输出: true

解释: 数组可以分割成 [1, 5, 5] 和 [11].

示例 2:

输入: [1, 2, 3, 5]

输出: false

解释: 数组不能分割成两个元素和相等的子集.

使用01背包的套路,根据题意,所有数字的总和肯定是个偶数,且总和的一半记为 sum,sum 就是背包问题的容量,dp[j] 定义为容量为 j 时的能分割的可能性。状态转移方程替换为:

dp[j] = dp[j] || dp[j-nums[i]]

想一想怎么理解?再考虑初始条件,如果 sum 为0,解法为 true。

func canPartitionZeroOne(nums []int) bool {
    length := len(nums)
    if length == 0 || length > 200 {
        return false
    }

    sum := 0
    for _, n := range nums {
        sum += n
    }
    if sum%2 != 0 || sum > 200*100 {
        return false
    }
    sum = sum / 2

    //高级背包解法
    //dp[i],表示和为i的可能性
    dp := make([]bool, sum+1)

    //init
    dp[0] = true

    for i := 0; i < length; i++ {
        for j := sum; j >= nums[i]; j-- {
            dp[j] = dp[j] || dp[j-nums[i]]
        }
    }

    return dp[sum]
}

完全背包问题

问题描述:

有 n 种物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?注意每种物品都有无限件可用。

问题分析:

和01背包问题相比,不同的是有无限件可用。

二维DP

按照套路,先定义状态。dp[i][j] 表示前 i 种物品的组合在容量 j 的背包的最大价值。由于每种物品可以选择0件或多件,可以定义状态转移方程如下:

dp[i][j] = max{dp[i-1][j], dp[i-1][j-weight[i]]+value[i], dp[i-1][j-2*weight[i]]+2*value[i], ... , dp[i-1][j-k*weight[i]]+k*value[i]} (满足 j >= k*weight[i]) 【公式1】

一维DP

同样考虑空间优化。定义状态,dp[j] 的定义为容量 j 的背包的最大价值。数学公式推导一下:

设 j = j-weight[i],带入【公式1】:

dp[i][j-weight[i]] = max{dp[i-1][j-weight[i]], dp[i-1][j-2*weight[i]]+value[i], dp[i-1][j-3*weight[i]]+2*value[i], ... , dp[i-1][j-k*weight[i]]+(k-1)*value[i]} (满足 j >= k*weight[i]) 【公式2】

将【公式1】和【公式2】合并:

dp[i][j] = max{dp[i-1][j], dp[i][j-weight[i]]+value[i]}

可见 dp[i][j] 只和上一层的状态 dp[i-1][j] 以及本层的前置状态 dp[i][j-weight[i]] 相关,故可以缩减为一维数组:

dp[j] = max{dp[j], dp[j-weight[i]]+value[i]}

总结完全背包的套路如下:

for i=1..N;
    for j=0..capacity; 
        dp[j]=max{dp[j],dp[j-weight[i]]+value[i]};

注意内部循环为升序,为了保证 j-weight[i] 是「本层」的状态,即保证 j 依赖 j-weight[i],j 在 j-weight[i] 的后面。

注意完全背包的套路和01背包几乎完全一样,只是内循环的顺序颠倒了。区别就在于01背包依赖的两个项都是「上一层」的状态,而完全背包依赖了「上一层」和「本层」的状态

画图加深理解:

image

示例

前文的《零钱兑换》问题,就是每种硬币可以使用无限次。所以可以使用完全背包的套路,来解零钱兑换问题。考虑初始条件后,代码和前文动态规划的代码基本一致。代码略。

另一个示例,来看看 LeetCode 的518题《零钱兑换II》:

给定不同面额的硬币和一个总金额。写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。

示例 1:

输入: amount = 5, coins = [1, 2, 5]

输出: 4

解释: 有四种方式可以凑成总金额:

5=5

5=2+2+1

5=2+1+1+1

5=1+1+1+1+1

示例 2:

输入: amount = 3, coins = [2]

输出: 0

解释: 只用面额2的硬币不能凑成总金额3。

示例 3:

输入: amount = 10, coins = [10]

输出: 1

注意:

你可以假设:

0 <= amount (总金额) <= 5000

1 <= coin (硬币面额) <= 5000

硬币种类不超过 500 种

结果符合 32 位符号整数

使用完全背包的套路,只不过根据本题题意,将状态转移方程替换为:

dp[j] = dp[j] + dp[j-weight[i]]

再考虑初始条件,如果 amount 为0,则只有一种组合情况为「空」。

//完全背包解法
func change(amount int, coins []int) int {
    dp := make([]int, amount+1)

    //init
    dp[0] = 1

    for _, coin := range coins {
        for i := coin; i <= amount; i++ {
            dp[i] += dp[i-coin]
        }
    }

    return dp[amount]
}

参考

原文载于背包问题套路

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,165评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,503评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,295评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,589评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,439评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,342评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,749评论 3 387
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,397评论 0 255
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,700评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,740评论 2 313
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,523评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,364评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,755评论 3 300
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,024评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,297评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,721评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,918评论 2 336

推荐阅读更多精彩内容