尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

动态规划核心:备忘录与自底向上,从递归优化到高效迭代

动态规划核心:备忘录与自底向上,从递归优化到高效迭代 1. 从“暴力递归”到“动态规划”的思维跃迁很多朋友在初次接触动态规划时都会觉得它很“玄学”——状态、转移方程、最优子结构这些概念听起来高大上但一到自己动手解决实际问题比如经典的“爬楼梯”或者“最长公共子序列”思路就又回到了最原始的暴力枚举结果自然是超时。这其实是因为我们还没有完成从“计算思维”到“规划思维”的转变。动态规划的核心在我看来不是一套死记硬背的模板而是一种用空间换时间并系统化地避免重复计算的思维方式。让我们从一个最直观的例子开始斐波那契数列。它的定义很简单F(0)0, F(1)1, F(n)F(n-1)F(n-2)。如果直接按照这个定义写一个递归函数代码非常简洁。但如果你尝试计算fib(50)程序可能会“卡住”很久。为什么因为递归树展开了巨大的、重复的子树。计算fib(5)需要fib(4)和fib(3)计算fib(4)又需要fib(3)和fib(2)…… 这里的fib(3)就被计算了两次。随着 n 增大这种重复是指数级爆炸的。动态规划要解决的正是这个“重复计算”的痛点。它提供了两条主流的实现路径也是我们今天要深入剖析的核心备忘录法和自底向上法。很多人把它们简单地理解为“递归加缓存”和“迭代填表”这没错但只看到了表象。真正理解这两种方法背后的优化逻辑、适用场景以及它们之间微妙的权衡才是你写出高效、优雅DP代码的关键。无论是解决力扣上的算法题还是在实际开发中处理最优资源分配、路径规划等问题这套思维框架都极具价值。2. 备忘录法化繁为简的“聪明递归”备忘录法常被称为“自顶向下”的动态规划。它的核心思想直白而有效在递归求解的过程中用一个“备忘录”记录下已经计算过的子问题的结果。当再次遇到相同的子问题时直接查表返回结果而不是重新计算。2.1 备忘录法的实现骨架与细节我们继续用斐波那契数列作为例子。一个没有优化的递归解法是这样的def fib_naive(n): if n 1: return n return fib_naive(n-1) fib_naive(n-2)加入备忘录后代码演变为def fib_memo(n, memoNone): # 初始化备忘录通常使用数组或字典 if memo is None: 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]这里有几个非常关键的实现细节直接影响到代码的正确性和效率备忘录的数据结构选择对于状态参数是整数且范围明确的问题如这里的n使用列表数组访问效率最高memo [-1] * (n1)用-1表示未计算。对于状态参数不规则或需要组合键如(i, j)的问题字典是更灵活的选择。递归基的处理递归基的解必须直接定义并返回不应存入备忘录。虽然存入也不影响结果但多此一举。在上例中n1时直接返回n这本身就是定义。“计算后存入”的顺序一定要在递归调用返回、得到子问题解之后再将当前问题的解存入备忘录。顺序反了备忘录就失去了意义。注意在像Python这样的语言中默认参数memoNone和内部的if memo is None: memo {}是一种惯用法它保证了在首次调用时初始化一个空的字典并且在后续的所有递归调用中传递的是同一个字典的引用。这是实现“共享备忘录”的关键技巧。2.2 备忘录法的优势与思维契合点备忘录法最大的优势在于它极其贴合人类面对复杂问题时的自然思考过程——分而治之。我们首先思考的是如何定义原问题然后思考原问题如何分解成子问题。我们不需要一开始就关心所有子问题的计算顺序只需要按照递归关系去分解。这种“自顶向下”的特性带来了两个好处逻辑清晰代码结构几乎就是问题递归定义的直接翻译易于理解和调试。惰性计算它只计算解决问题所必需的那些子问题。在某些情况下如果最优解路径只涉及一部分状态备忘录法可以避免计算全部状态从而在某些场景下比自底向上法更高效。例如在“编辑距离”问题中如果两个字符串非常相似最优编辑路径可能只集中在动态规划表格的对角线附近。备忘录法可能不会计算离对角线很远的那些dp[i][j]而自底向上法通常会填满整个表格。2.3 备忘录法的典型“坑”与规避策略尽管备忘录法思路直观但实践中也有几个常见的陷阱坑1状态定义不唯一导致备忘录失效备忘录的核心是“状态”的唯一标识。如果递归函数的参数不能唯一确定一个子问题备忘录就会出错。例如在经典的“01背包”问题中子问题由“当前考虑的物品索引i”和“剩余的背包容量c”共同决定。你的备忘录键必须是(i, c)这个二元组。如果你错误地只用i做键那么当剩余容量不同但索引相同时就会返回错误的结果。规避策略在设计递归函数时明确问自己“哪些参数一旦确定这个函数的返回值就是唯一确定的”这些参数就是你的“状态”需要全部作为备忘录的键。坑2递归深度过大导致栈溢出这是递归方法的通病。当问题规模很大时例如n10000递归调用层数过深会超出编程语言允许的调用栈深度导致RecursionError。规避策略转换为迭代这其实就是走向自底向上法。尾递归优化但大多数主流语言如Python、Java并不支持真正的尾递归优化。设置递归深度在Python中可以用sys.setrecursionlimit提高限制但这只是权宜之计不能从根本上解决问题且可能引发其他风险。实践建议对于明确可能深度很大的问题优先考虑自底向上法。坑3记忆化容器的初始化与传递特别是在多测试用例的场景下如果你使用全局变量或类的成员变量作为备忘录务必在每次求解新问题前清空备忘录。否则上一个测试用例的结果会污染当前用例导致错误。# 错误示例使用全局备忘录连续调用会出错 memo_global {} def fib_bad(n): if n in memo_global: return memo_global[n] if n 1: return n memo_global[n] fib_bad(n-1) fib_bad(n-2) return memo_global[n] print(fib_bad(5)) # 正确计算并填充了memo_global print(fib_bad(3)) # 错误直接返回了memo_global[3]中上次缓存的值并未重新计算。规避策略更安全的做法是将备忘录作为递归函数的参数传递如之前的例子或者将求解过程封装在一个函数/类内部每次调用时新建一个备忘录。3. 自底向上法稳扎稳打的“迭代推进”如果说备忘录法是“聪明人的递归”那么自底向上法就是“工程师的迭代”。它彻底摒弃了递归采用纯粹的循环来解决问题。其核心思想是先解决所有最小、最基本的子问题“底”然后利用这些基础解逐步构建更大规模子问题的解直至得到原问题的解“上”。3.1 自底向上法的实现范式状态与递推自底向上法的实现通常围绕一个核心的数据结构——DP表通常是数组或矩阵。表中的每一个位置dp[i]或dp[i][j]就代表一个特定状态下的子问题最优解。它的实现可以归纳为一个清晰的范式定义状态明确dp数组每个下标的含义。这是最关键的一步决定了整个DP的逻辑。例如在斐波那契中dp[i]表示第i个斐波那契数。确定递推公式状态转移方程找出dp[i]与之前状态如dp[i-1],dp[i-2]之间的关系。这就是问题的“最优子结构”体现。对于斐波那契公式就是dp[i] dp[i-1] dp[i-2]。初始化给出最小子问题递推起点的解。例如dp[0] 0,dp[1] 1。初始化不正确整个递推大厦就会倾斜。确定遍历顺序以怎样的顺序循环填充dp表这取决于状态之间的依赖关系。我们必须保证在计算dp[i]时它所依赖的所有子状态都已经被计算并填充好了。举例推导手动模拟一个小规模例子验证你的状态定义、递推公式和遍历顺序是否正确。这是调试DP代码最有效的方法。让我们用经典的“最长上升子序列”问题来完整走一遍这个流程。问题描述给定一个整数数组nums找到其中最长严格递增子序列的长度。步骤1定义状态dp[i]表示以nums[i]这个元素结尾的最长上升子序列的长度。注意这个定义它是以i结尾而不是[0...i]区间内的最大值。这个定义保证了状态的无后效性并且更容易找到递推关系。步骤2确定递推公式对于位置i我们如何得到dp[i]我们需要回头看所有在i之前的位置j(0 j i)。如果nums[i] nums[j]那么nums[i]就可以接在以nums[j]结尾的子序列后面形成一个更长的上升子序列。因此dp[i]应该取所有满足条件的dp[j] 1中的最大值。如果没有任何j满足nums[i] nums[j]那么dp[i] 1它自己构成一个子序列。 公式为dp[i] max(dp[j] 1) for all j i and nums[i] nums[j]初始值可设为1。步骤3初始化每个位置至少可以以自己结尾长度为1。所以dp数组初始化为全1。步骤4确定遍历顺序dp[i]依赖于所有j i的dp[j]。因此i的遍历顺序必然是从前往后0到n-1。对于每个固定的i内层循环j从0遍历到i-1。步骤5举例推导以nums [10, 9, 2, 5, 3, 7, 101, 18]为例。i0, nums[0]10, dp[0]1。i1, nums[1]9前面没有比9小的dp[1]1。i2, nums[2]2前面没有比2小的dp[2]1。i3, nums[3]5前面 nums[2]2 5所以 dp[3] dp[2]1 2。i4, nums[4]3前面 nums[2]2 3所以 dp[4] dp[2]1 2。i5, nums[5]7前面 nums[2]2, nums[3]5, nums[4]3 都小于7取最大的dp值加1即 max(dp[2], dp[3], dp[4]) 1 2 1 3。... 依次计算最终dp数组为[1,1,1,2,2,3,4,4]。整个数组的最长上升子序列长度就是dp数组中的最大值4。代码实现如下def lengthOfLIS(nums): if not nums: return 0 n len(nums) dp [1] * n # 初始化每个元素自身至少是一个长度为1的子序列 max_length 1 for i in range(n): for j in range(i): if nums[i] nums[j]: dp[i] max(dp[i], dp[j] 1) max_length max(max_length, dp[i]) # 随时更新全局最大值 return max_length3.2 遍历顺序自底向上法的灵魂所在遍历顺序是自底向上法最容易出错的地方。它完全由状态之间的依赖关系决定。我们来看几个典型场景一维DP简单依赖如斐波那契dp[i]依赖dp[i-1]和dp[i-2]。那么i必须从2开始从前向后遍历这样才能保证在计算dp[i]时dp[i-1]和dp[i-2]已有值。二维DP顺序依赖例如在“不同路径”问题中dp[i][j]到达(i,j)的路径数依赖dp[i-1][j]和dp[i][j-1]。这要求我们遍历i和j时必须保证在计算(i, j)时(i-1, j)和(i, j-1)已经计算好。通常采用两层循环外层遍历行i从0到m-1内层遍历列j从0到n-1或者行列互换这个顺序是满足条件的。二维DP倒序依赖这是01背包问题的核心难点。在经典的二维数组解法中dp[i][c]表示前i件物品在容量c下的最大价值。其状态转移为dp[i][c] max(dp[i-1][c], dp[i-1][c-weight[i]] value[i])。注意它依赖的是i-1行的数据。如果我们用一维数组滚动数组进行空间优化状态定义为dp[c]表示容量c下的最大价值递推式为dp[c] max(dp[c], dp[c - weight[i]] value[i])。此时我们必须从大到小遍历容量c。因为dp[c]更新时依赖的是当前物品枚举之前、也就是“上一轮”的dp[c - weight[i]]。如果从小到大遍历dp[c - weight[i]]可能在本轮已经被更新过即已经考虑了当前物品这就变成了“完全背包”问题的逻辑导致物品被重复添加。# 01背包核心循环一维数组优化后 def knapsack_01(weights, values, capacity): n len(weights) dp [0] * (capacity 1) for i in range(n): # 遍历物品 # 必须倒序遍历容量这是关键。 for c in range(capacity, weights[i] - 1, -1): dp[c] max(dp[c], dp[c - weights[i]] values[i]) return dp[capacity]提示当你对遍历顺序感到困惑时画出DP表手动模拟一下递推过程。思考在计算目标格子(i, j)时它所需要的其他格子是否已经被填充。这个方法能解决90%的遍历顺序问题。4. 备忘录法与自底向上法的深度对比与选型指南理解了两种方法的基本原理后我们需要在更深的层次上对比它们以便在实际问题中做出最佳选择。这不仅仅是“递归”和“迭代”的区别。4.1 思维模式的本质差异备忘录法自顶向下这是一种分解问题的思维。它从最终目标出发不断问“要解决这个问题我需要先解决哪些子问题” 它是一种“惰性”的、按需计算的方式思维路径是发散的最终通过备忘录收拢。自底向上法迭代递推这是一种构建问题的思维。它从已知的基石开始不断问“基于我已经知道的结果下一步能计算出什么” 它是一种“主动”的、系统性的构建方式思维路径是线性的、确定的。4.2 性能与资源消耗的微观分析很多人认为自底向上法一定更快因为它没有递归开销。但在大O时间复杂度上两者解决同一个DP问题通常是相同的因为它们都计算了每个子问题一次理想情况下。然而在常数时间和实际性能上存在细微差别时间开销递归开销备忘录法的函数调用、上下文切换确实会带来额外的开销。对于状态数极多百万级以上的问题这种开销可能变得显著。计算顺序自底向上法通常有非常规整的循环对CPU缓存友好特别是遍历多维数组时可能具有更好的局部性。而备忘录法的访问模式取决于递归树可能不那么规律。惰性计算优势如前所述备忘录法可能只计算部分状态。在状态空间巨大但实际可达状态稀疏的问题中这可能带来巨大的时间优势。空间开销显式空间两者都需要存储子问题的解空间复杂度主体相同。隐式空间备忘录法需要额外的调用栈空间存在栈溢出风险。自底向上法通常只有DP表的空间。空间优化自底向上法更容易进行空间优化如滚动数组。因为我们对遍历顺序有完全的控制权知道哪些旧状态不会再被使用。备忘录法由于计算顺序不确定进行同样的优化要困难得多。4.3 适用场景与实战选型建议根据我多年的刷题和项目经验可以总结出以下选型指南优先选择备忘录法的情况问题定义本身就是递归的例如树形DP如二叉树中的最大路径和问题的结构天然是递归的用备忘录法写起来逻辑非常清晰直观。状态空间不规则或稀疏当状态转移图不是规整的网格或者很多状态在求解最优解时根本不会被访问到。例如在一些图上的DP或某些组合问题中。原型验证和快速实现当你需要快速验证DP思路是否正确时备忘录法可以让你几乎无脑地将递归思路转化为可运行的代码是绝佳的思维实验工具。优先选择自底向上法的情况问题规模极大递归深度深这是最直接的原因为了避免栈溢出。状态空间规整且密集例如大多数基于序列或矩阵的经典DP问题LCS编辑距离不同路径等。自底向上的循环遍历在这种场景下非常高效。需要极致的性能或空间优化当你需要应用滚动数组等技巧来将空间复杂度从O(n^2)降到O(n)时自底向上法是唯一的选择。问题存在明显的计算顺序例如在依赖问题如课程安排或DAG上的DP自底向上的拓扑排序思路非常自然。一个实用的策略在面试或竞赛中我通常会先用备忘录法快速写出一个正确解确保思路无误。如果题目对性能或空间有更高要求或者我明确知道这是一个经典的自底向上问题我会再将其重构为自底向上版本并考虑空间优化。对于日常开发如果问题规模可控选择你思维负担更小、更容易维护的那种。5. 从经典问题看优化技巧的融会贯通掌握了两种基本方法我们还需要一些“组合技”来应对更复杂的情况。让我们通过两个经典问题看看如何灵活运用和融合这些技巧。5.1 空间优化滚动数组与状态压缩这是自底向上法最强大的武器之一。其核心思想是DP表中有很多状态在计算完后续状态后就不再需要了我们可以复用存储空间。滚动数组最常见的形式。例如在斐波那契数列中我们只需要保存前两个状态。def fib_iter_opt(n): if n 1: return n prev, curr 0, 1 # 分别代表 dp[i-2], dp[i-1] for i in range(2, n 1): prev, curr curr, prev curr # 滚动更新 return curr在二维DP中如果dp[i][...]只依赖于dp[i-1][...]如01背包的二维数组形式我们可以将二维数组压缩成两个一维数组甚至一个一维数组配合倒序遍历如前文01背包示例。状态压缩通常用于基于集合的状态DP如旅行商问题。如果状态可以用一个整数的二进制位来表示例如mask的二进制第i位为1表示第i个城市已访问那么我们可以用dp[mask][i]这样的形式将状态存储在数组里。虽然叫“压缩”但它更多是一种状态表示技巧配合自底向上遍历所有mask状态。5.2 时间优化利用数据结构加速状态转移有时状态转移方程本身需要在一个范围内查找最优值如果朴素遍历会使得时间复杂度升高。此时可以引入数据结构进行优化。案例最长上升子序列的O(n log n)解法我们前面给出了O(n^2)的解法。如何优化关键在于dp[i] max(dp[j] 1) for j i and nums[j] nums[i]这个查找过程。我们可以维护一个数组tails其中tails[k]存储长度为 k1 的所有上升子序列中结尾数字最小的那个。这个数组是单调递增的为什么因为更长的子序列其结尾数字不可能比更短的小。这样对于每个新来的数字nums[i]我们只需要在tails数组中二分查找第一个大于等于nums[i]的位置。如果找到说明我们可以用nums[i]替换那个位置的数以得到一个结尾更小的相同长度的序列。如果没找到即nums[i]比所有结尾都大就把它追加到tails末尾意味着我们发现了更长的上升子序列。这个过程将内层的O(n)查找优化为了O(log n)。def lengthOfLIS_optimized(nums): tails [] for num in nums: # 二分查找 leftmost position to replace left, right 0, len(tails) while left right: mid (left right) // 2 if tails[mid] num: left mid 1 else: right mid if left len(tails): tails.append(num) # 延长序列 else: tails[left] num # 替换使该长度序列的结尾最小化 return len(tails) # tails的长度就是LIS的长度这个解法虽然最终得到的tails不一定是一个真实的LIS但其长度一定等于LIS的长度。它展示了如何通过改变状态定义从“以i结尾的长度”变为“长度为k的最小结尾”和利用数据结构来优化DP的时间复杂度。5.3 当两种方法相遇用自底向上实现“记忆化搜索”这是一个高级技巧。对于一些状态转移非常复杂用循环难以理清顺序的问题我们有时可以用迭代的方式模拟备忘录法的计算过程。通常这需要借助一个队列BFS或栈DFS来显式管理待计算的状态。例如在一个有向无环图DAG上求最长路径每个节点的值依赖于其所有前驱节点。用备忘录法写递归很自然。用自底向上法我们需要先对图进行拓扑排序然后按拓扑序递推。但如果我们不知道拓扑序或者图不是严格的DAG我们可以用“记忆化搜索栈”的方式初始化所有节点的dp值为未知如-inf。将目标节点入栈。弹出栈顶节点u如果它的dp[u]已知则继续。否则检查它的所有前驱节点。如果某个前驱v的dp[v]未知则将u重新入栈稍后再算再将v入栈先去算v。如果所有前驱的dp值都已知则根据转移方程计算dp[u]。 这个过程本质上是用栈手动模拟了系统的递归调用栈实现了自底向上的计算。虽然不常用但在处理复杂依赖时是一个有力的工具。6. 避坑指南动态规划实战中的高频失误理论懂了例题也会了但自己写还是容易出错。下面是我总结的几个最容易翻车的地方附上根因分析和排查方法。失误1状态定义模糊或不具“无后效性”现象代码看起来没问题但结果不对或者状态转移方程极其复杂。根因状态的定义没有捕捉到问题的本质。“无后效性”是指一旦当前状态确定后续决策就只与当前状态有关而与如何到达这个状态的路径无关。如果状态定义包含了“历史信息”比如“是否使用过某个物品”而这个信息又会影响后续决策那么状态空间就会爆炸或者转移方程难以书写。排查与修正重新审视问题。尝试不同的状态定义角度。经典的角度有“以 i 结尾”、“考虑到第 i 个位置”、“处于某种情形下”。确保你的dp数组的每个元素都能唯一、完整地描述一个决策阶段的情况。失误2初始化错误或遗漏边界条件现象程序在开头几个case或某些边界情况如空输入、零值下出错。根因没有仔细考虑递推的起点。DP的递推像是多米诺骨牌第一块牌没摆好后面全倒。排查与修正务必手动推导 n0, n1 的情况。问自己dp[0]或dp[0][0]应该是什么循环的起始下标应该是多少对于涉及索引减法的转移方程如dp[i] dp[i-1] ...要确保i-1不会越界。失误3遍历顺序与状态依赖关系不匹配现象程序输出随机值、旧值或者逻辑上明显不对。根因在计算dp[i][j]时它所依赖的dp[i-1][j]或dp[i][j-1]还没有被正确计算出来可能还是初始值。排查与修正画图画一个小的DP表格用你的循环顺序一步步模拟填充过程。检查每个格子被填充时它依赖的格子是否已经填充了正确的值。这是调试DP最直观有效的方法。失误4空间优化时旧状态被意外覆盖现象使用滚动数组或一维数组优化后结果与未优化版本不一致。根因遍历顺序没有相应调整。最典型的就是01背包问题中一维数组必须倒序遍历容量。因为dp[c]依赖于“旧”的dp[c - weight[i]]正序遍历会使用本轮刚更新过的“新”值。排查与修正理解“依赖的是上一轮i-1的状态还是本轮i的状态”。如果依赖上一轮就必须保证在更新dp[x]时它所依赖的dp[y](y x) 还保持着上一轮的值。通常这意味着需要逆序遍历。失误5混淆“子序列”与“子数组”现象解“最长上升子序列”和“最大子数组和”问题时状态定义和初始化混淆。根因这两个问题看似相似实则不同。“子序列”可以不连续因此dp[i]通常定义为“以 i 结尾”的某种性质并且需要内层循环j来寻找前驱。而“子数组”必须连续因此dp[i]通常定义为“以 i 结尾”的某种性质并且转移只依赖于dp[i-1]因为连续前一个元素必须是i-1。排查与修正仔细读题明确“连续”这个条件。子数组问题如最大子数组和的DP通常是O(n)的而子序列问题如LIS的朴素DP是O(n^2)的。这是一个重要的区分信号。动态规划的掌握是一个从模仿到理解再到灵活创造的过程。备忘录法和自底向上法是你武器库中的两件核心装备。我的建议是初期多练习自底向上法因为它强迫你明确状态定义、转移方程和计算顺序这是DP的基本功。同时也要会用备忘录法来快速验证思路。当你对一个问题能熟练地在两种思路间切换并能根据场景选择最合适的方法甚至进行优化时你就真正驾驭了动态规划这门艺术。最后多总结错误每一个掉进去的坑都是通往精通的垫脚石。
返回列表