一、什么是动态规划

动态规划听起来挺高大上的,其实就是一种解决问题的思路。简单来说,就是把一个大问题拆分成一个个小问题,先解决小问题,再根据小问题的解去得到大问题的解。而且这些小问题之间是有联系的,解决后面的小问题可以利用前面小问题的结果,这样就能避免重复计算,提高效率。

举个生活中的例子,假如你要爬楼梯,每次可以走 1 步或者 2 步,问爬到第 n 级台阶有多少种方法。我们可以把这个大问题拆成小问题,比如先算爬到第 1 级台阶有几种方法,再算爬到第 2 级台阶有几种方法,然后依次类推,后面的台阶的方法数可以根据前面台阶的方法数得到。

二、最优子结构

最优子结构是动态规划的核心概念之一。它的意思是,一个大问题的最优解包含了它的子问题的最优解。也就是说,我们可以通过解决子问题的最优解来得到大问题的最优解。

还是以爬楼梯为例,要爬到第 n 级台阶,我们可以从第 n - 1 级台阶走 1 步上来,也可以从第 n - 2 级台阶走 2 步上来。那么爬到第 n 级台阶的方法数就等于爬到第 n - 1 级台阶的方法数加上爬到第 n - 2 级台阶的方法数。这里,爬到第 n - 1 级台阶和第 n - 2 级台阶就是子问题,而爬到第 n 级台阶的最优解(方法数)就包含了这两个子问题的最优解。

三、用动态规划解决斐波那契数列问题

斐波那契数列介绍

斐波那契数列是一个很经典的数列,它的定义是:$F(0) = 0$,$F(1) = 1$,$F(n) = F(n - 1) + F(n - 2)$($n \geq 2$)。也就是说,从第三项开始,每一项都等于前两项之和。

普通递归解法

我们先来看一下用普通递归的方法来实现斐波那契数列:

# Python 技术栈
def fibonacci_recursive(n):
    # 如果 n 为 0,返回 0
    if n == 0:
        return 0
    # 如果 n 为 1,返回 1
    elif n == 1:
        return 1
    # 否则,返回前两项之和
    else:
        return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)

# 测试
print(fibonacci_recursive(5))  # 输出 5

这种方法虽然简单,但是效率很低,因为会有大量的重复计算。比如计算 $F(5)$ 时,会计算 $F(4)$ 和 $F(3)$,而计算 $F(4)$ 时又会计算 $F(3)$ 和 $F(2)$,这样 $F(3)$ 就被重复计算了。

动态规划解法

我们可以用动态规划的方法来优化这个问题,避免重复计算。

# Python 技术栈
def fibonacci_dp(n):
    # 如果 n 为 0,返回 0
    if n == 0:
        return 0
    # 如果 n 为 1,返回 1
    elif n == 1:
        return 1
    # 创建一个长度为 n + 1 的列表,用于存储中间结果
    dp = [0] * (n + 1)
    # 初始化前两项
    dp[0] = 0
    dp[1] = 1
    # 从第 2 项开始计算
    for i in range(2, n + 1):
        # 当前项等于前两项之和
        dp[i] = dp[i - 1] + dp[i - 2]
    # 返回第 n 项的值
    return dp[n]

# 测试
print(fibonacci_dp(5))  # 输出 5

在这个方法中,我们用一个列表 dp 来存储中间结果,避免了重复计算,时间复杂度从指数级降到了线性级。

四、用动态规划解决背包问题

背包问题介绍

背包问题是一个经典的组合优化问题。假设有一个背包,它的容量为 $W$,有 $n$ 个物品,每个物品有自己的重量 $w_i$ 和价值 $v_i$。我们要在不超过背包容量的前提下,选择一些物品放入背包,使得背包中物品的总价值最大。

0 - 1 背包问题

0 - 1 背包问题是背包问题的一种特殊情况,每个物品只能选择放入或者不放入背包。

我们可以用动态规划来解决 0 - 1 背包问题。设 $dp[i][j]$ 表示前 $i$ 个物品放入容量为 $j$ 的背包中所能获得的最大价值。

状态转移方程为: $dp[i][j] = \begin{cases} dp[i - 1][j] & \text{if } w_i > j \ \max(dp[i - 1][j], dp[i - 1][j - w_i] + v_i) & \text{if } w_i \leq j \end{cases}$

下面是用 Python 实现的代码:

# Python 技术栈
def knapsack(weights, values, capacity):
    n = len(weights)
    # 创建一个二维数组 dp,用于存储中间结果
    dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
    # 遍历每个物品
    for i in range(1, n + 1):
        # 遍历每个容量
        for j in range(1, capacity + 1):
            # 如果当前物品的重量大于当前容量
            if weights[i - 1] > j:
                # 不选择当前物品
                dp[i][j] = dp[i - 1][j]
            else:
                # 选择当前物品和不选择当前物品的最大值
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1])
    # 返回最大价值
    return dp[n][capacity]

# 测试
weights = [2, 3, 4, 5]
values = [3, 4, 5, 6]
capacity = 8
print(knapsack(weights, values, capacity))  # 输出 10

在这个代码中,我们用一个二维数组 dp 来存储中间结果,通过状态转移方程来更新 dp 数组,最终得到最大价值。

五、应用场景

动态规划在很多领域都有广泛的应用,比如:

  • 资源分配问题:在有限的资源下,如何分配资源以达到最大的效益。例如,在项目管理中,如何分配人力、物力和财力,使得项目的收益最大。
  • 路径规划问题:在地图上找到从一个点到另一个点的最短路径。例如,在导航系统中,如何规划最优的行车路线。
  • 序列比对问题:在生物信息学中,比较两个 DNA 序列的相似度。

六、技术优缺点

优点

  • 避免重复计算:通过存储中间结果,避免了重复计算,提高了效率。
  • 可以解决复杂问题:可以解决一些复杂的组合优化问题,如背包问题、最长公共子序列问题等。

缺点

  • 空间复杂度较高:通常需要使用额外的空间来存储中间结果,对于大规模问题,可能会占用大量的内存。
  • 状态转移方程较难确定:对于一些复杂的问题,状态转移方程可能比较难确定,需要一定的数学基础和经验。

七、注意事项

  • 状态定义要准确:状态的定义直接影响到状态转移方程的确定,所以要准确地定义状态。
  • 边界条件要处理好:在动态规划中,边界条件非常重要,要确保边界条件的正确性。
  • 空间优化:如果空间复杂度较高,可以考虑进行空间优化,如将二维数组优化为一维数组。

八、文章总结

动态规划是一种非常实用的算法思想,它通过把大问题拆分成小问题,利用最优子结构的性质,避免了重复计算,提高了效率。我们通过斐波那契数列和背包问题这两个经典例子,详细介绍了动态规划的应用。在实际应用中,我们要根据具体问题准确地定义状态和状态转移方程,处理好边界条件,同时注意空间复杂度的问题。