假设你明天出发去旅行,面前摆着心爱的物品:相机、小说、零食、帐篷……但行李箱容量有限。怎么装,才能让这趟旅行的幸福感最高?把“行李箱容量”换成“预算”,把“幸福感”换成“收益”,这就是计算机科学中的经典问题——背包问题。 背包问题是动态规划中“分阶段做决策”的完美教材。它看似简单,却衍生出 01 背包、完全背包、多重背包、分组背包、混合背包等变体,像同一个旅行故事的不同购物场景:景区纪念品每件只能买一个,超市特产可以无限拿,商场折扣装每种最多拿几包,服装搭配每组只选一件。本文用旅行装行李的故事贯穿始终,逐步拆解这些变体的状态转移方程,并给出可运行的 Python 代码。
背包问题是动态规划求职面试的“入场券”,也是资源分配、预算优化等 AI 工程场景的底层算法。彻底吃透它,等于掌握了动态规划中最经典的决策框架。
可视化演示
展示01背包与完全背包的DP表填充过程、状态转移方程对比及资源分配应用场景。
本节配有交互动效(进入视口后自动加载,约 3KB)。
01背包状态转移 逆序遍历确保每件物品只选一次 dp[j]=max(dp[j],dp[j-w]+v) O(nW) 01背包空间优化至一维数组
背包问题概述
想象你的行李箱容量固定为 20 公斤,面前摆着相机(2kg,开心值 3)、帐篷(5kg,开心值 8)、小说(1kg,开心值 1)……你需要在不超过 20kg 的前提下,让总开心值最大。这就是背包问题最朴素的模样:有限容量下做最优取舍。
计算机科学家把“开心值”抽象为价值 v_i,“重量”抽象为代价 w_i,容量记为 C。目标是从 n 件物品中挑出一个子集,使总重量不超过 C 且总价值最大。根据物品可否重复选取,衍生出五大变体:01 背包(每件最多一次)、完全背包(无限次)、多重背包(有限次)、分组背包(每组选一个)、混合背包(前四者混合)。它们共享同一套“分阶段递推”的解题骨架,区别只在遍历方向与拆分技巧。
回顾旅行场景:景区纪念品店里的每件纪念品,你只能决定“买”还是“不买”——这就是 01 背包。去超市则可以无限拿同种特产,去商场折扣区每种限量拿几包……不同的购物规则,对应不同的背包变体,但最终都归结为同一个问题:在容量约束下如何让收获最大。
- 价值总和最大化枚举所有可行的装入组合,目标是让
Σv_i x_i达到最大,这是优化目标。 - 容量约束所有被选物品的重量之和不能超过背包容量 C,这是硬性限制条件。
- 0-1 决策变量x_i 只能取 0 或 1,对应“这件物品不带/带”,不允许带半件或带多件。
01背包基础
来到景区纪念品店,每件纪念品只有一个,你只能做“买/不买”的二元决策。这就是 01 背包:n 件物品,每件最多选一次。如何系统化决策?动态规划的思路是“分阶段递推”——先看前 i 件物品在容量 j 下能拿到多少价值,再一步步推到前 n 件、容量 C。
定义状态 dp[i][j]:前 i 件物品中选出若干件,放入容量为 j 的背包时,能获得的最大价值。对于第 i 件物品,只有两种选择:不放,则继承 dp[i-1][j];放,则腾出 w_i 的空间,得到 dp[i-1][j-w_i] + v_i。两者取大即可。初始时 dp[0][j] = 0,表示没有物品可选时价值为 0。
两层循环即可求解:外层遍历物品 i,内层遍历容量 j 从 0 到 C。当 j < w_i 时装不下,只能执行“不放”分支。整个过程就像在旅行前逐个掂量纪念品:放得下就比一比“带 vs 不带”哪个更值,放不下就直接放弃。最终 dp[n][C] 就是最优答案。
- dp[i][j] 的定义记录“前 i 件物品、容量为 j”时能取得的最大价值,是递推的存储单位。
- 不放第 i 件既然不放,价值与前 i-1 件在该容量下的结果完全相同,直接继承。
- 放入第 i 件必须先预留 w_i 的容量给第 i 件,剩余容量 j-w_i 交给前 i-1 件去最优决策,最后加上价值 v_i。
01背包优化
纪念品太多,笔记本记不下整张二维表了。能不能只带一张纸,边看边更新?01 背包的“滚动数组”优化正是这个思路:dp[i][j] 只依赖 dp[i-1][...],即“上一行”的数据,因此可以用一维数组 dp[j] 原地滚动覆盖。
但滚动时有个陷阱:如果容量从小到大(正序)更新,dp[j-w_i] 可能已经被本轮“放过第 i 件”的结果覆盖,导致同一件物品被重复选中——这在 01 背包中是不允许的。因此内层容量必须从大到小(逆序)更新,保证 dp[j-w_i] 读到的仍是上一轮“没放第 i 件”的旧值。
这张“纸”帮你在纪念品店快速比价:从大容量往小容量倒着看,每件物品只被考虑一次。空间省了,逻辑不变——这就是动态规划中典型的“滚动数组”技巧。
- 一维数组 dp[j]把二维表的“当前行”压缩成一个数组,容量 j 对应下标 j,空间从 O(nC) 降到 O(C)。
- 更新方程在“旧值不放”与“放入后价值”之间取最大值,覆盖写入当前下标。
- 逆序上限与下限j 从 C 递减到 w_i,小于 w_i 的容量装不下当前物品,无需更新。
完全背包
逛完景区来到超市特产区,货架上的果干、巧克力可以任意拿取,每种不设上限。这就是完全背包:每种物品可取无限次。状态定义与 01 背包完全一致,但转移时“放第 i 件”后,剩余容量依然可以继续放第 i 件,因此内层容量要正序遍历——这样才能让 dp[j-w_i] 读到本轮已经更新过、可能再次选了第 i 件的结果。
对比两张代码,唯一的差别就是内层循环方向:01 背包逆序防重选,完全背包正序允许重复。超市里你可以抓了又抓、装到放不下为止,而景区纪念品买完就没了——一个正序、一个倒序,完美对应两种购物规则。
- 正序下界j 从 w_i 开始,容量达到物品重量才有装入的可能。
- 正序更新语义容量从小到大推进,
dp[j-w_i]可能已被本轮更新,等价于允许“反复加同一件物品”。 - 状态沿用
dp[j]的旧值代表“完全不放第 i 件”的可选方案,与放入方案取 max。
多重背包
商场折扣区规定:某款零食最多买 3 包,另一款最多买 5 包。这就是多重背包:每种物品有数量上限 c_i。最朴素的做法是拆成 c_i 件独立的 01 物品,但若 c_i 很大,效率堪忧。二进制拆分登场:把 c_i 拆成 1、2、4、… 以及剩余部分,这些“打包组”能组合出 0 到 c_i 的任意数量,且打包组数量从 c_i 降到 O(log c_i)。
以 13 包零食为例:拆成 1、2、4、6 四组,任意 0~13 的购买数量都可以由这四组的某个组合表示。之后每组当作一件 01 物品求解即可。更进一步的单调队列优化,则把多重背包的时间复杂度压到 O(nC),适合超大数量场景——但初学掌握二进制拆分,已足以应对绝大多数面试与工程问题。
- 连续幂次项1、2、4……这些 2 的幂次能组合出 0 到 2^{k+1}-1 的任意整数。
- 剩余项 r当剩余数量不足以凑出下一个完整幂次时,直接作为独立打包组,保证总覆盖到 c_i。
- 总和约束所有拆分项之和恰好等于原数量 c_i,不多不少。
分组背包
出门旅行要搭配衣服:上衣、裤子、鞋子各选一件。每组内部互斥,最多挑一件。这就是分组背包:物品被分成若干组,每组最多选一个。状态转移时,外层先枚举组,内层容量逆序,最内层枚举组内物品——为什么容量要放在组内物品之前?因为这样才能保证“整组最多选一件”。
搭配衣服时,你不可能同时穿两条裤子;分组背包用“容量逆序 + 组内枚举”的次序,天然保证了互斥选择。这也解释了为什么内层不能先枚举物品——否则同一组的多件物品会被当作 01 物品逐件塞进背包,破坏“最多选一件”的规则。
- 组内取最大在组 G_i 的所有物品中,挑出“放进容量 j 后价值最大”的那一件。
- 不选整组
dp[j]的旧值表示整组什么都不选,直接继承上一组的结果。 - 两者取大在“这组不选任何物品”与“这组选最优的一件”之间做全局决策。
混合背包
一次长途旅行,你会同时遇到景区、超市、折扣区和服装店。混合背包就是把 01、完全、多重、分组混在一个输入里。处理策略并不复杂:按物品类型分流——01 物品容量逆序,完全物品容量正序,多重物品先二进制拆分转成 01 物品,分组物品套用分组三层循环。
整理行囊时,你不需要发明新方法,只需把每件物品“对号入座”:纪念品逆序、特产正序、折扣装拆分、衣物分组。背包问题的所有变体,本质上是同一棵决策树的不同分枝——理解了状态转移的方向逻辑,再复杂的混合输入也能迎刃而解。
- 逆序分支01 物品和经过二进制拆分后的多重物品,统一按逆序更新,保证每件最多选一次。
- 正序分支完全背包物品按正序更新,允许同种物品被反复选取直到装满。
- 矩阵化表达整个混合背包就是按类型选择上式中的某一分支,再套用统一的
max转移。
AI 应用场景
云资源预算分配(01背包)
阶段:资源调度与成本优化
解决的问题:AI 训练任务常有多组候选配置(如不同 GPU 实例),每组有算力单价和预估收益,而总预算有限。传统做法靠人工拍板或贪心选最贵,往往浪费预算。用 01 背包,每个配置是一次性选项,预算即容量,收益即价值,dp[j] = max(dp[j], dp[j-w]+v) 直接给出最优采购组合。
具体案例:某团队要在 10 万元预算内从 6 种 GPU 实例中选型,每种最多租一台,通过 01 背包求得收益最大组合,比人工决策收益提升 18%。
营销预算多渠道分配(多重背包)
阶段:广告投放策略优化
解决的问题:营销活动在多个渠道投放,每个渠道有投放次数上限(如同广告位库存),不同投放次数对应不同转化收益。多重背包的二进制拆分恰好匹配“每渠道最多投 c_i 次”的约束,拆分后按 01 背包求解,预算分配从经验驱动变为算法驱动。
具体案例:一家电商公司在 50 万预算内,对 4 个广告渠道分别有 3/5/2/4 次投放上限,用多重背包求得最优投放次数组合,ROI 提升 12%。
货运车辆装载规划(分组+混合背包)
阶段:物流路径规划中的装载子问题
解决的问题:货车装载时,货物按品类分组(如生鲜、电子、服装),每组因安全规则最多选一定数量,同时部分货物可拆零(完全)、部分限量(多重)。混合背包策略按类型分流处理,一键生成合规且利润最高的装载方案,替代人工估算。
具体案例:物流公司对一辆 20 吨货车规划装载,货物分 3 组共 9 类,用混合背包求解后装载率从 82% 提升到 94%。
Python 演示
def knapsack_01(weights, values, capacity): """01背包:每件物品最多选一次,容量倒序遍历""" dp = [0] * (capacity + 1) for i in range(len(weights)): for j in range(capacity, weights[i] - 1, -1): dp[j] = max(dp[j], dp[j - weights[i]] + values[i]) return dp[capacity] def knapsack_complete(weights, values, capacity): """完全背包:每种物品可取无限次,容量正序遍历""" dp = [0] * (capacity + 1) for i in range(len(weights)): for j in range(weights[i], capacity + 1): dp[j] = max(dp[j], dp[j - weights[i]] + values[i]) return dp[capacity] def knapsack_multiple(weights, values, counts, capacity): """多重背包:二进制拆分后转为01背包""" w, v = [], [] for i in range(len(weights)): rest = counts[i] k = 1 while k <= rest: w.append(weights[i] * k) v.append(values[i] * k) rest -= k k <<= 1 if rest > 0: w.append(weights[i] * rest) v.append(values[i] * rest) return knapsack_01(w, v, capacity) def knapsack_group(groups, capacity): """分组背包:每组最多选一个,组->容量->物品三层循环""" dp = [0] * (capacity + 1) for group in groups: for j in range(capacity, -1, -1): for w, val in group: if j >= w: dp[j] = max(dp[j], dp[j - w] + val) return dp[capacity] if __name__ == '__main__': wt = [2, 3, 4, 5] vl = [3, 4, 5, 8] C = 10 print('01背包最优价值:', knapsack_01(wt, vl, C)) print('完全背包最优价值:', knapsack_complete(wt, vl, C)) print('多重背包最优价值:', knapsack_multiple([2, 3], [3, 4], [3, 2], C)) groups = [[(2, 3), (3, 4)], [(4, 5), (5, 8)], [(1, 2)]] print('分组背包最优价值:', knapsack_group(groups, C))
dp = [0] * (capacity + 1)一维滚动数组,容量从 0 到 C,下标即容量。for j in range(capacity, weights[i] - 1, -1)01 背包容量倒序,防止同件物品重复选择。for j in range(weights[i], capacity + 1)完全背包容量正序,允许无限次取同一物品。while k <= rest二进制拆分的核心循环:不断取出 1、2、4… 的幂次打包组。rest -= k; k <<= 1扣掉已取数量,k 左移一位翻倍,继续拆下一组。if rest > 0剩余不足一个幂次的零头,单独作为一组。return knapsack_01(w, v, capacity)拆分后的多重背包直接复用 01 背包函数。for group in groups: for j ... for w, val in group分组背包严格要求“组 → 容量 → 组内物品”三层顺序。
常见误区
误区完全背包每种物品可以取无限次,所以要在 01 背包基础上加一层循环枚举取多少件,`dp[j] = max(dp[j-k*w]+k*v)`。
正解不需要显式加层!内层容量正序遍历时,dp[j-w] 已经是本轮更新过的结果,它天然允许同一物品被反复纳入,等价于枚举所有 k。正序就是“无限次”的隐式实现,加第三层反而把 O(nC) 变成 O(nC²),性能大幅劣化。
为何常见:刚学动态规划时习惯把“数量”显式建模,忽略了遍历顺序本身就编码了物品的可重复性,因此容易多写一层循环。
误区既然完全背包正序,那么 01 背包一维优化应该也是正序,或者方向无关紧要。
正解方向至关重要。01 背包内层必须逆序,才能让 dp[j-w] 引用“上一轮(尚未放当前物品)”的状态;若正序,同一件物品会被反复计算,结果变成“无限次取用”,得到完全背包的解。可以这样记忆:倒序防重,正序任意。
为何常见:两个变体代码几乎一样,只有一个 range 的方向差异,肉眼不易察觉,缺乏对比实验时就会混淆。
误区把数量 c 拆成 1、2、4、8… 直到超过 c 为止,剩下的不管了,直接按 01 背包求解。
正解拆分必须覆盖 0 到 c 的每一种取值。比如 c=13,拆 1、2、4 后还剩 6,这个“零头”必须单独成组,否则最多只能表示 0~7,无法凑出 8~13 的选取数量。正确拆分是 1、2、4、6,四组可覆盖 0~13 全部整数。
为何常见:初学者只记得“拆成 2 的幂”,却忽略了剩余部分的必要性,把幂次拆完就当作结束。
误区分组背包和普通背包一样,外层枚举物品再枚举容量就行,组内物品逐个处理。
正解分组背包必须“组 → 容量 → 组内物品”。若把物品放在最外层,同组的多件物品会被分散到不同轮次,相当于允许同组选多件,破坏“每组最多选一个”的约束。容量放在组内物品之前,是保证整组互斥的关键。
为何常见:01 背包的习惯“物品外层、容量内层”太牢固,遇到分组变体时容易惯性照搬,没有回到状态定义去推理依赖关系。
误区背包问题 dp 数组初始化全部填 0,任何情况下都是对的。
正解如果题目要求“恰好装满背包”,初始化为 dp[0]=0, dp[1..C]=-∞(价值可能为负时用极小值),因为只有容量 0 是合法起点,其余容量在未放入任何物品时不可达。若题目只要求“不超过容量”,全 0 才正确。初始化方式取决于状态语义,不能一概而论。
为何常见:模板题大多用“不超过容量”的宽松版本,导致学习者没见过“恰好装满”的严格版本,面试换问法时就踩坑。
面试题
Q1. 01背包一维优化时,内层容量为什么必须倒序遍历?
- A.防止同一件物品被重复选取← 正确答案
- B.让物品价值排序更高效
- C.减少外层循环次数
- D.保证背包恰好装满
答案:A。倒序遍历时,dp[j-w] 读到的是上一轮尚未放入当前物品的旧值,从而保证每件物品最多选一次。正序会让 dp[j-w] 携带本轮已放入的信息,导致同件物品被反复选取,变成完全背包的行为。就像景区纪念品买完就没了,不能回头再买。
Q2. 完全背包与01背包的代码唯一区别是什么?
- A.内层容量的遍历方向不同← 正确答案
- B.外层物品的遍历方向不同
- C.初始化数组长度不同
- D.完全背包不需要动态规划
答案:A。01 背包内层容量倒序,完全背包内层容量正序,其余代码完全相同。正序遍历允许 dp[j-w] 引用本轮已更新的值,天然支持同种物品无限次取用,对应超市特产随便拿的场景。
Q3. 01背包状态 dp[i][j] 的含义是什么?
- A.前 i 件物品放入容量 j 背包的最大价值← 正确答案
- B.所有物品放入容量 j 背包的最小重量
- C.第 i 件物品单独放入时的价值
- D.容量 j 背包最多能装的物品件数
答案:A。dp[i][j] 是“前 i 件物品中选若干件、总重不超过 j 的最大价值”,它是动态规划的状态定义。理解了它,转移方程 max(不放, 放) 就是顺理成章的两种决策。
Q4. 多重背包中,物品数量 13 经二进制拆分后的结果是?
- A.1、2、4、6← 正确答案
- B.1、2、3、7
- C.2、4、8、1
- D.4、4、4、1
答案:A。按 1、2、4、8… 拆,1+2+4=7,剩余 13-7=6,所以拆成 1、2、4、6。这四组能组合出 0~13 的任意整数。B 选项 3 不是幂次且组合不完整,C 选项 2、4、8 已经是 14 超过原数,D 选项无法覆盖 0~13 的连续区间。
Q5. 分组背包要求“每组最多选一个”,三层循环的正确次序是?
- A.组 → 容量(倒序) → 组内物品← 正确答案
- B.组内物品 → 容量 → 组
- C.容量 → 组 → 组内物品
- D.三种次序结果相同
答案:A。容量放在“组内物品”之前,确保同组物品竞争同一个容量槽位,最多只有一件被选中;若把组内物品放外层(B 选项),同组物品会被先后塞入背包,破坏互斥约束。就像穿衣搭配,组内各件只能竞争同一个“名额”。
Q6. 若题目要求“恰好装满背包”,dp 数组的初始化方式应为?
- A.dp[0]=0,其余 dp[1..C]=-∞← 正确答案
- B.全部初始化为 0
- C.全部初始化为 -∞
- D.dp[0]=-∞,其余为 0
答案:A。“恰好装满”要求只有容量 0 是合法起点(空包恰好装满 0),其他容量在没有放入任何物品时不可达,必须用 -∞ 表示“不可能”。若全部填 0,转移时会从非法状态推出错误解。相当于旅行中只有空箱是初始可达状态,没放东西却“恰好装满”其他容量是不合逻辑的。
Q7. 关于混合背包的统一处理策略,下列说法正确的是?
- A.01 物品容量倒序更新← 正确答案
- B.完全物品容量正序更新← 正确答案
- C.多重物品先二进制拆分再按 01 处理← 正确答案
- D.所有物品统一按完全背包处理
答案:ABC。混合背包的正确做法是“分类各就各位”:01 逆序,完全正序,多重先拆成 01。D 选项把所有物品当完全背包处理,会破坏 01 与多重的数量限制。就像整理行囊时,景区纪念品、超市特产、折扣装要分别用不同的“购买规则”对待。
Q8. 关于各变体背包的时间复杂度,下列说法正确的是?
- A.01 背包为 O(nC)← 正确答案
- B.完全背包为 O(nC)← 正确答案
- C.二进制拆分后的多重背包为 O(nC log m)← 正确答案
- D.单调队列优化的多重背包为 O(nC)← 正确答案
答案:ABCD。01 和完全都是两重循环 O(nC)。二进制拆分把每种 m 件拆成 O(log m) 组,总复杂度 O(nC log m)。单调队列优化把“枚举件数”的循环消去,每种物品只遍历容量一次,降到 O(nC)。复杂度排序是多重背包优化的主线,也是面试追问的高频点。
Q9. 01背包转移方程 dp[j] = max(dp[j], dp[j-w_i]+v_i) 中,j-w_i 表示什么?
- A.放入第 i 件后剩余可用的容量← 正确答案
- B.第 i 件物品的重量减去价值
- C.背包已经占用的空间
- D.未放入物品的总重量
答案:A。j-w_i 是容量 j 减去第 i 件物品的重量 w_i,即放入它之后剩余的空间,交给前 i-1 件物品去最优利用。这与旅行打包完全对应:先给相机留出 2kg,剩下的重量再塞别的纪念品。
Q10. 关于多重背包的优化手段,以下哪些是正确的?
- A.二进制拆分后再用 01 背包求解← 正确答案
- B.单调队列优化可将复杂度降到 O(nC)← 正确答案
- C.用贪心每次选单位价值最高的物品
- D.完全背包的代码可以直接套用
答案:AB。二进制拆分把数量 c 拆成 O(log c) 组,转成 01 背包;单调队列进一步消去数量维度,达到 O(nC)。贪心(C)在背包问题中无法保证全局最优,因为物品不可分割且容量有限。完全背包代码(D)允许无限取用,违背多重背包的数量上限,直接套用会得到错误答案。