1. 题目背景与核心需求
P5020 [NOIP 2018 提高组] 货币系统是一道经典的动态规划问题,考察选手对完全背包问题的理解和应用能力。题目给定一个货币系统(n种面值的货币),要求我们找到一个等价的货币系统m,使得m的系统能够表示的所有金额与原始系统完全相同,且m尽可能小(即货币种类数最少)。
这个问题的实际意义在于货币系统的优化设计。比如现实中我们使用的硬币有1元、5元、10元等面值,这样的设计就是为了用最少数量的硬币种类满足日常交易需求。题目正是对这一现实问题的抽象和建模。
2. 问题分析与解题思路
2.1 问题转化与数学建模
首先我们需要明确几个关键概念:
- 货币系统的"表示能力":一个货币系统能表示的所有非负整数金额的集合
- 货币系统的"等价":两个货币系统能表示的金额集合完全相同
- 货币系统的"最小化":在所有等价的货币系统中,选择货币种类最少的那个
通过分析可以发现,题目要求的就是找出原始货币系统的"基"——即那些不能被系统中其他货币线性组合表示的面值。这些面值必须保留,而其他面值则可以去除。
2.2 算法选择与优化
这个问题可以转化为一个完全背包问题的变种。具体思路是:
- 对原始货币面值进行排序
- 从小到大检查每个面值是否能被比它小的面值组合表示
- 如果不能被表示,则必须保留该面值;否则可以去除
这个思路的正确性基于以下观察:
- 大面值的货币如果能由小面值的组合表示,那么它对于系统的表示能力没有贡献
- 我们需要保留的是那些"基础"面值,它们不能被其他面值组合表示
3. 代码实现与详细解析
3.1 基础实现代码
cpp复制#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int MAXN = 105;
const int MAXA = 25005;
int a[MAXN];
bool dp[MAXA];
int solve(int n) {
sort(a, a + n);
memset(dp, 0, sizeof(dp));
dp[0] = true;
int res = 0;
for (int i = 0; i < n; ++i) {
if (!dp[a[i]]) {
res++;
for (int j = a[i]; j <= a[n-1]; ++j) {
if (dp[j - a[i]]) {
dp[j] = true;
}
}
}
}
return res;
}
int main() {
int T;
cin >> T;
while (T--) {
int n;
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
cout << solve(n) << endl;
}
return 0;
}
3.2 代码关键点解析
-
排序处理:首先对货币面值进行排序,这是为了确保我们总是先处理小面值,再处理大面值,满足动态规划的无后效性。
-
动态规划数组初始化:
dp数组表示某个金额是否能被表示,初始时只有0元可以被表示(dp[0] = true)。 -
核心逻辑:对于每个面值a[i],如果它还不能被表示(
!dp[a[i]]),则必须保留,并更新所有能由它参与组合表示的金额。 -
优化处理:内层循环只需要更新到最大面值即可,因为更大的金额对本题没有意义。
4. 算法优化与性能分析
4.1 时间复杂度分析
该算法的时间复杂度主要由两部分组成:
- 排序:O(n log n)
- 动态规划处理:O(n × max_a)
其中max_a是货币的最大面值。由于题目中max_a ≤ 25000,n ≤ 100,这个复杂度是完全可接受的。
4.2 空间优化技巧
我们可以对空间进行进一步优化:
- 使用bitset代替bool数组,可以节省空间并可能提高速度
- 动态规划的范围可以限制在最大面值内
优化后的代码示例:
cpp复制#include <bitset>
// ...
bitset<MAXA> dp;
dp[0] = 1;
// ...
if (!dp[a[i]]) {
res++;
dp |= dp << a[i];
}
这种优化利用了bitset的高效位操作,可以显著提升性能。
5. 常见问题与调试技巧
5.1 典型错误与修正
-
未排序导致错误:
- 症状:得到的结果比正确答案大
- 原因:处理顺序错误导致某些本应被表示的面值未被正确标记
- 解决:确保在处理前对数组进行排序
-
数组越界问题:
- 症状:程序运行时崩溃或结果异常
- 原因:动态规划数组大小不足
- 解决:确保dp数组大小足够(至少为最大面值+1)
-
多测试用例未重置:
- 症状:第二个测试用例开始结果错误
- 原因:未重置dp数组
- 解决:在每个测试用例开始前重置dp数组
5.2 调试与验证方法
-
小数据测试:
- 构造简单测试用例,如{3,4}应该输出2
- {2,4,8}应该输出1(只需要保留2)
-
边界测试:
- 所有面值互质的情况(应全部保留)
- 所有面值都是某个数的倍数的情况(可能只需保留最小的)
-
性能测试:
- 构造n=100,max_a=25000的数据测试运行时间
- 确保在题目限制内能快速完成
6. 算法扩展与变种思考
6.1 相关问题变种
-
最大不可表示金额:给定货币系统,求最大的不能被表示的正整数金额(类似硬币问题的Frobenius Number)
-
最少货币数目:给定金额和货币系统,求组成该金额的最少货币数目
-
组合数目统计:给定金额,统计有多少种不同的货币组合方式
6.2 实际应用场景
- 货币系统设计:现实中的硬币和纸币面值设计
- 资源分配优化:用最少数量的基本单位组合表示各种需求
- 编码理论:构建高效的数值表示系统
7. 竞赛技巧与注意事项
-
输入输出优化:
- 对于C++,使用ios::sync_with_stdio(false)加速输入输出
- 对于大量数据,考虑使用更快的输入方法
-
预处理技巧:
- 如果问题中某些参数固定,可以预先计算并存储结果
- 利用数学性质减少不必要的计算
-
代码模板准备:
- 准备常用的动态规划模板
- 准备好排序、输入输出等常用代码片段
在实际竞赛中,这类问题的解决时间通常控制在30-45分钟内比较理想,包括理解题意、设计算法、编写代码和测试调试。平时练习时,建议多思考不同的解题思路,比较它们的优劣,培养快速选择最优解法的能力。