【算法】背包类型动态规划
CR400BF_1145 · · 算法·理论
约定
物品的总数为 dp。除额外说明的情况,这些均是正整数。
如果一种类型的问题允许采用不同的问题作为基础(如多维费用背包既可以是多维费用的 0-1 背包,也可以是多维费用的完全背包),以 0-1 背包为基础。
本文中的数组大小仅是示例,与实际问题所需可能有出入。
其余均会在相应位置给出说明。
0-1 背包
0-1 背包是背包类型动态规划中最基础的问题。在 0-1 背包中,物品互不干扰,且每个物品仅可选择一次,要求所能取到的最大物品价值和。由于每个物品均仅有选与不选两种状态,这种背包被称为 0-1 背包。
骗分过样例,暴力出奇迹。
这个问题是很容易用 DFS 暴力解决的,它的时间复杂度为
事实上,大部分背包问题的常见解法是动态规划。一般地,我们定义
我们将遍历每个物品与每种背包容量。对于当前的物品,有两种情况:
- 如果当前的背包剩余容量不足以选择它,那么只能不选,即在背包剩余容量相同的情况下继承上一个物品的状态;
- 如果足够选择,那么若将它塞进背包中,就相当于在考虑前一个物品时减少了一定的背包剩余容量,同时得到了当前物品的价值。否则和情况 1 相同。
于是很容易得到状态转移方程为
其初始值为
int n,W,price[105],weight[105],dp[105][105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=W;j++)
{
if(j>=weight[i]) dp[i][j]=max(dp[i-1][j-weight[i]]+price[i],dp[i-1][j]);
else dp[i][j]=dp[i-1][j];
}
}
cout<<dp[n][W];
return 0;
}
接下来我们可以使用一种叫做“滚动数组”的优化方法。 ::::info[知识点-滚动数组]{open} 滚动数组是动态规划中一种极其重要的优化技巧,可以对空间复杂度进行革命性的优化。
举个例子,假设某状态
通常我们要开一个二维数组进行递推。但是我们可以发现,在转移过程中,对于当前的
int dp[2][105];
for(int i=1,cur=0;i<=n;i++,cur^=1)
for(int j=1;j<=n;j++) dp[cur][j]=max(dp[cur^1][j],dp[cur^1][j-1]);
如果依赖的维度更多,也可以通过类似的方式优化,不过会更加复杂。但这并不是终点,如果情况允许,我们可以直接在原基础上转移,这样就只剩下一个一维数组。
int dp[105];
for(int i=1;i<=n;i++)
for(int j=n;j>=1;j--) dp[j]=max(dp[j],dp[j-1]);
这种方法需要格外注意,如果
有时,如果在优化前的某些情况会直接继承上一个维度同样位置的数据,这些情况在优化时可以直接忽略,因为此时的数组中存储的就是上一个维度的数据。
滚动数组有一个缺点,就是可能会丢失一些信息。例如,有些时候可能要输出方案,这时使用滚动数组会抛弃前面的数据,不方便记录。
滚动数组并不仅仅适用于二维动态规划,它同样可以用于更高维度。比如 CSP-J 2025 T4 的这篇题解(没交到题解区,悲)中,就对三维的递推数组进行了滚动数组优化。
总之,它的好处在于能够大大优化空间复杂度,但缺点在于不容易理解、可读性下降及部分信息的丢失。
::::
在这里,
int n,W,price[105],weight[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
for(int j=W;j>=weight[i];j--) dp[j]=max(dp[j-weight[i]]+price[i],dp[j]);
cout<<dp[W];
return 0;
}
完全背包
完全背包相比 0-1 背包只有一个区别,就是每种物品都能选择无限个。这很容易解决,我们加一个循环尝试选取
这样效率是很低的,因为有一个三重循环。
我们可以注意到,最优的选择
按照同样的方法,采用滚动数组就能继续优化。但这下出了问题,
int n,W,price[105],weight[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
for(int j=weight[i];j<=W;j++) dp[j]=max(dp[j-weight[i]]+price[i],dp[j]);
cout<<dp[W];
return 0;
}
一定要记住,0-1 背包逆序枚举容量,完全背包正序枚举容量。
多重背包
在多重背包中,每种物品都有其可取用的次数限制。要解决这个问题,我们可以像完全背包一样,每种物品都尝试选取
int n,W,price[105],weight[105],number[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i]>>number[i];
for(int i=1;i<=n;i++)
for(int j=W;j>=weight[i];j--)
for(int k=0;k<=number[i]&&k*weight[i]<=j;k++) dp[j]=max(dp[j-k*weight[i]]+k*price[i],dp[j]);
cout<<dp[W];
return 0;
}
这样写可能有点丑。事实上,“第
int n,W,price[105],weight[105],number[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i]>>number[i];
for(int i=1;i<=n;i++)
for(int k=1;k<=number[i];k++)
for(int j=W;j>=weight[i];j--) dp[j]=max(dp[j-weight[i]]+price[i],dp[j]);
cout<<dp[W];
return 0;
}
但接下来想要优化可就没那么简单了。
二进制优化
二进制优化可行的前提是
我们来证明一下。 ::::info[证明] 该证明由 Gen AI 生成,模型版本为 Kimi K2 Thinking。
对于命题
令
则
存在性得证。
假设
若
由
由
将
该式恒不成立,故原假设不成立,即
因此表示存在且唯一,原命题得证。
对于命题
将
若
将
原命题得证。 :::: 说白了,就是
- 任何正整数
n 均可以唯一地表示为2^k-1(2^0+2^1+\dots+2^{k-1}) 与非负整数x(0\le x<2^k) 之和; - 任何整数
m\in[0,n] 均可以表示为集合\{2^0,2^1,\dots,2^{k-1},x\} 中的一个子集的元素之和。
这意味着,我们可以将“有
int n,W,price[105],weight[105],number[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i]>>number[i];
for(int i=1;i<=n;i++)
{
int k=1;
while(k<=number[i])
{
for(int j=W;j>=k*weight[i];j--) dp[j]=max(dp[j],dp[j-k*weight[i]]+k*price[i]);
number[i]-=k,k<<=1;
}
if(number[i])
for(int j=W;j>=number[i]*weight[i];j--)
dp[j]=max(dp[j],dp[j-number[i]*weight[i]]+number[i]*price[i]);
}
cout<<dp[W];
return 0;
}
混合背包
混合背包问题意味着有些物品只能取一次,有些物品能取无数次,有些物品能取的次数有一定的限制……听起来就很难,不过实际上混合背包非常简单。我们保持状态定义不变,依旧遍历每个(种)物品,并按照当前物品的类型选用不同问题的代码就行。
OI-Wiki 上的伪代码可以很好地解释它的思路。
for(循环物品种类)
{
if(是 0-1 背包) 套用 0-1 背包代码;
else if(是完全背包) 套用完全背包代码;
else if(是多重背包) 套用多重背包代码;
}
分组背包
在 0-1 背包中,物品互不干扰。但在分组背包中,物品被分为
这个转移方程如何保证每组中最多只选择一个物品的呢?很简单,因为
然后我们再进行滚动数组优化即可。
struct group
{
int number,price[105],weight[105],mi;
}a[105];
int W,c,dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>c;
for(int i=1;i<=c;i++)
{
cin>>a[i].number;
for(int j=1;j<=a[i].number;j++) cin>>a[i].weight[j]>>a[i].price[j],a[i].mi=min(a[i].mi,a[i].weight);
}
for(int i=1;i<=c;i++)
for(int j=W;j>=a[i].mi;j--)
for(int k=1;k<=a[i].number;k++) dp[j]=max(dp[j],dp[j-a[i].weight[k]]+a[i].price[k]);
cout<<dp[W];
return 0;
}
多维费用背包
在多维费用背包中,选择一个物品需要消耗多种不同的资源。为了方便,我们设第
不用说也知道这样做时空复杂度是惊人的!能优化吗?很可惜,一般情况下,每个资源维度都是必要不可缺少的,我们只能用滚动数组把
这里只给出二维费用背包的代码,更高维度或通用的代码请自行研究。
int n,m,W[105],price[105],weight[105][105],dp[105][105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>n>>m;
for(int i=1;i<=m;i++) cin>>W[i];
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++) cin>>weight[i][j];
cin>>price[i];
}
for(int i=1;i<=n;i++)
for(int j1=W[1];j1>=weight[i][1];j1--)
for(int j2=W[2];j2>=weight[i][2];j2--)
dp[j1][j2]=max(dp[j1][j2],dp[j1-weight[i][1]][j2-weight[i][2]]+price[i]);
cout<<dp[W[1]][W[2]];
return 0;
}
可行性背包
可行性背包并不要求能取到的最大价值和,只是要判断是否能刚好取到某个价值
显然,我们不得不把价值塞到状态的下标中,不然几乎没法转移。由于我们只关心能不能取到,我们可以定义
在转移中,只要
再加上滚动数组优化即可。
int n,W,V,price[105],weight[105];
bool dp[105][105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n>>V;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
dp[0][0]=true;
for(int i=1;i<=n;i++)
for(int j=W;j>=weight[i];j--)
for(int k=V;k>=price[i];k--) dp[j][k]=dp[j][k]||dp[j-weight[i]][k-price[i]];
bool can=false;
for(int j=0;j<=W;j++) can=can||dp[j][V];
cout<<(can?"Yes":"No");
return 0;
}
当然,在转移过程中检查能不能取到是完全可行的,并且这会节省大量的时间。
另一种办法,我们定义
最后只要判断能否用不超过
int n,W,V,price[105],weight[105],dp[105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n>>V;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
fill(dp+1,dp+V+1,INT_MAX);
for(int i=1;i<=n;i++)
for(int k=V;k>=price[i];k--) dp[k]=min(dp[k],dp[k-price[i]]+weight[i]);
cout<<(dp[V]<=W?"Yes":"No");
return 0;
}
但这种做法也有缺点。首先,它无法输出具体方案。其次,如果问题还要求一个取到特定的需求容量或者要求方案数,这种方法也无法胜任。
其他问题
求方案总数
求方案总数问题通常是求有多少种方案使得最终取出的价值和为
这很简单,我们定义
int n,W,V,price[105],weight[105],dp[105][105];
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n>>V;
dp[0][0]=1;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
for(int j=W;j>=weight[i];j--)
for(int k=V;k>=price[i];k--) dp[j][k]+=dp[j-weight[i]][k-price[i]];
int ans=0;
for(int i=0;i<=W;i++) ans+=dp[i][V];
cout<<ans;
return 0;
}
求最优方案
求最优方案问题要求在求最大价值和外,还需要输出具体选了哪些物品。
首先假设只有一个最优方案,并且升序输出物品编号。由于上面说到滚动数组会造成信息丢失,我们必须采用朴素的二维数组进行递推。递推完成后,我们可以从最后一个物品开始往前遍历,同时维护一个变量
不过这样会有个问题,如果选与不选当前物品的价值和相同,按上面的思路,这会被默认为选择了当前物品。但是这相比不选的情况,背包剩余容量更少了,却没有得到更大的价值和。所以我们需要判断选择当前物品的价值是否严格大于不选择当前物品的价值,如果是才选择当前物品。
int n,W,price[105],weight[105],dp[105][105];
vector<int> ans;
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=W;j++)
{
if(j>=weight[i]) dp[i][j]=max(dp[i-1][j-weight[i]]+price[i],dp[i-1][j]);
else dp[i][j]=dp[i-1][j];
}
}
int W_now=W;
for(int i=n;i>=1;i--)
{
if(W_now>=weight[i]&&dp[i][W_now]==dp[i-1][W_now-weight[i]]+price[i]&&dp[i][W_now]>dp[i-1][W_now])
{
ans.push_back(i);
W_now-=weight[i];
}
}
for(int i=ans.size()-1;i>=0;i--) cout<<ans[i]<<' ';
return 0;
}
那么如果有多个最优方案怎么办呢?也很简单,我们用 DFS 各尝试一遍选与不选就行。
int n,W,price[105],weight[105],dp[105][105];
vector<vector<int> > ans;
vector<int> temp;
void DFS(int i,int W_now)
{
if(i==0)
{
ans.push_back(temp);
return;
}
if(dp[i][W_now]==dp[i-1][W_now]) DFS(i-1,W_now);
if(W_now>=weight[i]&&dp[i][W_now]==dp[i-1][W_now-weight[i]]+price[i])
{
temp.push_back(i);
DFS(i-1,W_now-weight[i]);
temp.pop_back();
}
return;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=1;i<=n;i++)
{
for(int j=0;j<=W;j++)
{
if(j>=weight[i]) dp[i][j]=max(dp[i-1][j-weight[i]]+price[i],dp[i-1][j]);
else dp[i][j]=dp[i-1][j];
}
}
DFS(n,W);
for(int i=0;i<ans.size();i++)
{
for(int j=ans[i].size()-1;j>=0;j--) cout<<ans[i][j]<<' ';
cout<<endl;
}
return 0;
}
求最优方案总数
求最优方案总数,我们可以同时维护方案数与最大价值和两个数组,在转移中统计当前最优方案总数,如果最大价值和更新就重新统计。
这个应该很简单,代码就不给出了。
求第 k 优解
求第
由于一般的背包问题定义的都是最大的价值和,我们不能再沿用这个状态定义。不妨定义
其中
考虑代码实现。我们首先实现
有些时候,合并后的序列长度不足 INT_MIN标记它为不可达即可。
int n,W,k,price[105],weight[105],dp[105][105];
void merge(int a[],int b[])
{
int i=1,j=1,cnt=1,temp[105];
while(i<=k&&j<=k&&cnt<=k)
{
if(a[i]==INT_MIN&&b[j]==INT_MIN) break;
if(a[i]>b[j]) temp[cnt++]=a[i++];
else if(a[i]<b[j]) temp[cnt++]=b[j++];
else temp[cnt++]=a[i++],j++;
}
while(i<=k&&cnt<=k&&a[i]!=INT_MIN) temp[cnt++]=a[i++];
while(j<=k&&cnt<=k&&b[j]!=INT_MIN) temp[cnt++]=b[j++];
cnt--;
for(int i=1;i<=cnt;i++) a[i]=temp[i];
for(int i=cnt+1;i<=k;i++) a[i]=INT_MIN;
return;
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin>>k>>W>>n;
for(int i=1;i<=n;i++) cin>>weight[i]>>price[i];
for(int i=0;i<=W;i++)
for(int j=1;j<=k;j++) dp[i][j]=INT_MIN;
dp[0][1]=0;
for(int i=1;i<=n;i++)
{
for(int j=W;j>=weight[i];j--)
{
int b[105];
for(int t=1;t<=k;t++)
{
if(dp[j-weight[i]][t]==INT_MIN) b[t]=INT_MIN;
else b[t]=dp[j-weight[i]][t]+price[i];
}
merge(dp[j],b);
}
}
cout<<(dp[W][k]!=INT_MIN?dp[W][k]:-1);
return 0;
}
更新记录
- Update in 2026.01.26,修正可行性背包的状态定义,给出另一个方案。
- Update in 2026.02.03,修改多重背包二进制优化部分的公式。