买包子
1.题目描述
小明几乎每天早晨都会在一家包子铺吃早餐。他发现这家包子铺有 N 种蒸笼,其中第 i 种蒸笼恰好能放 Ai 个包子。每种蒸笼都有非常多笼,可以认为是无限笼。
每当有顾客想买 X 个包子,卖包子的大叔就会迅速选出若干笼包子来,使得这若干笼中恰好一共有 X 个包子。比如一共有 3 种蒸笼,分别能放 3、4 和 5 个包子。当顾客想买 11 个包子时,大叔就会选 2 笼 3 个的再加 1 笼 5 个的(也可能选出 1 笼 3 个的再加 2 笼 4 个的)。
当然有时包子大叔无论如何也凑不出顾客想买的数量。比如一共有 3 种蒸笼,分别能放 4、5 和 6 个包子。而顾客想买 7 个包子时,大叔就凑不出来了。
写出一个程序要求输入数组 A[i] 来表示这 N 种蒸笼分别能放多少包子(包子的上限为 10000),并计算出一共有多少种数目是包子大叔凑不出来的。 输入: 第一行输入一个整数 N,表示蒸笼的种类数。 第二行输入 N 个整数 A【i】,表示每种蒸笼能放的包子数量。 输出:
输出一个整数,表示包子大叔凑不出来的包子数量种类。
输入输出样例 输入:
2 4 5
输出:
6
样例说明: 凑不出的数目包括:1, 2, 3, 6, 7, 11。
2.分析:

这个问题可以看作是一个经典的“钱币找零问题”的变种,即使用给定面额的钱币(对应这里的蒸笼能放的包子数)来凑出任意金额(对应这里的包子总数)。 我们可以使用一个动态规划的方法来解决这个问题。定义一个布尔数组 dp,其中 dp[i] 表示金额 i 是否能被凑出来。 初始化 dp[0] = true,因为 0 个包子可以通过不选任何蒸笼来凑出。 对于每个蒸笼能放的包子数 Ai,遍历 dp 数组,如果 dp[j] 为真(表示金额 j 能被凑出来),则将 dp[j + Ai] 也设为真(表示金额 j + Ai 也能被凑出来)。 最后,统计 dp 数组中为假的元素个数,即为凑不出来的包子数量种类。 注意:
由于包子的上限为 10000,因此 dp 数组的大小应设为 10001(因为要从 0 开始计数)。 时间复杂度为 O(N * 10000),其中 N 是蒸笼的种类数,10000 是包子的上限。由于 N 和 10000 都不大,因此这个算法是可行的。
3.解题代码
#include <iostream>
#include <vector>
using namespace std;
int main() {
int N; // 蒸笼种类数
cin >> N;
vector<int> A(N + 1); // 蒸笼容量数组,A[0] 不使用
for (int i = 1; i <= N; ++i) {
cin >> A[i]; // 读取每种蒸笼的容量
}
const int MAX_BUNS = 10000; // 假设包子数量的上限(根据题目情况调整)
vector<bool> dp(MAX_BUNS + 1, false); // dp数组,初始化为false
dp[0] = true; // 可以凑成0个包子
// 动态规划填表
for (int j = 1; j <= N; ++j) { // 遍历每种蒸笼
for (int i = A[j]; i <= MAX_BUNS; ++i) { // 从当前蒸笼的容量开始向上遍历
if (dp[i - A[j]]) { // 如果可以凑成i-A[j]个包子
dp[i] = true; // 则可以加上一个当前蒸笼凑成i个包子
}
}
}
// 统计不能凑成的包子数量
int count = 0;
for (int i = 1; i <= MAX_BUNS; ++i) {
if (!dp[i]) {
++count;
}
}
cout << count << endl; // 输出结果
return 0;
}
这段代码通过动态规划的方式解决了问题,首先初始化一个布尔数组 dp,其中 dp[i] 表示是否能凑成 i 个包子。然后,通过遍历每种蒸笼的容量,更新 dp 数组,最后统计 dp 数组中为 false 的元素数量,即为不能凑成的包子数量。
4.视频解析
解决"买包子"问题的关键要点与启发
一、核心注意事项(写代码时容易踩的坑)
1. 最大公约数判断是关键
- 最容易忽略的点:必须首先检查所有蒸笼容量的最大公约数
- 错误做法:直接进行动态规划,不考虑gcd情况
- 正确逻辑:如果gcd > 1,则只有gcd的倍数能被凑出,意味着有无穷多个数凑不出来
- 题目陷阱:样例都是gcd=1的情况,但实际测试数据可能包含gcd>1的情况
2. 动态规划的实现细节
- 状态定义:
dp[i]表示能否凑出i个包子 - 初始化:
dp[0] = True(0个包子总是可以凑出的) - 遍历顺序:
- 外层循环:遍历每种蒸笼容量
- 内层循环:从小到大遍历目标数量(完全背包问题)
- 常见错误:内层循环方向错误,或者没有正确处理边界
3. 上限值的选择
- 理论依据:当gcd=1时,存在Frobenius数,超过后所有数都能凑出
- 实用策略:取10000作为上限(题目给出的包子数量上限)
- 优化考虑:可以取
max(A) * max(A)作为更紧的上界
4. 输入输出格式
- 注意读取输入的格式(第一行N,第二行N个数)
- 输出只需要一个整数
二、完整的正确实现要点
# 关键步骤检查清单:
1. ✓ 正确计算多个数的最大公约数
2. ✓ gcd > 1 时正确处理(输出 INF 或按题目要求)
3. ✓ dp数组大小足够(至少10001)
4. ✓ dp[0] = True 正确初始化
5. ✓ 完全背包的正确遍历顺序
6. ✓ 只统计正整数(从1开始计数)
三、深层启发与思考
1. 数学思维的重要性
这个问题本质上是数论问题,而非纯算法问题:
- Frobenius硬币问题:经典的数论问题
- Bézout定理:gcd=1是存在解的充要条件
- 完全背包只是实现工具,数学性质才是解题关键
2. 边界情况的系统性思考
- 极端输入:N=1时的情况
- 重复元素:输入数组可能有重复值(虽然不影响结果)
- 大数值:接近10000的容量值
- 小数值:包含1的情况(此时所有数都能凑出)
3. 算法选择的权衡
- 为什么不用BFS/DFS?状态空间太大,会超时
- 为什么动态规划合适?状态转移清晰,时间复杂度可控
- 空间优化可能:可以使用bitset或滚动数组,但没必要
4. 问题建模的通用性
这个问题可以推广到很多场景:
- 货币系统:给定面额,能否凑出指定金额
- 资源分配:给定包装规格,能否满足需求量
- 组合数学:线性丢番图方程的非负整数解存在性
5. 测试用例设计思维
好的测试应该覆盖:
# 测试用例设计
1. gcd > 1: [2, 4, 6] → INF
2. gcd = 1: [3, 4] → 3个不可凑出(1,2,5)
3. 包含1: [1, 5] → 0个不可凑出
4. 单个元素: [7] → 无穷多不可凑出(gcd=7>1)
5. 大数值: [9999, 10000] → 需要足够大的dp数组
6. 复杂度分析意识
- 时间复杂度:O(N × MAX),其中MAX=10000
- 空间复杂度:O(MAX)
- 实际性能:对于N≤100,MAX=10000,完全可行
四、编程习惯建议
先写数学分析,再写代码
- 在纸上先分析gcd情况
- 确定算法可行性再实现
模块化思考
- gcd计算单独封装
- 动态规划逻辑清晰分离
防御性编程
- 考虑输入边界情况
- 添加必要的注释说明数学原理
验证思维
- 用手算小样例验证逻辑
- 理解为什么样例输出是6
这个问题完美体现了数学理论指导算法设计的重要性。很多看似复杂的编程问题,背后都有深刻的数学原理,理解这些原理往往能让我们写出更优雅、更正确的代码。
