动态规划入门:斐波那契数与零钱兑换

动态规划经常用来解决具有重复子问题的题目。

一开始,我们可能会直接使用递归,将原问题拆成更小的子问题。但如果不同的递归分支反复计算相同的结果,就会浪费大量时间。

这时可以考虑使用备忘录,将已经计算过的结果保存下来,避免重复求解。

进一步思考,如果能够确定各个状态之间的依赖关系,还可以改成自底向上的迭代写法。

下面通过两道 LeetCode 题目,分别记录这几种实现方式。


1. LeetCode 509. 斐波那契数

斐波那契数列从 0 和 1 开始,后面的每一项都等于前两项之和。

其递推关系为:

fib(n)=fib(n−1)+fib(n−2)fib(n)=fib(n-1)+fib(n-2)

其中,fib(0) = 0,fib(1) = 1。

1.1 朴素递归

根据斐波那契数列的定义,最容易想到的就是递归。

要求 fib(n),只需要分别计算 fib(n - 1) 和 fib(n - 2),再将它们相加。

对于 n = 0、1、2,可以直接返回对应的结果,作为递归的终止条件。

Python 代码:

Python
class Solution:
    def fib(self, n: int) -> int:
        if n == 0:
            return 0
        if n == 1:
            return 1
        if n == 2:
            return 1
        return self.fib(n - 1) + self.fib(n - 2)

这种写法很直观,基本就是把数学定义翻译成了代码。

但它有一个明显的问题:递归过程中会反复计算相同的子问题。

例如,计算 fib(5) 时,需要分别计算 fib(4) 和 fib(3)。

而在计算 fib(4) 时,又需要计算 fib(3) 和 fib(2)。

纯文本
              fib(5)
             /      \
         fib(4)     fib(3)
         /    \
     fib(3)  fib(2)

可以看到,fib(3) 被重复计算了。

随着 n 增大,这类重复计算会越来越多,朴素递归的时间复杂度可以用 O(2n)O(2^n) 表示。

问题不在于每次递归计算有多复杂,而在于同一个状态被反复求解。

1.2 备忘录优化:自顶向下

既然重复计算浪费时间,那么能不能把已经计算过的结果保存下来,下次需要时直接使用?

这就是备忘录的思路,也叫记忆化搜索。

我们可以创建一个 memo 数组,用来记录各个 fib(n) 的计算结果。

每次进入递归函数时:

  1. 如果遇到基础状态,直接返回对应结果。
  2. 检查备忘录中是否已经保存当前状态。
  3. 如果已经计算过,就直接返回保存的结果。
  4. 如果还没有计算,就继续递归,并将得到的结果存入备忘录。

这样就不需要每次都重新展开同一棵递归子树。

Python 代码:

Python
class Solution:
    def fib(self, n: int) -> int:
        memo = [-1 for _ in range(n + 1)]
        return self.func(n, memo)

    def func(self, n, memo):
        if n == 0:
            return 0
        elif n == 1:
            return 1
        elif n == 2:
            return 1
        if memo[n] != -1:
            return memo[n]
        memo[n] = self.func(n - 1, memo) + self.func(n - 2, memo)
        return memo[n]

这里使用 -1 表示某个状态还没有被计算过。

因为斐波那契数不会出现负数,所以可以安全地使用这个值作为标记。

和朴素递归相比,记忆化搜索不再反复计算相同的非基础状态,时间复杂度降低到了 O(n)O(n)。

不过,这种写法仍然使用递归,而且还需要额外创建一个长度为 n + 1 的备忘录数组,因此空间复杂度为 O(n)O(n)。

这种从目标问题开始,逐步向下分解并保存结果的方式,就是自顶向下的动态规划。

1.3 自底向上:迭代实现

除了使用递归,我们还可以反过来思考。

既然已经知道:

fib(0)=0,fib(1)=1fib(0)=0,\quad fib(1)=1

而且每一项都可以通过前两项计算出来,那么完全可以从最基础的状态开始,依次计算后面的结果。

例如:

纯文本
fib(0) = 0
fib(1) = 1
fib(2) = 1
fib(3) = 2
fib(4) = 3
fib(5) = 5

这种方式就是自底向上的动态规划。

进一步观察可以发现,计算 fib(n) 时,只需要知道前两个状态的值。

因此,没必要保存从 fib(0) 到 fib(n) 的所有结果,只需要使用两个变量记录前两项,并在每轮循环中更新即可。

Python 代码:

Python
class Solution:
    def fib(self, n: int) -> int:
        if n < 2:
            return n
        fib_n_1 = 1
        fib_n_2 = 0
        fib_n = 0
        for i in range(2, n + 1):
            fib_n = fib_n_1 + fib_n_2
            fib_n_2 = fib_n_1
            fib_n_1 = fib_n
        return fib_n

其中:

  • fib_n_1 保存前一项的值。
  • fib_n_2 保存前两项的值。
  • fib_n 用来计算当前项。

每次计算出新的斐波那契数后,再更新前两个变量,为下一轮循环做准备。

这样既避免了递归中的重复计算,也不需要额外维护整个备忘录数组。

最终时间复杂度仍然为 O(n)O(n),但额外空间复杂度降低到了 O(1)O(1)。

通过这道题,可以看到三种写法之间的关系:

解法时间复杂度空间复杂度
朴素递归O(2n)O(2^n)O(n)O(n)
递归 + 备忘录O(n)O(n)O(n)O(n)
迭代 + 两个变量O(n)O(n)O(1)O(1)

这也是动态规划中比较常见的优化过程:先通过递归找到状态之间的关系,再用备忘录消除重复计算,最后根据状态依赖关系考虑能否改成迭代,甚至进一步压缩空间。


2. LeetCode 322. 零钱兑换

这道题给出了若干种不同面额的硬币,以及一个目标金额 amount。

每种硬币可以使用任意多次,要求计算凑出目标金额需要的最少硬币数量。如果无法凑出,则返回 -1。

例如:

纯文本
coins = [1, 2, 5]
amount = 11

答案:3

因为可以使用两枚面额为 5 的硬币,再加上一枚面额为 1 的硬币,刚好凑出 11,一共需要 3 枚。

2.1 分解子问题

这道题也可以先从递归的角度考虑。

假设我们现在需要凑出金额 amount,那么每次都可以从 coins 中选择一枚硬币。

选择之后,剩余需要凑出的金额就是:

amount - coin

例如,当 coins = [1, 2, 5]、amount = 11 时:

  • 选择面额 1,剩余金额为 10。
  • 选择面额 2,剩余金额为 9。
  • 选择面额 5,剩余金额为 6。

假设凑出这三个剩余金额需要的最少硬币数分别为 x、y、z,那么凑出金额 11 所需的最少硬币数就是:

dp(11)=1+min⁡(x,y,z)dp(11)=1+\min(x,y,z)

这里加上的 1,就是本次选择的那枚硬币。

当然,如果某种选择无法凑出剩余金额,就不能把它计入最小值的比较。

因此,我们可以定义:

dp(amount) 表示凑出指定金额所需的最少硬币数量。

对于每种硬币,分别递归计算剩余金额的最优结果,再从所有可行选择中取最小值。

对应的状态转移关系可以写成:

dp(i)=1+min⁡c∈coins, dp(i−c)可行dp(i−c)dp(i)=1+\min_{c\in coins,\ dp(i-c)\text{可行}}dp(i-c)

如果没有任何可行的选择,就说明当前金额无法凑出。

2.2 递归的终止条件

在实现之前,还需要考虑递归什么时候结束。

这里有两种情况:

第一种:amount == 0

说明目标金额已经凑齐,不需要再选择硬币,因此返回 0。

第二种:amount < 0

说明当前选择的硬币面额超过了剩余金额,这条递归路径不可行,因此返回 -1。

另外,这道题同样存在重复子问题。

例如,凑金额 11 时,不同的硬币选择顺序可能都会遇到“还需要凑出金额 6”这样的状态。

如果每次都重新计算,就会产生大量重复递归。

因此,可以沿用第 509 题的思路,通过备忘录保存每个剩余金额的计算结果。

2.3 备忘录实现

Python 代码:

Python
class Solution:
    def __init__(self):
        self.memo = []

    def coinChange(self, coins: List[int], amount: int) -> int:
        self.memo = [-666] * (amount + 1)
        return self.dp(coins, amount)

    def dp(self, coins, amount):
        if amount == 0:
            return 0
        if amount < 0:
            return -1
        if self.memo[amount] != -666:
            return self.memo[amount]
        res = float("inf")
        for coin in coins:
            subProblem = self.dp(coins, amount - coin)
            if subProblem == -1:
                continue
            res = min(res, subProblem + 1)
        self.memo[amount] = res if res != float("inf") else -1
        return self.memo[amount]

这里的备忘录使用 -666 表示某个金额还没有被计算过。

而 -1 表示已经计算过,但无法凑出这个金额。两者的含义不同,所以不能混用。

在 dp() 函数中,先将 res 初始化为正无穷:

Python
res = float("inf")

然后遍历所有硬币,尝试计算选择当前硬币后的子问题。

如果子问题返回 -1,说明当前选择不可行,直接跳过。

否则就使用:

Python
res = min(res, subProblem + 1)

不断更新最少硬币数量。

如果尝试完所有面额后,res 仍然是正无穷,就说明没有任何可行方案,应当返回 -1。

最后,将结果保存到备忘录中。下次遇到相同的金额时,就可以直接返回,不需要再次展开递归。

2.4 自底向上:使用 DP 数组

和斐波那契数一样,零钱兑换也可以改成迭代写法。

既然已经知道凑出金额 0 不需要任何硬币,那么就可以从 0 开始,依次计算凑出金额 1、2、3……所需要的最少硬币数。

这里使用一个 dp 数组:

dp[i] 表示凑出金额 i 所需要的最少硬币数量。

每次计算 dp[i] 时,依次尝试所有硬币。

如果选择了面额为 coin 的硬币,那么剩余金额就是 i - coin。

只要 i - coin >= 0,就可以尝试通过下面的关系更新答案:

dp[i]=min⁡(dp[i],dp[i−coin]+1)dp[i]=\min(dp[i],dp[i-coin]+1)

其中的 +1,同样表示本次选择的硬币。

2.5 初始化 DP 数组

这里有一个值得注意的细节:DP 数组的初始值。

我们可以先将所有位置初始化为 amount + 1:

Python
dp = [amount + 1] * (amount + 1)

这是因为题目中的硬币面额都是正整数。如果一个金额能够被凑出来,那么即使全部使用面额为 1 的硬币,所需数量也不会超过目标金额本身。

因此,amount + 1 可以用来表示一个暂时无法凑出的状态。

接着,将:

Python
dp[0] = 0

表示凑出金额 0 不需要任何硬币。

之后从小到大枚举金额,每次尝试所有面额,不断更新当前金额的最优结果。

Python 代码:

Python
class Solution:
    def coinChange(self, coins: List[int], amount: int) -> int:
        dp = [amount + 1] * (amount + 1)
        dp[0] = 0
        for i in range(len(dp)):
            for coin in coins:
                if i - coin < 0:
                    continue
                dp[i] = min(dp[i], 1 + dp[i - coin])
        return -1 if dp[amount] == amount + 1 else dp[amount]

这里的外层循环从小到大枚举金额 i,内层循环遍历每一种硬币。

由于硬币面额都大于 0,所以 i - coin 一定小于 i。

也就是说,在计算 dp[i] 时,所依赖的 dp[i - coin] 已经在之前的循环中计算过了。

这就是自底向上迭代能够成立的原因。

最后,如果 dp[amount] 仍然等于 amount + 1,就说明目标金额无法凑出,返回 -1;否则返回实际计算出的最少硬币数量。

2.6 复杂度分析

假设目标金额为 amount,硬币面额的种类数为 m。

使用备忘录时,每个非负金额状态最多需要完整计算一次,而每次计算都需要尝试 m 种硬币。

因此,记忆化搜索的时间复杂度为:

O(amount×m)O(amount\times m)

迭代版本同样需要依次计算每个金额,并尝试所有硬币,时间复杂度也是:

O(amount×m)O(amount\times m)

空间方面,两种写法都需要保存长度为 amount + 1 的状态数组,因此空间复杂度为 O(amount)O(amount)。

不过,递归版本还需要使用函数调用栈,而迭代版本没有这部分额外开销。


3. 总结

这两道题虽然要求不同,但解题过程中使用了相似的动态规划思路。

斐波那契数从朴素递归入手,通过备忘录避免重复计算,再利用每个状态只依赖前两项的特点,将空间复杂度优化到 O(1)O(1)。

零钱兑换则需要在每个状态下尝试多种硬币,选择能够得到最少硬币数的方案。它同样可以使用备忘录进行记忆化搜索,也可以按照金额从小到大的顺序,逐步填充 DP 数组。

通过这两道题,我觉得写动态规划时,可以先考虑三个问题:

  1. 状态是什么? 例如 fib(n) 表示第 n 个斐波那契数,dp[i] 表示凑出金额 i 的最少硬币数量。
  2. 状态之间有什么关系? 当前状态能否由规模更小、已经解决的子问题计算出来?
  3. 初始状态是什么? 哪些情况可以直接得到答案,作为递归终止条件或 DP 数组的初始化依据?

想清楚这些问题后,再考虑是使用自顶向下的备忘录,还是自底向上的迭代实现。

如果状态只依赖少量前面的结果,还可以进一步思考是否有必要保存整个 DP 数组。

谢谢你读到这里。 ✧

聊两句

评论审核后显示

有疑问、补充,或者想交流一下,都可以写在这里。

评论区将在滚动到这里时加载。