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

动态规划入门:从0-1背包问题到C++代码实现详解

1. 从“开心的金明”说起一道经典的动态规划入门题如果你刚开始接触算法竞赛或者正在学习C那么“开心的金明”这道题绝对是一个绕不开的经典。它来自NOIP2006普及组题目本身描述了一个非常生活化的场景金明有N元钱要去商场买M件物品每件物品有价格和重要度他希望买到的东西“总价值”价格乘以重要度的总和最大。这个场景几乎就是每个人购物时内心的小算盘——如何在有限的预算内买到最“值”的东西。这道题之所以经典是因为它完美地诠释了“0-1背包问题”的核心思想是无数算法初学者从“暴力枚举”思维转向“动态规划”思维的第一个关键跳板。我第一次遇到这道题时也和很多人一样第一反应是去尝试所有可能的购买组合。但稍微一算就知道M件物品的组合数是2^M当M稍微大一点比如超过20计算量就会爆炸程序会超时。这时你就不得不去寻找更聪明的方法。而动态规划正是解决这类“选择与约束”问题的利器。它教会我们的不是如何更快地枚举而是如何通过记录并复用子问题的解来避免重复计算从而将指数级复杂度降为多项式级。理解并亲手实现这道题的动态规划解法其意义远不止于通过一次比赛或完成一次作业它更是在你脑中建立起“状态”、“状态转移”这些核心概念的关键一步为你后续解决更复杂的背包问题如完全背包、多重背包乃至其他动态规划问题打下坚实的基础。接下来我将带你彻底拆解这道题。我们不会止步于ACAccept通过代码而是要深入每一步背后的“为什么”。我会从最朴素的思路开始一步步推导出动态规划的解法并给出两种最常见的实现方式基于二维数组和优化后的一维数组。同时我会分享在编写和调试这类代码时极易踩中的坑以及如何确保你的程序既正确又高效。无论你是正在备赛的学生还是希望巩固算法基础的开发者这篇内容都将提供你所需的全部细节和实战经验。2. 问题本质剖析如何将生活问题转化为数学模型在动手写代码之前我们必须先把题目描述翻译成严谨的数学模型。这是解决任何算法问题的第一步也是最容易出错的一步。题目给出的关键信息是总预算N单位元。物品总数M件。对于第i件物品我们知道其价格v[i]和重要度p[i]。金明的“满意度”定义为v[i] * p[i]的总和。目标在总花费不超过N元的前提下最大化总满意度。这里有一个非常重要的细节重要度p[i]是1到5之间的整数。这意味着v[i] * p[i]这个值我们通常称之为物品的“价值”或“权重”可能会很大但它仍然是一个整数。这个细节保证了我们在使用整数类型进行运算时不会出现精度问题。那么这个问题属于什么类型呢我们逐一分析其特性每个物品只有一件金明不能买两个一模一样的台灯。这对应了“0-1”特性即对于每个物品只有“选”或“不选”两种状态。有一个总花费的限制总价格不能超过N。这是一个典型的“容量”限制。目标函数是线性的总满意度是所有被选中物品的v[i]*p[i]的简单相加。这三个特征完美匹配了“0-1背包问题”的定义。在经典的0-1背包问题中我们有一个容量为V的背包和N件物品每件物品有体积w[i]和价值c[i]要求选择物品装入背包使得总体积不超过V且总价值最大。映射关系如下背包容量V- 总预算N物品体积w[i]- 物品价格v[i]物品价值c[i]- 物品满意度v[i] * p[i]经过这样的转化我们就把一个生活问题抽象成了一个标准的、可计算的算法问题。接下来的所有思考都将基于这个模型展开。注意很多初学者会混淆“价格”和“价值”。在这里花费的代价是价格v[i]而我们追求的目标是“价值”v[i]*p[i]。在状态转移时我们用价格来限制选择不能超支用价值来更新最优解。这个概念必须非常清晰。3. 动态规划的核心状态定义与转移方程推导理解了问题是0-1背包后我们正式进入动态规划的核心环节。动态规划的精髓在于“状态”和“状态转移”。我们需要设计一个“状态”让它能够描述解决问题过程中的某个“局面”然后找到从一个“局面”转移到另一个“局面”的规则。3.1 状态定义对于背包问题最经典的状态定义是dp[i][j]表示只考虑前i件物品在总花费恰好为j元或不超过j元的情况下能够获得的最大满意度。这里有两个关键点需要抉择“前 i 件物品”这体现了我们处理物品的顺序通常是逐个考虑。“总花费为 j 元”这里的j可以理解为当前可用的预算。定义成“恰好为 j 元”还是“不超过 j 元”会影响到初始化和最终答案的获取。在“开心的金明”这道题以及大多数背包问题中我们通常采用“不超过 j 元”的定义。因为题目要求就是总花费不超过N这样定义最直观且最终答案就是dp[M][N]。如果定义为“恰好”则需要初始化dp[0][0]0其他dp[0][j]为负无穷表示不可能达到并且最终答案需要遍历dp[M][0..N]取最大值稍微麻烦一点。因此我们确定状态dp[i][j]考虑前i件物品物品编号从1到i在总花费不超过j元的情况下能获得的最大满意度。3.2 状态转移方程推导状态定义好了现在思考如何计算dp[i][j]。我们面对第i件物品时只有两种选择买或者不买。不买第 i 件物品 如果我们决定不买第i件物品那么情况就完全等同于只考虑前i-1件物品且预算仍然是j元时的最优解。即dp[i][j] dp[i-1][j]买第 i 件物品 如果我们决定买第i件物品那么我们需要先支付它的价格v[i]。在支付之后我们的剩余预算就变成了j - v[i]元。并且因为我们已经考虑了第i件物品所以剩下的问题就变成了用j - v[i]元的预算在前i-1件物品中做选择能获得的最大满意度是多少这个值就是dp[i-1][j - v[i]]。最后再加上购买第i件物品带来的满意度v[i]*p[i]。 因此选择购买带来的总满意度是dp[i-1][j - v[i]] v[i]*p[i]前提是j v[i]。如果当前预算j连物品i都买不起这个选择根本不存在。我们的目标是最大化满意度所以对于每个状态dp[i][j]我们应该在“买”和“不买”这两个决策中选择能带来更大满意度的那个。由此我们得到完整的状态转移方程如果 j v[i]: dp[i][j] dp[i-1][j] // 买不起只能不买 否则 (j v[i]): dp[i][j] max(dp[i-1][j], // 不买 dp[i-1][j - v[i]] v[i]*p[i]) // 买3.3 初始化动态规划需要一个起点。在我们的定义中dp[0][j]表示“考虑前0件物品花费不超过j元的最大满意度”。没有物品可考虑满意度自然为0而且无论预算是多少j从0到N满意度都是0。 所以初始化非常简单dp[0][0..N] 0。有了初始状态和转移方程我们就可以从i1开始逐步计算到iM从j0计算到jN。最终dp[M][N]就是我们想要的答案考虑所有M件物品在总预算不超过N元时的最大满意度。4. 从理论到代码两种C实现方式详解理论清晰后我们开始编写代码。我会先给出最直观的二维数组解法然后讲解如何优化成一维数组这是背包问题必须掌握的技巧。4.1 基础版二维数组实现这种实现方式直接对应我们推导出的状态定义dp[i][j]非常利于理解。#include iostream #include algorithm // 用于max函数 using namespace std; int main() { int N, M; cin N M; // 为了方便数组从下标1开始存储物品信息符合日常思维 int v[M1]; // 价格 int p[M1]; // 重要度 for (int i 1; i M; i) { cin v[i] p[i]; } // 创建动态规划数组 dp[M1][N1] // dp[i][j] 含义考虑前i件物品总花费不超过j元的最大满意度 int dp[M1][N1]; // 初始化考虑0件物品时满意度均为0 for (int j 0; j N; j) { dp[0][j] 0; } // 动态规划核心过程 for (int i 1; i M; i) { // 逐个考虑每件物品 for (int j 0; j N; j) { // 枚举当前可能的预算 // 默认决策不买第i件物品 dp[i][j] dp[i-1][j]; // 如果当前预算j足够买第i件物品则尝试“买”这个决策 if (j v[i]) { dp[i][j] max(dp[i][j], dp[i-1][j - v[i]] v[i] * p[i]); } } } // 输出结果考虑所有M件物品预算不超过N元的最大满意度 cout dp[M][N] endl; return 0; }代码逐行解析与避坑点数组大小dp数组定义为dp[M1][N1]。1是因为我们的物品编号和预算都是从1开始计数的并且需要包含0的情况0件物品0元预算。这是非常容易出错的地方如果定义成dp[M][N]在访问dp[i][j]时可能会越界。循环顺序外层循环遍历物品i内层循环遍历预算j。这个顺序是固定的体现了“逐个考虑物品对于每个物品考虑所有可能的剩余预算”的过程。如果交换循环顺序逻辑就完全错误了。内层循环从0开始j必须从0开始循环。因为当预算j小于物品价格v[i]时状态dp[i][j]只能等于dp[i-1][j]买不起。如果从v[i]开始循环就会漏掉这些状态导致错误。状态转移先继承“不买”的状态dp[i-1][j]再在条件满足时用max函数和“买”的状态进行比较更新。这种写法逻辑清晰不易出错。这个版本的时间复杂度是O(M * N)空间复杂度也是O(M * N)。对于NOIP普及组的题目范围N30000, M25这个空间开销约25300004字节≈3MB和时间开销都是完全可以接受的。但它并不是最优的我们可以将空间复杂度优化到O(N)。4.2 优化版一维数组滚动数组实现观察状态转移方程dp[i][j]只依赖于dp[i-1][...]即上一行的数据。计算完第i行后第i-1行的数据就不再需要了。因此我们完全可以只用一个一维数组dp[j]来迭代更新。在新的定义下dp[j]在总花费不超过j元的情况下能获得的最大满意度。这个定义隐含了“已经考虑了哪些物品”的信息这个信息通过我们更新数组的顺序来体现。关键来了内层循环遍历j必须倒序为什么我们来看转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j - v[i]] value)在一维数组中我们试图用dp[j]来表示dp[i][j]用dp[j - v[i]]来表示dp[i-1][j - v[i]]。如果我们正序更新j从0到N 当更新到dp[j]时dp[j - v[i]]可能已经在本轮考虑第i件物品时被更新过了它代表的不再是dp[i-1][j - v[i]]而是dp[i][j - v[i]]。这意味着我们可能在同一件物品上重复计算相当于物品被拿了多次这就变成了“完全背包”问题的解法而不是“0-1背包”。如果我们倒序更新j从N到0 当更新dp[j]时dp[j - v[i]]由于位置靠后还没有被本轮更新它保存的仍然是上一轮i-1的值即dp[i-1][j - v[i]]。这保证了每件物品最多被考虑一次。优化后的代码如下#include iostream #include algorithm using namespace std; int main() { int N, M; cin N M; int v, p; // 不需要数组存储所有物品可以边读边处理 int dp[N1] {0}; // 一维dp数组并初始化为0 for (int i 0; i M; i) { cin v p; int value v * p; // 当前物品的满意度 // 核心倒序遍历预算j for (int j N; j v; --j) { dp[j] max(dp[j], dp[j - v] value); } // 当 j v 时dp[j]保持不变相当于二维版本中的 dp[i][j] dp[i-1][j] } cout dp[N] endl; return 0; }一维实现详解与优势空间优化空间复杂度从O(M*N)降为O(N)对于大数据量优势明显。代码简洁无需显式初始化第0行因为dp数组本身全部初始化为0就等价于dp[0][...]0。循环也从两层简化为两层但内层循环逻辑更精炼。倒序循环的边界内层循环for (int j N; j v; --j)。从N开始到v结束。当j v时当前物品买不起dp[j]保持原值不变这与二维版本中的逻辑一致。写成j v作为循环条件避免了内部的if判断效率稍高。边读边处理由于状态只依赖于上一轮和当前物品的信息我们可以不保存所有物品的v和p读入一个处理一个进一步节省内存。重要心得一维背包的倒序更新是必须养成的肌肉记忆。每次写0-1背包时都要条件反射般地检查内层循环是否是倒序。这是区分0-1背包和完全背包的关键代码标志。5. 调试、测试与常见问题排查即使理解了算法写出代码后也可能得不到正确结果。下面是一些常见的坑点和调试方法。5.1 数组越界这是最经典的错误。二维数组确保dp数组的第一维大小是M1第二维是N1。循环时i从1到Mj从0到N。一维数组确保dp数组大小为N1。在倒序循环中访问dp[j - v]时要保证j - v 0。我们的循环条件j v正好保证了这一点。5.2 初始化错误二维数组必须显式初始化dp[0][j] 0。如果使用局部数组其内容是未定义的垃圾值必须手动初始化。一维数组int dp[N1] {0};这个写法会将数组所有元素初始化为0。这是最安全的做法。如果使用vector可以用vectorint dp(N1, 0)。5.3 状态转移逻辑错误混淆“价格”和“价值”在状态转移中判断条件是j v[i]价格更新时加的是v[i]*p[i]价值。务必检查代码中是否用对了变量。一维数组内层循环顺序错误这是最隐蔽的错误。如果写成了正序for (int j v; j N; j)程序可能会通过样例但结果是错的会变成完全背包。必须用倒序。5.4 输入输出与数据类型输入格式题目通常是N M在第一行后面M行每行v p。要严格按照这个顺序读取。数据类型N最大为30000v[i]最大为30000p[i]最大为5所以v[i]*p[i]最大为150000。在计算过程中dp数组的值是满意度的累加最大可能值约为M * (max(v)*max(p)) 25 * 150000 3,750,000。这个值在32位有符号整数int范围约±21亿的范围内所以使用int是安全的。如果数据范围更大可能需要使用long long。5.5 如何设计测试用例自己设计测试用例是debug的好方法极小用例N0, M1物品价格v[1]10。结果应为0因为没钱买。刚好买一个N10, M2物品1(v10, p3)物品2(v5, p2)。最优解是买物品1满意度30。需要抉择的用例N5, M3物品(4,2)(3,2)(2,3)。预算5元。买物品1物品3花费6元超支不可行。买物品1花费4元满意度8。买物品2物品3花费5元满意度3*2 2*3 12。买物品3花费2元满意度6。最优解是买物品2和3满意度12。边界用例N30000, M25所有物品价格都是30000重要度都是5。此时任何一件都买不起结果应为0。通过这些小例子可以快速验证程序逻辑是否正确。6. 举一反三从“开心的金明”到更广阔的背包问题世界成功解决“开心的金明”意味着你已经掌握了0-1背包问题的基本思想。但这只是开始。背包问题是一个大家族理解它们之间的区别和联系至关重要。6.1 完全背包问题如果题目变成“每种物品有无限件”那就是完全背包问题。它的状态转移方程看起来和0-1背包很像但内涵不同dp[i][j] max(dp[i-1][j], dp[i][j - v[i]] w[i])// 注意是dp[i][j - v[i]]区别在于当你选择拿第i件物品时不是转移到dp[i-1][j-v[i]]而是转移到dp[i][j-v[i]]。这意味着你拿了第i件物品后仍然可以继续拿第i件物品。对应的在一维数组优化中内层循环就应该是正序的因为我们需要的就是本轮更新过的值。6.2 多重背包问题如果题目变成“每种物品有指定的数量s[i]件”那就是多重背包。它有多种优化方法最基础的是将其转化为0-1背包将s[i]件物品看成s[i]件不同的物品但效率低。更高效的方法有二进制拆分将s[i]拆分成1,2,4,...2^k, c的组合将这些组合视为新的“物品”转化为0-1背包和单调队列优化。6.3 背包问题求方案数或具体方案有时题目不仅要求最大价值还要求方案数或者要求输出具体选择了哪些物品。这就需要我们在动态规划的过程中记录额外的信息。求方案数将dp数组定义为方案数。初始化dp[0]1容量为0有一种方案什么都不选。转移时如果两种决策价值相同则方案数相加dp[j] dp[j] dp[j-v[i]]。求具体方案通常需要二维数组记录状态转移的路径。可以用一个额外的choice[i][j]数组在dp[i][j]更新时记录它是由哪个决策转移而来的0表示不选1表示选。最后从dp[M][N]倒推回去就能得到一组最优解。6.4 关于“恰好”与“不超过”我们之前采用了“不超过”的定义。如果题目要求“恰好花费N元”只需要修改两点初始化dp[0] 0表示容量为0时价值为0恰好。其他dp[j] (j0)初始化为一个非常小的负数如-INF表示“不可能达到”的状态。最终答案答案不再是dp[N]而是需要检查dp[N]是否大于等于0。如果它仍然是初始化的负无穷说明无法恰好花完N元。“开心的金明”这道题就像一把钥匙帮你打开了动态规划特别是背包问题的大门。理解它的每一个细节亲手实现它并思考它的各种变体你的算法能力会在这个过程中得到扎实的提升。编程竞赛和实际开发中的很多问题其内核往往就是这样一个经典的模型。
分享:

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

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