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

# 一、动态规划概述 动态规划(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. 代码实现 ```python 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. 代码实现 ```python 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. 代码实现 ```python 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. 滚动数组 使用有限的几个数组交替使用,而不是存储所有状态。 ```python # 背包问题的空间优化版本 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. 状态压缩 对于某些问题,可以使用位运算来表示状态,减少空间使用。 ## (二)预处理和记忆化搜索 对于某些复杂的动态规划问题,可以结合预处理和记忆化搜索来提高效率。 ```python # 记忆化搜索示例 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. [维基百科:动态规划](https://en.wikipedia.org/wiki/Dynamic_programming) 5. [MIT OpenCourseWare: Dynamic Programming](https://ocw.mit.edu/courses/electrical-engineering-and-computer-science/6-006-introduction-to-algorithms-fall-2011/lecture-videos/lecture-19-dynamic-programming-i-fibonacci-shortest-paths/) 本文由[mdnice](https://mdnice.com/?platform=6)多平台发布
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容