动态规划进阶:背包问题与分割等和子集

背包问题,可以说是动态规划里最经典的模型之一了。我记得刚入行那会儿,面试官问我「0-1背包」和「完全背包」的区别,我支支吾吾说了半天也没讲清楚。后来在实际项目中写资源分配算法时,才真正理解了它们的精髓。

说白了,背包问题就是:给你一个容量有限的背包,和一堆有重量有价值的东西,你怎么装才能让总价值最大?

嗯,这里有两个关键变种:

  • 0-1背包:每个物品只能拿一次,要么拿要么不拿
  • 完全背包:每个物品可以拿无限次

你想想看,这两种场景在实际开发中太常见了。比如预算分配、任务调度、资源切割……我曾在做电商优惠券系统时,就用背包模型解决了「满减凑单」的问题。

0-1背包:状态定义与转移方程

先看0-1背包。我们定义 dp[i][j] 表示「前 i 个物品中,选出总重量不超过 j 的最大价值」。

转移方程其实就一句话:

dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i])

左边是不拿第 i 个物品,右边是拿。哪个大选哪个。

核心要点:0-1背包的遍历顺序是「先物品,后容量,容量从大到小」。为什么?因为每个物品只能用一次,从大到小遍历可以保证 dp[j - w[i]] 是上一轮的状态,不会重复使用当前物品。

我习惯用一维数组来优化空间,代码更简洁:

def zero_one_knapsack(weights, values, capacity):
    n = len(weights)
    dp = [0] * (capacity + 1)
    
    for i in range(n):
        # 容量从大到小遍历
        for j in range(capacity, weights[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    
    return dp[capacity]

个人小技巧:写0-1背包时,我总会在内层循环的边界上多检查一遍。曾经因为 range(capacity, -1, -1) 写成了 range(capacity, 0, -1),漏掉了容量为0的情况,排查了半天……

完全背包:无限次使用的奥秘

完全背包和0-1背包的唯一区别:每个物品可以拿无限次。

你可能会想,那是不是把内层循环改成从小到大就行了?没错,就是这样。

def complete_knapsack(weights, values, capacity):
    n = len(weights)
    dp = [0] * (capacity + 1)
    
    for i in range(n):
        # 容量从小到大遍历
        for j in range(weights[i], capacity + 1):
            dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    
    return dp[capacity]

为什么从小到大就能无限次使用?因为当 j 从小到大遍历时,dp[j - w[i]] 可能已经被当前物品更新过了,这就相当于允许了重复选取。

我曾经踩过的坑:完全背包的「组合数」和「排列数」问题。如果求的是组合数(不考虑顺序),先遍历物品再遍历容量;如果求的是排列数(考虑顺序),先遍历容量再遍历物品。这两个顺序搞反了,结果会差很多。

经典例题:分割等和子集

这道题是0-1背包的经典应用。题目说:给你一个数组,能不能把它分成两个子集,使得两个子集的元素和相等?

说白了,就是问:能不能选出一些数,让它们的和等于总和的一半?

这不就是0-1背包吗?背包容量 = 总和的一半,每个数字的重量 = 它的值,价值也是它的值。我们只需要判断能不能恰好装满这个背包。

def can_partition(nums):
    total = sum(nums)
    if total % 2 != 0:
        return False
    
    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True  # 容量为0时,什么都不选,肯定可以
    
    for num in nums:
        for j in range(target, num - 1, -1):
            dp[j] = dp[j] or dp[j - num]
    
    return dp[target]

这里 dp[j] 表示能否凑出和为 j 的子集。初始状态 dp[0] = True,然后逐个数字往里加。

注意:这道题也可以用「位运算」优化,但面试时我更推荐用动态规划,思路清晰,不容易出错。我在实际项目中用DP版本写过资源均衡分配,稳定运行了两年多。

知识体系总览

下面这张图,是我自己总结的背包问题知识结构。每次讲给团队新人听,我都会先画一遍:

背包问题 0-1背包 完全背包 容量从大到小 每个物品一次 分割等和子集 容量从小到大 无限次使用 组合/排列 核心区别:遍历顺序决定能否重复使用 0-1背包:容量倒序 | 完全背包:容量正序

总结一下

背包问题的核心,其实就是两件事:

  1. 状态定义dp[j] 表示容量为 j 时的最优值
  2. 遍历顺序:0-1背包容量倒序,完全背包容量正序

我在面试别人时,经常看到有人把这两个顺序搞混。其实你只要记住一句话:「倒序防重复,正序允无限」。每次写代码前默念一遍,基本不会出错。

分割等和子集这道题,是0-1背包的「恰好装满」问题。它不关心价值,只关心能不能凑出目标值。这种思路在资源分配、任务拆分中非常实用。

实战建议:如果你刚开始学背包问题,建议先手写几遍0-1背包的二维DP版本,理解清楚状态转移过程,再优化成一维。我当年就是直接跳一维,结果面试时被问到「为什么容量要倒序」直接卡壳了……


公众号:蓝海资料掘金营,微信deep3321