面试题 08.11. 硬币
题目:
硬币。给定数量不限的硬币,币值为25分、10分、5分和1分,编写代码计算n分有几种表示法。(结果可能会很大,你需要将结果模上1000000007)
示例1:
输入: n = 5
输出:2
解释: 有两种方式可以凑成总金额:
5=5
5=1+1+1+1+1
示例2:
输入: n = 10
输出:4
解释: 有四种方式可以凑成总金额:
10=10
10=5+5
10=5+1+1+1+1+1
10=1+1+1+1+1+1+1+1+1+1
注意:
你可以假设:
0 <= n (总金额) <= 1000000
题目来源:力扣(LeetCode)
https://leetcode-cn.com/problems/coin-lcci/
解题思路:
一共有四种面值的硬币,并且每种面值的硬币数量不限。如何选取四种硬币使其总金额为 n?可以这样想,每种面值的硬币可以选,可以不选,选取的话还可以是选1个或多个,只要所用硬币的总金额不超过 n。
这样可以按照选取的硬币面值的总数划为不同的阶段,不同阶段允许选取不同面值数目的硬币,可以选取0种、1种、2种、3种、4种。种类数从小到大,按照每阶段新加入的种类,根据新硬币数目分为不同的状态(子问题),即在当前阶段,假设相对上一阶段加入的新面值硬币为10,那么当前阶段对10的选取可分:10面值的硬币一个都不选,选一个10面值的硬币,选两个10面值的硬币等等多种的状态值(子问题)。
使用动态规划:如何划分阶段,选取状态变量——如何选取每个状态变量的状态(子问题)——如何得到状态转移方程——如何得到最优递推关系式——边界条件的确定。
现在来推导最优递归公式:假设 i 为当前阶段可允许选取的硬币种类数。比如可以选1、5两种面值的硬币。j 为金额数目。
- 设数组 dp[i][j] 表示选取前 i 种类型的硬币可构成金额为 j 的所有组合数。
那么当前阶段根据第 i 种面值硬币的选取划分子问题:设第 i 种硬币的面值为 coins[i]。
- 第 i 种面值的硬币一个都不选,则可所有组合数为:dp[i-1][j]。等于 i-1 个种类阶段的种类数。
- 第 i 种面值的硬币选取一个,则可所有组合数为:dp[i-1][j-coin[i]]。相当于去掉第 i 种面值硬币,金额减小 coin[i] ,求 i-1 个种类阶段、金额为 j-coin[i] 种类数。
- 第 i 种面值的硬币选取两个,则可所有组合数为:dp[i-1][j-2*coin[i]]。
- 等等。
前面所有子问题需满足 j-2*coin[i] >=0。于是找到当前阶段的状态与上一阶段状态的关系,dp[i][j] 可由上述状态转移得到。
于是得到最优递推表达式为:
dp[i][j] = dp[i-1][j] + dp[i-1][j-coin[i]] + dp[i-1][j-2*coin[i]] + ...
。
代码:(三层循环,超时)
// 动态规划
// 找到状态,找到递推公式
// 根据示例可以猜出规律,给定一个总金额,硬币的面值种类从0到4个。先使用一种面值硬币计算组合数,后面一次增加一种新面值的硬币计算组合数。
// 于是可以设状态dp[i][j]表示由 i 种面值的硬币组合成 j 的组合数,i 表示前 i 种硬币,j 表示由前 i 种硬币构成的金额数。
// 考虑若第 i 种面值硬币没有使用,则dp[i][j]=dp[i-1][j],即前 i-1 种面值硬币构成j的所用组合数;
// 考虑若第 i 种面值硬币使用了,则dp[i][j]=dp[i-1][j-coins[i]]+dp[i-1][j-2*coins[i]]..., coins[i]为第i种硬币的面值。
// 上面两种情况相加得到最优递推公式为:
// dp[i][j] = dp[i-1][j] + dp[i-1][j-coins[i]]+dp[i-1][j-2*coins[i]]...;
// 找到边界条件:dp[0][0] = 1。默认dp[i][j]=0, dp[i][0]可由dp[0][0]得到。
public int waysToChange(int n) {
if (n == 0){
return 1;
}
// 硬币的面值数组
int[] coins = {1,5,10,25};
int c = coins.length;
int[][] dp = new int[c+1][n+1]; // 方便循环计算。
// 初始化第一个阶段的边界条件
dp[0][0] = 1;
// 每个阶段
for (int i = 1; i <=c ; i++) {
for (int j = 0; j <= n; j++) {
// 当前阶段的所有状态
for (int k = 0; k*coins[i-1] <= j; k++) {
// 递推公式
dp[i][j] = (dp[i][j]+dp[i-1][j-k*coins[i-1]])%1000000007;
}
}
}
return dp[c][n];
}
优化代码:(两层循环)
for (int k = 0; k*coins[i-1] <= j; k++) {
// 递推公式
dp[i][j] = (dp[i][j]+dp[i-1][j-k*coins[i-1]])%1000000007;
}
三层循环中的最里面这层循环可以优化掉的,这层存在是因为递推公式是这种累加形式: dp[i][j] = dp[i-1][j] + dp[i-1][j-coin[i]] + dp[i-1][j-2*coin[i]] + … 。每次相加都需要从上一阶段状态表中去找。多了一层查表的循环。
进一步考虑: 我们把上述递推写成这个形式:dp[i][j] = dp[i-1][j] + dp[i][j-coin[i]] + dp[i][j-2*coin[i]] + ...
。dp[i][j-coin[i]] + dp[i][j-2*coin[i]] + … 代替 dp[i-1][j-coin[i]] + dp[i-1][j-2*coin[i]] + … 。 如何理解呢?也就是当选取一次第 i 种面值的硬币,不从上一阶段取寻找答案,而是将该状态作为求解当前阶段值的递推过程中的一步,即求 dp[i][j-coin[i]] 可以在第二层循环求得;如果选取两次第 i 种面值的硬币,就是求 dp[i][j-2*coin[i]] 。
三层循环是从上一层的状态值参与计算,所以边界条件只需要第一层的初始条件,但将第三层循环优化后,计算上一层的状态值参与计算,还有当前阶段的状态值参与计算,所以当前阶段的初始条件也得知道。因为 j 是从0开始的,所以需要知道每个阶段的边界条件 dp[i][0] 的值,不再是通过 dp[0][0] 递推得到,而是事先就知道,可知 dp[i][0] = 1。
代码:
public int waysToChange(int n) {
if (n == 0){
return 1;
}
// 硬币的面值
int[] coins = {1,5,10,25};
int c = coins.length;
int[][] dp = new int[c+1][n+1];
// 初始化每个阶段的边界条件
for (int i = 0; i <=c; i++) {
dp[i][0] = 1;
}
for (int i = 1; i <=c ; i++) {
for (int j = 0; j <= n; j++) {
// 没有第 i 种面值硬币情况,需从上一阶段获得该情况下的组合数。
dp[i][j] = dp[i-1][j];
// 由第 i 种面值硬币情况下的组合数,但需要判断是否超过总金额。
if (j - coins[i - 1] >= 0) {
dp[i][j] = (dp[i][j] + dp[i][j - coins[i - 1]]) % 1000000007;
}
}
}
return dp[c][n];
}
再优化:(空间优化,一位动态数组)
前面使用的二维动态数组保存每一阶段每一累加金额下的最大组合数。其实,仔细思考,我们需要的是当前金额下的最多组合数,所以在当前金额下,前一阶段的组合数可以被下一阶段的组合数覆盖掉,不在乎表示的是哪一个阶段的组合数。
当金额数小于当前阶段的第 i 种硬币的面值,那么该段金额区间的组合数(上一阶段的结果)不会被当前阶段所覆盖,而覆盖的情况是从金额数大于或等于第 i 种硬币的面值的区间,从该点开始,第 i 种面值的硬币才能加入组合。所以这之后的每种金额的组合数会随着第 i 种面值的硬币加入而增加。
代码:
public int waysToChange(int n) {
if (n == 0){
return 1;
}
// 硬币的面值
int[] coins = {1,5,10,25};
int c = coins.length;
int[] dp = new int[n+1];
dp[0] = 1;
for (int i = 1; i <=c ; i++) {
// j 是从当前阶段最后第 i 种硬币的面值考虑,
for (int j = coins[i-1]; j <= n; j++) {
dp[j] = (dp[j] + dp[j - coins[i - 1]]) % 1000000007;
}
}
return dp[n];
}
参考了下面这位大佬的分析,我只是自己理解并总结笔记。https://leetcode-cn.com/problems/coin-lcci/solution/bei-bao-si-xiang-jie-jue-ling-qian-dui-huan-wen–2/