news 2026/10/23 13:29:11

P3010 Dividing the Gold 题解:0/1背包转换与方案数DP详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
P3010 Dividing the Gold 题解:0/1背包转换与方案数DP详解

出门前还在想“今晚把这题刷完就睡”,结果一道[USACO11JAN] Dividing the Gold S让我折腾到凌晨。这题在洛谷是P3010,USACO 2011年1月的Silver组题目,表面看就是个“把金子分成两堆让重量差最小”,可实际上它同时考了0/1背包的经典转换、方案数计数、以及边界条件处理。很多新手上去就想贪心或暴力枚举,三天三夜都过不了;而真正理解背包模型后,代码不过四十行。这篇文章就把完整思路、可提交的C++实现,还有我踩过的几个坑一起写出来,适合刚学动态规划、准备信奥普及组到提高组过渡的同学参考。

1. 这题不是简单平分:原题模型与背包化思路

1.1 题面到底在说什么

先交代题目内容。给你N块金子,每块有一个重量w[i],现在要把这些金子分给两个人,要求两个人分到的总重量之差尽可能小。如果有多种分法能达到这个最小差值,题目还要你输出“一共有多少种分法”。

输入格式很简单:第一行一个整数N,第二行N个整数,表示每块金子的重量。输出也是两个数,第一行是最小重量差,第二行是对应的方案数。

这里有个很容易被忽略的点:题目问的是“重量差最小”而不是“完全平分”。所以哪怕总重量是奇数、或者无论怎么分都没法做到两边一样重,你也得算出差值最小的那种分法,而不是直接放弃。

1.2 目标等价变形:让其中一堆尽量接近总和一半

设所有金子的总重量为S。如果第一个人分到了重量x,那第二个人自然分到S-x,两者的重量差就是:

|S - 2x|

要让这个差的绝对值最小,本质上就是让x尽量接近S/2。

因为两个人在这个问题里是对称的,我们只需要考虑x小于等于S/2的情况。也就是说,问题变成了:

从N块金子中选出一个子集,使得子集总重量尽量接近 floor(S/2),但不能超过它。

设target = S / 2(向下取整),我们要找一个最大的可达重量best,满足best不超过target。那么答案里的最小差值就是:

diff = S - 2 * best

这一步转化是整个题目的灵魂。很多同学卡住,就是因为还在枚举“第一个人拿哪些、第二个人拿哪些”,实际上只需要关心其中一堆能凑出多少就行,另一堆自动是补集。

1.3 为什么不能靠排序贪心

有人会想:把金子按重量从大到小排序,每次把当前这块放到总重量较小的一堆里,这不就是最优吗?

拿一组数据试试就知道:金子重量为3、4、5、6,总重量S=18,target=9。按贪心思路模拟:

  • 6放进第一堆,第一堆变成6,第二堆0;
  • 5放进第二堆(因为第二堆更轻),第二堆变成5;
  • 4放进第一堆,第一堆变成10;
  • 3放进第二堆,第二堆变成8。

最后两堆重量是10和8,差2。

但实际上最优分法是3+6给一个人,4+5给另一个人,两边都是9,差值0。贪心在这道题上就是错的。

原因很本质:这是一个0/1背包问题,每个金块只有“选”和“不选”两种状态,互相影响;贪心的局部最优没法保证全局最优。一旦你意识到要用背包,后面就顺了。

2. 状态设计和方案计数:最容易出错的三个细节

2.1 可达性数组与倒序更新

核心数据结构是两个数组:

  • ok[j]:布尔值,表示能否从若干金块中凑出总重量j;
  • cnt[j]:整数,表示凑出总重量j的方案数。

初始状态都只有j=0是可达的,凑出0的方案数为1。

每来一块重量为w的金子,我们就从target开始,倒着更新到w。为什么必须倒序?这是0/1背包的经典规则:倒序能保证每个物品只被使用一次。

举个例子,假设当前只有一个重量为5的金块,正序更新会发生什么?

  • 从0到target遍历j,当j=5时,ok[5]变成1;
  • 继续往后遍历,当j=10时,由于ok[5]已经为1,ok[10]也会变成1。

可你手里明明只有一个5,怎么能凑出10?这就是同一个物品被重复使用了。倒序遍历时,更新ok[j]只会用到j-w这个位置,而j-w小于j,在倒序过程中还没有被当前这块金子更新过,所以不会重复。

方案数数组的更新同理。这一条看起来简单,却是新手最容易写崩的地方。

2.2 方案数是怎么滚出来的

cnt[j]的转移逻辑可以用一句话概括:

如果凑出j-w是有方案的,那么在这些方案的基础上放上这块金子w,就能凑出j。

所以转移是:

cnt[j] += cnt[j - w]

这里有个隐藏细节:为什么不会把同一个方案重复计算多次?

因为我们是按物品一个一个处理的,并且每个物品只会被用来更新一次cnt。倒序保证使用当前物品时,cnt[j-w]还是“不包含当前物品”的方案数。这样组合起来的就是“集合方案”,不是“排列方案”。

比如重量为1的两块金子,我们想凑出重量1。处理第一块时,cnt[1]变成1;处理第二块时,cnt[1]再加一次变成2。这两个方案分别是“选第一块”和“选第二块”。由于两块金子在物理上是不同的对象,所以这就是2种方案,没问题。

2.3 相同重量、不同金块的计数语义

这里得说清楚一个很多人纠结的问题:如果两块金子重量相同,它们算不算不同方案?

实际分配金子时,每块金子是独立物体,哪怕重量一样,也是不同的选择。所以DP按“处理不同下标的金块”来累加方案数,天然就是正确的。比如上面的[1,1],凑出1就有2种方案,而不是1种。

还有一个更隐蔽的问题:方案数要不要除以2,或者乘以2?

我这里定义的cnt[best],统计的是“选出一堆重量为best的金块的组合数”。因为best不超过总重量一半,我把这堆看作较小的一堆,另一堆自动取补集。在洛谷/USACO这类题目常见的题解口径里,直接输出cnt[best]就能过。

但如果你手上的题面明确说“两堆人的身份是有区别的”,那最终方案数可能还要再乘以2,因为两个人谁拿较小堆、谁拿较大堆是两种分配。反过来,如果你把“A拿X、B拿Y”和“A拿Y、B拿X”看作同一种划分,并且diff刚好为0,那每个划分会被统计两次,需要除以2。

我建议做题前先想清楚题目里问的“ways”到底区分不区分人。P3010的原题里一般是直接按cnt[best]输出。为了稳妥,你可以本地用极端数据验证一下:输入两个3,代码输出的是2。如果你觉得答案应该是1,那就是题目语义不同,需要除2。

3. 完整可提交的C++代码与逐段说明

3.1 完整代码

下面这段代码是我实际调试后能稳定通过的一版,C++17,包含注释。注意我这里先按“不取模、使用long long”来写,因为很多USACO老题输出的是精确整数;如果你的题目要求方案数取模,把注释里的MOD打开即可。

#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<int> w(n); int sum = 0; for (int i = 0; i < n; ++i) { cin >> w[i]; sum += w[i]; } int target = sum / 2; vector<char> ok(target + 1, 0); vector<long long> cnt(target + 1, 0); ok[0] = 1; cnt[0] = 1; for (int i = 0; i < n; ++i) { int wi = w[i]; for (int j = target; j >= wi; --j) { if (!ok[j - wi]) continue; if (!ok[j]) { ok[j] = 1; cnt[j] = cnt[j - wi]; } else { cnt[j] += cnt[j - wi]; } } } int best = target; while (best > 0 && !ok[best]) { --best; } int diff = sum - 2 * best; cout << diff << '\n'; cout << cnt[best] << '\n'; return 0; }

3.2 关键代码段拆解

最核心的就是中间的三层结构:

for (int i = 0; i < n; ++i) { int wi = w[i]; for (int j = target; j >= wi; --j) { if (!ok[j - wi]) continue; ... } }

先判断j - wi是否可达。如果不可达,说明就算加上当前这块也凑不出j。这里有个小技巧:很多写法会先把ok[j-wi]赋值给ok[j],但我更习惯先用continue跳过不可达状态,后面再处理,这样可以保证cnt数组里不会混进无效的累加。

当ok[j]为0时,说明之前没有方案能凑出j,那么用cur这块金子是目前唯一能凑出j的方式,直接cnt[j] = cnt[j-wi]。当ok[j]已经是1时,说明之前已经有一些方案能凑出j,现在又发现了新方案,所以cnt[j] += cnt[j-wi]。

为什么这里不会重复计算同一个方案?因为倒序遍历,cnt[j-wi]还停留在“当前物品未使用”的状态。这一点我在前面已经强调过,代码里体现得非常直接。

找最优值的一段:

int best = target; while (best > 0 && !ok[best]) { --best; }

这里是从大到小找第一个可达的重量作为best。因为target已经是总和的一半向下取整,我们要的是“不超过target的最大可达重量”,所以第一个碰到的ok[best]就是答案。如果一直没找到,best最终会跌到0,因为ok[0]一定为1。

3.3 自测用例与期望输出

本地调试时我习惯多跑几组数据,这里列出几组有代表性的:

输入targetbestdiffcnt[best]说明
3个金块:3 5 46521总重12,凑5是一边的重量
4个金块:3 4 5 699023+6和4+5两种完美平分
2个金块:3 33302两个相同金块算两种方案
5个金块:1 1 1 1 33315凑3有4种三块1和1种单独3
1个金块:73071只有一块时只能一边拿7另一边空

以第4组为例,总重量是7,target=3,best=3,diff=1。方案数5是怎么来的?选三个重量1:C(4,3)=4种;选一个重量3:1种。所以cnt[3]=5,完全符合手算。

代码的时间和空间复杂度也很清晰:最坏情况每个金块都要扫一遍0到target,总复杂度O(n * sum / 2),空间O(sum / 2)。SUM不大的时候完全没问题,这也是P3010这类Silver题敢用背包的原因。

4. 提交后容易踩的坑和验证思路

4.1 N=1时差值和方案数的语义

先看一个极端情况:只有一块金子,重量比如是7。总和是7,target=3,能凑出的最大不超过3的重量是0,所以best=0,diff=7,cnt[0]=1。

这意味着什么呢?一边拿7,另一边拿0。如果题目允许“有一边为空”,这个结果是合理的。但有些题面会默认“分成两堆”时每堆都必须非空,那N=1就是非法情况,或者答案需要特殊处理。

我建议写代码前先看题面有没有“non-empty”之类的限制。USACO这类题一般不会把空堆当成问题,毕竟人家问的是“分给两个人”,其中一个人一个子都没拿到也是分法。但要是你参加的比赛中明确说了每堆都必须有金子,那N=1的时候要单独特判。

4.2 数组越界与循环条件

一个低级的坑是循环边界。target可能比某个w[i]小,比如只有一个金块重量7的情况,target=3。这时for (int j = target; j >= w[i]; --j)根本不会执行,因为3>=7不成立。

另一种错误写法是:

for (int j = target; j >= 0; --j) { if (j >= w[i]) { ... } }

这样写本身没错,但每次循环都要判断,而且当j小于w[i]时continue会浪费大量时间。更危险的是有人把j声明成unsigned int,然后写j >= w[i],当j减到0后继续减会变成很大的数,导致死循环。用int就不会有这个问题。

4.3 方案数精确输出还是取模

这是我在提交前犹豫最久的地方。P3010这道题在洛谷上很多人直接用long long存方案数,也能过,说明官方数据里的方案数没有大到爆掉。但如果你拿这题去扩展,或者自己造了几百个重量为1的金块,C(200,100)级别的方案数早就超出long long了。

如果题目要求取模,改动很简单:定义一个MOD常量,所有对cnt的赋值和累加都改成取模。我在前面给的完整代码保留了没取模的版本,但注释里提醒了这一点。

如果你在比赛中遇到方案数可能巨大的题,第一件事是确认题面有没有“输出对某个数取模的结果”。没有取模要求但你又怕溢出,那就得考虑上高精度或者用__int128。USACO老题通常不会故意卡你这一点,所以不用自己吓自己。

4.4 一个用于验证正确性的暴力工具

DP写完最难确认的就是方案数对不对。我的笨办法是定期写个暴力对拍:

int brute() { int ansDiff = 1e9, ways = 0; for (int mask = 0; mask < (1 << n); ++mask) { int s = 0; for (int i = 0; i < n; ++i) { if (mask >> i & 1) s += w[i]; } int diff = abs(sum - 2 * s); if (diff < ansDiff) { ansDiff = diff; ways = 1; } else if (diff == ansDiff) { ++ways; } } return ways; }

n小于等于20的时候,暴力枚举所有子集是可行的。拿它和DP结果对拍几组随机数据,如果完全一致,说明你的方案数转移没写错。这个对拍习惯能帮你节省大量调试时间,尤其是首次写方案数DP的时候。

4.5 正序循环会翻车的实际演示

为了让你更直观地感受到倒序的重要性,我用[3,3]这组数据模拟一下“正序更新”会得到什么。

初始cnt[0]=1。处理第一个3时,正序从0到target=3:

  • j=3,ok[0]=1,cnt[3]+=cnt[0],变成1。 处理第二个3时,还是正序:
  • j=3,ok[0]=1,cnt[3]+=cnt[0],变成2。
  • j=6,因为target是3,根本没这个位置,所以没影响。

看起来[3,3]在target=3下正序也能得2?问题不大。但如果target更大,比如总重量为10,target=5,输入两个5,正序就会出大问题。处理第一个5时j=5可达;处理第二个5正序到j=5,继续加;然后j=10也会假设能用同一个5凑出10?实际target=5看不见j=10,所以也不行。

为了暴露问题,换一个场景:只有一块金子重量5,target=10。正序更新时j=5达到,j=10也会因为ok[5]变成可达而被更新成可达,于是best会变成10,diff=0。可你手里只有一块5,不可能凑出10。这就是为什么倒序是原则性要求,不是随便写写。

5. 从P3010延伸:总重量很大时还能怎么做

5.1 Meet in the Middle:总重量大的时候的替代方案

背包DP的前提是target不能太大。如果总重量达到10^9甚至更大,数组直接开不下,那就要换思路。

n比较小(比如n<=40)的时候,可以用meet in the middle。把金块分成前后两半,每半最多2^n/2个子集,分别枚举出所有子集和。然后让后一半的所有子集和排好序,对前一半的每一个和a,二分查找一个b,让a+b尽量接近target,同时不超过target。

这样复杂度大约是O(2^(n/2) * log(2^(n/2))),对于n=40大概是一百万级别的枚举加排序,完全可行。

方案数怎么数?如果你只是求最小差值,不用统计方案数,那meet in the middle很轻松。但如果还要方案数,就得在二分查找时把跟b一样大的其他值一起算上,再配合计数数组,稍微麻烦一点。不过大部分信奥题考到这里已经够用了。

5.2 同类题目的套路:背包凑半值模型

P3010这个模型非常经典:一堆物品,选一个子集,让子集重量尽量接近总和的一半。很多题都是它的变体。

比如洛谷P1466 Subset Sums,问的是“把1到N分成两组,使两组和相等,共有多少种分法”,本质上就是凑出S/2的方案数,只是权值变成了1,2,...,N。再比如一些“把一队人分成两队,使两队实力最接近”的题,赋值换成数组就一模一样。

甚至“调度问题”里,把若干任务分给两台机器,让两台机器总耗时差最小,也是同一个模型:机器的总耗时就相当于子集重量。这类问题的本质都是0/1背包取半值,你需要会的是那个“差分等价转换”,而不是死记代码模板。

5.3 我自己调试这题的三个经验

最后抖点干货,都是实际操作中摸索出来的。

第一,在调试时把ok和cnt的整个数组打印出来看。每个金块处理一轮之后,打印一次当前“可达重量和对应方案数”的表格。不要只看最终答案,中间状态能帮你快速定位更新顺序的问题。我最初写错就是因为在cnt[j]已经非0时没加else分支,结果方案数被覆盖而不是累加,打印中间状态一眼就能看出来。

第二,处理前先把金块重量从小到大排一遍序,有时候能让DP提前命中target,虽然复杂度量级没变,但常数会小一些。这个优化在n较大时感受比较明显。

第三,如果两个方案数很大,记得先确认题目要不要取模。不要等提交后因为WA才发现自己漏看了“mod 1000000007”这几个字。USACO的题目通常不搞模数,但洛谷上很多扩展题会加,看清题目永远比猜题目靠谱。

刷题刷到凌晨虽然累,但把“Dividing the Gold”这种老题吃透,后面遇到任何“背包凑半值”的变体都能秒转换。希望这篇记录能帮你少走我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 22:56:49

二维码实战全解:从底层原理到批量生成与扫码避坑指南

二维码这东西&#xff0c;已经日常到几乎没有人再问它为什么能扫出来了。每天扫码付钱、扫码点餐、扫码连Wi-Fi&#xff0c;但我翻了翻手头很多项目&#xff0c;发现只要沾到二维码的环节&#xff0c;几乎都能看见同几个坑&#xff1a;生成时不留边距、用了太低的纠错等级、或者…

作者头像 李华
网站建设 2026/10/22 1:54:21

本地模型健康检查指南:从进程检测到推理质量验证

先讲个我自己的糊涂事。上个月我在一台闲置工作站上部署本地模型&#xff0c;看到控制台刷刷刷输出一堆加载日志&#xff0c;显存占用也上去了&#xff0c;就很放心地把它丢在后台。等到第二天拿起客户端准备调用时&#xff0c;才发现请求全部超时&#xff0c;翻日志才知道模型…

作者头像 李华
网站建设 2026/10/22 1:54:21

Java毕设实战:SSM框架教务管理系统选型与实现全复盘

每年三四月份&#xff0c;计算机毕设群里的日常就是同一个问题来回刷&#xff1a;SSM和Spring Boot到底选哪个做管理系统&#xff1f;我自己做完这个SSM框架的教务管理系统之后&#xff0c;答案反而很明确——如果目标是让答辩老师愿意听你把框架一层层讲明白&#xff0c;SSM才…

作者头像 李华
网站建设 2026/10/22 1:54:21

Java Arrays工具类全解:从sort排序到parallelPrefix,一文吃透数组操作

凡是写过Java的人&#xff0c;基本都绕不过数组。不管是学习阶段做的图书管理系统&#xff0c;还是工作里处理批量数据、做算法题、写接口返回结构&#xff0c;数组都是最底层、最常用的一块积木。但很多人在实际开发里对数组的处理方式其实非常原始&#xff1a;复制一个数组要…

作者头像 李华
网站建设 2026/10/22 1:54:21

零基础学React完整路线:从JS基础到组件化实战,避开新手常见坑

1. 零基础学React&#xff0c;先想清楚这几件事1.1 React到底是什么&#xff0c;值不值得花时间学学React之前&#xff0c;我建议你先搞清楚一个基本事实&#xff1a;React不是一个全家桶框架&#xff0c;它本质上是一个用来构建用户界面的JavaScript库。核心思想就两条&#x…

作者头像 李华
网站建设 2026/10/22 1:54:21

Agent+Harness工作流:LLM信息处理与上下文超长优化实战

1. 从“躺平挖 alpha”说起&#xff1a;这套工作流到底在解决什么问题“躺平挖 alpha”这个说法第一次看到的时候我笑了很久&#xff0c;但仔细一想&#xff0c;它精准地描述了一种状态&#xff1a;你不想每天手动去翻几百条信息、盯十几个数据源、重复做那些机械的筛选和整理动…

作者头像 李华