多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

开心的金明:0-1背包模板题解析与DFS序误会澄清

开心的金明:0-1背包模板题解析与DFS序误会澄清 做题的人十有八九绕不开这个名字开心的金明。别被它带点生活气息的名字骗了这其实是一道非常硬核的经典动态规划题来自NOIP2006普及组在很多OJ上都以题目1103的编号出现。题目本身讲的是预算买东西金明手里有N元面对m件物品每件物品有价格和重要度要在预算内让价格乘以重要度的总和最大。预算约束、每件物品只能买一次、求最大价值这三个词组合在一起几乎就是0-1背包模板题的身份证。对刚接触DP的初学者来说这是绕不开的入门关对有经验的人来说它是压箱底的模板隔段时间翻出来写一遍反而能想通不少以前含糊的东西。最近搜这道题的时候热词里总跟着模板题和dfs序模板题后面那个说法其实是个挺有迷惑性的误会这篇文章顺便把它讲清楚。1. 先把题目读透这到底在考什么1.1 题目背景与输入输出构成金明这个角色最早出现在NOIP2006普及组题目背景是他家买了新房子妈妈给了他一张随便买但不超过N元的额度让他自己布置房间。输入第一行是两个正整数N和mN是总预算m是他看上的物品件数接下来m行每行两个正整数v和pv表示这件物品的价格p表示金明对它的重要度重要度取值范围是1到5。注意这里有个容易看漏的细节题目要求最大化的是价格乘以重要度的总和不是单纯的价格也不是重要度而是两者的乘积。换句话说一件贵且重要的物品价值会比便宜但重要的物品高这个乘积就是这道题里物品的价值。输出一行一个整数表示在不超过N元的前提下能得到的最大总价值。以洛谷P1060的样例为例预算10005件物品分别是800配重要度2、400配重要度5、300配重要度5、400配重要度3、200配重要度2最优选择是400×5、300×5、200×2这三件总花费900总价值3900。注意这里不是要求刚刚好花完1000元只要不超过就行这一点直接决定了DP数组的初始化方式。数据范围在不同OJ上略有差别数量级一致N不超过30000m不超过25。这个范围很有意思m很小而N很大它决定了为什么很多解法都能过也提醒你读题第一件事永远是看数据范围而不是闷头写代码。1.2 为什么说它是模板题这道题被冠以模板题三个字不是因为它简单而是因为它把0-1背包的要素暴露得干干净净没有任何干扰项。0-1背包指的是有若干物品每个物品只能选一次选或不选都行在容量限制下求最大价值。开心的金明里容量是预算N物品是m件商品选或不选对应买或不买唯一需要动点脑筋的地方就是把价值定义成价格乘重要度。除此之外没有分组、没有依赖、没有额外费用、没有必须装满的要求连物品本身的选择顺序都不影响结果。正因为模型纯粹它才配当模板。你在这道题里学到的状态定义、转移方程、滚动数组写法可以原封不动搬到其他几十道题里。很多教学博客都拿它作为背包专题的第一题就是这个原因。我也建议初学者不要急着跳过把这道题的每个细节抠明白再去啃分组背包和依赖背包事半功倍。1.3 澄清一个搜索热词dfs序模板题是个误会如果搜题时看到dfs序模板题这个标签很容易懵这道题既没有树也没有图的DFS序跟树的深度优先遍历顺序半毛钱关系都没有。我查了一下这个说法大概率是两种情况的混合第一种有人用DFS深度优先搜索加剪枝写过这道题因为m不超过25纯暴力枚举所有买法最多也就2的25次方种可能配合预算剪枝在小数据下真的能过于是搜索引擎或题库把DFS标签挂了上去第二种算法题网站自动打标签时把搜索类问题和动态规划类问题合并出了这么个词。我的建议是别被这个标签带偏。这道题的标准解法是动态规划DFS暴力枚举只配当对拍验证工具后面会专门讲怎么用。你如果按树的DFS序来理解这个词那完全是另一个领域的东西跟金明没有交集。记住这一点搜题时能省不少困惑。2. 状态设计与状态转移0-1背包的骨架2.1 从暴力枚举到动态规划思路是怎么一步步逼出来的很多新手拿到这道题第一反应是穷举每件物品买或不买m件物品就是2的m次方种组合每种算一下总花费和总价值取合法且价值最大的。m等于25时2的25次方大概3300万理论极限下勉强能跑但时间紧一点就悬而且一旦m变成30、40这个方案直接破产。所以必须找更聪明的办法。有人会想那贪心行不行按价值除以价格从高到低买很快就能找出反例。假设预算10元三件商品A价格6、重要度5价值30B价格5、重要度4价值20C价格5、重要度4价值20。按性价比排序会先买A花掉6元还剩4元什么都买不了总价值30可是最优解明明是买B和C正好花10元拿40。贪心只盯着局部最划算却牺牲了组合的全局最优这就是背包问题为什么必须用动态规划的根本原因。那动态规划的直觉从哪来观察这个过程当我们依次决定第1件、第2件……第i件物品买不买时真正影响后续决策的只剩下还剩多少钱这一个信息。至于之前具体买了哪几件不关键重要的是花了多少钱、拿到了多少价值。这就是无后效性当前状态一旦确定未来只跟当前状态有关跟怎么走到这个状态的路径无关。于是我们可以用处理到第几件物品和当前总花费这两个维度来浓缩所有历史信息这就是状态设计。2.2 状态定义与转移方程正式定义状态dp[i][j]表示从前i件物品中选总花费不超过j元时能获得的最大价值总和。注意这里写的是不超过j元不是恰好j元这个措辞直接决定后面初始化怎么做。对于第i件物品设它的价格为v[i]重要度为p[i]那么它的价值w[i]等于v[i]乘以p[i]。面对它只有两种决策不买问题退化成从前i减1件里选总花费不超过j元即dp[i][j]等于dp[i-1][j]买要求j大于等于v[i]先花掉v[i]元剩下的j减去v[i]元交给前i-1件物品去安排总价值是dp[i-1][j-v[i]]加上w[i]。两者取较大值就得到转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j-v[i]] v[i] * p[i])前提是j大于等于v[i]。这就是0-1背包最核心的一行公式。理解它的关键是买第i件时前i-1件的决策必须是已经完成的最优状态所以我们永远从dp[i-1]那一行取数据这正是后面滚动数组要倒序循环的根本原因。我见过不少人把方程背得滚瓜烂熟但问一句为什么必须从dp[i-1]取就答不上来那这题等于还没学会。2.3 边界条件为什么要全部初始化为0初始状态是dp[0][j]等于0意思是一件物品都不选时无论预算多少总价值都是0这很直觉。因为状态定义为不超过j元dp[0][j]天然覆盖了所有j所以整个数组初始化为0就够了不用额外处理负数。这里有一个对比很能说明问题如果题目改成必须刚好花完N元状态就要定义成恰好花费j元那么初始化就不能全0了要设dp[0][0]为0其他dp[0][j]为负无穷表示不可能恰好凑出那些金额。金明这道题没有这个要求所以全部初始化为0最后答案直接取dp[m][N]。很多初学者在别的背包题里照搬全0初始化其实是因为没分清题目是不超过还是恰好。这个细节我每次讲背包都会强调一遍因为它决定了你是输出dp[N]还是要遍历所有j取max。3. 代码实现与细节打磨3.1 二维DP先写一版能保证对的对新手来说我建议先写二维版本逻辑直观不会踩滚动数组的坑。C代码如下#include cstdio #include algorithm using namespace std; const int MAXM 30; const int MAXN 30005; int dp[MAXM][MAXN]; int v[MAXM], p[MAXM]; int main() { int N, m; scanf(%d%d, N, m); for (int i 1; i m; i) { scanf(%d%d, v[i], p[i]); } for (int i 1; i m; i) { for (int j 0; j N; j) { dp[i][j] dp[i - 1][j]; if (j v[i]) { dp[i][j] max(dp[i][j], dp[i - 1][j - v[i]] v[i] * p[i]); } } } printf(%d\n, dp[m][N]); return 0; }这里先把dp[i][j]默认成不买第i件时的值再检查能不能买、买了是否更优。j从0扫到N每一件物品都完整地更新所有预算区间。二维数组大小是25乘30000不到100万个int内存完全没问题。这样写虽然比滚动数组多开一点空间但每个状态的含义一目了然调试的时候很好用。如果你在学DP的早期阶段这版代码值得背下来但要在理解的基礎上背。3.2 一维滚动数组模板的标准形态当N变大比如一下子到10万二维数组就会膨胀。更关键的是观察转移方程会发现dp[i]这一行只由dp[i-1]推来再往前的数据没用所以完全可以用一维数组反复覆盖。标准写法int dp[MAXN]; for (int i 1; i m; i) { for (int j N; j v[i]; j--) { dp[j] max(dp[j], dp[j - v[i]] v[i] * p[i]); } } printf(%d\n, dp[N]);注意内层循环必须从N倒着走到v[i]这是0-1背包最容易出错的地方。原因很简单一维数组dp[j]更新之后同一轮循环里如果继续往后遍历dp[j-v[i]]可能已经是本轮刚更新过的值它代表的是已经买了第i件物品的状态你再在此基础上买第i件等于同一件商品被买了两次这就不叫0-1背包了变成完全背包了。倒序遍历保证dp[j-v[i]]用的还是上一轮、即没考虑第i件时的旧值。用生活化的话说正序就是同一件衣服反复试穿然后买走好几件倒序才是每件只看一次。3.3 数据范围与输入输出的细节提醒这道题所有数据用int就够。价格v最多到10000重要度p最多5单件价值上限5万25件全买也就125万离int上限差得远。不过写习惯了也不吃亏把dp和w声明成int完全没问题只有在需要装更大结果的题目里才考虑long long。另外一个小技巧可以在读入时顺手把w[i]等于v[i]乘以p[i]算好存起来后面状态转移里直接写w[i]既减少重复乘法也让代码更干净。我自己的习惯是变量名用v和w一个表示花费一个表示价值这样后面套别的背包模板时思路能直接迁移。还有一个小经验生产环境或者比赛里scanf和printf比cin和cout稳尤其是数据量大的时候虽然这题量不大但早点养成习惯没坏处。4. 常见错误、排查与实测心得4.1 常见错误速查表平时带新人时我发现下面这几个错误几乎人人都踩过至少一个整理成表格方便自查错误典型表现根本原因解决办法滚动数组正序循环小数据能过样例能过大数据WA同一物品被重复选择退化成完全背包j从N递减到v[i]价值写错答案系统性偏小把重要度p当成价值直接用价值必须是v[i]乘以p[i]先算w[i]数组开小运行时错误REdp只开到N却访问dp[N]开到N5以上状态定义混淆输出dp[N]但答案不对定义成恰好花j元却没扫最大值定义成不超过j元或最后对j取max读入顺序搞反整题全错题目先给价格后给重要度按scanf(%d%d, v[i], p[i])读入其中价值写错这个坑最隐蔽。很多新手以为重要度就是价值结果样例输出对不上排查半天才发现忘了乘价格。我建议在草稿纸上先把样例手算一遍比如800乘2等于1600、400乘5等于2000心里有数之后写代码就不容易跑偏。而正序循环这个错往往是样例恰好能过等交上去才WA最气人也最锻炼人。4.2 用DFS对拍模板题也值得写暴力验证前面说dfs序是个误会但DFS本身在调试这道题时有大用处。我的习惯是任何模板题写完DP后都用暴力DFS对拍一遍确认模板没写歪。暴力思路特别简单从第1件物品开始递归地决定买或不买走到第m件之后记录合法方案的最大价值。int ans 0; void dfs(int idx, int cost, int value) { if (idx m) { if (cost N) ans max(ans, value); return; } dfs(idx 1, cost, value); // 不买第idx件 if (cost v[idx] N) { // 买了不超预算再进入分支 dfs(idx 1, cost v[idx], value v[idx] * p[idx]); } }对拍流程是写一个随机数据生成器N取1到50m取1到8价格1到20重要度1到5然后让暴力DFS和DP各跑一遍比较输出是否一致。m这么小DFS瞬间跑完生成几千组数据也不会有压力。只要有一次不一致就说明DP状态转移或者循环方向有bug这时候把那一组小数据单独拎出来手动模拟很快能定位。这个习惯我从一开始写题保持到现在别觉得模板题就不用验证恰恰是模板题最该验证因为它将来要被你套用到几十道题上地基歪了上面的楼全是歪的。4.3 从这道模板题能延伸出多少变式模板题的另一个意义是它的可扩展性。以开心的金明为起点简单改几个条件就是新题恰好装满把状态定义成恰好花费j元初始化时dp[0][0]为0、其余为负无穷。分组背包每类物品只能选一个多一层小组循环。依赖背包金明的预算方案那题主件没买就不能买附件需要对附件做小背包后合并。多重背包每件物品有数量限制用二进制拆分转成0-1背包。求方案数把max改成求和理解计数背包的套路。超大容量N大到10亿没法开数组时反转状态用价值做下标、存最小花费。这些变式在各大OJ上都能找到对应题目而它们的核心骨架都是这道题里写的dp[j]等于max(dp[j], dp[j-v[i]]加w[i])。模板题的价值不在背代码而在于让你以后遇到每个东西选或不选、容量有限、求最优这类问题时能在第一时间把它归约到背包模型上。最后分享点个人体会。这道题我前前后后写过不下十遍每次重写都有新收获第一次学会了二维DP第二次搞懂了滚动数组为什么必须倒序第三次开始拿它当对拍模板后来带新人时又拿它讲递归和递推的区别。踩过最大的坑就是正序循环当年样例数据过了但交上去WA对着代码看了一晚上才反应过来。所以别嫌模板题简单把每一个为什么都抠明白比刷十道新题都值。如果你也是刚开始学动态规划建议把这道题当成第一个必须做到烂熟于心的模板之后的路会顺很多。
返回列表