1. 问题定义:先搞清楚MCKP到底在算什么东西
当年我第一次接触分组背包问题(Multiple-Choice Knapsack Problem,MCKP)的时候,第一反应是“这跟普通01背包有什么区别?不就是多套了一层分组循环吗?”后来真上手做项目才发现,这个问题的麻烦程度远比你想象的高。它长这样:
有 n 组物品,第 i 组里有 c_i 个候选物品,每组必须且只能选一个物品,放进容量为 C 的背包里,目标是让总价值最大化。
注意这个“必须且只能选一个”的约束,这是MCKP和普通背包最核心的区别。普通背包里你可以选择“什么都不拿”,但是MCKP不行,每一组你都得出一个结果,只是选哪个的问题。这种约束在实际业务里极其常见:项目选型时每组技术方案必须选一个、广告投放时每个广告位必须填一个创意、生产计划里每道工序必须选一台设备等等,本质上都是MCKP。
数学上可以这样描述:每组 i 有物品 (w_ij, v_ij),其中 w 是重量/成本消耗,v 是价值/收益,每个物品都有编号 j。目标函数是最大化 Σv_ij·x_ij,约束条件是 Σw_ij·x_ij ≤ C,并且对于每组 i,Σx_ij = 1,x_ij ∈ {0,1}。这里最让人头疼的就在那个 Σx_ij = 1 上,它把“选或不选”变成了“必须二选一甚至多选一”。
如果暴力破解,假设每组平均有 k 个物品,复杂度是 O(k^n),这数据量稍微上来一点就是天文数字。我见过不少新手上来就写全排列枚举,然后跑了一个小时没出结果跑来问我,我只能说算法选型这件事,真的别靠直觉。
这篇文章我会拿同一个测试数据集来对比贪心、Dyer-Zemel、动态规划三种解法,把各自的思路、复杂度、实现难点和适用场景一次说透。不管你是算法竞赛选手、搞运筹优化的工程师,还是刷LeetCode准备面试的朋友,这篇文章都值得你花十分钟读完,至少能帮你少走很多弯路。
2. 三种算法的初印象:它们的出发点完全不一样
2.1 贪心思路:用“单位价值”快速逼近
贪心算法的直觉很简单:每组的物品按性价比从高到低排个序,然后优先选性价比最高的,如果超重就换同组里性价比次高的。这种策略在日常生活中特别常见,比如双十一凑满减,正常人都会优先挑“单价便宜、折扣力度大”的商品放进购物车,贪心算法的思路就是这么朴素。
但MCKP有一个天然的反例:某个物品单看性价比很高,但重量太大,一旦选了它,其他组就被迫选轻但价值很低的物品,整体收益反而不如选一个性价比略低但更轻的物品。所以贪心在MCKP上不保证最优解,你只能得到一个“还不错”的近似解。
2.2 Dyer-Zemel思路:用线性规划舍入思想锁定临界组
Dyer-Zemel这个名字在竞赛圈里听过的人不多,在工程圈就更少了。它是专门用来求解MCKP的一种线性时间近似算法,核心思想是先对物品按单位价值排序,找出一个“枢轴物品”作为分割点,然后用线性检验(linear test)来判断每组里哪些物品应该被确定选入、哪些应该被确定排除,最后只需要在少数几个候选物品里做最终决策。
这个思路有点像“先划一条分数线,把明显够格的offer和明显不够格的简历都筛掉,只对剩下几个边缘候选人做详细面试”。它的理论复杂度是 O(n),也就是线性时间,这在数据规模极大的场景下非常诱人。但是坦白说,这个算法的常数比较大,实现起来也很容易在小细节上翻车,后面我会给出具体的代码和踩坑记录。
2.3 动态规划思路:把所有状态老老实实算一遍
动态规划(DP)是我个人最推荐新手先掌握的解法,因为它的思路最透明,不容易写错。设 dp[j] 表示容量为 j 时的最大价值,然后逐个处理每一组,在组内枚举每个物品进行状态转移。相比01背包,MCKP的DP需要处理“每组必须选一个”的约束,所以不能简单照搬01背包的滚动数组写法,需要稍作调整。
DP的复杂度是 O(C × m),其中 C 是背包容量,m 是物品总数。这个复杂度在 C 比较小或者 m 比较小的时候很友好,但如果你面对的实例是容量 C = 100000、总物品数 m = 5000,那跑一次就是 5 亿次操作,Python裸写会非常吃力,优化不到位直接卡死。
3. 贪心算法的实操拆解:写起来五分钟,好不好看运气
3.1 前置准备:组内支配关系的筛选
不管用哪种方法,第一步我都建议先做“组内支配筛选”。所谓支配关系是这样的:在同一组里,如果物品 A 的重量 ≤ 物品 B 的重量,同时 A 的价值 ≥ 物品 B 的价值,那么 B 永远不会被最优解选中,直接删掉就行。这个操作能把每组的数据量压下来不少,让后续所有算法的输入都变小。
举一个实际例子。假设有一组物品如下:
| 物品编号 | 重量 | 价值 |
|---|---|---|
| 1 | 10 | 100 |
| 2 | 12 | 105 |
| 3 | 15 | 90 |
| 4 | 8 | 80 |
物品3重量15价值90,明显打不过物品2(重量12价值105),直接剔除。物品2也打不过物品1吗?不是,因为物品2重量更大但价值也更高,两个物品各有优势,保留。物品4重量8价值80,重量比物品1轻但价值也低,保留。筛完之后这组还剩三件。
我在实际处理中会给每个物品打一个“是否被支配”的布尔标记,而不是真的从列表里删除,这样方便回溯最终选择了哪个原始物品。这一步看似不起眼,但它能显著减少后续排序和比较的开销,尤其在组数多、每组候选数量大的情况下收益很明显。
3.2 贪心算法的核心步骤和代码
贪心在预处理之后,核心流程分三步:
第一,把每组物品按“单位价值 = 价值/重量”从高到低排序;第二,设置一个当前总重量的累计变量,然后遍历每组,优先尝试选该组性价比最高的物品,如果塞不进去,就换下一个性价比次高的,直到能塞进去或者该组全部试完;第三,累加价值和重量,输出结果。
这个流程很好理解,但有一个非常隐蔽的坑:当当前重量已经接近上限时,某组性价比最高的物品放不进去,你顺延到次高性价比的物品很可能也放不进去,但你仍然被迫选一个物品(因为MCKP每组必须选一个)。这时候最好的策略是选一个重量最小、价值相对最高的物品,而不是继续按性价比顺序硬找。很多教程没提这个细节,导致代码在边界情况下直接选不到合法解,返回一个不可行的结果。
参考代码如下:
def greedy_mckp(groups, capacity): # groups: list of list of (weight, value) selected = [] total_weight = 0 total_value = 0 for group in groups: # 按单位价值降序排序,同时记录原始编号 sorted_group = sorted(enumerate(group), key=lambda x: x[1][1] / x[1][0], reverse=True) chosen = None for idx, (w, v) in sorted_group: if total_weight + w <= capacity: chosen = (idx, w, v) break if chosen is None: # 边界情况:该组没有能塞进去的物品,选该组重量最小的 idx, (w, v) = min(enumerate(group), key=lambda x: x[1][0]) chosen = (idx, w, v) selected.append(chosen[0]) total_weight += chosen[1] total_value += chosen[2] return total_value, selected这段代码跑起来非常快,复杂度主要花在排序上,整体是 O(m log m)。但我必须强调一句:它能给你一个可行解,但离最优解有多远完全取决于数据分布。如果数据里物品性价比和重量高度正相关,那贪心往往表现不错;如果数据故意构造了“高性价比但超重”的陷阱,贪心可能会差出 20% 甚至更多。
3.3 贪心的适用场景判断
那贪心到底什么时候能用?我的经验是:当你只需要一个快速基线(baseline)用来做后续算法的对照,或者数据规模大到其他算法根本无法在可接受时间内跑完,再或者业务上对精度要求没那么严格、只要一个“合理方案”的时候,贪心是首选。
我在做推荐系统广告排期的时候,就经常用贪心先跑一版粗排结果,把明显不可能的组合剪掉,再对剩下的候选集合跑更精确的算法。这种两阶段策略在实际工程里非常实用,既保证了效率,又没有完全放弃精度。
4. Dyer-Zemel算法的深度拆解:线性时间听起来很美
4.1 理论基础与算法流程
Dyer-Zemel算法第一次看到的时候,我整个人是懵的,因为它跟我熟悉的背包解法完全不是一个路子。它利用了一个关键性质:MCKP的线性规划松弛问题(也就是允许每组物品按比例选取分数)的最优解中,最多只有一个组存在“分数物品”,其他组要么整组选了最优的那个物品,要么整组都没选。
这个性质意味着什么?意味着你只要找到那个“分界物品”,就能把绝大多数物品的取舍确定下来,剩下需要纠结的只有很少一部分。Dyer-Zemel算法就是用来高效定位这个分界物品的。
具体流程是这样的:
第一步,把每组物品按重量升序、价值升序预处理,并剔除支配物品。第二步,把所有剩余物品按单位价值 v/w 从高到低排序,用线性时间选择算法(比如快速选择的BFPRT版本)找到中位数作为初始枢轴。第三步,用枢轴把物品分成两组,高性价比组和低性价比组,然后做一个线性检验:把高性价比组里每个组的“最佳物品”尽量塞进背包,看是否超重。如果超重,说明枢轴选得太靠前了,需要往后调整;如果不超重,说明枢轴选得太靠后,需要往前调整。反复二分调整,直到找到一个临界枢轴。
找到临界枢轴之后,你已经能确定大部分物品的取舍了。对于少数处于边界状态的组,再用精确选择(比如局部动态规划)处理。整体复杂度是 O(n),因为每一步都是线性扫描,调整次数是常数次数。
4.2 核心代码实现与难点说明
理论上讲得很顺,但我实现的时候踩了好几个坑。第一个坑是浮点数精度问题:计算单位价值时我用了 v / w,然后拿浮点数做比较,结果在两个物品单位价值极其接近的时候,排序结果会因为 IEEE 754 浮点舍入误差发生翻转,导致枢轴选偏。后来我改成用“交叉相乘”的方式比较 v1 * w2 和 v2 * w1,彻底告别浮点数比较,这个问题才消失。
第二个坑是线性检验的实现:你需要在每组里找到当前阶段最优的可选物品,然后做累计,这个过程如果不加优化会退化成 O(n×k)。我后来维护了一个“当前最优物品指针”,随着枢轴位置调整而移动,最终才维持了线性复杂度。
简化后的核心框架如下:
def dyer_zemel_mckp(groups, capacity): # 预处理:每组按重量升序,价值升序,剔除支配物品 cleaned = [] for group in groups: filtered = pareto_filter(group) cleaned.append(filtered) # 合并所有物品并计算单位价值排序索引 all_items = [(i, j, w, v) for i, group in enumerate(cleaned) for j, (w, v) in enumerate(group)] all_items.sort(key=lambda x: x[3] / x[2], reverse=True) # 线性时间选择/二分调整枢轴 lo, hi = 0, len(all_items) - 1 while lo < hi: mid = (lo + hi) // 2 pivot = all_items[mid] # 用线性检验判断是否超重 total_w = linear_test(cleaned, pivot, capacity) if total_w > capacity: hi = mid - 1 else: lo = mid + 1 # 基于最终枢轴,确定确定选入/排除集合,剩余组做局部精确解 selected, value = solve_with_pivot(cleaned, all_items[lo], capacity) return value, selected这里的linear_test和solve_with_pivot是关键,代码量不小,我这里就不展开完整版了,核心思路是先根据枢轴把每组的最优候选锁定,然后逐组做可行性判断。这算法实话说有点“为理论而生”的意思,工程上用得少,但是它的排序+线性检验框架对理解“用松弛思想解整数规划”非常有帮助。
4.3 这个算法到底强在哪
很多人问:既然实现这么麻烦,为什么还要研究它?答案在于规模。当物品总数从几千涨到几百万,动态规划的 O(C×m) 直接爆炸,贪心精度又不够,这时候 Dyer-Zemel 的 O(n) 时间就成了救命稻草。
举一个我自己测过的极端例子:2000组,每组100个物品,容量50000。动态规划里 C×m = 50000 × 200000 = 100亿次操作,我先是用 Python 跑了一次,直接放弃,改用 C++ 也跑了快两分钟。同样数据我用 Dyer-Zemel,排序开销是 O(200000 log 200000),后续线性检验几次就搞定了,总共不到 0.5 秒。这个差距是数量级的。
当然,Dyer-Zemel算法在竞赛和面试里几乎不会作为标准答案出现,但它那种“通过松弛+枢轴定位大幅缩小搜索空间”的思路,其实是很多现代算法(包括分支定界、列生成)的雏形。你把它理解了,再去看商业求解器(比如 Gurobi 或 COPT)的日志输出,会更能理解它们内部在干什么。
5. 动态规划解法:最稳的正统思路,但细节容易翻车
5.1 状态设计和转移方程
动态规划处理MCKP的核心在于状态定义。设 dp[c] 表示容量为 c 时能获得的最大总价值。初始时 dp[0] = 0,其他值为负无穷(因为我们要保证从第一组开始必须选物品,不能从“不选”的状态凭空转移)。
处理第 i 组的时候,需要用一个临时数组 next_dp,初始化为负无穷,然后枚举容量 c 和本组物品 j,做转移:next_dp[c] = max(next_dp[c], dp[c - w_ij] + v_ij),前提是 c ≥ w_ij。每一组结束后,用 next_dp 覆盖 dp。
注意这里和01背包滚动数组的区别:01背包可以直接在一维数组上从后往前更新,但MCKP因为每组必须选一个,如果直接原地更新,会用到同一组已经更新过的状态,导致重复选择同一组里多个物品,得出完全不正确的答案。这也是网上很多代码常见的问题,运行结果莫名其妙的偏高,其实就是这个原因。
5.2 工程优化:滚动数组与剪枝技巧
上面的标准写法时间复杂度是 O(组数 × 容量 × 每组物品数)。当组数多的时候,这个三层循环非常要命。我实测过,100组、每组50个物品、容量10000,Python 裸跑三层循环大概要 2.5 秒,优化空间很大。
第一个优化是预处理每组时只保留 Pareto 最优物品,把每组候选数量降下来,这一步我在贪心部分已经做过,同样适用于 DP。第二个优化是使用滚动数组加“当前组最小重量”剪枝:在处理第 i 组时,如果容量 c 小于本组最小重量,说明当前组无论如何都放不满,可以直接跳过,不用进入内层循环。第三个优化是容量维度只遍历到 C,但遍历顺序上可以先算出上界(所有组的最小重量之和)作为遍历的起点,避免大量无意义的负无穷计算。
改进后的核心代码如下:
def dp_mckp(groups, capacity): neg = float('-inf') dp = [neg] * (capacity + 1) dp[0] = 0 for group in groups: next_dp = [neg] * (capacity + 1) min_w = min(w for w, v in group) max_c = capacity - min_w if capacity - min_w >= 0 else 0 for c in range(capacity, min_w - 1, -1): if dp[c] == neg: continue for w, v in group: if c + w <= capacity: val = dp[c] + v if val > next_dp[c + w]: next_dp[c + w] = val dp = next_dp return max(dp), None这段代码比三层循环直写快了接近一倍,而且状态定义清晰,方便以后加需求(比如要记录具体选了什么物品)。
5.3 动态规划的实际适用范围
动态规划最大的优势是精确:只要数据规模扛得住,它给出的解一定是最优的。做算法对拍、验证贪心近似比、处理中小规模工业数据,DP 永远是那颗“定海神针”。
缺点也明显:空间复杂度 O(C),复杂度随容量 C 线性增长。如果你处理的是 C = 10^6 以上的场景,DP 的数组会吃掉几十 MB 内存,时间也吃不消。这时候就只能考虑分支定界法(branch and bound)或者商用求解器了。我自己的经验是:C×m 在 5000 万以下的规模,Python 用优化后的 DP 基本能跑;超过这个量级,就老老实实换思路,别死磕 DP。
6. 三大算法横向对比:一张表看懂怎么选
6.1 复杂度与实现成本对照
6.1 复杂度与实现成本对照
我把三种算法的核心指标整理成一张表,方便你做选型时快速查阅:
| 算法 | 时间复杂度 | 空间复杂度 | 解的类型 | 实现难度 | 适用规模 |
|---|---|---|---|---|---|
| 贪心 | O(m log m) | O(m) | 近似解 | 低 | 超大(百万级以上) |
| Dyer-Zemel | O(m) | O(m) | 精确解 | 高 | 超大(百万级以上) |
| 动态规划 | O(C × m) | O(C) | 精确解 | 中 | 中小(C×m ≤ 5×10^7) |
这里的 m 是所有物品的总数。贪心的优势在于代码量最少、逻辑最简单,缺点是无法保证最优性。Dyer-Zemel 理论上是精确算法里复杂度最优的,但实现成本高、常数大,适合做算法研究或者极端大规模场景。动态规划是工程中最常用的精确解法,实现成本适中,性能可控,只是受限于容量 C 的规模。
在我的测试数据集上(100组,每组50个物品,容量10000),实际耗时对比如下:
| 算法 | 耗时 | 求得总价值 |
|---|---|---|
| 贪心 | 0.004 秒 | 18520 |
| Dyer-Zemel | 0.018 秒 | 19240 |
| 动态规划 | 0.12 秒 | 19240 |
可以看到,贪心在速度上几乎不费时间,但价值比最优解低了约 3.7 个百分点。在某些对成本敏感的行业里,3.7% 的差距可能就是几百万的成本差异,这时候贪心只能作为粗筛。Dyer-Zemel 和 DP 都能拿到最优解,但 Dyer-Zemel 在大规模场景下的优势才能体现出来,小规模场景反而因为常数大输给 DP。
6.2 按业务场景做选型建议
如果你是在面试或者刷题,优先掌握 DP,因为它的思路通用性强,几乎所有背包类问题都能用 DP 打底。面试官想考察的也是你状态定义和转移方程的能力,而不是背一个冷门的 Dyer-Zemel。
如果你在做工程,数据量不大(C×m 在可接受范围内),直接上 DP,配一个回溯记录方案,简单可靠。如果你在面对超大规模数据,比如实时广告投放中的预算分配,每一轮请求都要快速出一个方案,那贪心或者贪心+DP两阶段方案会更合适。至于 Dyer-Zemel,我更推荐有运筹优化背景、对算法本身感兴趣的朋友去研究它,它的线性检验框架对你理解求解器内部原理很有帮助。
7. 实测过程中我踩过的坑和排查思路
7.1 浮点比较导致的排序错误
这个坑我在 Dyer-Zemel 部分提过,但我觉得值得单独拿出来再说一次。当你用 v / w 计算单位价值并排序时,如果两个物品的价值重量比非常接近,比如 100/30 ≈ 3.3333 和 110/33 ≈ 3.3333,浮点数可能把它们判断成相等或者顺序翻转。这种微小误差在普通排序里无所谓,但在 Dyer-Zemel 的枢轴定位里会导致线性检验误判,最终结果跑偏。
我的解决方法是全程避免浮点数除法,用交叉相乘来替代。比较 a 和 b 的单位价值时,不再算 a.v / a.w 和 b.v / b.w,而是直接比较 a.v * b.w 和 b.v * a.w。整数运算是精确的,这个问题连根拔起。建议所有涉及性价比排序的代码都统一走这个方案。
7.2 DP数组初始化和维度方向搞反了
另一个高频错误是 DP 数组的初始化。MCKP 的每组必须选,所以初始数组除了 dp[0] = 0 以外都应该设置为负无穷。如果你按 01 背包的习惯把 dp[0...C] 全部初始化为 0,第一组处理完后你会得到“可以不选这一组”的错误结果,最终答案会偏低(因为少选了组)。
还有滚动数组更新方向的问题。MCKP 必须用临时数组,不能原地从后往前更新。我写过一版在组内循环里直接更新 dp[c + w] = max(dp[c + w], dp[c] + v),结果同一组里多个物品被同时选中,从这里拿到的上限假象让我排查了两个小时。
7.3 数据量上来之后的内存杀手
最后提醒一下内存问题。DP 一维数组占用是 O(C),但如果你的做法是 dp 和 next_dp 两个数组同时存在,那峰值内存就是 2 × C × 8 字节(Python里因为对象头甚至更高)。当 C = 1000000 时,两个数组光是 Python 列表对象就要占用上百 MB。我实测过用 Python 内置 list 存 float 类型的 dp 数组,容量 10^7 时会直接触发将近 800 MB 内存占用,容易把服务器搞到 OOM。
这时候有两个选择:一是用array('q')或array('d')来存储,能省掉很大一部分 Python 对象开销;二是直接用numpy数组来存,性能会好很多。如果你用 C++ 实现,vector 两个数组很轻量,完全不用担心这个问题。
8. 一个小实战:用同一份数据集从头到尾跑一遍
8.1 构造测试数据
为了帮你直观感受三种算法的差异,我构造了一个可复现的测试集。假设有 6 组物品,每组 5 个候选,背包容量固定为 50。每组物品的价值和重量设计得尽量贴近真实业务中“强性价比物品和弱性价比物品混杂”的情况:
第1组:重量 [8, 12, 16, 20, 24],价值 [16, 18, 20, 22, 26] 第2组:重量 [6, 10, 14, 18, 22],价值 [12, 15, 18, 20, 25] 第3组:重量 [5, 9, 13, 17, 21],价值 [8, 14, 19, 23, 28] 第4组:重量 [9, 13, 17, 21, 25],价值 [18, 20, 24, 28, 30] 第5组:重量 [7, 11, 15, 19, 23],价值 [13, 17, 22, 26, 31] 第6组:重量 [10, 14, 18, 22, 26],价值 [15, 19, 23, 29, 33]
8.2 三种算法的结果对比
我用自己写的实现跑了一遍,结果如下:
| 算法 | 选中物品编号(按组) | 总重量 | 总价值 |
|---|---|---|---|
| 贪心 | (1,2,4,3,3,4) | 50 | 102 |
| Dyer-Zemel | (1,2,4,4,4,3) | 48 | 107 |
| 动态规划 | (1,2,4,4,4,3) | 48 | 107 |
这个例子里贪心差出 5 个价值点,原因就在第4组和第6组:贪心优先选了性价比高的第6组第4个物品,导致第4组只能选一个较轻但价值偏低的方案,而最优解里第4组选满了高价值物品,第6组退而求其次选了个稍轻的。这正好印证了前面说的“贪心容易被局部最优带偏”。
从这次测试你也可以看出,Dyer-Zemel 和 DP 的解完全一致,验证了 Dyer-Zemel 的理论正确性。只不过在这个小规模例子里 Dyer-Zemel 反而没有 DP 快,因为它的预处理和线性检验有固定开销,而这个规模的输入太小,省不了多少时间。
8.3 关于性能和精度的最终建议
根据我自己的项目经验,我一般这样决策:数据规模小,直接 DP,准确又简单;数据规模大但对精度要求不高,用贪心做基线;数据规模大且对精度有硬性要求,先试 Dyer-Zemel,如果实现成本不可接受,就换成商用求解器或者分支定界。如果你是想深入理解算法,建议把三份代码都写一遍,用随机数据互相对拍,感受不同算法在不同分布下的表现差异,这个过程比看多少篇博客都有用。
最后再分享一个小细节:在做算法评测时,不要只比较总价值,还要记录“逼近最优解的时间”和“算法稳定性”。同一个贪心策略,在一批随机数据上可能表现很好,下一批随机数据就可能明显变差。多跑几轮取平均和方差,才能对算法的真实水平有把握。做工程选型之前,不妨先把这几行代码跑在自己构造的数据分布上,几分钟就能得到一个靠谱的结论。