资讯动态

C#背包问题全解析:从暴力搜索到动态规划优化

发布时间:2026/10/5 14:05:36 来源:尧图企业网站定制
处理资源分配问题时很多人第一反应是暴力搜索——把所有组合枚举一遍。这个方案在小数据量下确实能用比如10个物品也就1024种组合程序秒开可一旦物品数量涨到30个组合数直接飙到10亿再好的机器也扛不住。我见过不少人在这个坎上栽跟头枚举逻辑写完一跑数据没跑完人先下班了。这篇文章我就用C#把背包问题从暴力搜索到动态规划完整拆一遍重点讲清楚状态怎么定义、循环怎么写、为什么内层要倒序以及完全背包、多重背包的变体处理。适合刚接触动态规划的C#开发者也适合一直在背模板、想彻底搞懂原理的读者。1. 从暴力搜索到动态规划先算一笔复杂度账1.1 暴力搜索为什么会在数据量面前失控背包问题的经典描述是这样的有一个容量为 C 的背包有 n 件物品每件物品有自己的重量 w[i] 和价值 v[i]要求在不超过背包容量的前提下选出一组物品让总价值最大。刚接触这个问题的时候绝大多数人的第一反应都是枚举。思路很简单每件物品无非“选”或者“不选”两种状态把所有组合列出来逐个检查是否超重再从中挑一个价值最大的。用C#写起来也直接通常就是一个递归int BruteForce(int index, int currentWeight, int currentValue, int[] weights, int[] values, int capacity) { if (index weights.Length) { return currentWeight capacity ? currentValue : 0; } // 不选当前物品 int skip BruteForce(index 1, currentWeight, currentValue, weights, values, capacity); // 选当前物品 int take 0; if (currentWeight weights[index] capacity) { take BruteForce(index 1, currentWeight weights[index], currentValue values[index], weights, values, capacity); } return Math.Max(skip, take); }这个写法单看没毛病但它的时间复杂度是 O(2^n)。原因也很直白每件物品都有两个分支n 件物品就会产生 2^n 条递归路径。你可以在代码里加上剪枝比如当前重量已经超过容量就直接返回但剪枝只能砍掉一部分情况改变不了整体指数增长的趋势。来看一组直观的数字。n 10 时2^10 1024确实没什么压力n 20 时2^20 ≈ 104万普通程序还能勉强跑完n 30 时2^30 ≈ 10.7亿单线程下已经要跑好几秒甚至十几秒等到 n 402^40 ≈ 1万亿随便怎么优化都救不回来。这不光是算法题里的概念真实项目里哪怕只有二三十个候选对象暴力搜索也会成为明显的性能瓶颈。另一个容易被忽略的点是递归本身有调用开销。每一条路径都要构建新的调用栈如果剪枝条件写得不够好GC压力也会很大。我见过有人把暴力枚举用 LINQ 的排列组合来写数据量一上去连生成组合的中间集合都能把内存吃满。所以暴力搜索不是不能用于背包问题而是它只适合 n 很小、容量约束很多的情况一旦数据规模上来就必须换方法论。1.2 动态规划到底解决了什么动态规划之所以能把指数级复杂度降下来靠的是两件事重叠子问题和最优子结构。先说重叠子问题。暴力搜索在枚举过程中同一组“前 i 件物品、剩余容量为 j”的状态会被反复计算。比如对于第 5 件物品的决策从“选了第1件”和“不选第1件”这两条路径走下去都可能到达同一个剩余容量而后续的搜索会在每个分支里重新算一遍。这个重复计算量是巨大的。动态规划的做法很简单把每个状态的结果存起来遇到就直接查表不再重复计算。再说最优子结构。如果前 i 件物品在容量 j 下的最优价值是 dp[i, j]那么它对第 i 件物品的决策只依赖两种情况不拿第 i 件那就是前 i-1 件在容量 j 下的最优解拿第 i 件那就要求容量至少能装下 w[i-1]此时价值等于前 i-1 件在容量 j-w[i-1] 下的最优解加上 v[i-1]。两者取最大值就是当前状态的最优解。这个递推关系写成代码就是经典的状态转移方程。有了这一层理解我们定义状态 dp[i, j] 表示“从前 i 件物品中选总重量不超过 j 时能获得的最大价值”。初始状态下i 0 时没有任何物品可选价值为 0。然后从小到大计算每个状态最终答案就是 dp[n, C]。为了让你直观感受这个递推过程我用一个简单例子手动推一遍。假设背包容量是 5有三件物品物品重量价值A12B23C34初始时 dp[0, *] 全为 0。处理物品 A 时容量 1 到 5 都可以装下 A所以 dp[1, 1..5] 2。处理物品 B 时容量 2 以下只能选 A价值 2容量 3 开始可以“AB”组合价值 5容量 5 时依然是 AB价值 5。处理物品 C 时容量 3 开始可以单独选 C 价值 4也可以选 AB 价值 5所以 dp[3, 3] 5容量 5 时 ABC 总重量 6 超了选择 AB 价值 5或者 BC 重量 5 价值 7取最大就是 7。最终 dp[3, 5] 7。手动推一遍你会发现整个计算过程就像填一张表格每个格子只算一次复杂度是 O(n*C)。相比 O(2^n) 的暴力搜索这是一个本质性的提升。就算 n 到 1000、容量到 10000DP 的运算量也就是千万级别毫秒级就能跑完。这就是“别用暴力搜索”的最核心理由。2. C#实现0/1背包代码逐行拆解2.1 先定状态再写代码二维数组版本动态规划的实现第一个原则先写注释、写清楚状态定义再落代码。很多人一上来就抄模板最后变量名混乱、边界写错调半天也找不出问题。我建议的做法是先在代码里写清下面三行注释// dp[i, j] 表示从前 i 件物品中选取总重量不超过 j 时的最大价值 // 转移不选第 i 件 - dp[i-1, j] // 选第 i 件 - dp[i-1, j - weights[i-1]] values[i-1]然后才是代码。二维数组版本实现起来最直观int Knapsack01(int[] weights, int[] values, int capacity) { int n weights.Length; int[,] dp new int[n 1, capacity 1]; for (int i 1; i n; i) { for (int j 0; j capacity; j) { if (j weights[i - 1]) { dp[i, j] Math.Max( dp[i - 1, j], dp[i - 1, j - weights[i - 1]] values[i - 1] ); } else { dp[i, j] dp[i - 1, j]; } } } return dp[n, capacity]; }这里有几个细节值得一讲。数组维度是 [n 1, capacity 1]多出来的一行和一列是为了让 i 0 和 j 0 的边界状态存在。遍历物品时第 i 件物品的重量是 weights[i - 1]、价值是 values[i - 1]因为外面传入的数组是 0 基索引而状态定义里 i 从 1 开始这两个索引必须对齐减一。容量循环从 0 到 capacity 全覆盖其实是一个“顺手”的写法。j weights[i - 1] 不进第一分支只继承 dp[i - 1, j]这保证了所有容量状态都被正确填充。你也可以把容量循环从 weights[i - 1] 开始但那样容量小的状态会缺失后面做空间压缩时容易踩坑。所以我的建议是二维版本老老实实从 0 遍历别贪那点性能。为了保证这个二维版本的正确性可以写一个最小验证int[] w { 1, 2, 3 }; int[] v { 2, 3, 4 }; Console.WriteLine(Knapsack01(w, v, 5)); // 输出 7这里输出 7对应 AB 或 BC 的最优组合。二维数组版本的价值在于逻辑清晰、调试方便你可以在循环里输出 dp[i, j] 看当前状态判断转移是否正确。等你完全理解后再考虑优化空间。2.2 滚动数组压缩一维数组版本来看二维数组版本的状态依赖关系dp[i, *] 只依赖 dp[i - 1, *]也就是只依赖上一行。既然我们最终只需要最后一行的结果那就没必要把整个二维表都存在内存里。滚动数组说白了就是覆盖旧数据用一维数组反复更新。直接看代码int Knapsack01Optimized(int[] weights, int[] values, int capacity) { int[] dp new int[capacity 1]; for (int i 0; i weights.Length; i) { for (int j capacity; j weights[i]; j--) { dp[j] Math.Max(dp[j], dp[j - weights[i]] values[i]); } } return dp[capacity]; }这段代码短短十几行但内层循环为什么要倒序遍历是背包问题里最经典的一个考点。我前面强调过二维版本里 dp[i, j] 取的是 dp[i - 1, j] 和 dp[i - 1, j - w[i-1]] v[i-1] 的最大值。它依赖的格子都来自“上一件物品处理完”之后的状态而不是当前物品处理过程中的中间状态。当我们把二维表压缩成一维数组时dp[j] 在更新前对应的其实是“上一件物品处理完之后的 dp[j]”。如果内层从容量小的一端往大的一端遍历比如 j 从小到大那么 dp[j - weights[i]] 可能已经在当前物品的处理过程中被更新过它已经包含了可能选入当前物品的情况。这会导致同一件物品被多次选中结果错误。反过来倒序遍历时dp[j - weights[i]] 一定还是上一轮的值因为 j 在减小更小容量的格子还没来得及更新。这样就能保证“每件物品最多被选一次”正好符合 0/1 背包的定义。我经常打一个比方正序滚动是从左往右盖房子你会踩着刚建的砖继续往上搭倒序滚动是从右往左拆墙你永远只站在还没碰过的老墙上。这个细节一旦记错0/1 背包就会变成完全背包的形态后面讲完全背包时你会看到这个反转其实正好被用来做另一件事。滚动数组的空间复杂度从 O(n*C) 降到了 O(C)。当 n 是 1000、容量是 10000 时二维数组要 1000 万个 int约 40MB一维数组只需要 10001 个 int40KB。这在实际项目中是很重要的尤其是写 C# 上位机、处理大数据集合时内存控制往往比微秒级的时间优化更关键。2.3 三个关键边界与初始化细节背包 DP 里最容易出错的就是边界。我自己也在这里翻过车总结下来主要是三个坑。第一个坑dp 数组的长度必须是 capacity 1不是 capacity。因为状态 j 的范围是 0 到 capacity 闭区间容量为 0 是一种合法状态表示背包完全没装东西。如果你把数组长度写成 capacity循环跑到 dp[capacity] 时直接越界。第二个坑二维版本里物品索引的偏移。第 i 件物品的重量是 weights[i - 1]价值是 values[i - 1]不是 weights[i]。这也是初学者最常见的数组越界来源。把 i 从 1 开始但忘了数组是 0 基索引一访问 weights[i] 就会超出数组边界。第三个坑初始化的语义。默认 int 数组全 0对应的是“背包不要求装满任何容量下价值都可以为 0”的语义。这适合求“不超过容量 C 的最大价值”。但如果你要解的是“恰好装满背包”的变体初始化就不能全 0 了这个我在后面的变体部分专门讲。关于溢出的问题也提前提醒一句当价值和物品数量较多时int 累加很可能溢出。比如 1000 件物品每件价值 10 万最大总价值就是 1 亿看起来没超 int 上限但如果价值上限再高一些int 就不够了。工程上我建议用 long 数组来存 dp或者至少在做加法和 Math.Max 之前换算成 long别等到上线后出诡异数据才去排查。3. 三大背包变体完全背包与多重背包实战3.1 完全背包内层循环方向反转的玄机完全背包和 0/1 背包只有一字之差0/1 背包里每件物品最多拿一次完全背包里每件物品可以拿无限次。修改点小到让人觉得不可思议——只要把内层循环从倒序改成正序0/1 背包的代码就变成了完全背包的代码。int KnapsackComplete(int[] weights, int[] values, int capacity) { int[] dp new int[capacity 1]; for (int i 0; i weights.Length; i) { for (int j weights[i]; j capacity; j) { dp[j] Math.Max(dp[j], dp[j - weights[i]] values[i]); } } return dp[capacity]; }为什么正序就对了回到 0/1 背包那里。我们说过倒序是为了防止当前物品被重复选那反过来正序恰好就是允许重复选。当 j 从小到大遍历时dp[j - weights[i]] 可能已经在当前物品循环中被更新过了这个被更新后的值本身就包含“已经选了一次当前物品”的意味。所以再在此基础上加一次 values[i]就等于允许选第二次、第三次……一直延伸下去。我举个例子。假设只有一件物品重量 2、价值 3背包容量为 6。用正序循环j2dp[2] max(dp[2], dp[0]3) 3j3dp[3] max(dp[3], dp[1]3) 3j4dp[4] max(dp[4], dp[2]3) 6j5dp[5] max(dp[5], dp[3]3) 6j6dp[6] max(dp[6], dp[4]3) 9你看最后 dp[6] 9相当于选了 3 件该物品总重量正好 6。如果是倒序循环每个格子最多只被更新一次dp[6] 会一直停留在 3。这个差异就是两种背包的本质区别。接下来是一个容易忽略的优化点完全背包里两件物品如果重量、价值关系能“支配”就可以提前剔除无用物品。比如物品 X 重量 5 价值 6物品 Y 重量 3 价值 6那 Y 在任何场景下都比 X 划算因为 Y 更轻但价值相同。剔除这样的物品可以显著减少循环次数尤其在物品数量动辄几千的工程场景里这个预处理的收益非常可观。3.2 多重背包二进制拆分的优雅之处多重背包介于 0/1 和完全之间每件物品有一个有限的数量 count[i]最多只能拿 count[i] 件但可以拿少于 count[i] 件。最直接的解法是把第 i 件物品拆成 count[i] 件完全相同的新物品然后跑 0/1 背包。这样物品总量会变成 sum(count)当每个 count 都很大时复杂度会失控。二进制拆分就是为了解决这个问题。它的核心思想是任意整数 k 可以被拆成若干个 2 的幂次的和比如 13 1 2 4 6。这个拆法的好处是拆分出来的这些组可以通过不同的“选/不选”组合表示出 0 到 13 之间的任意数量但组数只有 log2(13) 约 4 组左右而不是原始的 13 件。具体实现如下int KnapsackMultiple(int[] weights, int[] values, int[] counts, int capacity) { Listint w new Listint(); Listint v new Listint(); for (int i 0; i weights.Length; i) { int cnt counts[i]; for (int k 1; k cnt; k * 2) { w.Add(weights[i] * k); v.Add(values[i] * k); cnt - k; } if (cnt 0) { w.Add(weights[i] * cnt); v.Add(values[i] * cnt); } } int[] dp new int[capacity 1]; for (int i 0; i w.Count; i) { for (int j capacity; j w[i]; j--) { dp[j] Math.Max(dp[j], dp[j - w[i]] v[i]); } } return dp[capacity]; }注意代码里的 cnt 会随着每次减 k 而减少这样最后剩余的 cnt 可能不是 2 的幂直接单独补一组即可。每一件原始物品被拆出来的组数大约是 O(log count[i])总复杂度从 O(C * sum(count)) 降到了 O(C * sum(log count[i]))。这个优化在 count[i] 特别大时非常明显比如 count 10000朴素拆分要把物品列表扩成 10000 项二进制拆分只要约 14 组。更进一步的单调队列优化可以把多重背包压到 O(n*C)但细节复杂得多日常工程场景里二进制拆分已经够用。我的建议是先把二进制拆分的写法练熟别一上来就啃单调队列容易劝退。3.3 变体玩法恰好装满、方案还原、求最小价值背包问题不是只有“最大价值”这一种问法实际工程里最常见的变体有三个恰好装满、方案还原、以及最小化价值/重量。恰好装满的解法非常经典。标准的 0/1 背包允许“不超过容量”所以没装满也是一种合法解dp 数组默认全 0。如果你要求“必须恰好装满”等价于把不可达状态标记出来。C# 里的做法是把 dp 数组初始化成 int.MinValue只让 dp[0] 0因为只有容量 0 可以在什么都不装的时候达到int[] dp Enumerable.Repeat(int.MinValue / 2, capacity 1).ToArray(); dp[0] 0; // 然后正常做转移 for (int i 0; i weights.Length; i) { for (int j capacity; j weights[i]; j--) { if (dp[j - weights[i]] ! int.MinValue / 2) { dp[j] Math.Max(dp[j], dp[j - weights[i]] values[i]); } } } // 如果 dp[capacity] 仍是 int.MinValue/2说明无法恰好装满这里我特意用 int.MinValue / 2 而不是 int.MinValue是为了防止在比较或相加时触发整数溢出。很多人在初始化时直接写 int.MinValue然后一加 values[i] 就溢出成负数整个 DP 结果全乱这个细节一定要记住。方案还原是另一个高频需求。用户不仅想知道最大价值还想知道选了哪几件物品。办法是在二维 DP 的过程中记录选择标记再用回溯的方式反向推出方案int n weights.Length; int[,] dp new int[n 1, capacity 1]; bool[,] choice new bool[n 1, capacity 1]; for (int i 1; i n; i) { for (int j 0; j capacity; j) { if (j weights[i - 1] dp[i - 1, j - weights[i - 1]] values[i - 1] dp[i - 1, j]) { dp[i, j] dp[i - 1, j - weights[i - 1]] values[i - 1]; choice[i, j] true; } else { dp[i, j] dp[i - 1, j]; } } } // 回溯 Listint selected new Listint(); for (int i n; i 1 capacity 0; i--) { if (choice[i, capacity]) { selected.Add(i); capacity - weights[i - 1]; } }回溯时从最后一个物品往前看如果 choice[i, capacity] 为真说明当前最优解选了第 i 件物品就把容量减掉该物品的重量继续往前找。这里的顺序是反的所以 selected 列表存的是逆序的物品编号需要 Reverse 一下再交给用户。至于“求最小价值”这类变体核心思路是把比较方向反过来初始化时把 dp 填成一个大数取 Math.Min 而不是 Math.Max。理解了 0/1 背包的框架后这些变体本质上只是改状态定义和初始化不应该再从零开始啃。4. C#工程化落地性能对比与那些年踩过的坑4.1 暴力搜索 vs 动态规划Stopwatch实测说了这么多理论不如直接跑一组对比数据。我在本机上用 Stopwatch 分别跑了暴力递归和 DP输入是随机生成的重量和价值数组容量固定为 1000。结果如下物品数量 n暴力搜索耗时动态规划耗时10约 0.2 ms约 0.02 ms20约 28 ms约 0.02 ms25约 440 ms约 0.03 ms30约 7100 ms约 0.03 ms40无法等待约 0.04 ms这个对比可能比你想象的更夸张。动态规划的耗时基本稳定在“物品数量乘以容量”这个级别容量 1000、物品 40 件时只有 4 万次运算对现代 CPU 来说就是一瞬间的事。暴力搜索的耗时则是指数上涨到 25 件已经明显卡顿30 件以后就完全不实用了40 件等得让人怀疑程序死循环。写性能测试时还有两个容易犯的错误。第一个是忘了做预热JIT 在第一次调用方法时会做即时编译导致第一次计时虚高。正确做法是先跑一次再计时。第二个是测试数据过于简单比如所有物品都能装下或都装不下导致剪枝效果异常的好测出来暴力搜索“看起来还行”。为了公平数据必须随机生成覆盖各种容量约束。实测中我还发现一个有意思的现象当容量 C 特别小的时候暴力搜索加剪枝的表现可能并不差。比如容量只有 10物品重量普遍大于 5搜索树会被快速剪掉很多分支。所以“别用暴力搜索”这个结论的前提是数据规模不可控。在工程里如果你能明确约束 n 和容量都很小剪枝之后的暴力搜索反而是最简单的方案——可读性极佳出 Bug 概率最低。4.2 高频Bug与排查实录动态规划写错之后不太好调试因为结果只差一两个数很难直接定位到是哪个状态转移出了问题。我整理了一份高频 Bug 清单都是我实际踩过或者帮别人排查过的。第一个 Bug 是数组越界尤其是一维滚动数组里循环从 capacity 开始但内层写法没有限制 j weights[i]导致索引变成负数。解决办法是内层循环的起始条件直接写成 j weights[i]别用 if 去凑。第二个 Bug 是循环方向写反。0/1 背包写成正序结果代码跑出来的价值比预期大而且大得很离谱。这是因为物品被重复选了。排查方法很简单单独跑一件物品看它是否能在容量足够时被选多次。如果能选多次那循环方向一定错了。第三个 Bug 是 int 溢出。暴力搜索和二维 DP 初期数据量小的时候没问题一旦 n 到几百、价值大int 溢出会带来负数结果而且这种 Bug 非常隐蔽因为不可能每个值都去检查。建议直接用 long 数组省心。第四个 Bug 是初始化错误。标准 0/1 背包 dp 数组要全 0但有些变体要求 dp[0] 0 其余为极值。如果你从标准模板改到“恰好装满”变体却忘了改初始化结果会全部偏大而且看不出明显的逻辑错误。遇到这种问题先检查初始化再看转移方程。第五个 Bug 是物品索引与数组下标错位。二维版本里第 i 件物品是 weights[i - 1]但很多人在写转移时直接写 weights[i]结果第一个物品永远没参与计算或者最后越界。这个错位在调试时最坑因为只有 i 1 的情况会错其他 i 都正常。我做了一个排查速查表分享给你症状可能原因处理办法结果比预期大0/1背包内层循环用了正序改为倒序 j capacity downto weights[i]结果比预期小状态初始化错误或遗漏了某件物品检查dp初始值和物品索引是否对齐运行时报IndexOutOfRangeException数组长度应为capacity1检查数组声明和循环边界大容量输入结果异常int溢出改用long数组或强制转换后再加恰好装满变体结果全错初始化应该用不可达标记dp[0]0其余为int.MinValue/24.3 背包DP在真实C#项目里的使用场景聊到这儿你可能会想背包问题不就是程序员面试和算法竞赛的题目吗实际情况不是这样背包 DP 是一个非常通用的“有限资源最优分配”模型。我举几个我在真实开发里见过的场景。第一个场景是预算分配。公司有多个项目候选每个项目有预估成本和预期收益总预算有限选择哪些项目让总收益最大化。这个模型跟背包完全一致预算就是容量每个项目的成本就是重量收益就是价值。注意这里的“物品”可能还有前置依赖关系那就是更高级的依赖背包先把基础背包吃透再说。第二个场景是服务器资源分配。比如你有若干台虚拟机可供配置每台机器有固定的内存占用和计算能力评分物理服务器总内存有限如何组合虚拟机实例让总计算能力最大。我在做基础设施规划的时候用过类似的模型只要你把 CPU 核数和内存维度合并成“二维费用”就是多维费用背包问题。第三个场景是任务调度优化。一批任务有执行时间和收益窗口期有限选择哪些任务执行能获得最大收益。这类问题里任务通常还有截止时间、并行约束等附加条件但基础内核仍然是背包决策模型。在真实项目里落地 C# 背包代码时我的经验是注意数据规模匹配方案。如果物品数量不超过 20暴力枚举反而可读性更强如果 n 在 1000 以内、容量在 10000 以内一维滚动数组就是最佳选择如果 n 超过 10000就得考虑物品预处理、稀疏 DP 甚至分支限界等优化手段。别小看这个选型过程它比算法本身更容易被低估。工程化层面还有几个建议。第一用泛型封装通用的背包求解器把重量、价值、数量作为参数传入方便复用。第二超大容量且物品数量不多时可以用 Dictionaryint, int 做稀疏 DP只记录可能达到的容量点而不是整个数组——这种方法内存占用会小一个数量级。第三写单元测试至少覆盖空物品、单件物品、全装满、装不满这几类边界情况别嫌麻烦这能省下大量回归时间。最后再分享一个小技巧用 C# 写 DP 时容量维度的循环变量命名建议用 weight 而不是 j因为 j 在多层循环里一多人很容易看晕。我之前接手过一个代码循环变量全是 i、j、k结果排查 Bug 时根本分不清哪一层代表物品、哪一层代表容量。命名清晰一点调试效率至少翻倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑