背包问题

一类精巧的动态规划

问题雏形, 01背包

有 N 件物品,一个容量为 C 的背包。
第i件物品的重量是 W[i], 价值是 V[i]。
在不超过背包容量的情况下,怎么装物品使得装入的总价值最大?求出最大总价值。

分析

看一个具体的例子:

假设背包的大小是 10,有 4 个物品,
体积分别是 [2,3,5,7],
价值分别是 [2,5,2,5]。

1、如果仅考虑将前一个物品放入背包,只要背包容量大于2,都可以获取价值为2的最大价值

表格首列为物品编号+(物品体积,物品价值)

物品\容量 0 1 2 3 4 5 6 7 8 9 10
1 (2,2) 0 0 2 2 2 2 2 2 2 2 2
2 (3,5)
3 (5,2)
4 (7,5)

2、现在考虑仅将前两个物品放入背包, 如果背包容量不小于5就可以把两个物品都放入,获得价值2+5=7; 如果不能全部放入,就需要选择体积不超,价值最大的物品

物品\容量 0 1 2 3 4 5 6 7 8 9 10
1 (2,2) 0 0 2 2 2 2 2 2 2 2 2
2 (3,5) 0 0 2 5 5 7 7 7 7 7 7
3 (5,2)
4 (7,5)

3、同理,考虑仅将前三个物品放入背包

物品\容量 0 1 2 3 4 5 6 7 8 9 10
1 (2,2) 0 0 2 2 2 2 2 2 2 2 2
2 (3,5) 0 0 2 5 5 7 7 7 7 7 7
3 (5,2) 0 0 2 5 5 7 7 7 7 7 9
4 (7,5)

4、现在考虑将全部四个物品放入背包,可以依据前三个物品放入的结果来制定方案

物品\容量 0 1 2 3 4 5 6 7 8 9 10
1 (2,2) 0 0 2 2 2 2 2 2 2 2 2
2 (3,5) 0 0 2 5 5 7 7 7 7 7 7
3 (5,2) 0 0 2 5 5 7 7 7 7 7 9
4 (7,5) 0 0 2 5 5 7 7 7 7 7 10

不难发现,前n个物品在背包容量为c的情况下能得到的最大价值f(n, c),可以依赖前n-1个物品的情况来得到:

1. 如果背包总容量小于 W[n],不能装第n个物品,得到的价值是 f(n-1, c)
2. 装第 n 个物品,得到的价值是  f(n-1, c-W[n]) + V[n]
即使第 n 个物品可以装,得到的结果也不一定比不装好,
所以 f(n,v) = max(f(n-1, v), f(n-1, c-W[n]) + V[n])

动态规划

在物品数量和背包容量两个状态上实施动态规划即可;
定义 dp[i][j] 表示 将前 i 个物品放入容量为 j 的背包里所获得的最大价值

  • 递推关系:dp[i][j] = max(dp[i-1][j], dp[i-1][j-W[i]]+V[i])
  • 初始状态:dp[i][0] = 0

初始实现

为了好处理边界,dp数组可以申请为(n+1) * (C+1)

func zeroOnePack(C int, W, V []int) int {
	if C <= 0 || len(W) != len(V) {
		return 0
	}
	n := len(W)
	dp := make([][]int, n+1)
	for i := range dp {
		dp[i] = make([]int, C+1)
	}
	for i := 1; i <= n; i++ { // 遍历物品
		for j := W[i-1]; j <= C; j++ { // 遍历背包容量
			dp[i][j] = max(dp[i-1][j], dp[i-1][j-W[i-1]]+V[i-1])
		}
	}
	return dp[n][C]
}

空间优化

dp[i][...] 只依赖于 dp[i - 1][...], dp数组可以降维; 降维后容量需要从后往前遍历,以保证子问题用到的数据不被覆盖——再回想一下填表过程就能明白。

func zeroOnePack(C int, W, V []int) int {
	if C <= 0 || len(W) != len(V) {
		return 0
	}
	n := len(W)
	dp := make([]int, C+1)
	for i := 0; i < n; i++ {
		// 需要逆向,如果正向,会变成完全背包问题
		for j := C; j >= W[i]; j-- {
			dp[j] = max(dp[j], dp[j-W[i]]+V[i])
		}
	}
	return dp[C]
}

完全背包

上面讨论的问题,物品只能被选中 1 次或 0 次,因此称为 01 背包问题。
还有一类问题,物品可被选中 多次0 次,每种物品都有无限件可用,称为 完全背包问题
解法类似,都是动态规划。略有不同,将01背包一维dp的解法稍作修改,内层循环即遍历容量时从前向后遍历即可:

01背包中之所以逆向,是为了 max 里的两项为前一状态的值,防止过早覆盖,所以逆序;
完全背包中,正序写,则max里的两项是当前状态的值。

对应二维dp   dp[i][j] = max(dp[i][j], dp[i][j-W[i-1]]+V[i-1])
01背包则是   dp[i][j] = max(dp[i-1][j], dp[i-1][j-W[i-1]]+V[i-1])
func completePack(C int, W, V []int) int {
	if C < 0 || len(W) != len(V) {
		return 0
	}
	n := len(W)
	dp := make([]int, C+1)
	for i := 0; i < n; i++ {
		for j := W[i]; j <= C; j++ {
			dp[j] = max(dp[j], dp[j-W[i]]+V[i])
		}
	}
	return dp[C]
}

多重背包问题

多重背包问题限定了一种物品的个数,解决多重背包问题,只需要把它转化为0-1背包问题即可。
比如,有2件价值为5,重量为2的同一物品,就可以分为物品a和物品b,a和b的价值都为5,重量都为2。
如果同一种物品有多个,可以用二进制优化;比如有 x 个, 可以分成 1、2、4、。。。、rest 这样的物品
(1、2、4、。。。、rest, 可以凑出 [1, x]内的所有数字)

示例代码如下:

type Good struct {
	v, w int
}

func main() {
	var n, v int
	_, _ = fmt.Scanf("%d %d", &n, &v)
	var goods []Good
	for i := 0; i < n; i++ {
		var v, w, s int
		_, _ = fmt.Scanf("%d %d %d", &v, &w, &s)
		for k := 1; k <= s; k *= 2 {
			s -= k
			goods = append(goods, Good{v: v * k, w: w * k})
		}
		if s > 0 {
			goods = append(goods, Good{v: v * s, w: w * s})
		}
	}
	fmt.Println(pack(goods, v))
}

func pack(goods []Good, v int) int {
	dp := make([]int, v+1)
	for _, good := range goods {
		for c := v; c >= good.v; c-- {
			dp[c] = max(dp[c], dp[c-good.v]+good.w)
		}
	}
	return dp[v]
}

还可以利用单调队列对多重背包问题优化,略。

初始化细节

有的问题求“恰好装满背包”的最优解,有的问题不要求把背包装满。

对于“恰好装满”的问题,在初始化时,dp[0] 还是 0, 但是其他元素需要是 -∞

第二种问题,即不要求必须恰好装满背包,初始化所有元素为 0 就行。

可以这样理解:
初始化的 dp 数组事实上就是在没有任何物品可以放入背包时的合法状态。
如果要求背包恰好装满,那么此时只有容量为 0 的背包可能被价值为 0 的虚拟物品“恰好装满“,其他容量的背包均没有合法解,属于未定义的状态,
再结合状态转移方程,它们的值就都应该是 -∞
如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为 0,所以初始时状态的值也就全部为 0。

相关实践

给定一个整型数组 arr, 整数 k 和 整数 taget,在 arr 中取出 k 个元素使其和为 target
求可能的组合方案数
func kSum(k, target int, arr []int) int {
	// 和普通 01 背包问题相比,仅仅是多了一层状态需要考虑
	// 这层状态记录的是背包里面元素的个数
	// 放入第 r 个元素的时候,必须确保背包里面已经有 r-1 个元素
	dp := make([][]int, target+1)
	for i := range dp {
		dp[i] = make([]int, k+1)
	}
	dp[0][0] = 1
	for _, v := range arr {
		for j := target; j >= v; j-- {
			for r := 1; r <= k; r++ {
				dp[j][r] += dp[j-v][r-1]
			}
		}
	}
	return dp[target][k]
}