【动态规划】背包问题
背包问题无疑是最经典的dp问题,其次就是关于字符串匹配问题,数组最长递增(减)序列长度等等。背包问题变体很多。以下内容多为摘抄。(from 崔添翼背包九讲)
动态规划问题实际上与备忘录式深搜有些类似。
1. 0-1背包
1.1 题目描述
题目:
有n个重量和价值分别为wi, vi的物品。从这些物品中挑选出总重量不超过W的物品,求所有挑选方案中价值总和的最大值。
限制条件:
1<= n <= 100; 1<= wi, vi <= 100; 1 <= W <= 1000 |
样例:
输入
n = 4 (w, v) = {(2,3),(1,2),(3,4),(2,2)} W = 5 |
输出
7 (选择第0,1,3号物品) |
1.2 解法
先从深搜开始,仔细分析问题,就会发现一个特点:每种物品有两种选择,放或者不放。
int n, int W; int w[MAX_N], v[MAX_N]; int process( int i, int j){ int res; if (i == n){ res = 0; } else if (j < w[i]){ res = process(i + 1, j); } else { res = max(process(i + 1, j), process(i + 1, j - w[i]) + v[i]); } return res; } void solve(){ printf ( "%d\n" , process(0, W)); } |
深搜的一个缺点就是会重复计算,所以有了备忘录式深搜,剪枝操作减少不必要的计算。
int n, int W; int w[MAX_N], v[MAX_N]; int dp[MAX_N][MAX_W + 1]; int process( int i, int j){ if (dp[i][j] >= 0) { return dp[i][j]; } int res; if (i == n){ res = 0; } else if (j < w[i]){ res = process(i + 1, j); } else { res = max(process(i + 1, j), process(i + 1, j - w[i]) + v[i]); } return dp[i][j] = res; } void solve(){ memset (dp, -1, sizeof (dp)); printf ( "%d\n" , process(0, W)); } |
还有一种深搜写法:
int process( int i, int j, int sum){ int res; if (i == n){ res = sum; } else if (j < w[i]){ res = process(i + 1, j, sum); } else { res = max(process(i + 1, j, sum), process(i + 1, j - w[i], sum + v[i]); } return res; } |
这种写法不利于备忘录式搜索的实现,尽量不要用这种形式。
根据备忘录式深搜,dp[i][j]为从第i个物品开始挑选总重小于j时,总价值的最大值。
i = n; dp[n][j] = 0;
j < w[j] dp[i + 1][j]
其他 max(dp[i + 1][j], dp[i + 1][j - w[i] + v[i])
void solve(){ for ( int i = n - 1; i >= 0; --i){ for ( int j = 0; j <= W; ++j){ if (j < w[i]){ dp[i][j] = dp[i + 1][j]; } else { dp[i][j] = max(dp[i + 1][j], dp[i + 1][j - w[i]] + v[i]); } } } printf ( "%d\n" , dp[0][W]); } |
重新定义dp方式:dp[i + 1][j] 为 从前面i个物品中选出总重量不超过j的物品时总价值的最大值。
dp[0][j] = 0;
dp[i + 1][j] = dp[i][j] j < w[i]
max(dp[i][j], dp[i][j - w[i]] + v[i]) 其他
void solve(){ for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ if (j < w[i]){ dp[i + 1][j] = dp[i][j]; } else { dp[i + 1][j] = max(dp[i][j], dp[i][j - w[i]] + v[i]); } } } printf ( "%d\n" , dp[n][W]); } |
背包问题的基本方程式: dp[i + 1][j] = max(dp[i][j], dp[i][j - w[i]] + v[i]);
动态规划只与之前的状态有关,不会和下一个状态有联系。对于此问题的状态定义,dp[i + 1][j]为将前i件物品放入容量为j的背包,即问题的子问题。若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只和前i - 1件物品相关的问题。如果不放第i件物品,那么问题就转化为“前i - 1件物品放入容量为j的背包中”,价值为dp[i - 1][j];如果放第i件物品,那么问就转化为“前i - 1件物品放入剩下的容量为j - w[i] 的背包中, 然后将第i件物品放入背包中”,此时能获得的最大价值就是dp[i][j - w[i]]再加上通过放入第i件物品获得的价值v[i],即dp[i][j - w[i]] + v[i] 。
0-1背包问题解题思路就是如此,但是在具体的代码实现中可以优化代码。
1.3 代码优化
空间复杂度优化:分析dp方程:dp[i+1][a]的计算只来自dp[i][b](a >= b),那么就联想到数组复用。使用一维数组实现代码。dp[i + 1][j]由dp[i][j]和dp[i][j - w[i]]两个子问题推导得到,所以要保证在推dp[i + 1][j]时能够取用dp[i][j]和dp[i][j - w[i]]的值。也就是说,推导dp[j]时要使用上一循环的dp[j]和dp[j - w[i]],那么就必须保证在本次循环推导dp[j]之前不能改写dp[j]和dp[j - w[i]],所以要从W到w[i]循环,才能保证使用的是上一循环的值。
int dp[MAX_W + 1]; void solve(){ for ( int i = 0; i < n; ++i){ for ( int j = W; j >= w[i]; --j){ dp[j] = max(dp[j], dp[j - w[i]] + v[i]); } } printf ( "%d\n" , dp[n][W]); } |
这里dp[j - w[i]]对应着原来的dp[i][j - w[i]],如果将j的循环顺序颠倒,即w[i]-W,那么就成了dp[i+1][j]由dp[i][j]推导得到,与题意不符。
时间复杂度优化:常数优化:
for i : 1 to N
for j : W to wi
可以优化为
for i :1 to N
for j : W to max(W- sum(vi)(from i to n); wi)
时间复杂度优化:O(nlogn)
1.4 初始化问题
0-1背包问题有两种,一是恰好装满背包,即物品总重等于W;一是不要求必须装满,只要总重不大于W即可。
如果是第一中问题,初始化时dp[0] 为0,但是dp[j](j = 1, ... W)都初始化为负无穷(INT_MIN);如果是第二种,则dp[j]全部初始化为0。初始化的数组可以理解为没有任何物品可以放入背包时的合法状态。如果背包并非必须被装满,那么任何容量的背包都有一个合法解“什么都不装”,这个解的价值为0,所以初始时状态的值也就全部为0了。如果要求背包恰好装满,那么此时只有容量为0的背包可以在什么也不装且价值为0的情况下被“恰好装满”,其它容量的背包均没有合法的解,属于未定义的状态,应该被赋值为INT_MIN了(即只有容量为0的背包可以什么物品都不装就能装满,此时价值为0,其他容量背包必须装入物品后才有可能处于恰好装满状态,然而初始化时是不能装任何物品的)。这样的初始化能够保证,如果子问题的状态是合法的(恰好装满),那么才能得到合法的状态;如果子问题状态是非法的,则当前问题的状态依然非法,即不存在恰好装满的情况。
2.完全背包
2.1题目描述
题目:
有n个重量和价值分别为wi, vi的物品。从这些物品中挑选出总重量不超过W的物品,求所有挑选方案中价值总和的最大值。这里每种物品可以挑选任意件。
限制条件:
1<= n <= 100; 1<= wi, vi <= 100; 1 <= W <= 1000 |
样例:
输入
n = 3 (w,v) = {(3,4), (4,5), (2,3)} W = 7 |
输出:
10 (0号物品选1个,2号物品选两个) |
2.2解法
这个问题与01背包很相似,区别在于物品选择发生了改变,01背包是选0次或1次,完全背包则是可以取0件,1件,...,至多W/w[i]件。直接写出递归式:
dp[i+1][j] 为从前i件物品中挑选总重量不超过j时总价值的最大值。
dp[0][j] = 0
dp[i + 1][j] = max{dp[i][j - k * w[i]] + k * v[i] | 0 <= k <= j / w[i]}
注意在代码实现中dp[i + 1][j] = max(dp[i + 1][j], dp[i][j - k * w[i]] + k * v[i]) (k from 0 to j / w[i]循环,因为需要存储一个临时最大值与后面的值进行比较,所以用dp[i + 1][j] 存储临时值)(注意与01背包的区别,是max(dp[i +1][*], *) 而不是max(dp[i][*], *))
程序在01背包的二重循环基础上又加了一层循环,时间复杂度高
1 2 3 4 5 6 7 8 9 10 11 12 | int dp[MAX_N + 1][MAX_W + 1]; void solve(){ for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ for ( int k = 0; k * w[i] <= j; ++k){ dp[i + 1][j] = max(dp[i + 1][j], dp[i][j - k * w[i]] + k * v[i]); } } } printf ( "%d\n" , dp[n][W]); } |
在dp[i + 1][j]的计算中选择k(k >= 1)的情况,与在dp[i + 1][j - w[i]]的计算中选择k - 1个的情况是相同的,所以dp[i + 1][j]的递推中k >= 1 部分的计算已经在dp[i + 1][j - w[i]]的计算中完成了。有如下变形:
max{dp[i][j - k * w[i]] + k * v[i] | 0 <= k <= j/w[i]}
= max(dp[i][j], max{dp[i][j - k * w[i]] + k * v[i] | 1 <= k <= j/w[i])
= max(dp[i][j], max{dp[i][(j - w[i]) - k * w[i]] + k * v[i] | 0 <= k <= j /w[i] } + v[i])
= max(dp[i][j], dp[i + 1][j - w[i]] + v[i])
这样递推式就变为
dp[i + 1][j] = max(dp[i][j], dp[i + 1][j - w[i]] + v[i])
1 2 3 4 5 6 7 8 9 10 11 12 | void solve(){ for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ if (j < w[i]){ dp[i + 1][j] = dp[i][j]; } else { dp[i + 1][j] = max(dp[i][j], dp[i + 1][j - w[i]] + v[i]); } } } printf ( "%d\n" , dp[n][W]); } |
2.3代码优化
1 2 3 4 5 6 7 8 | void solve(){ for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ dp[j] = max(dp[j], dp[j - w[i]] + v[i]); } } printf ( "%d\n" , dp[W]); } |
注意到第二层循环方向是顺序,而01背包是逆序。由二维递推式可以看出dp[i+1][j]是由dp[i][j]和dp[i+1][j-w[i]]推导出的,而01背包则是由dp[i][j]和dp[i][j-w[i]]推导出的,前者是用到此次循环推导后的dp[i+1][j-w[i]],后者是用到上一次循环推导的dp[i][j-w[i]]。
另一方面,从直观上理解为什么01背包问题中要按照j递减的次序来循环,让j递减是为了保证第i次循环中的状态dp[i + 1][j]是由状态dp[i][j - w[i]]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果dp[i][j - wi]。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果dp[i + 1][j - wi],所以就可以并且必须采用j递增的顺序循环。这就是这个简单的程序为何成立的道理。
3.多重背包
3.1题目描述:
有n种物品和一个容量为W的背包。第i种物品最多有mi件可用,每件耗费的空间是wi,价值是vi。求解将物品装入背包可使这些物品的耗费的空间总和不超过背包容量时的最大价值。
3.2 解法
这个问题与完全背包类似,区别在于每种类别可选次数有了限制,不得大于给定数值。那么对于第i种物品可以取0件,1件,...,mi件。
令dp[i][j]表示前i种物品挑选总重量不超过j时总价值的最大值。有递推式
dp[i][j] = max{dp[i-1][j - k * wi] + k * vi | 0 <= k <= mi}
考虑如何转化为01背包问题。
1)把第i种商品换成mi件01背包中的商品,那么原来有n个可以取mi件的商品,现在有Σmi件只能取一次的商品。这就成了解决01背包问题
2)二进制思想。我们考虑把第i种物品换成若干件物品,使得原问题中第i种物品可取的每种策略——取0...mi间——均能等价于取若干件代换以后的物品。另外取超过mi件的策略必不能出现。
方法是:将第i种物品分成若干件01背包中的物品,其中每件物品有一个系数。这件物品的费用和价值均是原来的费用和价值乘以这个系数。令这些系数为1,2,22...2k-1,Mi - 2k + 1,且k是满足mi - 2k + 1 > 0的最大整数。例如,如果mi为13,则相应的k = 3,这种最多取13件的物品应被分成系数分别为1,2,4,6的四件物品。
分成的这几件物品的系数和为Mi,表明不可能取多于Mi件的第i种物品。另外这种方法也能保证对于0... mi间的每一个整数,均可以用若干个系数的和表示。这里算法正确性的证明可以分0...2k-1和2k...mi两段来分别讨论得出。这样就将第i种物品分成了O(logmi)种物品,将原问题转化为了复杂度为O(WΣlogmi)的01背包问题。当给的空间小于于物品个数乘以它的容量的时候,那么对于这个物品来说相当于完全背包,反之则为01背包。下面给出O(logm)时间处理一件多重背包中物品的过程:
F为状态数组,C为每件物品耗费的空间,W为每件物品的价值,M为第i种物品最多可用件数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | //n为物品的种类,w为背包的容量,v[]是物品体积,price[]是物品价值,num[]是物品数量int v[105],price[105],num[105]; long long dp[50005];void Zero_Pack(int value, int vv, int w){ for ( int i = w; i >= vv; i--) dp[i] = max(dp[i], dp[i-vv] + value); } void Complete_Pack( int value, int vv, int w){ for ( int i = vv; i <= w; i++) dp[i] = max(dp[i], dp[i-vv] + value); } long long Multiple_Pack( int v[], int price[], int num[], int n, int w){ for ( int i = 1; i <= n; i++){ if (num[i] * v[i] > w){ Complete_Pack(price[i], v[i], w); } else { int k = 1; while (k < num[i]){ Zero_Pack(k * price[i],k * v[i],w); num[i] -= k; k = k << 1; } Zero_Pack(num[i] * price[i],num[i] * v[i],w); } } return dp[w]; } void solve(){ int res = Multiple_pack(v, price, num, n, w); printf ( "%d\n" , res);} |
3.3 多重背包变体
当问题是“每种有若干件的物品能否恰好填满给定容量的背包”,即只须考虑填满背包的可行性,不需考虑每件物品的价值时,多重背包问题同样有O(VN)复杂度的算法。
1)可以使用单调队列的数据结构,优化基本算法的状态转移方程,使每个状态的值可以以均摊O(1)的时间求解。http://blog.csdn.net/flyinghearts/article/details/5898183
2) 可以定义dp[i+1][j] 为用前i种物品是否能装满容量为j的背包,那么需要能用前i-1种物品装满j, j-wi, j - mi * wi的某一种。递推式:
dp[i+1][j] = (0 <= k <= mi, 且k * wi <= j 时存在dp[i][j - k * wi]为真的k)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | bool dp[N + 1][W + 1]; void solve(){ dp[0][0] = true ; for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ for ( int k = 0; k <= m[i] && k * w[i] <= j; ++k){ dp[i + 1][j] |= dp[i][j - k * w[i]]; } } } if (dp[n][W]) printf ( "Yes\n" ); else printf ( "No\n" ); } |
这样做时间复杂度有些高,一般用DP求取bool结果会有浪费,同样的复杂度通常能获得哼多的信息。
令dp[i+1][j] 为用前i种物品装满重量为j时第i种物品最多能剩余多少个(不能得到j的情况为-1)。如果前i - 1种物品重量加和为j的话,第i种物品就能剩mi个;前i - 1种物品重量加和为j - wi时第i种物品还剩下k的话,用这第i种物品重量加和为j时第i种物品就能剩下k-1个。
dp[i+1][j] = mi (dp[i][j] >= 0)
dp[i+1][j] = -1 (j < mi 或者 dp[i+1][j-wi] <= 0)
dp[i+1][j] = dp[i+1][j-wi] - 1;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | int dp[MAX_W + 1]; void solve (){ memset (dp, -1, sizeof (dp)); dp[0] = 0; for ( int i = 0; i < n; ++i){ for ( int j = 0; j <= W; ++j){ if (dp[j] >= 0){ dp[j] = m[i]; } else if (j < w[i] || dp[j - w[i]] <= 0){ dp[j] = -1; } else { dp[j] = dp[j - w[i]] - 1; } } } if (dp[W] >= 0) printf ( "Yes\n" ); else printf ( "No\n" ); } |
4.混合三种背包问题
4.1 问题描述:
如果将前面1、2、3中的三种背包问题混合起来。也就是说,有的物品只可以取一次(01背包),有的物品可以取无限次(完全背包),有的物品可以取的次数有一个上限(多重背包)。应该怎么求解呢?
4.2 解法
先考虑01背包与完全背包的混合。01背包与完全背包的代码只有一处不同,即第二重循环的次序是顺序还是逆序。所以如果第i种物品是01背包,那么就顺序,如果是完全背包即可以取无限次,则逆序
1 2 3 4 5 6 7 | for ( int i = 1; i <= N; ++i){ if 第i件物品属于01背包 for ( int j = W; j >= w[i]; --j){ dp[j] = max(dp[j], dp[j - w[i]] + v[i]); else if 第i件物品属于完全背包 for ( int j = w[i] ; j <= W; ++j){ dp[j] = max(dp[j], dp[j - w[i]] + v[i]); |
再考虑多重背包的问题。
1)利用单调队列,可以给出均摊O(NW)的解法
2)将每个种类物品分成O(logmi)个01背包问题;最容易理解的写法如下:
1 2 3 4 5 6 7 | for ( int i = 1; i <= N; ++i){ if 第i件属于01背包 ZeroOnePack(dp, wi, vi); else if 第i件物品属于完全背包 CompletePack(dp, wi, vi); else if 第i件物品属于多重背包 MultiplePack(dp, wi, vi, mi); |
5.二维费用的背包问题
5.1 问题描述
对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种费用。对于每种费用都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。设第i件物品所需的两种费用分别为C i 和D i 。两种费用可付出的最大值(也即两种背包容量)分别为V 和U。物品的价值为W i 。
5.2解法
之前的问题费用占一维,物品数占一维。现在费用变为两种,只需在状态数组上加上一维表示新加的费用即可。在此基础上进行空间复杂度的优化,即去掉表示物品数的一维,用两维数组表示状态。若每件物品只可取一次,则内两层循环为逆序循环,如是完全背包问题则顺序循环,如是多重背包问题则拆分物品。当发现由熟悉的动态规划题目变形得来的题目时,在原来的状态中加一维以满足新的限制是一种比较通用的方法。
dp[i, v, u] = max{dp[i - 1, v, u], dp[i - 1, v - ci, u - di] + wi}
5.3 变体
有时二维费用的条件是以一种隐含的方式给出的:最多只能取U件商品。事实上这相当于每件物品多了一种费用:件数。只不过每个物品的件数费用均为1,可以付出的最大件数费用为U。令dp[v, u]表示付出费用v, 最多选u件时得到的最大价值。则根据物品属性采取不同策略循环求得(01背包,完全背包或者多重背包)。
6.分组背包问题
6.1 问题描述
有N件物品和一个容量为V 的背包。第i件物品的费用是C i ,价值是W i 。这些物品被划分为K组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
6.2解法
之前的问题都是从N件物品里选取,此问题变为从K组划分中选取物品,这实际上就是把第一层循环改变了,同时在划分组内选择又有i种选择(all i in K group).很容易得出递推式:
1 2 3 4 | for ( int k = 0; k <= K; ++k) for ( int v = V; v >= 0; --v) for all i in group K dp[v] = max(dp[v], dp[v - ci] + wi) |
7.有依赖的背包问题
7.1 问题描述
这种背包问题的物品间存在某种“依赖”的关系。也就是说,物品i依赖于物品j,表示若选物品i,则必须选物品j。为了简化起见,我们先设没有某个物品既依赖于别的物品,又被别的物品所依赖;另外,没有某件物品同时依赖多件物品。
7.2 解法
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 如何调用 DeepSeek 的自然语言处理 API 接口并集成到在线客服系统
· 【译】Visual Studio 中新的强大生产力特性
· 2025年我用 Compose 写了一个 Todo App