LeetCode322 零钱兑换

14 阅读4分钟

leetcode.cn/problems/co…

image.png 这是一道典型的动态规划题目,分析如下:

首先,动态规划问题的一般形式就是求最值。

求解动态规划的核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值呗。

虽然动态规划的核心思想就是穷举求最值,但是问题可以千变万化,穷举所有可行解其实并不是一件容易的事,需要你熟练掌握递归思维,只有列出正确的「状态转移方程」,才能正确地穷举。

而且,你需要判断算法问题是否具备「最优子结构」,即是否能够通过子问题的最值得到原问题的最值。

另外,动态规划问题存在「重叠子问题」,如果暴力穷举的话效率会很低,可能超时,所以需要使用「备忘录」来优化穷举过程,避免不必要的计算。

以上提到的最优子结构、状态转移方程、重叠子问题优化就是动态规划三要素。实现一般有两种方式,框架伪代码如下:

# 自顶向下递归的动态规划
def dp(状态1, 状态2, ...):
    for 选择 in 所有可能的选择:
        # 此时的状态已经因为做了选择而改变
        result = 求最值(result, dp(状态1, 状态2, ...))
    return result


# 自底向上迭代的动态规划
# 初始化 base case
dp[0][0][...] = base case
# 进行状态转移
for 状态1 in 状态1的所有取值:
    for 状态2 in 状态2的所有取值:
        for ...
            dp[状态1][状态2][...] = 求最值(选择1,选择2...)

分析

  • 是否满足最优子结构?

假设你有面值为 1, 2, 5 的硬币,你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10, 9, 6 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1, 2, 5 的硬币),求个最小值,就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的。

  • 如何列出正确的状态转移方程?

1、确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount。

2、确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」。

3、明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的返回值。就本题来说,状态只有一个,即「目标金额」;而题目要求我们计算凑出目标金额所需的最少硬币数量。所以我们可以这样定义 dp 函数:dp(n) 表示,输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量。

解法一:自顶向下的递归DP + 备忘录

func coinChange(coins []int, amount int) int {
    memo := make(map[int]int)
    return dp(coins, amount, memo)
}

// dp函数定义:输入一个目标金额 n,返回凑出目标金额 n 所需的最少硬币数量
func dp(coins []int, amount int, memo map[int]int) int{
    if amount == 0{
        return 0
    }
    if amount < 0{
        return -1
    }
    if v, ok := memo[amount]; ok{ // 避免重复计算
        return v
    }
    res := math.MaxInt // 要求全局最小值,初始化为最大值
    for _, coin := range coins{
        // 计算子问题结果
        sub := dp(coins, amount-coin, memo)
        if sub == -1{ // 子问题无解,跳过
            continue
        }
        // 子问题最优解,再加上当前这枚硬币
        res = min(res, sub+1)
    }
    // 结果存入备忘录
    if res == math.MaxInt{
        memo[amount] = -1
    }else {
        memo[amount] = res
    }
    return memo[amount]
}

func min(a, b int) int{
    if a < b {
        return a
    }
    return b
}

解法二:自底向上的迭代DP

func coinChange(coins []int, amount int) int {
	// dp[i]表示为了凑目标金额 i至少需要的硬币数
	dp := make([]int, amount+1) // 0 ... amount,所以有amount+1个情况
	dp[0] = 0
	for i := 1; i < len(dp); i++ {
		dp[i] = math.MaxInt/2 // 初始值设一个不会与可能答案冲突的正整数,除2防止下面+1溢出
	}
	for i := 1; i < len(dp); i++ { // 外层 for 循环在遍历所有状态的所有取值
		for _, coin := range coins { // 内层 for 循环在求所有选择的最小值
			need := i - coin
			if need < 0 { // 子问题无解
				continue
			}
			dp[i] = min(dp[i], dp[need]+1) // 子问题答案再+当前这枚硬币
		}
	}
	if dp[amount] == math.MaxInt/2 {
		return -1
	}
	return dp[amount]
}

func min(a, b int) int {
	if a < b {
		return a
	}
	return b
}