一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第5天,点击查看活动详情。
大厂高频算法面试题:《货币问题系列》,通过货币问题四连击,您将学到如何从暴力递归改动态规划,而不是一上来就硬憋dp,一切从尝试入手,从暴力递归开始,通过暴力递归填dp格子,进一步看有没有优化空间,需不需要斜率优化。
一、货币问题I
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
复制代码
示例 2:
输入:coins = [2], amount = 3
输出:-1
复制代码
示例 3:
输入:coins = [1], amount = 0
输出:0
复制代码
提示:
- 1 <= coins.length <= 12
- 1 <= coins[i] <= 2^31 - 1
- 0 <= amount <= 10^4
1、分析
从左往右的尝试模型,每种货币都尝试,用0张、用1张、用2张...,最后求min就是组成aim(amount)的最少货币数
2、实现
2.1、暴力递归
暴力实现测试会超时,但思路是对的,需要进一步优化。
public static int coinChange(int[] coins, int amount) {
int ans = process(coins, 0, amount);
return ans == Integer.MAX_VALUE ? -1 : ans;
}
// arr[index...]面值,每种面值张数自由选择,
// 搞出rest正好这么多钱,返回最小张数
// 拿Integer.MAX_VALUE标记怎么都搞定不了
private static int process(int[] arr, int index, int rest) {
// 当前index来到数组越界位置,如果剩余rest等于0,说明本次货币不需要用,前边已经搞定了
if (index == arr.length) { // base case
return rest == 0 ? 0 : Integer.MAX_VALUE;
}
int ans = Integer.MAX_VALUE;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
int next = process(arr, index + 1, rest - zhang * arr[index]);
if (next != Integer.MAX_VALUE) {
ans = Math.min(ans, zhang + next);
}
}
return ans;
}
复制代码
2.2、dp(填表)
由暴力递归改动态规划,通过递归分析得出index位置依赖于index+1,所以从下往上推,填格子
public static int coinChange(int[] coins, int amount) {
int ans = dp(coins, amount);
return ans == Integer.MAX_VALUE ? -1 : ans;
}
public static int dp(int[] arr, int aim) {
if (aim == 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 0;
for (int j = 1; j <= aim; j++) {
dp[N][j] = Integer.MAX_VALUE;
}
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
int ans = Integer.MAX_VALUE;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
int next = dp[index + 1][rest - zhang * arr[index]];
if (next != Integer.MAX_VALUE) {
ans = Math.min(ans, zhang + next);
}
}
dp[index][rest] = ans;
}
}
return dp[0][aim];
}
复制代码
2.3、dp(斜率优化)
发现有枚举行为的动态规划,可以进一步优化,把最内层的for循环去掉,观察临近位置,看哪些位置能替换枚举行为,变成几个有限位置做决策,在dp中可以认为这种操作为斜率优化。
public static int coinChange(int[] coins, int amount) {
int ans = dp(coins, amount);
return ans == Integer.MAX_VALUE ? -1 : ans;
}
public static int dp(int[] arr, int aim) {
if (aim == 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 0;
for (int j = 1; j <= aim; j++) {
dp[N][j] = Integer.MAX_VALUE;
}
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - arr[index] >= 0
&& dp[index][rest - arr[index]] != Integer.MAX_VALUE) {
dp[index][rest] = Math.min(dp[index][rest], dp[index][rest - arr[index]] + 1);
}
}
}
return dp[0][aim];
}
复制代码
二、货币问题II
给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 1:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
复制代码
示例 2:
输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。
复制代码
示例 3:
输入:amount = 10, coins = [10]
输出:1
复制代码
提示:
- 1 <= coins.length <= 300
- 1 <= coins[i] <= 5000
- coins 中的所有值 互不相同
- 0 <= amount <= 5000
1、分析
从左往右的尝试模型:每种货币用0张,用1张,用2张......
2、实现
2.1、暴力递归
暴力实现测试会超时,但思路是对的,需要进一步优化。
public int change(int amount, int[] coins) {
return coinsWay(coins, amount);
}
public static int coinsWay(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process(arr, 0, aim);
}
// arr[index....] 所有的面值,每一个面值都可以任意选择张数,组成正好rest这么多钱,方法数多少?
private static int process(int[] arr, int index, int rest) {
if (index == arr.length) { // base case 没钱了
return rest == 0 ? 1 : 0;
}
int ways = 0;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
ways += process(arr, index + 1, rest - (zhang * arr[index]));
}
return ways;
}
复制代码
2.2、dp(填表)
由暴力递归改动态规划,通过递归分析得出index位置依赖于index+1,所以从下往上推,填格子
public int change(int amount, int[] coins) {
return dp(coins, amount);
}
public static int dp(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
int ways = 0;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
ways += dp[index + 1][rest - (zhang * arr[index])];
}
dp[index][rest] = ways;
}
}
return dp[0][aim];
}
复制代码
2.3、dp(斜率优化)
发现有枚举行为(最内层for循环),可进一步通过观察邻近位置进行斜率优化
public int change(int amount, int[] coins) {
return dp(coins, amount);
}
public static int dp(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - arr[index] >= 0) {
dp[index][rest] += dp[index][rest - arr[index]];
}
}
}
return dp[0][aim];
}
复制代码
三、货币问题III
arr是货币数组,其中的值都是正数。再给定一个正数aim。
每个值都认为是一张货币,
认为值相同的货币没有任何不同,
返回组成aim的方法数
例如:arr = {1,2,1,1,2,1,2},aim = 4
方法:1+1+1+1、1+1+2、2+2
一共就3种方法,所以返回3
1、分析
货币问题II中的货币是无限张,此题的货币张数是有限的,这是关键点
统计不同面值的货币出现的次数
2、实现
2.1、暴力递归
public static class Info {
public int[] coins; // 货币数组
public int[] zhangs; // 张数数组
public Info(int[] c, int[] z) {
coins = c;
zhangs = z;
}
}
public static int coinsWay(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
Info info = getInfo(arr); // 统计不同货币的张数信息
return process(info.coins, info.zhangs, 0, aim);
}
// coins 面值数组,正数且去重
// zhangs 每种面值对应的张数
private static int process(int[] coins, int[] zhangs, int index, int rest) {
if (index == coins.length) {
return rest == 0 ? 1 : 0;
}
int ways = 0;
for (int zhang = 0; zhang * coins[index] <= rest && zhang <= zhangs[index]; zhang++) {
ways += process(coins, zhangs, index + 1, rest - (zhang * coins[index]));
}
return ways;
}
private static Info getInfo(int[] arr) {
// key:货币面值,value:货币张数
HashMap<Integer, Integer> counts = new HashMap<>();
for (int value : arr) {
if (!counts.containsKey(value)) {
counts.put(value, 1);
} else {
counts.put(value, counts.get(value) + 1);
}
}
int N = counts.size();
int[] coins = new int[N];
int[] zhangs = new int[N];
int index = 0;
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
coins[index] = entry.getKey();
zhangs[index++] = entry.getValue();
}
return new Info(coins, zhangs);
}
复制代码
2.2、dp(填表)
根据暴力递归改动态规划
public static int dp(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
Info info = getInfo(arr);
int[] coins = info.coins;
int[] zhangs = info.zhangs;
int N = coins.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
int ways = 0;
for (int zhang = 0; zhang * coins[index] <= rest && zhang <= zhangs[index]; zhang++) {
ways += dp[index + 1][rest - (zhang * coins[index])];
}
dp[index][rest] = ways;
}
}
return dp[0][aim];
}
复制代码
2.3、dp(斜率优化)
发现有内层for循环,画图找邻居位置,得出依赖关系,进一步斜率优化,省掉内层for循环
public static int dp(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
Info info = getInfo(arr);
int[] coins = info.coins;
int[] zhangs = info.zhangs;
int N = coins.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest];
if (rest - coins[index] >= 0) {
dp[index][rest] += dp[index][rest - coins[index]];
}
if (rest - coins[index] * (zhangs[index] + 1) >= 0) {
dp[index][rest] -= dp[index + 1][rest - coins[index] * (zhangs[index] + 1)];
}
}
}
return dp[0][aim];
}
复制代码
四、货币问题IV
arr是货币数组,其中的值都是正数。再给定一个正数aim。
每个值都认为是一张货币,
即便是值相同的货币也认为每一张都是不同的,
返回组成aim的方法数
例如:arr = {1,1,1},aim = 2
第0个和第1个能组成2,第1个和第2个能组成2,第0个和第2个能组成2
一共就3种方法,所以返回3
1、分析
货币问题III是值相同的货币没有任何不同,而此题值相同的货币认为是不同的,这是这道题的关键。
2、实现
2.1、暴力递归
先用暴力解求出,再进一步优化
从左往右的尝试模型:每张货币要不要问题
public static int coinWays(int[] arr, int aim) {
return process(arr, 0, aim);
}
// arr[index....] 组成正好rest这么多的钱,有几种方法
private static int process(int[] arr, int index, int rest) {
if (rest < 0) {
return 0;
}
if (index == arr.length) { // 没钱了!
return rest == 0 ? 1 : 0;
}
// 可能性一:要当前货币
// 可能性二:不要当前货币
return process(arr, index + 1, rest) + process(arr, index + 1, rest - arr[index]);
}
复制代码
2.2、dp(填表)
根据暴力递归改动态规划,没有枚举行为,填好dp格子就是最优解
填表:index位置依赖index+1位置,所以从下往上填,从左往右填
public static int dp(int[] arr, int aim) {
if (aim == 0) {
return 1;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
dp[N][0] = 1;
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
dp[index][rest] = dp[index + 1][rest] + (rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : 0);
}
}
return dp[0][aim];
}
复制代码