题目来源
题目描述
class Solution {
public:
int waysToChange(int n) {
}
};
相当于给定一个面值数组,其中的值都是正数且没有重复。再给定一个正数aim。每个值都认为是一种面值,且认为张数是无限的。返回组成aim的最少货币数。
题目解析(测试未通过,待研究)
为什么要对 dp[idx][rest] %= 1000000007;
暴力递归
既然有一些硬币面值,那么我们就先用一个数组,将所有硬币的面值存储起来。注意,这里有一个潜规则:硬币在数组中的顺序,最好按照大小排序,升序和降序都可以
int[] coins = {1, 5, 10, 25}; //升序排列
还有一个情况:假设n = 10,硬币的组合方式有一种是1+1+1+1+1+5,这等价于1+5+1+1+1+1。这两种其实是一种情况。从这里我们可以推测出,在使用硬币的时候,相同的硬币最好放在一起使用,也就是不要出现上面一个5,将1分割成了两部分,如下图:
那怎么做呢?应该这样做:在使用一种硬币的时候,保证一次性拿够(数量)
算法如下:
class Solution {
long process(std::vector<int> &coins, int idx, int rest){
int N = coins.size();
if(idx == N){
return rest == 0 ? 1 : 0;
}
long ways = 0;
for (int zhang = 0; zhang * coins[idx] <= rest; ++zhang) {
ways += process(coins, idx + 1, rest - zhang * coins[idx]);
}
return ways;
}
public:
int waysToChange(int aim) {
if(aim < 0){
return -1;
}
std::vector<int> coins{1, 5, 10, 25};
return (int)process(coins, 0, aim);
}
};
以上就是递归的版本,核心就在于,相对于每一种硬币来说,我可以选择不要,又或者是要1个,或者是2个……,以这样的方式,一个一个的去尝试,这非常的暴力。
暴力递归改动态规划
(1)准备一个数组。先来分析递归函数的可变参数以及变化范围
long process(std::vector<int> &coins, int idx, int rest)
- idx:0~N
- rest:0~aim
有两个变化维度,所以准备一个二维数组:
long dp[N + 1][aim + 1];
(2)返回值。分析主函数值怎么调用递归函数的
return (int)process(coins, 0, aim);
所以应该返回dp[0][aim]
(3)填表
(3.1)使用base case 初始化表
if(idx == N){
return rest == 0 ? 1 : 0;
}
- 如上:dp[N][0] = 1,dp[N][1…rest] = 0
(3.3)根据依赖填写表的其他部分
long ways = 0;
for (int zhang = 0; zhang * coins[idx] <= rest; ++zhang) {
ways += process(coins, idx + 1, rest - zhang * coins[idx]);
}
return ways;
- 可以看出dp[idx][rest] 依赖 dp[idx + 1][rest - zhang* coins[idx]]
- 所以,应该从下往上,从左往右(从右往左也可以)填写
for (int idx = N - 1; idx >= 0; --idx) {
for (int rest = 0; rest <= aim; ++rest) {
}
}
(4)综上,递归函数如下
class Solution {
public:
int waysToChange(int aim) {
if(aim < 0){
return -1;
}
std::vector<int> coins{1, 5, 10, 25};
int N = coins.size();
std::vector<std::vector<long>> dp(N + 1, std::vector<long>(aim + 1, 0));
dp[N][0] = 1;
for (int idx = N - 1; idx >= 0; --idx) {
for (int rest = 0; rest <= aim; ++rest) {
dp[idx][rest] = dp[idx + 1][rest];
if(rest - coins[idx] >= 0){
dp[idx][rest] += dp[idx][rest - coins[idx]];
}
}
}
return dp[0][aim];
}
};
继续优化(待续)
其实上面写的dp,只是较为经典的写法,本质上,这段代码还是存在一定的小问题,那就是存在枚举问题,也就是说,效率还不够高。还可以进行优化。
(这里应该有一张表格)
从上面动态规划填表中可以看出:
假设rest = 7时
-
d
p
[
2
]
[
7
]
=
d
p
[
3
]
[
7
]
+
d
p
[
3
]
[
5
]
+
d
p
[
3
]
[
3
]
+
d
p
[
3
]
[
1
]
dp[2][7] = dp[3][7] + dp[3][5] + dp[3][3] + dp[3][1]
dp[2][7]=dp[3][7]+dp[3][5]+dp[3][3]+dp[3][1]
+ dp[3][-1] -
d
p
[
2
]
[
9
]
=
d
p
[
3
]
[
9
]
+
d
p
[
3
]
[
7
]
+
d
p
[
3
]
[
5
]
+
d
p
[
3
]
[
3
]
+
d
p
[
3
]
[
1
]
dp[2][9] = dp[3][9] + dp[3][7] + dp[3][5] + dp[3][3] + dp[3][1]
dp[2][9]=dp[3][9]+dp[3][7]+dp[3][5]+dp[3][3]+dp[3][1]
+ dp[3][-1]
从而:
- d p [ 2 ] [ 9 ] = d p [ 3 ] [ 9 ] + d p [ 2 ] [ 7 ] ; dp[2][9] = dp[3][9] + dp[2][7]; dp[2][9]=dp[3][9]+dp[2][7];
推而广之:
- d p [ i d x ] [ r e s t ] = d p [ i d x + 1 ] [ r e s t ] + d p [ i d x ] [ r e s t − c o i n s [ i d x ] ] ; dp[idx][rest] = dp[idx + 1][rest] + dp[idx][rest - coins[idx]]; dp[idx][rest]=dp[idx+1][rest]+dp[idx][rest−coins[idx]];
- 我们只需要确保rest - coins[idx]存在表格中即可
class Solution {
public:
int waysToChange(int aim) {
if(aim < 0){
return -1;
}
std::vector<int> coins{1, 5, 10, 25};
int N = coins.size();
std::vector<std::vector<int>> dp(N + 1, std::vector<int>(aim + 1, 0));
dp[N][0] = 1;
for (int idx = N - 1; idx >= 0; --idx) {
for (int rest = 0; rest <= aim; ++rest) {
dp[idx][rest] = dp[idx + 1][rest];
if(rest - coins[idx] >= 0){
dp[idx][rest] += dp[idx][rest - coins[idx]];
}
}
}
return dp[0][aim];
}
};
备忘录 VS 动态规划
- 备忘录算法就是某种形态的动态规划方法
- 备忘录算法不关心到达某一个递归路径的路程,只是每次算之前先看看有没有计算过,如果没有,就去算
- 动态规划方法则是规定好每一个递归霍城的计算顺序,一次进行计算,后面的计算过程严格依赖前面的计算过程。
- 动态规划方法:表有多大,就算几次
所以如果单独一个格子计算没有枚举行为(for循环),那么它们的复杂度是一样的;但是如果有for枚举,那么需要弄出严格表结构之后继续优化