
🔥承渊政道: 个人主页
❄️个人专栏: 《C语言基础语法知识》 《数据结构与算法》 《C++知识内容》 《Linux系统知识》 《算法刷题指南》 《测评文章活动推广》 《大模型语言路线学习》
✨逆境不吐心中苦,顺境不忘来时路!✨ 🎬 博主简介:

在动态规划的学习过程中,背包问题 几乎是绕不开的一座"大山".它不仅是动态规划中的经典模型,也是很多算法题的底层抽象来源.无论是求最大价值、方案数量,还是判断是否能够恰好装满,很多问题本质上都可以转化为背包模型来解决.而在背包问题的体系中,完全背包问题 是非常重要的一类.相比01背包中"每个物品只能选择一次"的限制,完全背包允许每个物品被选择多次,甚至可以无限次选择.正是这个看似简单的变化,使得它在状态转移、遍历顺序以及空间优化上,都与01背包有着明显区别.本文将围绕 "完全背包问题从状态定义到空间优化" 这一主题展开,按照由浅入深的方式,逐步讲清楚完全背包的核心思想.我们会先从题目模型出发,理解什么样的问题可以抽象成完全背包;然后分析二维 DP 的状态定义和状态转移方程;接着进一步推导如何将二维 DP 优化为一维 DP;最后总结完全背包常见题型、代码模板以及容易出错的细节.学习完全背包,最重要的不是单纯记住代码,而是理解每一个状态背后的含义.只有明白
dp[i][j]表示什么,知道当前状态是如何由之前的状态推导而来,才能真正掌握这类问题的解题方法.当题目稍微变形,比如求最大价值、求组合数、求排列数、判断可行性时,也能快速判断应该如何定义状态、如何初始化、如何遍历.希望通过这篇文章,你能够真正理解完全背包的本质,掌握从二维 DP 到一维 DP 的优化思路,并在之后遇到类似问题时,不再只是套公式,而是能够根据题意独立分析出正确的动态规划解法.废话不多说,下面跟着小编的节奏🎵一起去疯狂的学习吧!
目录
1.完全背包问题背景介绍
在动态规划算法中,背包问题是一类非常经典的问题模型.它通常描述的是:给定一个容量有限的背包,以及若干种物品,每种物品都有对应的体积、重量或花费,同时也可能具有一定的价值,要求在不超过背包容量的前提下,选择合适的物品,使得最终收益最大、方案数最多,或者判断是否能够刚好装满背包.
在最基础的 01 背包问题 中,每个物品只能选择一次.也就是说,对于每件物品,我们只有两种选择:选 或者 不选 .而 完全背包问题 则是在此基础上进行扩展:每种物品可以被选择无限次.也就是说,只要背包容量允许,同一种物品可以重复放入背包.
正是因为"物品可以重复选择"这个特点,完全背包问题在现实场景中非常常见.例如:
买东西时,某种商品可以买多件;
兑换零钱时,每种面额的硬币可以使用多枚;
凑金额时,某个数字可以被重复使用;
选择材料时,同一种材料可以取多份;
做任务或获得收益时,同一种操作可以反复执行.
这些问题表面上看起来不完全一样,但它们背后都具有一个共同特征:每种选择可以重复使用,并且需要在某个限制条件下求最优结果或方案数量.这正是完全背包模型的核心.
从算法角度来看,完全背包问题通常可以抽象为:
给定 n 种物品和一个容量为 V 的背包,每种物品有体积 v[i] 和价值 w[i],每种物品可以选择任意多次,求在总体积不超过 V 的情况下,能够获得的最大价值.
它和 01 背包的区别可以简单概括为:
cpp
01 背包:每个物品最多选 1 次
完全背包:每个物品可以选 0 次、1 次、2 次、......无限次
因此,完全背包的状态转移也会发生变化.对于当前第 i 种物品来说,我们不再只是考虑"选一次"或"不选",而是要考虑它可以被重复选择.也正因为如此,在进行一维 DP 空间优化时,完全背包的容量遍历顺序通常是 从小到大,这样才能保证当前物品可以被重复使用.
完全背包问题是动态规划中非常重要的基础模型.很多经典算法题都可以归纳到这个模型中,例如零钱兑换、零钱兑换方案数、完全平方数、组合总和等.掌握完全背包,不仅能够帮助我们解决一类具体题目,更重要的是能够加深对动态规划中"状态定义、状态转移、初始化和遍历顺序"的理解.
可以说,完全背包问题是从 01 背包走向更复杂背包模型的重要一步.理解它之后,再学习多重背包、分组背包、二维费用背包等问题时,也会更加容易.
2.完全背包(OJ题)

算法思路:解法(动态规划):
背包问题的状态表示非常经典,如果大家不知道怎么来的,就把它当成一个模板记住吧~
我们先解决第一问:
1.状态表示:
dp[i][j] 表示:从前 i 个物品中挑选,总体积不超过 j,所有的选法中,能挑选出来的最大价值.(这里是和01背包一样哒)
2.状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论.但是最后一个物品能选很多个,因此我们的需要分很多情况:
i. 选 0 个第 i 个物品:此时相当于是去前 i - 1 个物品中挑选,总体积不超过 j.此时最大价值为 dp[i - 1][j];
ii. 选 1 个第 i 个物品:此时相当于是去前 i - 1 个物品中挑选,总体积不超过 j - v[i].因为挑选了一个 i 物品,此时最大价值为 dp[i - 1][j - v[i]] + w[i];
iii. 选 2 个第 i 个物品:此时相当于是去前 i - 1 个物品中挑选,总体积不超过 j - 2 * v[i].因为挑选了两个 i 物品,此时最大价值为 dp[i - 1][j - 2 * v[i]] + 2 * w[i];
iv. ......
综上,我们的状态转移方程为:
`dp[i][j]=max(dp[i-1][j], dp[i-1][j-v[i]]+w[i], dp[i-1][j-2*v[i]]+2*w[i]...)`
当我们发现,计算一个状态的时候,需要一个循环才能搞定的时候,我们要想到去优化.优化的方向就是用一个或者两个状态来表示这一堆的状态,通常就是用数学的方式做一下等价替换.我们发现第二维是有规律的变化的,因此我们去看看 dp[i][j - v[i]] 这个状态:
dp[i][j-v[i]]=max(dp[i-1][j-v[i]], dp[i-1][j-2*v[i]]+w[i], dp[i-1][j-3*v[i]]+2*w[i]...)
我们发现,把 dp[i][j - v[i]] 加上 w[i] 正好和 dp[i][j] 中除了第一项以外的全部一致,因此我们可以修改我们的状态转移方程为:
dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]).
3.初始化:
我们多加一行,方便我们的初始化,此时仅需将第一行初始化为 0 即可.因为什么也不选,也能满足体积不小于 j 的情况,此时的价值为 0.
4.填表顺序:
根据状态转移方程,我们仅需从上往下填表即可.
5.返回值:
根据状态表示,返回 dp[n][V].
接下来解决第二问:
第二问仅需微调一下 dp 过程的五步即可.
因为有可能凑不齐 j 体积的物品,因此我们把不合法的状态设置为 -1.
1.状态表示:
dp[i][j] 表示:从前 i 个物品中挑选,总体积正好等于 j,所有的选法中,能挑选出来的最大价值.
2.状态转移方程:
dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i]).
但是在使用 dp[i][j - v[i]] 的时候,不仅要判断 j >= v[i],又要判断 dp[i][j - v[i]] 表示的情况是否存在,也就是 dp[i][j - v[i]] != -1.
3.初始化:
我们多加一行,方便我们的初始化:
i. 第一个格子为 0,因为正好能凑齐体积为 0 的背包;
ii. 但是第一行后面的格子都是 -1,因为没有物品,无法满足体积大于 0 的情况.
4.填表顺序:
根据状态转移方程,我们仅需从上往下填表即可.
5.返回值:
由于最后可能凑不成体积为 V 的情况,因此返回之前需要特判一下.
空间优化:
背包问题基本上都是利用滚动数组来做空间上的优化:
i. 利用滚动数组优化;
ii. 直接在原始代码上修改.
在完全背包问题中,优化的结果为:
i. 仅需删掉所有的横坐标.






核心代码
cpp
#include <iostream>
#include <string.h>
using namespace std;
//定义数据范围上限
const int N = 1010;
//全局变量定义
//n:物品数量 V:背包最大容量
//v[]:物品体积 w[]:物品价值
int n, V, v[N], w[N];
//二维dp数组:dp[i][j] 表示前i个物品,背包容量为j时的最大价值
int dp[N][N];
int main()
{
//1.输入数据
//读取物品数量n和背包容量V
cin >> n >> V;
//循环读取n个物品的体积和价值(下标从1开始)
for(int i = 1; i <= n; i++)
cin >> v[i] >> w[i];
//第一问:完全背包(不要求装满背包)
//状态定义:前i个物品,容量j,能装的最大价值(物品可重复选)
//遍历物品
for(int i = 1; i <= n; i++)
//遍历背包容量
for(int j = 0; j <= V; j++)
{
//情况1:不选第i个物品,价值等于前i-1个物品的结果
dp[i][j] = dp[i - 1][j];
//情况2:选第i个物品(完全背包:可重复选,因此用dp[i]而非dp[i-1])
//前提:当前容量 >= 物品体积
if(j >= v[i])
//取两种情况的最大值
dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
//输出结果:不要求装满时,容量V的最大价值
cout << dp[n][V] << endl;
//第二问:完全背包(必须装满背包)
//重置dp数组为0
memset(dp, 0, sizeof dp);
//初始化:必须装满的核心设置
//dp[0][0] = 0:0个物品,容量0,价值0(合法)
//dp[0][j>0] = -1:0个物品无法装满容量>0的背包,标记为非法状态
for(int j = 1; j <= V; j++) dp[0][j] = -1;
//动态规划填表
for(int i = 1; i <= n; i++)
for(int j = 0; j <= V; j++)
{
//不选第i个物品
dp[i][j] = dp[i - 1][j];
//选第i个物品:需要满足 容量足够 + 前序状态合法
if(j >= v[i] && dp[i][j - v[i]] != -1)
dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
//输出结果:
//dp[n][V] = -1 → 无法装满,输出0
//否则输出装满后的最大价值
cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;
return 0;
}
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
// 定义数据范围上限
const int N = 1010;
// 全局变量定义
// n:物品数量 V:背包最大容量
// v[]:物品体积 w[]:物品价值
int n, V, v[N], w[N];
// 二维 dp 数组
// dp[i][j] 表示前 i 个物品,背包容量为 j 时的最大价值
int dp[N][N];
// 求解函数:返回两个结果
// first:完全背包,不要求装满
// second:完全背包,必须装满
pair<int, int> solve(vector<pair<int, int>> items, int capacity)
{
// 赋值给全局变量
n = items.size();
V = capacity;
for (int i = 1; i <= n; i++)
{
v[i] = items[i - 1].first;
w[i] = items[i - 1].second;
}
// 第一问:完全背包,不要求装满背包
memset(dp, 0, sizeof dp);
// 状态定义:
// dp[i][j] 表示前 i 个物品,背包容量为 j 时能获得的最大价值
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= V; j++)
{
// 情况1:不选第 i 个物品
dp[i][j] = dp[i - 1][j];
// 情况2:选第 i 个物品
// 完全背包可以重复选,所以使用 dp[i][j - v[i]]
if (j >= v[i])
{
dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
}
}
int answer1 = dp[n][V];
// 第二问:完全背包,必须装满背包
memset(dp, 0, sizeof dp);
// 初始化非法状态
// dp[0][0] = 0 表示 0 个物品装满容量 0 是合法的
// dp[0][j] = -1 表示 0 个物品无法装满容量 j
for (int j = 1; j <= V; j++)
{
dp[0][j] = -1;
}
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= V; j++)
{
// 情况1:不选第 i 个物品
dp[i][j] = dp[i - 1][j];
// 情况2:选第 i 个物品
// 需要容量足够,并且前面的状态是合法的
if (j >= v[i] && dp[i][j - v[i]] != -1)
{
dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
}
}
int answer2 = (dp[n][V] == -1 ? 0 : dp[n][V]);
return {answer1, answer2};
}
void printItems(const vector<pair<int, int>>& items)
{
cout << "[";
for (int i = 0; i < items.size(); i++)
{
cout << "(体积:" << items[i].first
<< ", 价值:" << items[i].second << ")";
if (i != items.size() - 1)
{
cout << ", ";
}
}
cout << "]";
}
void runTest(
vector<pair<int, int>> items,
int capacity,
int expected1,
int expected2
)
{
pair<int, int> result = solve(items, capacity);
cout << "物品列表 = ";
printItems(items);
cout << endl;
cout << "背包容量 = " << capacity << endl;
cout << "不要求装满的最大价值 = " << result.first << endl;
cout << "期望结果 = " << expected1 << endl;
cout << "必须装满的最大价值 = " << result.second << endl;
cout << "期望结果 = " << expected2 << endl;
if (result.first == expected1 && result.second == expected2)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main()
{
// 测试用例1:普通情况
// 物品:
// 体积 1,价值 2
// 体积 2,价值 4
// 体积 3,价值 4
// 容量 5
//
// 不要求装满:可以选 5 个体积为 1 的物品,总价值 10
// 必须装满:同样可以装满,总价值 10
runTest(
{
{1, 2},
{2, 4},
{3, 4}
},
5,
10,
10
);
// 测试用例2:容量无法被任何组合刚好装满
// 物品体积为 2 和 4,背包容量为 5
// 不要求装满:最多装体积 4,价值 8
// 必须装满:无法刚好装满,输出 0
runTest(
{
{2, 3},
{4, 8}
},
5,
8,
0
);
// 测试用例3:可以重复选择同一个物品
// 选择 3 个体积为 3、价值为 5 的物品
// 总体积 9,总价值 15
runTest(
{
{3, 5},
{5, 8}
},
9,
15,
15
);
// 测试用例4:存在多种装法,选择价值最大的
// 容量 10
// 可以选 5 个体积 2 价值 3 的物品,总价值 15
// 也可以选 2 个体积 5 价值 10 的物品,总价值 20
runTest(
{
{2, 3},
{5, 10}
},
10,
20,
20
);
// 测试用例5:背包容量为 0
// 不管是否要求装满,最大价值都是 0
runTest(
{
{1, 10},
{2, 20}
},
0,
0,
0
);
// 测试用例6:只有一个物品
// 体积 3,价值 7,容量 10
// 不要求装满:最多选 3 个,总体积 9,总价值 21
// 必须装满:无法刚好装满容量 10,输出 0
runTest(
{
{3, 7}
},
10,
21,
0
);
return 0;
}

核心代码
cpp
//空间优化后
#include <iostream>
#include <string.h>
using namespace std;
//定义数据最大范围,适配题目数据规模
const int N = 1010;
//全局变量定义
//n:物品数量 V:背包最大容量
//v[]:存储每个物品的体积 w[]:存储每个物品的价值
int n, V, v[N], w[N];
//一维dp数组:滚动数组优化,dp[j]表示背包容量为j时的最大价值
int dp[N];
int main()
{
//1.输入数据
//读取物品总数n 和 背包最大容量V
cin >> n >> V;
//循环读取n个物品的体积和价值(物品下标从1开始)
for(int i = 1; i <= n; i++)
cin >> v[i] >> w[i];
//第一问:完全背包(不要求装满背包)
//完全背包核心:物品可**无限次选取**
//外层循环:遍历每一个物品
for(int i = 1; i <= n; i++)
//内层循环:**正序遍历**背包容量!(完全背包一维优化核心)
//正序遍历:允许同一个物品被重复选取(和01背包逆序遍历相反)
for(int j = v[i]; j <= V; j++)
//状态转移方程:
//dp[j] = 不选当前物品的价值(原值) vs 选当前物品的价值(dp[j-v[i]]+w[i])
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
//输出结果:背包容量为V时的最大价值(不要求装满)
cout << dp[V] << endl;
//第二问:完全背包(必须装满背包)
//重置dp数组为0,重新初始化
memset(dp, 0, sizeof dp);
//必须装满的核心初始化:
//dp[0] = 0:容量0,价值0(合法,刚好装满)
//dp[j>0] = -0x3f3f3f3f:用极小值标记**非法状态**(无法装满)
for(int j = 1; j <= V; j++) dp[j] = -0x3f3f3f3f;
//完全背包动态规划(逻辑和第一问一致)
for(int i = 1; i <= n; i++)
for(int j = v[i]; j <= V; j++)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
//输出结果:
//如果dp[V] < 0 → 无法装满背包,输出0
//否则输出装满背包时的最大价值
cout << (dp[V] < 0 ? 0 : dp[V]) << endl;
return 0;
}
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;
// 定义数据最大范围,适配题目数据规模
const int N = 1010;
// 全局变量定义
// n:物品数量 V:背包最大容量
// v[]:存储每个物品的体积 w[]:存储每个物品的价值
int n, V, v[N], w[N];
// 一维 dp 数组
// dp[j] 表示背包容量为 j 时的最大价值
int dp[N];
// 求解函数:返回两个答案
// first:完全背包,不要求装满
// second:完全背包,必须装满
pair<int, int> solve(vector<pair<int, int>> items, int capacity)
{
n = items.size();
V = capacity;
for (int i = 1; i <= n; i++)
{
v[i] = items[i - 1].first;
w[i] = items[i - 1].second;
}
// 第一问:完全背包,不要求装满背包
memset(dp, 0, sizeof dp);
// 完全背包核心:
// 外层遍历物品,内层正序遍历容量
// 正序遍历允许当前物品被重复选取
for (int i = 1; i <= n; i++)
{
for (int j = v[i]; j <= V; j++)
{
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
int answer1 = dp[V];
// 第二问:完全背包,必须装满背包
memset(dp, 0, sizeof dp);
// 必须装满的核心初始化
// dp[0] = 0,表示容量 0 可以合法装满
// dp[j > 0] = 极小值,表示初始状态下无法装满
for (int j = 1; j <= V; j++)
{
dp[j] = -0x3f3f3f3f;
}
// 完全背包动态规划
for (int i = 1; i <= n; i++)
{
for (int j = v[i]; j <= V; j++)
{
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
int answer2 = (dp[V] < 0 ? 0 : dp[V]);
return {answer1, answer2};
}
void printItems(const vector<pair<int, int>>& items)
{
cout << "[";
for (int i = 0; i < items.size(); i++)
{
cout << "(体积:" << items[i].first
<< ", 价值:" << items[i].second << ")";
if (i != items.size() - 1)
{
cout << ", ";
}
}
cout << "]";
}
void runTest(
vector<pair<int, int>> items,
int capacity,
int expected1,
int expected2
)
{
pair<int, int> result = solve(items, capacity);
cout << "物品列表 = ";
printItems(items);
cout << endl;
cout << "背包容量 = " << capacity << endl;
cout << "不要求装满的最大价值 = " << result.first << endl;
cout << "期望结果 = " << expected1 << endl;
cout << "必须装满的最大价值 = " << result.second << endl;
cout << "期望结果 = " << expected2 << endl;
if (result.first == expected1 && result.second == expected2)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main()
{
// 测试用例1:普通情况
// 物品:
// 体积 1,价值 2
// 体积 2,价值 4
// 体积 3,价值 4
// 容量 5
// 最优:选 5 个体积为 1 的物品,价值 10
runTest(
{
{1, 2},
{2, 4},
{3, 4}
},
5,
10,
10
);
// 测试用例2:必须装满时无法刚好装满
// 体积只有 2 和 4,容量为 5,无法凑出 5
// 不要求装满:选体积 4 的物品,价值 8
// 必须装满:无法装满,输出 0
runTest(
{
{2, 3},
{4, 8}
},
5,
8,
0
);
// 测试用例3:可以重复选择同一个物品
// 选 3 个体积为 3、价值为 5 的物品
// 总体积 9,总价值 15
runTest(
{
{3, 5},
{5, 8}
},
9,
15,
15
);
// 测试用例4:多种装法,选择价值最大的
// 容量 10
// 可以选 5 个体积 2、价值 3 的物品,总价值 15
// 也可以选 2 个体积 5、价值 10 的物品,总价值 20
runTest(
{
{2, 3},
{5, 10}
},
10,
20,
20
);
// 测试用例5:背包容量为 0
// 容量 0 本身就是装满状态,最大价值为 0
runTest(
{
{1, 10},
{2, 20}
},
0,
0,
0
);
// 测试用例6:只有一个物品,容量不能刚好装满
// 体积 3,价值 7,容量 10
// 不要求装满:最多选 3 个,总体积 9,总价值 21
// 必须装满:无法凑出 10,输出 0
runTest(
{
{3, 7}
},
10,
21,
0
);
// 测试用例7:只有一个物品,容量可以刚好装满
// 体积 5,价值 11,容量 20
// 可以选 4 个,总价值 44
runTest(
{
{5, 11}
},
20,
44,
44
);
// 测试用例8:不要求装满和必须装满结果不同
// 容量 7
// 不要求装满:选 1 个体积 6、价值 20 的物品,价值 20
// 必须装满:只能选 体积3 + 体积4,价值 6 + 7 = 13
runTest(
{
{3, 6},
{4, 7},
{6, 20}
},
7,
20,
13
);
return 0;
}

3.零钱兑换(OJ题)

算法思路:解法(动态规划):
先将问题转化成我们熟悉的题型.
i. 在一些物品中挑选一些出来,然后在满足某个限定条件下,解决一些问题,大概率是背包模型;
ii. 由于每一个物品都是无限多个的,因此是一个完全背包问题.
接下来的分析就是基于完全背包的方式来的.
1.状态表示:
dp[i][j] 表示:从前 i 个硬币中挑选,总和正好等于 j,所有的选法中,最少的硬币个数.
2.状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论.但是最后一个物品能选很多个,因此我们的需要分很多情况:
i. 选 0 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j.此时最少的硬币个数为 dp[i - 1][j];
ii. 选 1 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j - coins[i].因为挑选了一个 i 硬币,此时最少的硬币个数为 dp[i - 1][j - coins[i]] + 1;
iii. 选 2 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j - 2 * coins[i].因为挑选了两个 i 硬币,此时最少的硬币个数为 dp[i - 1][j - 2 * coins[i]] + 2;
iv. ......
结合我们在完全背包里面的优化思路,我们最终得到的状态转移方程为:
dp[i][j] = min(dp[i - 1][j], dp[i][j - coins[i]] + 1).
这里教给大家一个技巧,就是相当于把第二种情况 dp[i - 1][j - coins[i]] + 1 里面的 i - 1 变成 i 即可.
3.初始化:
初始化第一行即可.
这里因为取 min,所以我们可以把无效的地方设置成无穷大(0x3f3f3f3f).
因为这里要求正好凑成总和为 j,因此,需要把第一行除了第一个位置的元素,都设置成无穷大.
4.填表顺序:
根据状态转移方程,我们仅需从上往下填表即可.
5.返回值:
根据状态表示,返回 dp[n][V].但是要特判一下,因为有可能凑不到.






核心代码
cpp
//解题思路:完全背包问题 - 每种硬币可无限使用,求凑成总金额的最小硬币数
class Solution
{
public:
//函数功能:返回凑成总金额amount所需的最小硬币数,无法凑成返回-1
int coinChange(vector<int>& coins, int amount)
{
//动态规划四步走:创建dp表 -> 初始化 -> 填表 -> 返回值
//定义无穷大常量:因为求最小值,用极大值表示无法凑成的状态
const int INF = 0x3f3f3f3f;
// n:硬币的种类数量
int n = coins.size();
//1.创建 dp 表
//二维dp数组定义:
//dp[i][j] 表示:使用前 i 种硬币,凑成总金额 j 所需的**最小硬币数量**
vector<vector<int>> dp(n + 1, vector<int>(amount + 1));
//2.初始化
//0种硬币时:
//凑金额0:需要0个硬币(默认初始化)
//凑金额j>0:无法凑成,赋值为无穷大INF
for(int j = 1; j <= amount; j++)
dp[0][j] = INF;
//3.填表(完全背包核心)
//外层循环:遍历所有硬币种类(i从1开始)
for(int i = 1; i <= n; i++)
//内层循环:遍历所有金额
for(int j = 0; j <= amount; j++)
{
//情况1:不选第i种硬币
//最小数量 = 前i-1种硬币凑j的最小数量
dp[i][j] = dp[i - 1][j];
//情况2:选第i种硬币(完全背包:可重复选)
//前提:当前金额 >= 硬币面值
if(j >= coins[i - 1])
//取「不选」和「选」的最小值
//选的话:dp[i][j-硬币面值] + 1(多加1个当前硬币)
dp[i][j] = min(dp[i][j], dp[i][j - coins[i - 1]] + 1);
}
//4.返回值
//最终结果:n种硬币凑amount的最小数量
//如果结果 >= INF,说明无法凑成,返回-1;否则返回最小数量
return dp[n][amount] >= INF ? -1 : dp[n][amount];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 解题思路:完全背包问题
// 每种硬币可无限使用,求凑成总金额的最小硬币数
class Solution
{
public:
// 函数功能:返回凑成总金额 amount 所需的最小硬币数,无法凑成返回 -1
int coinChange(vector<int>& coins, int amount)
{
// 动态规划四步走:创建 dp 表 -> 初始化 -> 填表 -> 返回值
// 定义无穷大常量:因为求最小值,用极大值表示无法凑成的状态
const int INF = 0x3f3f3f3f;
// n:硬币的种类数量
int n = coins.size();
// 1. 创建 dp 表
// dp[i][j] 表示:
// 使用前 i 种硬币,凑成总金额 j 所需的最小硬币数量
vector<vector<int>> dp(n + 1, vector<int>(amount + 1, 0));
// 2. 初始化
// 0 种硬币时:
// 凑金额 0:需要 0 个硬币
// 凑金额 j > 0:无法凑成,赋值为 INF
for (int j = 1; j <= amount; j++)
{
dp[0][j] = INF;
}
// 3. 填表:完全背包
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= amount; j++)
{
// 情况1:不选第 i 种硬币
dp[i][j] = dp[i - 1][j];
// 情况2:选第 i 种硬币
// 完全背包:当前硬币可以重复使用,所以是 dp[i][j - coins[i - 1]]
if (j >= coins[i - 1])
{
dp[i][j] = min(dp[i][j], dp[i][j - coins[i - 1]] + 1);
}
}
}
// 4. 返回值
return dp[n][amount] >= INF ? -1 : dp[n][amount];
}
};
void printVector(const vector<int>& coins)
{
cout << "[";
for (int i = 0; i < coins.size(); i++)
{
cout << coins[i];
if (i != coins.size() - 1)
{
cout << ", ";
}
}
cout << "]";
}
void runTest(Solution& solution, vector<int> coins, int amount, int expected)
{
int result = solution.coinChange(coins, amount);
cout << "coins = ";
printVector(coins);
cout << endl;
cout << "amount = " << amount << endl;
cout << "最少硬币数 = " << result << endl;
cout << "期望结果 = " << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main()
{
Solution solution;
// 测试用例1:经典示例
// 11 = 5 + 5 + 1,所以最少需要 3 枚硬币
runTest(solution, {1, 2, 5}, 11, 3);
// 测试用例2:无法凑成
// 只有面值 2,无法凑成金额 3
runTest(solution, {2}, 3, -1);
// 测试用例3:金额为 0
// 凑成金额 0 不需要任何硬币
runTest(solution, {1}, 0, 0);
// 测试用例4:刚好使用一枚硬币
runTest(solution, {1, 3, 4}, 4, 1);
// 测试用例5:贪心不一定最优
// 6 = 3 + 3,只需要 2 枚
// 如果贪心先选 4,则 4 + 1 + 1 需要 3 枚
runTest(solution, {1, 3, 4}, 6, 2);
// 测试用例6:只有一种硬币,可以凑成
// 10 = 5 + 5
runTest(solution, {5}, 10, 2);
// 测试用例7:只有一种硬币,无法凑成
runTest(solution, {5}, 11, -1);
// 测试用例8:多种硬币
// 27 = 10 + 10 + 5 + 2
runTest(solution, {2, 5, 10, 1}, 27, 4);
// 测试用例9:硬币面值都大于金额
runTest(solution, {7, 8, 9}, 5, -1);
// 测试用例10:重复面值也可以正常处理
runTest(solution, {1, 2, 2, 5}, 11, 3);
return 0;
}

核心代码
cpp
//空间优化后
//解题思路:完全背包问题 - 硬币可无限次使用,求凑成总金额的最小硬币数
class Solution
{
public:
//函数功能:计算凑成总金额amount所需的最小硬币数,无法凑成则返回-1
int coinChange(vector<int>& coins, int amount)
{
//动态规划标准四步:创建dp表 → 初始化 → 填表 → 返回值
//定义无穷大常量:用于标记无法凑成的状态(因为求最小值,初始化为极大值)
const int INF = 0x3f3f3f3f;
//n:硬币的种类数量
int n = coins.size();
//1.创建 dp 表 + 初始化
//一维滚动数组:dp[j] 表示 凑成总金额为 j 所需的最小硬币数
//初始化为无穷大:代表默认所有金额都无法凑成
vector<int> dp(amount + 1, INF);
//基准条件:凑成金额 0,需要 0 个硬币
dp[0] = 0;
//3.填表(完全背包核心)
//外层循环:遍历每一种硬币(物品)
for(int i = 1; i <= n; i++)
//内层循环:正序遍历金额(背包容量)
//正序遍历 = 完全背包专属:允许硬币被重复选取
for(int j = coins[i - 1]; j <= amount; j++)
//状态转移方程:
//不选当前硬币:保持 dp[j] 原值
//选当前硬币:dp[j - 当前硬币面值] + 1(多加1枚当前硬币)
//取两种情况的最小值
dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1);
//4.返回值
//若最终值仍为无穷大 → 无法凑成该金额,返回-1
//否则返回最小硬币数
return dp[amount] >= INF ? -1 : dp[amount];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 空间优化后
// 解题思路:完全背包问题
// 硬币可无限次使用,求凑成总金额的最小硬币数
class Solution
{
public:
// 函数功能:计算凑成总金额 amount 所需的最小硬币数,无法凑成则返回 -1
int coinChange(vector<int>& coins, int amount)
{
// 动态规划标准四步:创建 dp 表 → 初始化 → 填表 → 返回值
// 定义无穷大常量:
// 用于标记无法凑成的状态
const int INF = 0x3f3f3f3f;
// n:硬币的种类数量
int n = coins.size();
// 1. 创建 dp 表 + 初始化
// dp[j] 表示凑成总金额 j 所需的最小硬币数
vector<int> dp(amount + 1, INF);
// 基准条件:
// 凑成金额 0,需要 0 个硬币
dp[0] = 0;
// 2. 填表:完全背包
// 外层遍历硬币,内层正序遍历金额
for (int i = 1; i <= n; i++)
{
for (int j = coins[i - 1]; j <= amount; j++)
{
// 不选当前硬币:dp[j] 保持原值
// 选当前硬币:dp[j - coins[i - 1]] + 1
dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1);
}
}
// 3. 返回值
return dp[amount] >= INF ? -1 : dp[amount];
}
};
void printVector(const vector<int>& coins)
{
cout << "[";
for (int i = 0; i < coins.size(); i++)
{
cout << coins[i];
if (i != coins.size() - 1)
{
cout << ", ";
}
}
cout << "]";
}
void runTest(Solution& solution, vector<int> coins, int amount, int expected)
{
int result = solution.coinChange(coins, amount);
cout << "coins = ";
printVector(coins);
cout << endl;
cout << "amount = " << amount << endl;
cout << "最少硬币数 = " << result << endl;
cout << "期望结果 = " << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main()
{
Solution solution;
// 测试用例1:经典示例
// 11 = 5 + 5 + 1
// 最少需要 3 枚硬币
runTest(solution, {1, 2, 5}, 11, 3);
// 测试用例2:无法凑成
// 只有面值 2,无法凑成金额 3
runTest(solution, {2}, 3, -1);
// 测试用例3:金额为 0
// 凑成金额 0 不需要任何硬币
runTest(solution, {1}, 0, 0);
// 测试用例4:刚好使用一枚硬币
runTest(solution, {1, 3, 4}, 4, 1);
// 测试用例5:贪心不一定最优
// 6 = 3 + 3,只需要 2 枚
// 如果贪心先选 4,则 4 + 1 + 1 需要 3 枚
runTest(solution, {1, 3, 4}, 6, 2);
// 测试用例6:只有一种硬币,可以凑成
// 10 = 5 + 5
runTest(solution, {5}, 10, 2);
// 测试用例7:只有一种硬币,无法凑成
runTest(solution, {5}, 11, -1);
// 测试用例8:多种硬币
// 27 = 10 + 10 + 5 + 2
runTest(solution, {2, 5, 10, 1}, 27, 4);
// 测试用例9:所有硬币面值都大于 amount
runTest(solution, {7, 8, 9}, 5, -1);
// 测试用例10:硬币面值有重复
runTest(solution, {1, 2, 2, 5}, 11, 3);
// 测试用例11:较大金额
// 100 = 25 * 4
runTest(solution, {1, 5, 10, 25}, 100, 4);
// 测试用例12:存在硬币但无法凑成
// 面值都是偶数,无法凑成奇数 7
runTest(solution, {2, 4, 6}, 7, -1);
return 0;
}

4.零钱兑换II(OJ题)

算法思路:解法(动态规划):
先将问题转化成我们熟悉的题型.
i. 在一些物品中挑选一些出来,然后在满足某个限定条件下,解决一些问题,大概率是背包模型;
ii. 由于每一个物品都是无限多个的,因此是一个完全背包问题.
接下来的分析就是基于完全背包的方式来的.
1.状态表示:
dp[i][j] 表示:从前 i 个硬币中挑选,总和正好等于 j,一共有多少种选法.
2.状态转移方程:
线性 dp 状态转移方程分析方式,一般都是根据最后一步的状况,来分情况讨论.但是最后一个物品能选很多个,因此我们的需要分很多情况:
i. 选 0 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j.此时最少的硬币个数为 dp[i - 1][j];
ii. 选 1 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j - coins[i].因为挑选了一个 i 硬币,此时最少的硬币个数为 dp[i - 1][j - coins[i]] + 1;
iii. 选 2 个第 i 个硬币:此时相当于就是去前 i - 1 个硬币中挑选,总和正好等于 j - 2 * coins[i].因为挑选了两个 i 硬币,此时最少的硬币个数为 dp[i - 1][j - 2 * coins[i]] + 2;
iv. ......
结合我们在完全背包里面的优化思路,我们最终得到的状态转移方程为:
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]] + 1.
这里教给大家一个技巧,就是相当于把第二种情况 dp[i - 1][j - coins[i]] + 1 里面的 i - 1 变成 i 即可.
3.初始化:
初始化第一行即可.
第一行表示没有物品,没有物品正好能凑能和为 0 的情况.因此 dp[0][0] = 1,其余位置都是 0 种情况.
4.填表顺序:
根据状态转移方程,我们仅需从上往下填表即可.
5.返回值:
根据状态表示,返回 dp[n][V].






核心代码
cpp
class Solution {
public:
int change(int amount, vector<int>& coins) {
//题目保证最终答案在 32 位有符号整数范围内
//这里设置一个上限,防止中间状态计算时数值过大导致溢出
const long long LIMIT = INT_MAX;
//dp[i] 表示凑成金额 i 的组合数
//使用 long long 是为了尽量避免 int 溢出
vector<long long> dp(amount + 1, 0);
//凑成金额 0 的方法只有 1 种:
//什么硬币都不选
dp[0] = 1;
//外层遍历硬币
//这样可以保证统计的是"组合数",而不是"排列数"
//例如 1 + 2 和 2 + 1 只会被算作同一种组合
for (int coin : coins) {
//内层正序遍历金额
//因为每种硬币可以使用无限次
//所以 sum 从 coin 开始递增
for (int sum = coin; sum <= amount; sum++) {
//状态转移:
//当前金额 sum 的组合数
//加上使用一个 coin 后,凑成 sum - coin 的组合数
dp[sum] += dp[sum - coin];
//防止中间状态过大导致 long long 溢出
//因为最终答案只需要返回 int 范围内的结果
//所以超过 INT_MAX 后可以进行截断
if (dp[sum] > LIMIT) {
dp[sum] = LIMIT;
}
}
}
//dp[amount] 就是凑成目标金额 amount 的组合数
return dp[amount];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
using namespace std;
class Solution {
public:
int change(int amount, vector<int>& coins) {
// 题目保证最终答案在 32 位有符号整数范围内
// 这里设置一个上限,防止中间状态计算时数值过大导致溢出
const long long LIMIT = INT_MAX;
// dp[i] 表示凑成金额 i 的组合数
vector<long long> dp(amount + 1, 0);
// 凑成金额 0 的方法只有 1 种:什么硬币都不选
dp[0] = 1;
// 外层遍历硬币,保证统计的是组合数,不是排列数
for (int coin : coins) {
// 正序遍历金额,因为每种硬币可以无限使用
for (int sum = coin; sum <= amount; sum++) {
dp[sum] += dp[sum - coin];
// 防止中间状态过大导致溢出
if (dp[sum] > LIMIT) {
dp[sum] = LIMIT;
}
}
}
return dp[amount];
}
};
void test(int amount, vector<int> coins, int expected) {
Solution solution;
int result = solution.change(amount, coins);
cout << "amount = " << amount << ", coins = [";
for (int i = 0; i < coins.size(); i++) {
cout << coins[i];
if (i != coins.size() - 1) {
cout << ", ";
}
}
cout << "]" << endl;
cout << "输出结果: " << result << endl;
cout << "期望结果: " << expected << endl;
if (result == expected) {
cout << "测试通过" << endl;
} else {
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main() {
// 示例 1
// 组合方式:
// 5
// 2 + 2 + 1
// 2 + 1 + 1 + 1
// 1 + 1 + 1 + 1 + 1
test(5, {1, 2, 5}, 4);
// 示例 2
// 无法用硬币 2 凑成金额 3
test(3, {2}, 0);
// 示例 3
// 只有一种方式:10
test(10, {10}, 1);
// 边界测试:amount = 0
// 什么都不选,也是一种方案
test(0, {1, 2, 5}, 1);
// 普通测试
test(4, {1, 2, 3}, 4);
// 普通测试
// 组合为:
// 10
// 5 + 5
// 5 + 2 + 2 + 1
// 5 + 2 + 1 + 1 + 1
// 5 + 1 + 1 + 1 + 1 + 1
// 2 + 2 + 2 + 2 + 2
// ...
test(10, {1, 2, 5}, 10);
return 0;
}

5.完全平方数(OJ题)

算法思路:解法(动态规划):
这里给出一个用拆分出相同子问题的方式,定义一个状态表示.(用完全背包方式的解法就仿照之前的分析模式就好啦~~)
为了叙述方便,把和为 n 的完全平方数的最少数量简称为最小数量.
对于 12 这个数,我们分析一下如何求它的最小数量:
- 如果
12本身就是完全平方数,我们不用算了,直接返回1; - 但是
12不是完全平方数,我们试着把问题分解一下:- 情况一:拆出来一个
1,然后看看11的最小数量,记为x1; - 情况二:拆出来一个
4,然后看看8的最小数量,记为x2;(为什么拆出来4,而不拆出来2呢?) - 情况三:拆出来一个
9......
- 情况一:拆出来一个
其中,我们接下来求 11、8 的时候,其实又回到了原来的问题上.
因此,我们可以尝试用 dp 的策略,将 1、2、3、4、6 等等这些数的最小数量依次保存起来.再求较大的 n 的时候,直接查表,然后找出最小数量.
1.状态表示:
dp[i] 表示:和为 i 的完全平方数的最少数量.
2.状态转移方程:
对于 dp[i],根据思路那里的分析我们知道,可以根据小于等于 i 的所有完全平方数 x 进行划分:
-
x = 1时,最小数量为:1 + dp[i - 1]; -
x = 4时,最小数量为:1 + dp[i - 4]......
一直枚举到 x <= i 为止.
为了方便枚举完全平方数,我们采用下面的策略:for(int j = 1; j * j <= i; j++)
综上所述,状态转移方程为:
`dp[i] = min(dp[i], dp[i - j * j] + 1)`
3.初始化:
当 n = 0 的时候,没法拆分,结果为 0;
当 n = 1 的时候,显然为 1.
4.填表顺序:
从左往右.
5.返回值:
根据题意,返回 dp[n] 的值.






核心代码
cpp
//题目:给定整数n,求和为n的完全平方数的最少数量
//本质:完全背包问题(完全平方数可无限选,求最小物品数)
class Solution
{
public:
//函数功能:返回和为n的最少完全平方数的数量
int numSquares(int n)
{
//dp数组定义:dp[i] 表示 组成数字 i 的最少完全平方数的个数
vector<int> dp(n + 1);
//初始化:数字1只能由1个1²组成,最少数量为1
dp[1] = 1;
//外层循环:从2开始遍历到目标数n,依次计算每个数的最小平方数个数
for(int i = 2; i <= n; i++)
{
//初始最坏情况:i 由 i 个 1² 组成,数量为 1 + dp[i-1]
dp[i] = 1 + dp[i - 1];
//内层循环:枚举所有小于等于i的完全平方数 j²
for(int j = 2; j * j <= i; j++)
//状态转移:选当前平方数j²,更新最小值
//dp[i - j*j] + 1:组成i-j²的最小数量 + 当前1个平方数j²
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
//返回最终结果:组成n的最少完全平方数数量
return dp[n];
}
};
完整测试代码
cpp
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 题目:给定整数 n,求和为 n 的完全平方数的最少数量
// 本质:完全背包问题
// 完全平方数可无限选,求最小物品数
class Solution
{
public:
// 函数功能:返回和为 n 的最少完全平方数的数量
int numSquares(int n)
{
// dp[i] 表示组成数字 i 的最少完全平方数的个数
vector<int> dp(n + 1, 0);
// 特殊情况:n = 0
if (n == 0)
{
return 0;
}
// 初始化:
// 数字 1 只能由 1 个 1² 组成
dp[1] = 1;
// 从 2 开始遍历到目标数 n
for (int i = 2; i <= n; i++)
{
// 最坏情况:i 由 i 个 1² 组成
dp[i] = 1 + dp[i - 1];
// 枚举所有小于等于 i 的完全平方数 j²
for (int j = 2; j * j <= i; j++)
{
// 选择当前平方数 j²
dp[i] = min(dp[i], dp[i - j * j] + 1);
}
}
// 返回最终结果
return dp[n];
}
};
void runTest(Solution& solution, int n, int expected)
{
int result = solution.numSquares(n);
cout << "n = " << n << endl;
cout << "最少完全平方数数量 = " << result << endl;
cout << "期望结果 = " << expected << endl;
if (result == expected)
{
cout << "测试通过" << endl;
}
else
{
cout << "测试失败" << endl;
}
cout << "------------------------" << endl;
}
int main()
{
Solution solution;
// 测试用例1:经典示例
// 12 = 4 + 4 + 4
runTest(solution, 12, 3);
// 测试用例2:经典示例
// 13 = 4 + 9
runTest(solution, 13, 2);
// 测试用例3:n = 1
// 1 = 1
runTest(solution, 1, 1);
// 测试用例4:n 本身是完全平方数
// 16 = 16
runTest(solution, 16, 1);
// 测试用例5:n = 2
// 2 = 1 + 1
runTest(solution, 2, 2);
// 测试用例6:n = 3
// 3 = 1 + 1 + 1
runTest(solution, 3, 3);
// 测试用例7:n = 4
// 4 = 4
runTest(solution, 4, 1);
// 测试用例8:n = 5
// 5 = 4 + 1
runTest(solution, 5, 2);
// 测试用例9:n = 27
// 27 = 9 + 9 + 9
runTest(solution, 27, 3);
// 测试用例10:n = 43
// 43 = 25 + 9 + 9
runTest(solution, 43, 3);
// 测试用例11:n = 100
// 100 = 100
runTest(solution, 100, 1);
// 测试用例12:n = 0
// 如果自己测试允许 n = 0,则答案为 0
runTest(solution, 0, 0);
return 0;
}


🚀真正的勇者不是流泪的人,而是含泪奔跑的人!
敬请期待下一篇文章内容【动态规划算法】(一文讲透二维费用的背包问题)
每日心灵鸡汤:就做你自己吧,没有很好也没关系
最近很喜欢的一句话:每个人都有阴暗面,没有什么好奇怪的,对不同的人有不同的态度,喜欢就交流,不喜欢就陌路,不用理解,各有各的路.每当有人说我人好的时候,我常常就会想,其实并不是我好,而是我付出的善意真诚好.他们也会给予我同样的善意真心,不是我人好,而是他们也都很好.性格因人而异,我不主张盲目的善良,也不接受道德绑架,要有不伤害人的教养,也不缺保护自己的能力.在一百个人口中有一百个样子,所以为什么要在乎别人口中的自己呢?学会与很多声音共处,但不会被左右.尊重所有声音,但只成为自己.
