拓冰建站拓冰建站
首页 / 资讯中心 / 正文

背包算法详解:从动态规划核心到实战应用

1. 从“装东西”到“做决策”背包算法的本质如果你问一个程序员算法里最经典、最实用、也最常被面试官拿来“拷问”的是哪个背包算法Knapsack Problem绝对能排进前三。我第一次接触它是在一个资源分配的项目里当时的需求很简单服务器带宽有限但有一堆不同大小和价值的任务包要发送怎么选才能让总价值最高我吭哧吭哧写了一大堆if-else结果不是超时就是结果不理想。直到同事甩过来一句“这不就是个0-1背包问题吗” 我才恍然大悟原来这个看似简单的“装东西”问题背后藏着一套精妙的决策方法论。简单来说背包算法要解决的就是一个资源有限条件下的最优选择问题。给你一个容量为W的背包和一堆物品每个物品有自己的重量weight[i]和价值value[i]。你的目标是从这些物品中挑选一部分放进背包使得在总重量不超过背包容量的前提下背包里物品的总价值最大。听起来是不是特别像我们日常生活中的各种决策比如投资理财你手头有10万本金背包容量面前有多个投资项目每个项目需要不同的投入重量并承诺不同的回报价值。你如何组合投资让总收益最大化广告投放一天有100万的广告预算背包容量有多个广告位每个广告位有不同的点击成本重量和预估转化价值价值。如何分配预算使总转化价值最高任务调度一个CPU核心在一个时间片内背包容量可以执行多个计算任务每个任务耗时不同重量优先级也不同价值。如何选择任务组合使得完成的优先级总和最高你看它绝不仅仅是一个“装东西”的数学游戏而是一个普适的优化框架。对于刚入门算法的朋友理解背包问题是打开动态规划大门的一把关键钥匙对于有经验的开发者它是解决实际资源分配、组合优化问题的利器。这篇文章我就结合自己踩过的坑和实战经验带你彻底搞懂背包算法的核心思想、几种经典变体以及如何把它用代码实实在在地实现出来并应用到你的项目里。2. 0-1背包最经典的“要或不要”决策模型我们先从最基础也是面试中最常见的0-1背包开始。为什么叫“0-1”因为对于每个物品你只有两种选择拿1或者不拿0。物品不能被分割也不能重复选取。这恰恰模拟了现实中最常见的那种“非此即彼”的离散选择。2.1 暴力搜索最直观但不可行的起点最笨的办法是什么枚举所有可能性。对于n个物品每个物品有拿或不拿两种状态那么总共有2^n种组合。我们遍历所有组合检查总重量是否超限并记录价值最大的那个。代码写起来大概是这样伪代码思路max_value 0 best_combination [] # 遍历所有子集 for i in range(2**n): current_weight 0 current_value 0 temp_combination [] # 检查每个物品是否在当前子集中 for j in range(n): if (i j) 1: # 如果第j位是1表示拿这个物品 if current_weight weight[j] capacity: break # 超重这个组合无效 current_weight weight[j] current_value value[j] temp_combination.append(j) if current_value max_value: max_value current_value best_combination temp_combination.copy()这个方法在物品数量少比如 n20的时候还能凑合一旦n达到30组合数就超过10亿完全不可行。我们需要更聪明的方法。2.2 动态规划用“记忆”避免重复计算动态规划DP是解决背包问题的标准武器。它的核心思想是将大问题分解为小问题并存储这些小问题的解避免重复计算。对于0-1背包我们定义一个二维数组dp[i][w]。它的含义是考虑前i个物品物品编号从1到i在背包容量为w的情况下能够获得的最大价值。那么对于第i个物品我们面临的选择是什么不拿第 i 个物品那么问题就退化成了“考虑前 i-1 个物品容量为 w”的子问题。此时的最大价值就是dp[i-1][w]。拿第 i 个物品前提是这个物品的重量weight[i-1]不能超过当前容量w。如果拿了背包的剩余容量就变成了w - weight[i-1]我们需要在这个剩余容量下从前 i-1 个物品里找最优解。此时的总价值是value[i-1] dp[i-1][w - weight[i-1]]。我们的目标是在这两个选择中选最优的。于是状态转移方程就出来了dp[i][w] max(dp[i-1][w], dp[i-1][w - weight[i-1]] value[i-1]) 其中第二个选项仅在w weight[i-1]时成立。初始化时dp[0][...] 0表示考虑0个物品价值为0。我们用一个具体的例子走一遍。假设背包容量W4物品如下物品重量价值物品1115物品2320物品3430我们构建dp表行 i 从0到3列 w 从0到4i0没有物品所有dp[0][w] 0。i1考虑物品1w0: 容量为0放不下任何东西dp[1][0] dp[0][0] 0。w1: 可以放物品1。max(dp[0][1]0, dp[0][0]1515) 15。w2,3,4: 容量更大但也只能放一个物品1所以都是15。i2考虑物品1和2w0,1: 同i1时因为物品2重量为3放不下。w2: 还是放不下物品2dp[2][2] dp[1][2] 15。w3: 选择不放物品2价值15或放物品2价值dp[1][0]2020。选20。w4: 选择不放物品2价值15或放物品2价值dp[1][1]2035。选35。i3考虑所有物品w4: 选择不放物品3价值35或放物品3价值dp[2][0]3030。选35。最终dp[3][4] 35就是最大价值。通过回溯dp表从后往前看决策我们可以知道最优组合是拿了物品1和物品2。注意这里有一个初学者极易混淆的点。dp[i][w]定义中的i是“考虑前i个物品”而不是“只从前i个物品里选”。它包含了在前i个物品中做选择的所有可能性。物品的索引通常从1开始对应到代码里访问weight和value数组时需要用i-1。2.3 空间优化滚动数组与一维DP上面我们用了O(n*W)的二维空间。但仔细观察状态转移方程dp[i][w]只依赖于dp[i-1][...]也就是上一行的数据。那我们完全可以用一个一维数组dp[w]来滚动更新。这个一维数组dp[w]表示在当前考虑的物品范围内容量为 w 的背包所能获得的最大价值。状态转移变为dp[w] max(dp[w], dp[w - weight[i]] value[i])。但是这里有一个至关重要的细节内层循环遍历容量 w必须从大到小遍历为什么因为dp[w]更新时需要用到dp[w - weight[i]]这个值是“旧”的即考虑上一个物品时的结果。如果我们从小到大遍历w那么在更新dp[w]时dp[w - weight[i]]可能已经被“当前”物品更新过了这就相当于同一个物品被多次放入背包这违背了0-1背包每个物品只能选一次的原则。而从大到小遍历可以保证在计算dp[w]时dp[w - weight[i]]对应的还是“未考虑当前物品”的状态。优化后的核心代码Python如下def knapsack_01(weights, values, capacity): n len(weights) dp [0] * (capacity 1) # 初始化一维DP数组 for i in range(n): # 遍历每个物品 # 内层循环倒序确保每个物品只被使用一次 for w in range(capacity, weights[i] - 1, -1): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[capacity] # 示例 weights [1, 3, 4] values [15, 20, 30] capacity 4 print(knapsack_01(weights, values, capacity)) # 输出35这种一维DP的写法空间复杂度降到了O(W)是面试和竞赛中的标准写法务必熟练掌握。3. 完全背包与多重背包当物品可以重复时现实世界不总是“非此即彼”。很多时候物品是可以拿多个的。这就引出了背包问题的两个重要变体。3.1 完全背包物品无限供应在完全背包问题中每种物品都有无限件。你可以在不超过背包容量的前提下拿任意多个同种物品。状态定义和0-1背包一样。但状态转移方程发生了变化因为物品i可以取无限个所以当我们决定拿一个物品i时并不是转移到dp[i-1][w-weight[i]]而是可以转移到dp[i][w-weight[i]]。意思是拿了这一个之后仍然可以考虑继续拿物品 i。所以状态转移方程为dp[i][w] max(dp[i-1][w], dp[i][w - weight[i-1]] value[i-1])。同样我们可以进行空间优化。神奇的是优化后的一维DP代码和0-1背包几乎一样唯一的区别就是内层循环遍历容量 w要从小到大遍历def knapsack_complete(weights, values, capacity): n len(weights) dp [0] * (capacity 1) for i in range(n): # 遍历每种物品 # 内层循环正序允许物品重复使用 for w in range(weights[i], capacity 1): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[capacity] # 示例硬币找零问题用给定面额的硬币凑出总金额求最少硬币数 # 这可以看作是完全背包价值是硬币数每个价值为1求最小价值。 def coin_change(coins, amount): dp [float(inf)] * (amount 1) dp[0] 0 for coin in coins: for a in range(coin, amount 1): dp[a] min(dp[a], dp[a - coin] 1) return dp[amount] if dp[amount] ! float(inf) else -1为什么正序就可以了因为正序遍历时当计算dp[w]时dp[w - weight[i]]可能已经因为本次循环被更新过了即已经放入过当前物品 i。这就相当于在容量w下我们可以放入多个物品 i。这个特性让完全背包的代码写起来异常简洁。3.2 多重背包物品有数量限制多重背包更贴近实际每种物品i有固定的数量count[i]。比如仓库里有3台型号A的服务器5台型号B的。最直接的思路是把多重背包转化为0-1背包把有count[i]个的物品 i拆分成count[i]个独立的物品每个重量和价值相同。然后跑0-1背包的算法。但是如果count[i]很大比如1000这样拆分会导致物品总数爆炸效率低下。更优的方法是使用二进制拆分。这个技巧非常巧妙是必须掌握的。它的思想是任何一个正整数都可以用一系列2的幂次方的数之和来表示比如 13 1 2 4 6。我们把count[i]个物品拆分成若干“组”每组物品的“数量”是2的幂次1, 2, 4, 8...直到剩下的数不足下一个2的幂次就单独成一组。例如有13个物品i。我们将其拆分为1个“新物品”重量1*weight[i]价值1*value[i]1个“新物品”重量2*weight[i]价值2*value[i]1个“新物品”重量4*weight[i]价值4*value[i]1个“新物品”重量6*weight[i]价值6*value[i]这样我们只用4个“新物品”就表示了原来13个物品的所有选择可能性从选0个到选13个。因为用1,2,4,6可以组合出0到13之间的任何整数。然后我们对这些拆分后的“新物品”集合运行标准的0-1背包算法即可。复杂度从O(W * Σcount[i])优化到了O(W * Σlog(count[i]))。def knapsack_multiple(weights, values, counts, capacity): # 第一步二进制拆分构建新的重量和价值列表 new_weights [] new_values [] for i in range(len(weights)): k 1 remaining counts[i] while k remaining: new_weights.append(k * weights[i]) new_values.append(k * values[i]) remaining - k k 1 # k * 2 if remaining 0: # 处理剩下的部分 new_weights.append(remaining * weights[i]) new_values.append(remaining * values[i]) # 第二步对拆分后的物品集合运行0-1背包算法 dp [0] * (capacity 1) for i in range(len(new_weights)): for w in range(capacity, new_weights[i] - 1, -1): # 0-1背包倒序 dp[w] max(dp[w], dp[w - new_weights[i]] new_values[i]) return dp[capacity]4. 背包问题的实战应用与变形思考理解了基础模型我们来看看背包算法如何解决真实问题以及一些常见的变形。4.1 恰好装满背包标准的背包问题是“不超过容量”求最大价值。有时问题会要求“恰好装满背包”求最大价值或最小价值。比如用硬币凑出某个金额必须刚好凑齐。处理这种变形只需要在初始化dp数组时做手脚。对于求最大值的情况我们让dp[0] 0表示容量为0的背包被“恰好装满”时价值为0。让其他dp[w] -inf负无穷。因为其他容量在初始状态下是“不可能被恰好装满”的非法状态我们用负无穷来表示。在状态转移时只有从合法的状态dp[...] ! -inf转移过来结果才是合法的。def knapsack_exact(weights, values, capacity): dp [float(-inf)] * (capacity 1) dp[0] 0 # 容量为0时恰好装满价值为0 for i in range(len(weights)): for w in range(capacity, weights[i] - 1, -1): # 只有前一个状态是合法的才能转移 if dp[w - weights[i]] ! float(-inf): dp[w] max(dp[w], dp[w - weights[i]] values[i]) return dp[capacity] if dp[capacity] ! float(-inf) else -1 # 返回-1表示无法恰好装满4.2 求方案数或具体方案有时我们不仅关心最大价值还关心有多少种方式能达到这个价值或者具体是哪些物品。求方案数将dp数组的含义从“最大价值”改为“方案数”。初始化dp[0]1容量为0有一种方案什么都不选。状态转移时如果放入物品能获得更大价值则方案数被覆盖如果价值相等则方案数相加。dp_count [0] * (capacity 1) dp_count[0] 1 dp_value [0] * (capacity 1) # 仍然需要价值数组来判断 for i in range(n): for w in range(capacity, weights[i]-1, -1): new_value dp_value[w - weights[i]] values[i] if new_value dp_value[w]: dp_value[w] new_value dp_count[w] dp_count[w - weights[i]] # 新方案覆盖旧方案 elif new_value dp_value[w]: dp_count[w] dp_count[w - weights[i]] # 价值相等方案数累加求具体方案这需要我们在动态规划的过程中额外记录“决策路径”。通常用一个二维的choice数组choice[i][w]表示在状态(i, w)下是否选择了物品 i。在DP过程结束后我们从最终状态(n, W)开始回溯如果choice[i][w]为真说明选了物品 i然后跳到状态(i-1, w-weight[i])否则跳到(i-1, w)。直到回溯到i0。4.3 多维费用背包背包的约束条件可能不止一个。比如一个任务既有时间成本又有内存成本。这就是二维费用背包。状态定义从dp[w]变为dp[t][m]表示在时间t和内存m的限制下的最大收益。状态转移方程是类似的只是多了一重循环。def knapsack_2d(time_costs, mem_costs, values, max_time, max_mem): dp [[0] * (max_mem 1) for _ in range(max_time 1)] for i in range(len(values)): for t in range(max_time, time_costs[i] - 1, -1): for m in range(max_mem, mem_costs[i] - 1, -1): dp[t][m] max(dp[t][m], dp[t - time_costs[i]][m - mem_costs[i]] values[i]) return dp[max_time][max_mem]4.4 分组背包物品被分为若干组每组内的物品互斥最多只能选一个。比如从几个不同的课程套餐里各选一门课。解法是在最外层循环遍历“组”然后内层循环遍历背包容量在最内层循环遍历该组内的每个物品尝试更新dp值。关键点对于每一组我们需要用上一组的结果来更新当前组所以内层对容量的循环要放在遍历组内物品的循环之外。def knapsack_group(groups, capacity): # groups: [[(weight1, value1), (weight2, value2), ...], [...], ...] dp [0] * (capacity 1) for group in groups: # 遍历每一组 for w in range(capacity, -1, -1): # 遍历背包容量倒序 for weight, value in group: # 遍历组内每个物品 if w weight: dp[w] max(dp[w], dp[w - weight] value) return dp[capacity]5. 性能优化与边界条件处理当问题规模变大时基础的DP解法可能会遇到性能瓶颈。这里分享几个实战中的优化思路和常见坑点。5.1 容量或价值过大时的优化标准的DP复杂度是O(n*W)。如果背包容量W非常大比如10^9但物品总价值V相对较小我们可以转换思路DP状态表示达到某个价值所需的最小重量。定义dp[v]为总价值恰好为 v 时所需的最小重量。初始化dp[0]0其他为无穷大。然后遍历物品对于每个物品我们尝试更新dp数组注意是0-1背包需要倒序遍历价值。def knapsack_large_capacity(weights, values, capacity): total_value sum(values) dp [float(inf)] * (total_value 1) dp[0] 0 for i in range(len(weights)): for v in range(total_value, values[i] - 1, -1): if dp[v - values[i]] ! float(inf): dp[v] min(dp[v], dp[v - values[i]] weights[i]) # 最后从高价值向低价值遍历找到第一个 dp[v] capacity 的 v for v in range(total_value, -1, -1): if dp[v] capacity: return v return 0这样复杂度变成了O(n * V)在V W时非常有效。5.2 初始化与边界条件的陷阱负重量或负价值有些题目中物品的重量或价值可能是负数。这通常意味着这个物品会“增加”背包容量或“减少”总价值。处理这类问题需要小心调整DP的遍历顺序和范围。对于负重量可能需要正序遍历容量对于负价值可能需要调整DP数组的索引偏移因为数组索引不能为负。浮点数重量/价值DP数组的索引通常是整数。如果重量或价值是浮点数一般需要先乘以一个倍数如100转化为整数或者使用其他方法如基于价值的DP。内存优化与缓存友好一维DP是常规操作。在极端性能要求下可以考虑使用位运算来加速或者使用滚动数组的两种状态当前和上一行来减少内存分配开销但这在大多数应用场景下不是瓶颈。5.3 从理论到实践调试与验证心得写背包DP的代码尤其是变形题很容易出错。我的调试习惯是先写暴力搜索对于小规模数据n20写一个暴力枚举所有组合的算法作为“标准答案生成器”。对比输出用随机生成的小数据同时运行你的DP算法和暴力算法对比结果是否一致。不一致时打印出DP表手动模拟计算过程找出第一个出错的状态。关注初始化检查dp[0]的设置是否正确是0还是负无穷。检查数组大小是否足够通常是capacity1。检查循环顺序这是最容易出错的地方。问自己这是0-1背包倒序还是完全背包正序如果是多维或多重约束嵌套循环的顺序对吗验证最终答案DP结束后dp[capacity]不一定就是答案。比如“恰好装满”问题需要判断dp[capacity]是否合法不是初始的非法值。背包算法是一个“套路”很深但极其有用的工具。掌握它的核心在于理解dp数组状态的定义以及状态之间是如何转移的。一旦内化了这个“状态机”思维很多复杂的优化问题都能被规约到背包模型上来。下次当你面临一个“有限资源下如何最优选择”的问题时不妨先想想这能不能抽象成一个背包问题
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门