引言
动态规划和贪心算法可以说是数据结构中比较难的一部分了,很多人一直搞不清如何使用,本文将用一道题目(力扣上第322题)来详细解释动态规划和贪心算法及他们各自的优劣。
动态规划
来看一道力扣上的第322题,零钱兑换问题,许多大佬可能一眼就看出来了这是一道经典的完全背包问题,这道题目最直接的可能就是用动态规划来解了。
什么是动态规划
动态规划(Dynamic Programming, DP)是一种自底向上的方法,适用于解决那些具有重叠子问题和最优子结构性质的问题。它通过将复杂的问题分解成更简单的子问题,并保存每个子问题的解以避免重复计算,从而提高解决问题的效率。
动态规划的基本思想与特性
动态规划的核心在于两个关键特性:
- 最优子结构:一个问题的最优解可以由其子问题的最优解构建而成。
- 重叠子问题:在求解过程中,相同的子问题会被多次遇到和求解。
动态规划通过存储这些子问题的结果来避免不必要的重复计算,从而大大提高了解决问题的效率。
解题思路
-
定义状态:定义一个数组
f,其中f[i]表示达到金额i所需的最小硬币数。初始化时,f[0] = 0,因为当金额为0时,需要的硬币数量也为0;对于其他所有可能的金额,我们先假设需要无穷多枚硬币,即Infinity。 -
状态转移方程:对于每一个金额
i,尝试用每一个硬币去更新当前金额的最小硬币数。具体来说,如果选择了一枚面额为coins[j]的硬币,那么我们可以基于之前已经计算好的f[i - coins[j]]来更新当前金额i所需的最小硬币数。状态转移方程如下:f[i] = min(f[i], f[i - coins[j]] + 1)这里
j是硬币数组的索引,coins[j]是第j个硬币的面值。 -
边界条件与结果输出:如果最终金额对应的解仍然是无穷大,说明没有符合条件的硬币组合,应该返回-1。否则,返回
f[amount],即达到目标金额所需的最小硬币数。
代码实现
以下是使用JavaScript编写的动态规划解决方案:
const coinChange = function(coins, amount) {
// 用于保存每个目标总额对应的最小硬币个数
const f = [] // dp 数组 初始化
// 提前定义已知情况
f[0] = 0
// 遍历 [1, amount] 这个区间的硬币总额
for(let i=1;i<=amount;i++) {
// 求的是最小值,因此我们预设为无穷大,确保它一定会被更小的数更新
f[i] = Infinity
// 循环遍历每个可用硬币的面额
for(let j=0;j<coins.length;j++) {
// 若硬币面额小于目标总额,则问题成立
if(i-coins[j]>=0) {
// 状态转移方程
f[i] = Math.min(f[i],f[i-coins[j]]+1)
}
}
}
// 若目标总额对应的解为无穷大,则意味着没有一个符合条件的硬币总数来更新它,本题无解,返回-1
if(f[amount]===Infinity) {
return -1
}
// 若有解,直接返回解的内容
return f[amount]
};
// 示例调用
console.log(coinChange([1, 2, 5], 11)); // 输出: 3 (11 = 5 + 5 + 1)
console.log(coinChange([2], 3)); // 输出: -1 (无法凑成金额3)
这段代码首先初始化了一个数组
f,用来记录到达每个金额所需的最小硬币数。然后,它遍历所有可能的金额值,并检查每一个可用的硬币是否能够更新到达该金额所需最小硬币数。最后,根据f[amount]的值决定返回结果还是-1。
贪心算法
什么是贪心算法
贪心算法(Greedy Algorithm)是一种用于解决优化问题的策略,它在每一步都选择当前来看最优的选择,期望通过一系列局部最优解来达到全局最优解。这种方法简单且直观,适用于那些具有特定结构的问题。
贪心算法的基本思想与特性
贪心算法的核心在于每一步都做出当前条件下最好的选择,而不考虑未来的影响。对于某些类型的优化问题,这种方法能够快速得到满意的结果。然而,它的局限性在于,当面临的问题具有复杂的依赖关系时,贪心算法可能无法保证最终结果是最优的。
解题思路
1.初始化: 假设coins数组是有序的(升序排列),并且定义一个计数器count来记录使用的硬币总数。
2.寻找最大面值硬币: 从coins数组中最大的硬币开始尝试减少总额。如果当前最大面值的硬币不能使用(即大于剩余金额),则尝试下一个较小面值的硬币。
3.更新总额: 每次成功使用一枚硬币后,更新剩余金额,并增加计数器count。
4.终止条件: 当总额为0时,表示已经找到了一个解;否则,如果没有合适的硬币可用且总额不为0,则返回-1表示无解。
代码实现
function coinChangeGreedy(coins, amt) {
// 假设coins 是有序的 升序
let i = coins.length - 1;
let count = 0;
while (amt > 0) { // 还要找零
while (i >= 0 && coins[i] > amt) {
i--; // 如果当前最大面值大于剩余金额,尝试较小面值
}
if (i < 0) break; // 如果没有适合的硬币则提前退出
amt -= coins[i];
count++;
}
return amt === 0 ? count : -1;
}
console.log(coinChangeGreedy([1, 5, 10, 20, 50, 100], 131)); // 输出: 4 (100 + 20 + 10 + 1)
console.log(coinChangeGreedy([1, 20, 50], 60)); // 输出: 11 (50+1*10)
这段代码首先从最大的硬币开始尝试减少总额。如果当前最大面值的硬币不能使用(即大于剩余金额),则尝试下一个较小面值的硬币。这个过程持续进行,直到总额为零或者没有合适的硬币可用。
通过上面这段代码,有人可能会问第二个例子这不是最优解啊,直接三个20不就好了吗,我想说你说的对,第二个例子用动态规划可能会好一点,请继续往下看。
贪心算法VS动态规划
概念
-
动态规划:
- 是一种将复杂问题分解成更简单子问题的方法,并保存每个子问题的解以避免重复计算。
- 它确保了能够找到全局最优解,即使问题存在复杂的依赖关系。
-
贪心算法:
- 核心思想是在每一步都选择当前看起来最优的选择,期望通过一系列局部最优解来达到全局最优解。
- 优点在于其简单性和高效性,尤其适合那些具有特定结构的问题。
性能对比
时间复杂度
-
贪心算法:通常具有较低的时间复杂度O(n),其中n是硬币种类的数量。这是因为它只遍历一次硬币数组,并且每次尽可能多地使用最大面值的硬币来减少总额。
-
动态规划:最坏情况下的时间复杂度为O(n * amount),其中n是硬币种类的数量,amount是目标金额。这反映了它对所有可能选择的全面考虑。
空间复杂度
-
贪心算法:几乎不需要额外空间,除了用于计数的几个变量外。
-
动态规划:需要额外的空间来存储状态转移表,增加了内存消耗。
适用场景与局限性
局部最优与全局最优
-
贪心算法:它的局限性在于,局部最优不一定等于全局最优。对于某些不满足特定条件的问题,贪心算法可能会给出错误的答案。
-
动态规划:能够处理更广泛的问题类型并确保找到全局最优解。无论硬币面值如何组合,它总能找到最少硬币数量的解。
特定条件下的表现
-
贪心算法:当硬币面值是按照常规货币系统设计时(如
[1, 5, 10, 20, 50, 100]),贪心算法可以快速找到正确的答案。 -
动态规划:对于那些不适合贪心算法的情况,例如硬币面值为
[1, 20, 50]和目标金额60,动态规划能够提供正确的解(两枚20面值的硬币),而贪心算法可能会失败。
小结
在本文中,我们探讨了动态规划和贪心算法在解决硬币找零问题中的应用及优劣。贪心算法简单高效,适用于特定结构的问题,但局部最优解不一定能保证全局最优解,尤其在复杂依赖关系或非标准硬币面值组合时可能失效。动态规划则能处理更广泛的问题类型,确保找到全局最优解,但其时间和空间复杂度较高。选择哪种算法取决于具体问题结构;对于不适合贪心算法的情况,,尽管动态规划计算成本更高,但不可否认它是更好的选择。希望大家能理解并灵活运用这两种算法,从而更好地应对各种优化挑战。