【例9.17】货币框架(信息学奥赛一本通- P1273)
【题目描述】
给你一个n种面值的货币系统,求组成面值为m的货币有多少种方案。
【输入】
第一行为n和m;
下面n行为具体的面值。
【输出】
一行,方案数。
【输入样例】
3 10 //3种面值组成面值为10的方案
1 //面值1
2 //面值2
5 //面值5
【输出样例】
10 //有10种方案
【提示】
全部数据
n≤20,m≤4000。
解题思路
1. 状态定义
集合:选择货币的方案
限制:选择哪些种类面值的货币,凑多少钱
属性:货币面值加和
条件:等于m
统计量:方案数
状态定义:dp[i][j]表示在前i种货币中选择货币凑j元钱的方案数。
初始状态:前i种货币中选择货币凑0元的方案数为1(不选任何货币也算一种方案),即dp[i][0] = 1。
2. 状态转移方程
记第i种货币的面值为a[i]
集合:在前i种货币中选择货币凑j元钱的方案。
分割集合:是否选择第i种面值的货币
子集1:如果不选择第i种面值的货币,那么在前i种货币中选择货币凑j元的方案数,为在前i-1种货币中选择货币凑j元的方案数,即dp[i-1][j]
子集2:在要凑的钱数j大于等于第i种货币的面值a[i]的情况下,如果选择第i种货币,下一次还可以选择第i种货币。那么还需要在前i种货币中选择货币凑j-a[i]元。在前i种货币中选择货币凑j元的方案数,为在前i种货币中选择货币凑j-a[i]元的方案数,即dp[i][j-a[i]]
以上两种情况得到的方案数加和,即为在前i种货币中选择货币凑j元的方案数:
dp[i][j] = dp[i-1][j] + dp[i][j-a[i]] (当j >= a[i])
dp[i][j] = dp[i-1][j] (当j < a[i])
/*
#include
using namespace std;
long long dp[21][4001];//dp[i][j]代表有i种面值可以选时 组成面值j的方案数 这里一定要用longlong,不然方案数很快就超了
int a[21];
int main(){
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=0;i<=n;i++) dp[i][0]=1;//不管有多少种面值货币,组成面值为0的货币都只有0种方案
for(int i=1;i<=n;i++){//遍历货币系统面值
for(int j=1;j<=m;j++){//遍历要组成的货币面值
dp[i][j]=dp[i-1][j];//先继承前面的方案数
if(j>=a[i]) dp[i][j]=dp[i][j]+dp[i][j-a[i]];
}
}
cout<
using namespace std;
int n,m;
int a[21];
long long dp[4001];
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
dp[0]=1;
for(int i=1;i<=n;i++){//n轮,每轮更新数据,因为dp[i]只与dp[i-1]有关
for(int j=1;j<=m;j++){
if(j>=a[i])//要组成的面值j>=a[i]代表第i种纸币才会开始派上用场,小于时派不上用场所以方案数跟上一轮是一样的
dp[j]=dp[j]+dp[j-a[i]];
}
}
cout<
一维数组为什么这么写我们把它拆解成三个最核心的逻辑概念来理顺:
1. 这里的 dp 数组到底存的是什么?
把 dp 数组想象成一个记账本。
dp[j] 的含义是:“此时此刻,我有多少种方法凑出金额 j”。
最开始,dp[0] = 1(凑出 0 元有一种方法:什么都不拿),其他全是 0。
2. 外层循环:上帝视角(逐个解锁硬币)
for(int i=1; i<=n; i++)
这个循环的意思是:“世界在进化”。
第 1 轮(i=1): 这个世界只有第 1 种硬币(比如 1 元)。跑完内层循环后,dp 数组记录的是“只用 1 元硬币凑各个金额的方法数”。
第 2 轮(i=2): 上帝解锁了第 2 种硬币(比如 2 元)。我们在“只用 1 元”的基础上,去计算“加入了 2 元后”的新方法数。
第 n 轮: 所有硬币都解锁了,最终结果出炉。
这就是为什么代码能行:它不是一下子算出来的,而是一层层“涂”上去的。
3. 内层循环:最关键的“继承与更新”
for(int j=1; j<=m; j++) {
if(j >= a[i])
dp[j] = dp[j] + dp[j - a[i]];
}
这行代码 dp[j] = dp[j] + dp[j - a[i]] 其实包含了两层含义,我们把它拆开读:
第一层含义:dp[j] (等号右边的旧值) —— 继承
在这一轮 i 开始之前,dp[j] 里存着什么?
存着**“不使用当前硬币 a[i] 就能凑出 j 的方法数”**。
逻辑: 既然我不打算用新硬币,那以前能凑出的方法,现在依然有效,我要保留下来。
第二层含义:dp[j - a[i]] —— 使用新硬币
这表示:如果我一定要用至少一枚当前的硬币 a[i],那还剩下 j - a[i] 的金额需要凑。
那么,凑出 j - a[i] 有多少种方法,就意味着我就增加了多少种凑出 j 的新路子。
第三层含义:正序循环 (从小到大) 的魔法 —— 无限使用
这是最烧脑的地方。为什么要从 1 循环到 m?
举个例子:a[i] = 2 (2元硬币),我们算 dp[4]。
算 dp[2] 时: 我们加上了 dp[0](用了一枚 2 元)。此时 dp[2] 已经包含了“有 2 元硬币”的情况。
算 dp[4] 时: 代码是 dp[4] += dp[4-2] 即 dp[4] += dp[2]。
注意! 这里的 dp[2] 是刚刚被更新过的(里面已经用过一枚 2 元了)。
当我们把这个“新 dp[2]”加到 dp[4] 里时,实际上是在已经用了一枚 2 元的基础上,又加了一枚 2 元(共两枚)。
结论:
正序循环 (1 -> m): 当你算大金额 j 时,引用的小金额 j - a[i] 已经加过当前硬币了。这意味着你可以在一个硬币的基础上再叠一个,从而实现**“无限使用”**(完全背包)。
倒序循环 (m -> 1): 当你算大金额 j 时,引用的小金额 j - a[i] 还是上一轮(旧世界)的值。这意味着你只能用这枚硬币一次(0/1 背包)。
模拟一张图,彻底理顺
假设我们要凑 4 元。 目前 dp 数组是:[1, 0, 0, 0, 0] (下标 0~4) 第一轮:解锁 1 元硬币
j=1: dp[1] += dp[0] -> 1 (1)
j=2: dp[2] += dp[1] -> 1 (1+1)
...
结束时 dp 全是 1。含义:全用 1 元硬币,每种金额只有 1 种凑法。
第二轮:解锁 2 元硬币 (重点来了!) 此时 dp 初始状态(继承自上一轮):[1, 1, 1, 1, 1]
j=2:
dp[2] = dp[2](旧, 1+1) + dp[0](新, 直接拿个2元)
dp[2] = 1 + 1 = 2 种。
注意:现在 dp[2] 里包含了“1+1”和“2”两种情况。
j=3:
dp[3] = dp[3](旧, 1+1+1) + dp[1](新, 拿个2元+剩下的1元)
dp[3] = 1 + 1 = 2 种。
j=4:
dp[4] = dp[4](旧, 1+1+1+1) + dp[2](新)
关键点:这里的 dp[2] 是刚才更新过的 2。
这 2 种方法分别是:(1+1) 和 (2)。
我们在它们基础上再加个 2 元,变成了:(1+1 +2) 和 (2 +2)。
加上旧的 (1+1+1+1),dp[4] 变成了 3 种。
总结
这个逻辑之所以通顺,是因为它完成了一个分类讨论: 凑出金额 j 的方案总数 = 完全不用当前硬币的方案数 (继承旧值) +至少用了一枚当前硬币的方案数 (累加新值)。
你写这段代码的时候,就像在对每一个金额 j 说:“嘿,如果我以前能凑出 j,那算数;如果我能先凑出 j-a[i],再补上一张 a[i],这也算一种新方法,都加进来!”
算法分析
关键点解析
为什么需要if(j >= a[i])判断?
这个条件判断是必不可少的,原因有二:
- 防止数组越界:当
j < a[i]时,j - a[i]会变成负数,访问dp[负数]会导致未定义行为 - 逻辑正确性:只有当目标金额j大于等于当前货币面值a[i]时,才能使用该货币进行组合
复杂度分析
时间复杂度:O(n×m)
- 空间复杂度:
- 二维数组解法:O(n×m)
- 一维数组解法:O(m)(优化了空间复杂度)
算法优化思路
通过观察状态转移方程dp[i][j] = dp[i-1][j] + dp[i][j-a[i]],可以发现当前状态只与上一行和当前行的前面状态有关,因此可以使用滚动数组技术将空间复杂度从O(n×m)优化到O(m)。
这种优化在动态规划问题中很常见,特别是当状态转移只依赖于有限的前面状态时。

浙公网安备 33010602011771号