【学习】《算法图解》第十一章学习笔记:动态规划

一、动态规划概述

动态规划(Dynamic Programming,简称DP)是一种通过将复杂问题分解为更简单的子问题来解决问题的方法。它是一种强大的算法设计技术,特别适用于具有重叠子问题和最优子结构性质的问题。

(一)算法适用场景

动态规划主要适用于以下场景:

  • 最优化问题(求最大值、最小值)
  • 计数问题(求方案数)
  • 具有重叠子问题特性的问题
  • 具有最优子结构特性的问题

(二)算法基本思想

动态规划的核心思想是:

  1. 将原问题分解为相互依赖的子问题
  2. 求解每个子问题仅一次,并将结果存储在表格中
  3. 自底向上地构建解决方案
  4. 通过组合子问题的解来得到原问题的解

二、动态规划的关键要素

(一)最优子结构

如果问题的最优解包含其子问题的最优解,则该问题具有最优子结构性质。这是应用动态规划的必要条件。

例如,最短路径问题具有最优子结构:如果从A到C的最短路径经过B,那么A到B的这段路径一定是A到B的最短路径。

(二)重叠子问题

当问题的递归算法会重复计算相同的子问题时,我们称该问题具有重叠子问题特性。动态规划通过存储已解决的子问题的结果来避免重复计算。

例如,计算斐波那契数列时,递归算法会重复计算相同的子问题,而动态规划可以避免这种重复。

(三)状态转移方程

状态转移方程是动态规划的核心,它描述了问题的递推关系,即如何从已解决的子问题推导出更大问题的解。

一般形式:dp[i] = 与dp[i-1], dp[i-2], ...等相关的表达式

三、动态规划的实现方法

(一)自底向上(迭代法)

从最小的子问题开始,逐步构建更大问题的解,最终得到原问题的解。这种方法通常使用数组或表格来存储中间结果。

优点:

  • 避免了递归调用的开销
  • 空间效率通常更高
  • 易于实现和理解

(二)自顶向下(记忆化搜索)

从原问题开始,递归地分解为子问题,但使用缓存(通常是哈希表)存储已解决的子问题的结果,避免重复计算。

优点:

  • 只计算需要的子问题
  • 保持了问题的递归结构
  • 有时更容易实现

四、经典动态规划问题

(一)背包问题

1. 问题描述

有n个物品,每个物品有重量w[i]和价值v[i]。背包最大容量为W。如何选择物品放入背包,使得总价值最大?

2. 状态定义

dp[i][j] 表示考虑前i个物品,背包容量为j时能获得的最大价值。

3. 状态转移方程

dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])  如果 j >= w[i]
dp[i][j] = dp[i-1][j]  如果 j < w[i]

4. 代码实现

def knapsack(weights, values, capacity):
    n = len(weights)
    # 创建二维数组
    dp = [[0 for _ in range(capacity + 1)] for _ in range(n + 1)]
    
    # 填充dp表
    for i in range(1, n + 1):
        for j in range(1, capacity + 1):
            if weights[i-1] <= j:
                # 可以放入背包
                dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]] + values[i-1])
            else:
                # 不能放入背包
                dp[i][j] = dp[i-1][j]
    
    return dp[n][capacity]

(二)最长公共子序列

1. 问题描述

给定两个序列X和Y,找出它们的最长公共子序列(不要求连续)。

2. 状态定义

dp[i][j] 表示序列X的前i个字符与序列Y的前j个字符的最长公共子序列长度。

3. 状态转移方程

dp[i][j] = dp[i-1][j-1] + 1  如果 X[i-1] == Y[j-1]
dp[i][j] = max(dp[i-1][j], dp[i][j-1])  如果 X[i-1] != Y[j-1]

4. 代码实现

def longest_common_subsequence(X, Y):
    m, n = len(X), len(Y)
    # 创建二维数组
    dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    
    # 填充dp表
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if X[i-1] == Y[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    # 构造最长公共子序列
    lcs = []
    i, j = m, n
    while i > 0 and j > 0:
        if X[i-1] == Y[j-1]:
            lcs.append(X[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
    
    return ''.join(reversed(lcs))

(三)编辑距离

1. 问题描述

给定两个字符串str1和str2,计算将str1转换为str2所需的最少操作次数。操作包括:插入、删除和替换字符。

2. 状态定义

dp[i][j] 表示将str1的前i个字符转换为str2的前j个字符所需的最少操作次数。

3. 状态转移方程

dp[i][j] = dp[i-1][j-1]  如果 str1[i-1] == str2[j-1]
dp[i][j] = min(dp[i-1][j] + 1,    # 删除
               dp[i][j-1] + 1,    # 插入
               dp[i-1][j-1] + 1)  # 替换
           如果 str1[i-1] != str2[j-1]

4. 代码实现

def edit_distance(str1, str2):
    m, n = len(str1), len(str2)
    # 创建二维数组
    dp = [[0 for _ in range(n + 1)] for _ in range(m + 1)]
    
    # 初始化边界条件
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    
    # 填充dp表
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if str1[i-1] == str2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(dp[i-1][j] + 1,    # 删除
                               dp[i][j-1] + 1,    # 插入
                               dp[i-1][j-1] + 1)  # 替换
    
    return dp[m][n]

五、算法分析

(一)时间复杂度

动态规划的时间复杂度通常取决于状态数量和状态转移的复杂度:

  • 对于一维DP问题:通常为O(n)或O(n²)
  • 对于二维DP问题:通常为O(n²)或O(n³)
  • 对于背包问题:O(n×W),其中n是物品数量,W是背包容量

(二)空间复杂度

标准动态规划的空间复杂度通常与状态数量相同:

  • 一维DP问题:通常为O(n)
  • 二维DP问题:通常为O(n²)

但许多问题可以通过空间优化(如滚动数组)将空间复杂度降低:

  • 背包问题可以优化到O(W)
  • 某些二维DP问题可以优化到O(n)

(三)算法优缺点

优点:

  • 能够解决具有重叠子问题的复杂问题
  • 避免了递归算法的重复计算
  • 对于特定问题,提供了多项式时间的解决方案

缺点:

  • 状态设计和转移方程推导可能较为复杂
  • 空间需求可能较大
  • 不适用于没有最优子结构的问题

六、动态规划与其他算法的比较

(一)动态规划 vs 贪心算法

特性 动态规划 贪心算法
决策过程 考虑所有可能的子问题解 每步做出局部最优选择
适用条件 最优子结构+重叠子问题 贪心选择性质+最优子结构
复杂度 通常较高 通常较低
正确性 总能得到全局最优解 不一定得到全局最优解

(二)动态规划 vs 分治法

特性 动态规划 分治法
子问题 重叠子问题 独立子问题
解决方式 自底向上或记忆化搜索 递归
存储方式 存储子问题的解 不存储中间结果
典型应用 背包问题、最短路径 归并排序、快速排序

七、动态规划的优化技巧

(一)空间优化

许多动态规划问题可以通过观察状态转移方程,发现当前状态只依赖于有限的前序状态,从而进行空间优化。

1. 滚动数组

使用有限的几个数组交替使用,而不是存储所有状态。

# 背包问题的空间优化版本
def knapsack_optimized(weights, values, capacity):
    n = len(weights)
    # 只使用一维数组
    dp = [0 for _ in range(capacity + 1)]
    
    for i in range(n):
        # 逆序遍历,避免覆盖需要的值
        for j in range(capacity, weights[i] - 1, -1):
            dp[j] = max(dp[j], dp[j - weights[i]] + values[i])
    
    return dp[capacity]

2. 状态压缩

对于某些问题,可以使用位运算来表示状态,减少空间使用。

(二)预处理和记忆化搜索

对于某些复杂的动态规划问题,可以结合预处理和记忆化搜索来提高效率。

# 记忆化搜索示例
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]

八、动态规划的应用场景

(一)实际应用

  1. 生物信息学:序列比对、RNA结构预测
  2. 自然语言处理:词性标注、语法分析
  3. 计算机图形学:图像分割、曲线拟合
  4. 运筹学:资源分配、调度问题
  5. 金融领域:投资组合优化、期权定价

(二)算法变种

  1. 概率动态规划:状态转移具有概率性
  2. 区间动态规划:状态定义在区间上
  3. 树形动态规划:状态定义在树结构上
  4. 数位动态规划:按照数字位数进行状态定义和转移

九、总结

动态规划是一种强大的算法设计技术,特别适用于具有重叠子问题和最优子结构的问题。它通过将复杂问题分解为子问题,并存储子问题的解来避免重复计算,从而提高算法效率。

动态规划的关键步骤包括:

  1. 确定状态表示和定义
  2. 推导状态转移方程
  3. 确定边界条件和初始状态
  4. 确定计算顺序
  5. 提取最终解

掌握动态规划需要大量的练习和对问题的深入理解。通过不断练习不同类型的动态规划问题,可以培养识别问题特征和设计解决方案的能力。

参考资料

  1. 《算法图解》第十一章,Aditya Bhargava 著
  2. Introduction to Algorithms (CLRS),第15章
  3. Dynamic Programming for Coding Interviews,Meenakshi & Kamal Rawat 著
  4. 维基百科:动态规划
  5. MIT OpenCourseWare: Dynamic Programming