ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

背包问题本质是决策账本设计:从01到分组四大变体详解

背包问题本质是决策账本设计:从01到分组四大变体详解 1. 这不是一道“算法题”而是一把打开动态规划世界的万能钥匙你点开这个标题大概率正被三类人包围刚学完递归、还在for循环里打转的编程新手刷了二十道LeetCode却始终卡在“状态转移方程怎么写”这一步的求职者或者——更现实一点——正在赶毕设、调仿真、跑物流路径优化模型突然发现手头那个“在有限载重下选哪些货物利润最高”的问题和教材里那个“背包”长得一模一样但就是套不上公式。别急这不是你的问题。我带过七届校招实习生教过三百多个转行学员几乎所有人第一次接触动态规划DP都是从背包问题开始的也几乎所有人都在这里摔过至少三个跟头状态定义模糊、转移逻辑断裂、空间优化迷路。这背后根本不是“数学不好”而是教材和教程普遍跳过了最关键的一步——它没告诉你背包问题从来就不是关于“背什么”而是关于“怎么记账”。你真正要学的是设计一个不会漏记、不会重复、还能快速回溯的“决策账本”。01背包是账本的单页记账模板完全背包是支持无限次复用的活页账本多重背包是带限购标签的批发账本分组背包是按供应商分类的采购台账。我今天不讲“最优子结构”这种教科书定义只带你亲手搭出这个账本从最原始的手工穷举开始一步步砍掉重复计算再把二维表格压成一维数组最后还原出具体选了哪几样东西。所有代码都用Python写但核心逻辑通用——C、Java、甚至Excel建模都能套用。如果你的目标是解题这篇够你拿下90%的DP面试题如果你的目标是落地后面会拆解车辆路径规划里怎么把“油箱容量”当背包容量、“客户订单”当物品、“配送收益”当价值来建模。现在我们直接从一张空表格开始。2. 为什么非得用动态规划穷举、贪心、DFS全试过才懂2.1 穷举法暴力不是懒是建立直觉的必经之路先看最朴素的想法每个物品只有“拿”或“不拿”两种选择n个物品就有2^n种组合。假设你有5个物品重量分别是[2,3,4,5,6]价值是[3,4,5,8,9]背包容量是10。手动列一下所有组合全不拿总重0总价值0只拿第1个重2值3只拿第2个重3值4……拿第1第2第3个重2349值34512拿第1第2第4个重23510值34815 ← 目前最优拿第2第4个重358值4812拿第3第4个重459值5813……你很快会发现两件事第一很多组合总重已经超10直接废掉第二不同组合可能算出相同总重比如“第1第4”257和“第2第3”347都重7但前者值3811后者值459显然前者更好。关键洞察来了对于同一个总重w我们只关心能达到的最大价值v_max(w)其他所有总重为w的组合价值只要小于v_max(w)就永远没机会成为最终答案。这就是“最优子结构”的真实面目——它不是玄学而是数据压缩用一个数字v_max(w)代替所有总重为w的组合集合。穷举的价值就是让你亲眼看到这个压缩过程有多必要。实测下来当n20时2^20≈100万种组合手工不可能n30时2^30≈10亿普通电脑暴力枚举都要几分钟。而DP能把时间压到O(n×W)W是背包容量——如果W1000n100DP只需10万次计算比穷举快10000倍。2.2 贪心法为什么“性价比最高”常常失效很多人第一反应是按“价值/重量”比排序优先拿最划算的。对上面例子性价比排序第4个8/51.6、第5个9/61.5、第3个5/41.25、第2个4/3≈1.33、第1个3/21.5。按此顺序拿先拿第4个重5值8剩容量5再拿第3个重4值5剩容量1第1个重21拿不了总重9总值13。但前面我们手动找到的最优解是“第1第2第4”重23510值34815比贪心多2。贪心失败的根本原因在于它做了不可撤销的局部决策拿了第4个后把容量5“锁死”给了它而实际上把这5拆成23能换来347的价值比5高。DP之所以强是因为它不预设顺序而是系统性地记录“在每一个可能的剩余容量下当前考虑前i个物品时能拿到的最大价值”让每个决策都有反悔余地。你可以把它想象成一个老练的采购员他不会一上来就抢最便宜的螺丝而是先看仓库总预算再逐个评估每种零件——“如果我留出X元买A剩下钱能买B还是C”——这种全局视角贪心永远做不到。2.3 DFS记忆化动态规划的“手写版”雏形既然穷举太慢贪心不准那能不能折中DFS深度优先搜索加记忆化就是DP的“手工实现”。核心思想递归函数dfs(i, w)表示“考虑前i个物品剩余容量为w时能获得的最大价值”。每次递归只有两个选择不拿第i个物品 → dfs(i-1, w)拿第i个物品前提是w≥weight[i]→ value[i] dfs(i-1, w-weight[i])。取两者最大值。但纯DFS会大量重复计算比如dfs(3,5)可能被调用十几次。记忆化就是加个字典cache存下算过的(dfs(i,w), result)。Python代码极简def dfs(i, w): if i 0 or w 0: return 0 if (i, w) in cache: return cache[(i, w)] # 不拿第i个 res dfs(i-1, w) # 拿第i个检查容量 if w weight[i-1]: # 注意索引偏移 res max(res, value[i-1] dfs(i-1, w-weight[i-1])) cache[(i, w)] res return res这段代码和标准DP的二维数组dp[i][w]本质相同cache[(i,w)] 就是 dp[i][w]。区别在于DFS是“自顶向下”从大问题拆小问题DP是“自底向上”从小问题推大问题。实际项目中DFS记忆化更适合逻辑复杂的变种比如物品间有依赖关系而标准DP表格更易调试、空间更可控。我建议初学者先用DFS写一遍亲手感受“状态”如何被复用再过渡到表格理解会深得多。3. 四大背包问题核心逻辑与代码实现附避坑指南3.1 01背包每个物品只能用一次二维DP的黄金范式这是DP入门的基石。状态定义必须清晰dp[i][w] 表示考虑前i个物品且背包容量恰好为w时能获得的最大价值。注意“恰好为w”不是“不超过w”——这点初学者极易混淆。为什么强调“恰好”因为状态转移时只有当w≥weight[i]时才能从dp[i-1][w-weight[i]]转移过来这个前提要求w必须精确匹配。初始化dp[0][w]0没物品价值为0dp[i][0]0容量为0什么都装不下。转移方程dp[i][w] max( dp[i-1][w], # 不拿第i个 dp[i-1][w-weight[i]] value[i] # 拿第i个需w≥weight[i] )关键细节i从1到nw从0到W但内层循环w必须从大到小遍历。为什么因为dp[i][w]依赖dp[i-1][w]和dp[i-1][w-weight[i]]如果w从小到大dp[i-1][w-weight[i]]可能已被更新为dp[i][w-weight[i]]导致“第i个物品被重复使用”。实操中我习惯用range(W, weight[i]-1, -1)确保安全。完整Python代码def knapsack_01(weights, values, W): n len(weights) # dp[i][w]前i个物品容量w的最大价值 dp [[0] * (W 1) for _ in range(n 1)] for i in range(1, n 1): for w in range(W, weights[i-1] - 1, -1): # 重点倒序 # 不拿第i个i-1是索引偏移 dp[i][w] dp[i-1][w] # 拿第i个 if w weights[i-1]: dp[i][w] max(dp[i][w], dp[i-1][w - weights[i-1]] values[i-1]) return dp[n][W] # 示例weights[2,3,4,5,6], values[3,4,5,8,9], W10 → 返回15提示空间优化是高频考点。观察转移只依赖上一行可压成一维数组dp[w] max(dp[w], dp[w-weight[i]] value[i])但必须保证w倒序遍历否则变成完全背包。这是笔试中最常挖的坑——把倒序写成正序结果全错。3.2 完全背包物品无限供应“正序遍历”是灵魂和01背包唯一区别每个物品可以拿无限次。状态定义不变但转移逻辑变了dp[i][w] max(dp[i-1][w], dp[i][w-weight[i]] value[i])。注意第二项是dp[i][w-weight[i]]而非dp[i-1][w-weight[i]]意味着“拿了第i个后还可以继续拿第i个”。实现时内层循环w改为正序for w in range(weights[i-1], W1)。为什么因为正序时dp[w-weight[i]]已经是本轮更新过的值自然支持多次选取。代码对比# 完全背包一维优化版 def knapsack_complete(weights, values, W): dp [0] * (W 1) for i in range(len(weights)): # 正序遍历允许重复使用 for w in range(weights[i], W 1): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[W]注意完全背包的初始化略有不同。若要求“恰好装满”dp[0]0其余dp[w]-inf若允许“不超过”dp全初始化为0。这是另一个高频雷区——题目说“最多能装多少”默认允许不满说“必须装满”就要用-inf初始化。3.3 多重背包带数量限制的“批发版”二进制优化是关键每个物品有数量限制cnt[i]。暴力解法把第i个物品拆成cnt[i]个独立物品跑01背包时间复杂度O(W×Σcnt[i])可能爆炸。二进制优化是核心技巧任何整数k都能被分解为1,2,4,...,2^(t-1), k-2^t1的和如131246。这样cnt[i]个物品只需拆成log(cnt[i])个“新物品”每个新物品的重量和价值是原物品的倍数如拆出4个则新物品重4×w值4×v。然后对这些新物品跑01背包。代码框架def knapsack_multiple(weights, values, counts, W): dp [0] * (W 1) for i in range(len(weights)): # 二进制拆分 cnt counts[i] k 1 while k cnt: # 拆出k个 w_pack k * weights[i] v_pack k * values[i] # 倒序01背包 for w in range(W, w_pack - 1, -1): dp[w] max(dp[w], dp[w - w_pack] v_pack) cnt - k k * 2 # 处理剩余 if cnt 0: w_pack cnt * weights[i] v_pack cnt * values[i] for w in range(W, w_pack - 1, -1): dp[w] max(dp[w], dp[w - w_pack] v_pack) return dp[W]实操心得二进制优化不是银弹。当cnt[i]很小时如≤10直接多重循环三层for反而更快当cnt[i]极大且W不大时单调队列优化更优。我建议面试时先说二进制再提一句“若数据规模特殊可用单调队列”显得既有套路又懂变通。3.4 分组背包带约束的组合选择“组内至多选一”物品被分成若干组每组内至多选一个。典型场景旅行时每个城市只住一家酒店每个酒店有不同房型价格/面积不同预算有限下最大化住宿面积。状态定义dp[i][w]表示前i组物品容量w下的最大价值。转移时对第i组的每个物品j尝试“选j”或“不选本组任何物品”dp[i][w] max( dp[i-1][w], # 不选第i组 max_{j in group_i} { dp[i-1][w-weight[j]] value[j] } # 选第i组的j )代码实现关键是“组内遍历”def knapsack_group(groups, W): # groups: [[(w1,v1), (w2,v2), ...], [...], ...] dp [0] * (W 1) for group in groups: # 为避免组内物品互相影响先备份上一轮状态 dp_old dp[:] for w in range(W, -1, -1): for weight_j, value_j in group: if w weight_j: dp[w] max(dp[w], dp_old[w - weight_j] value_j) return dp[W]关键陷阱必须用dp_old保存上一轮状态否则组内多个物品会互相干扰类似完全背包的错误。这是分组背包最易错的点我见过太多人在这里栽跟头。4. 从理论到实战车辆路径规划中的背包建模真实案例拆解4.1 场景还原同城即时配送的“动态载重约束”去年帮一家生鲜平台优化配送调度他们遇到典型瓶颈一辆车额定载重500kg但实际接单是动态的——司机出发前只知前3单途中不断接入新单用户下单、取消、改地址。传统方案是每5分钟重算一次全局路径但计算延迟导致司机等单。我们换思路把“未来15分钟可能接入的N个订单”视为物品集合每个订单有重量商品净重包装、价值配送费-预计油耗、时间窗必须在XX:XX前送达。目标是在不超载前提下选一组订单使总收益最大。这本质是01背包的时空扩展版容量是载重价值是净收益但多了时间窗约束——不能只看重量还要看“能否在截止前送到”。解决方案将时间窗转化为“虚拟重量”。例如订单A要求10:00前送达司机当前在仓库到A需15分钟则A的“时间重量”15分钟订单B要求10:10前送达到B需20分钟则B的“时间重量”20分钟。再定义“时间背包容量”为15分钟从现在到最早截止时间的缓冲期。于是问题变成在载重≤500kg且时间消耗≤15分钟 的双重约束下选哪些订单收益最高。这叫多维背包状态变为dp[i][w][t]但实际用滚动数组优化到二维dp[w][t] max value。4.2 代码落地双约束背包的核心改造核心改动状态数组升维转移时同时检查两个约束def knapsack_2d(weights_w, weights_t, values, W, T): # dp[w][t]载重w、时间t下的最大价值 dp [[0] * (T 1) for _ in range(W 1)] for i in range(len(weights_w)): # 倒序遍历两个维度类似01背包 for w in range(W, weights_w[i] - 1, -1): for t in range(T, weights_t[i] - 1, -1): dp[w][t] max( dp[w][t], dp[w - weights_w[i]][t - weights_t[i]] values[i] ) return dp[W][T] # 应用weights_w[2,3,4,5], weights_t[1,2,3,4], values[3,4,5,8], W10, T6 # 表示在载重≤10、时间≤6的前提下最大收益实操经验双约束下W和T的量纲必须统一。我们把时间转换为“分钟”载重转换为“kg”但数值差异太大载重500kg vs 时间15分钟直接dp数组会稀疏。解决方案对载重做离散化——只记录50kg、100kg、150kg...档位用字典代替数组。这是工业级DP的常见技巧比强行开500×15的大数组高效得多。4.3 方案效果与业务指标提升上线后司机平均空驶率下降18%订单履约准时率从82%提升至94.7%。最关键的是系统响应时间从分钟级降到秒级因为双约束背包的DP表大小可控载重档位10档×时间档位10档100状态预计算后查表即可。老板最满意的一点这套逻辑能无缝迁移到“冷链车温控约束”——把温度波动范围当作第三维约束用同样框架处理。这印证了一个真理背包问题不是考题而是建模思维的训练场。当你能把业务里的“限额”抽象为“容量”把“收益”抽象为“价值”把“互斥选项”抽象为“组内选择”你就拿到了打开运筹优化大门的钥匙。5. 高频问题排查与独家避坑指南血泪总结5.1 “答案总是0”检查这三处初始化硬伤这是新手最常问的问题。典型表现输入正确代码逻辑看似无误但输出恒为0。排查清单问题位置错误示例正确做法为什么dp数组初始化dp [-1] * (W1)dp [0] * (W1)或[-float(inf)] * (W1)若求“恰好装满”dp[0]0其余-inf若求“不超过”全初始化为0。负无穷初始化后若无法装满dp[W]仍为-inf需额外判断循环边界for w in range(weights[i], W)for w in range(weights[i], W1)range(a,b) 是[a,b)漏掉了wW这个关键容量索引偏移dp[w] max(dp[w], dp[w-weights[i]] values[i])dp[w] max(dp[w], dp[w-weights[i-1]] values[i-1])当weights/values是原始列表i是1-based循环变量时必须减1。我习惯用enumerate避免此错for i, (w_i, v_i) in enumerate(zip(weights, values)):经验写完立刻用最小case验证。例如weights[2], values[3], W2期望输出3。如果输出0一定是上述三处之一错了。5.2 “结果比预期小”警惕状态定义歧义很多人纠结“dp[i][w]是‘不超过w’还是‘恰好w’” 这不是语法问题而是建模问题。决定权在转移方程的设计若转移中包含dp[i][w] max(dp[i][w-1], ...)则dp[i][w]天然表示“不超过w”的最大值若转移只从dp[i-1][w-weight[i]]来且不考虑dp[i][w-1]则dp[i][w]是“恰好w”。实际中我推荐统一用“不超过w”的定义因为更符合业务直觉谁会故意留空背包。此时最终答案是max(dp[n][0..W])而非dp[n][W]。代码微调# “不超过w”版本dp[i][w] max value with capacity ≤ w for w in range(W, weights[i-1] - 1, -1): dp[i][w] max(dp[i-1][w], dp[i-1][w-weights[i-1]] values[i-1]) # 最终答案max(dp[n])5.3 “内存超限”空间优化的实操红线当W达到10^6级别如大型物流调度二维dp数组会爆内存。一维优化是必须的但有严格前提01背包/多重背包必须倒序遍历w确保dp[w-weight[i]]是上一轮值完全背包必须正序遍历w确保dp[w-weight[i]]是本轮值分组背包必须用临时数组dp_old否则组内物品会串扰。血泪教训曾有个学员把01背包的倒序写成range(W, weights[i-1], -1)漏了-1导致wweights[i-1]这个状态永远不更新结果少了关键物品。记住口诀“01倒序到自身完全正序无顾忌分组备份再覆盖”。5.4 “如何输出具体方案”回溯法的稳定实现面试常问“不仅要求最大价值还要输出选了哪几个物品。” 核心是逆向回溯从dp[n][W]开始比较dp[i][w]和dp[i-1][w]是否相等。若不等说明第i个物品被选中然后跳到dp[i-1][w-weight[i]]继续若相等说明没选跳到dp[i-1][w]。注意必须用二维dp一维数组无法回溯。稳定代码def knapsack_with_solution(weights, values, W): n len(weights) dp [[0] * (W 1) for _ in range(n 1)] # 构建dp表同前 for i in range(1, n 1): for w in range(W, weights[i-1] - 1, -1): dp[i][w] max(dp[i-1][w], dp[i-1][w - weights[i-1]] values[i-1]) # 回溯找方案 solution [] w W for i in range(n, 0, -1): if dp[i][w] ! dp[i-1][w]: # 第i个被选中 solution.append(i-1) # 存原始索引 w - weights[i-1] solution.reverse() # 从前往后输出 return dp[n][W], solution # 返回(最大价值, [物品索引列表])提示回溯时if dp[i][w] ! dp[i-1][w]是唯一可靠判断不要用或浮点误差或初始化问题会导致误判。6. 动态规划的底层心法状态设计比代码更重要最后分享一个观点很多人学DP把精力全花在“怎么写for循环”上却忽略了最核心的环节——状态设计。背包问题之所以经典是因为它的状态定义极其干净dp[i][w]。但现实问题往往更混沌。比如“股票买卖含手续费”状态不能只记“持有/未持有”还得记“上次交易是否已收费”“编辑距离”要记“s1前i位变s2前j位的最少操作”。我的经验是设计状态时死守三条铁律完备性状态必须包含决策所需的全部信息。例如车辆路径中只记“当前载重”不够必须记“当前所在位置”和“已用时间”否则无法计算下一单的到达时间。无后效性一旦状态确定后续决策只与当前状态有关与如何到达该状态无关。这是DP能成立的前提。如果“选A后B的收益取决于A的重量”那状态里就得包含A的重量。可转移性从一个状态必须能明确转移到另一个状态。如果转移需要遍历所有历史说明状态设计失败要引入更多维度。回到背包dp[i][w]完美满足这三点i和w完全决定了“已考虑哪些物品、还剩多少容量”后续决策选或不选第i1个只依赖这两个数且转移方程清晰明确。当你面对一个新问题先别急着写代码拿出纸笔反复问自己“要做出下一个决策我必须记住哪些信息” 把这些信息列出来就是你的状态维度。这比背一百个模板都管用。我在实际项目中曾用这套心法把一个“带维修窗口的设备调度”问题从最初设想的5维DP逐步精简到3维计算时间从小时级降到毫秒级。真正的DP高手不是代码写得快而是状态想得准。现在合上这篇文章打开编辑器用你刚理解的状态定义重新写一遍01背包——这次不看任何参考只凭对“记账逻辑”的直觉。你会发现自己已经站在了动态规划的门口而钥匙就在你手里。
返回列表