返回

斐波那契数的动态规划求解

前端

斐波那契数列问题经常在编程学习和面试中出现,它是一个看似简单但蕴含着丰富解法的经典问题。很多人第一次接触斐波那契数列可能觉得很容易,用简单的递归就能解决。但随着问题规模的增大,递归方法的效率会急剧下降,这时候就需要用到动态规划的思想来优化。

我们先来看看斐波那契数列的定义:它的第一项和第二项都是1,从第三项开始,每一项都是前两项的和。比如数列的前几项就是:1, 1, 2, 3, 5, 8, 13...

如果用简单的递归来计算第n项斐波那契数,代码就像这样:

def fib_recursive(n):
  if n <= 1:
    return n
  return fib_recursive(n-1) + fib_recursive(n-2)

这段代码非常简洁易懂,但它的效率却很低。因为它在计算过程中会重复计算很多次相同的子问题。比如要计算fib_recursive(5),它会先计算fib_recursive(4)和fib_recursive(3),而计算fib_recursive(4)又需要计算fib_recursive(3)和fib_recursive(2),这样fib_recursive(3)就被计算了两次。随着n的增大,这种重复计算的次数会呈指数级增长,导致程序运行速度非常慢。

为了避免重复计算,我们可以使用动态规划的思想。动态规划的核心就是把大问题分解成小问题,并把小问题的解存储起来,以便下次遇到相同的小问题时可以直接使用,而不需要重新计算。

应用到斐波那契数列问题上,我们可以用一个数组或者字典来存储已经计算过的斐波那契数。比如我们可以用一个列表fib_table来存储,fib_table[i]表示第i项斐波那契数。初始时,fib_table[0] = 1,fib_table[1] = 1。然后我们可以从前往后依次计算fib_table[2]、fib_table[3]...,直到计算出fib_table[n]。

def fib_dynamic(n):
  fib_table = [0] * (n + 1)
  fib_table[0] = 1
  fib_table[1] = 1
  for i in range(2, n + 1):
    fib_table[i] = fib_table[i-1] + fib_table[i-2]
  return fib_table[n]

这种方法叫做自底向上的动态规划,因为它从最小的子问题开始,逐步向上解决更大的问题。它的时间复杂度是O(n),空间复杂度也是O(n)。

除了自底向上,我们还可以使用自顶向下的动态规划,也叫做备忘录法。这种方法仍然使用递归,但在递归过程中,我们会把已经计算过的子问题的解存储在一个备忘录(通常是一个字典)中。当遇到一个子问题时,我们先检查备忘录中是否已经存在该子问题的解,如果存在就直接返回,否则就递归计算,并将结果存储到备忘录中。

def fib_memo(n, memo={}):
  if n in memo:
    return memo[n]
  if n <= 1:
    return n
  memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
  return memo[n]

这种方法的时间复杂度和空间复杂度都和自底向上相同,都是O(n)。

当然,斐波那契数列问题还有其他一些优化方法,比如矩阵快速幂,可以将时间复杂度降低到O(log n)。但对于一般的应用场景,动态规划已经足够高效了。

常见问题解答

1. 动态规划和递归有什么区别?

递归是一种函数调用自身的方法,而动态规划是一种算法设计思想。递归可以用来实现动态规划,但不是所有的递归都是动态规划。动态规划的关键在于存储子问题的解,避免重复计算。

2. 什么时候应该使用动态规划?

当一个问题可以分解成多个重叠的子问题,并且这些子问题的解可以被存储和重用时,就可以考虑使用动态规划。

3. 动态规划有哪些应用场景?

动态规划有很多应用场景,比如斐波那契数列、最长公共子序列、背包问题、编辑距离等等。

4. 如何判断一个问题是否适合用动态规划?

判断一个问题是否适合用动态规划,可以考虑以下两个方面:

  • 是否具有最优子结构:问题的最优解是否可以由子问题的最优解构成?
  • 是否具有重叠子问题:在求解过程中,是否会重复计算相同的子问题?

如果这两个条件都满足,那么这个问题就适合用动态规划。

5. 动态规划有哪些优缺点?

优点:

  • 可以高效地解决一些复杂的问题,避免重复计算。
  • 代码实现相对简单。

缺点:

  • 需要额外的空间来存储子问题的解。
  • 对于一些问题,动态规划的解法可能比较难以理解。