ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

动态规划核心思想与实战:从最优子结构到经典案例解析

动态规划核心思想与实战:从最优子结构到经典案例解析 1. 项目概述为什么动态规划是算法面试的“定海神针”如果你刷过LeetCode或者准备过技术面试对“动态规划”这四个字一定不会陌生。它几乎是算法题中最高频、最核心也最让初学者头疼的考点之一。我见过太多朋友一看到题目描述里有“最长”、“最短”、“最多”、“最少”这类字眼再结合重叠子问题的特征心里就咯噔一下“完了又是动态规划。”然后就开始机械地套公式结果往往是状态定义错误、转移方程写不出来或者代码写出来漏洞百出。动态规划Dynamic Programming 简称DP之所以难不是因为它背后的数学有多高深而是因为它要求我们具备一种将复杂问题“拆解”并“记录”的思维模式这种模式和我们直观的、线性的思考习惯不太一样。简单来说动态规划就是一种通过把原问题分解为相对简单的子问题的方式来高效求解复杂问题的方法。它的核心思想就两点记忆化存储和最优子结构。为了避免重复计算相同的子问题我们会把每个子问题的解存起来而大问题的最优解可以通过其子问题的最优解组合得到。这听起来有点像分治法但最大的区别在于动态规划的子问题往往是重叠的而分治法的子问题通常是独立的。举个例子计算斐波那契数列用递归分治会重复计算大量f(3), f(2)而动态规划则从f(1), f(2)开始一步步推导并记录效率天差地别。这篇文章我想彻底讲清楚动态规划。我不会只给你一堆抽象的定义和公式而是会结合我这些年面试别人和被面试的经验从为什么需要DP开始拆解它的核心思维框架然后手把手带你推导几个经典到不能再经典的应用案例最后分享一套我实战中总结的解题模板和避坑指南。无论你是正在啃《算法导论》的学生还是备战金三银四的求职者抑或是想巩固基础的在职工程师我希望这篇超详细的总结能成为你手边常备的参考资料。我们不止要“会用”DP更要“懂”它最终达到看到问题就能下意识反应出DP解法的境界。2. 动态规划的核心思想与两大要素拆解很多教程一上来就扔出状态转移方程我觉得这是本末倒置。在写方程之前我们必须深刻理解支撑动态规划成立的两个基石这也是判断一个问题能否用DP解决的黄金标准。2.1 最优子结构大问题的最优解包含小问题的最优解这是动态规划能生效的前提。它意味着一个问题的最优解可以通过组合其子问题的最优解来获得。注意是“最优解”而不是“某个解”。这听起来有点绕我们用一个最直观的例子——最短路径问题来解释。假设我们要从城市A到城市D中间必须经过B或C。那么从A到D的最短路径一定是“从A到B的最短路径加上B到D的边”与“从A到C的最短路径加上C到D的边”这两者中的更短者。这里“从A到D”是大问题“从A到B”和“从A到C”就是它的子问题。大问题A-D的最优解最短路径依赖于子问题A-B, A-C的最优解各自的最短路径。如果子问题取的不是最优解比如从A到B走了一条很绕的路那么拼凑出来的A-D路径也必然不是最短的。一个常见的误区有些人会把“问题能分解”等同于“具有最优子结构”。不是的。举个例子求无权图的最长简单路径不能重复经过节点。假设A-B-C是从A到C的最长路径但这并不意味着A-B是A到B的最长路径。很可能A-B有一条更长的路径但那条路径上的某个节点在A-B-C中已经用过了导致无法构成A-C的简单路径。因此最长简单路径问题就不具有最优子结构无法用标准的动态规划求解。实操心得面试中当你尝试用DP解题时首先要向面试官也是向自己论证该问题具有最优子结构。你可以这样思考“要得到dp[i]的最优值我是不是只需要知道dp[0...i-1]这些子问题的最优值然后通过某种计算取最大、最小、相加等就能得到”如果答案是肯定的那么这条路很可能走得通。2.2 重叠子问题递归算法会反复求解相同的子问题这是动态规划提升效率的关键。如果子问题是不重叠的比如归并排序那么用分治法就非常高效每个子问题只计算一次。但动态规划面对的场景通常是递归树中有大量相同的节点被重复计算。我们再用斐波那契数列F(n) F(n-1) F(n-2)来可视化一下。用递归计算F(5)F(5) / \ F(4) F(3) / \ / \ F(3) F(2) F(2) F(1) / \ F(2) F(1)可以看到F(3)计算了两次F(2)计算了三次。当n更大时这种重复是指数级增长的。动态规划正是看到了这一点它选择只计算每个独特的子问题一次并把结果存起来这个过程叫“记忆化搜索”或“制表法”下次需要时直接查表从而将时间复杂度从指数级O(2^n)降到了线性级O(n)。如何识别重叠子问题一个很实用的方法是在你大脑里或者草稿纸上画出问题的递归树。如果发现树中有多个相同的节点代表相同的子问题参数那么它大概率就有重叠子问题。对于线性DP如背包、最长子序列重叠性往往非常明显。注意事项具有重叠子问题是用DP进行优化的理由但不是DP可用的必要条件。有些问题具有最优子结构但子问题不重叠如某些特定形式的分治理论上也可以用DP思想但可能提升不了效率。我们通常关注的是两者都具备的经典DP问题。2.3 思维框架从暴力递归到动态规划的优雅过渡理解两大要素后我们来看看如何系统性地思考一个DP问题。我推荐一个四步法这也是面试中梳理思路的绝佳方式定义状态定义dp数组这是最关键的一步直接决定了问题能否顺利解决。状态就是描述一个子问题的变量。通常我们需要问自己问题中哪些变量在变化变化的范围是什么一个状态必须能够唯一标识一个子问题。例如在背包问题中变化的量是“当前考虑的物品序号i”和“剩余的背包容量j”所以状态可以定义为dp[i][j]。确定状态转移方程这是DP的灵魂描述了子问题之间的关系。也就是如何从已知的、更小的子问题的解推导出当前子问题的解。通常形式是dp[状态] 某种操作(dp[状态1], dp[状态2], ...)。例如斐波那契的转移方程就是dp[i] dp[i-1] dp[i-2]。初始化基础状态任何递推都需要一个起点。我们需要明确最小的、不可再分的子问题的解是什么并正确地为dp数组赋初值。比如在斐波那契中dp[0]0, dp[1]1。初始化错误会导致整个递推链崩塌。确定计算顺序与输出我们需要决定以什么顺序填充dp数组比如是从前到后还是从后到前以确保在计算dp[当前状态]时它所依赖的dp[更小子状态]都已经被计算出来了。最后dp数组的哪个位置存储了我们最终想要的答案。这套思维框架将贯穿我们后面所有的案例讲解。接下来我们就进入实战环节用几个经典案例把这套框架“焊死”在你的脑子里。3. 经典案例深度剖析从入门到精通理论讲得再多不如实际解一道题。我挑选了三个难度递进、面试超高频的案例我们会严格按照上面的四步法一步步推导出代码。3.1 案例一爬楼梯问题LeetCode 70—— 一维DP的起点问题描述假设你正在爬楼梯。需要n阶你才能到达楼顶。每次你可以爬1或2个台阶。你有多少种不同的方法可以爬到楼顶第一步定义状态我们问自己变化的量是什么是当前所在的楼梯阶数。设dp[i]为爬到第i阶楼梯有多少种不同的方法。这就是我们的状态定义。i的范围是从0到n。第二步推导状态转移方程思考要爬到第i阶最后一步只能是从第i-1阶爬1阶上来或者从第i-2阶爬2阶上来。既然最后一步只有这两种互斥的可能性那么爬到第i阶的总方法数就等于爬到第i-1阶的方法数加上爬到第i-2阶的方法数。 所以我们的状态转移方程是dp[i] dp[i-1] dp[i-2]。看这就是斐波那契数列但它的物理意义完全不同。第三步初始化基础状态我们需要最小的子问题解。dp[0]表示爬到第0阶地面有几种方法只有一种就是不爬。所以dp[0] 1。dp[1]呢爬到第1阶只能从地面爬1阶所以只有1种方法。dp[1] 1。 有了dp[0]和dp[1]我们就可以推出dp[2],dp[3]... 直到dp[n]。第四步计算顺序与输出计算顺序很自然从i2开始一直计算到in。最终答案就是dp[n]。代码实现与优化def climbStairs(n: int) - int: if n 1: return 1 # 初始化 dp [0] * (n 1) dp[0], dp[1] 1, 1 # 状态转移 for i in range(2, n 1): dp[i] dp[i-1] dp[i-2] return dp[n]空间优化注意到dp[i]只依赖于前两个状态dp[i-1]和dp[i-2]我们可以用两个变量滚动记录将空间复杂度从 O(n) 降到 O(1)。def climbStairs_opt(n: int) - int: if n 1: return 1 prev, curr 1, 1 # 分别代表 dp[i-2], dp[i-1] for i in range(2, n 1): # 计算新的 dp[i] new prev curr # 滚动更新 prev, curr curr, new return curr避坑技巧面试时写完基础DP解法后如果能主动提出空间优化方案绝对是加分项。可以先写出清晰的二维或一维dp数组版本然后说“这里我们可以观察到状态转移只依赖于前几个状态因此可以进一步优化空间到O(1)”再写出优化版本。这体现了你对算法本质的深刻理解。3.2 案例二背包问题0-1背包—— 二维DP的典范背包问题是动态规划的“必修课”0-1背包更是基础中的基础。理解它很多复杂的变种问题如分割等和子集、目标和都能迎刃而解。问题描述给你一个可装载重量为W的背包和N个物品。每个物品有重量wt[i]和价值val[i]。现在让你用这个背包装物品每个物品最多装一个即0-1选择问在不超过背包容量的前提下能装下的最大价值是多少第一步定义状态变化量有两个1.当前可供选择的物品范围前i个物品2.背包的剩余容量w。因此我们定义一个二维DP数组dp[i][w]表示对于前i个物品当前背包容量为w时可以装下的最大价值。 这里i的范围是[0, N]w的范围是[0, W]。dp[0][...]表示前0个物品即没有物品可选。第二步推导状态转移方程对于每个物品i注意我们的i是从1开始编号的对应物品列表中的第i-1个我们面对容量w的背包只有两种选择不把物品i装进背包那么最大价值就是前i-1个物品在容量w下的最大价值即dp[i-1][w]。把物品i装进背包前提是背包能装下即w wt[i-1]那么最大价值就是“物品i的价值val[i-1]”加上“前i-1个物品在剩余容量w - wt[i-1]下的最大价值”即val[i-1] dp[i-1][w - wt[i-1]]。我们的目标是最大化价值所以在这两种选择中取最大值如果 w wt[i-1]装不下: dp[i][w] dp[i-1][w] 否则能装下: dp[i][w] max(dp[i-1][w], val[i-1] dp[i-1][w - wt[i-1]])这就是经典的状态转移方程。第三步初始化基础状态当物品数量为0 (i0) 时无论背包容量多大能装的最大价值都是0。即dp[0][w] 0。 当背包容量为0 (w0) 时无论有多少物品都无法装入最大价值也是0。即dp[i][0] 0。 在代码中我们通常将整个dp数组初始化为0这恰好满足了上述初始化条件。第四步计算顺序与输出我们需要一个两层循环。外层循环遍历物品i从1到N内层循环遍历背包容量w从1到W或者从0到W但容量0已经初始化了。这样能保证在计算dp[i][w]时它所依赖的dp[i-1][w]和dp[i-1][w-wt[i-1]]都已经被计算出来了。 最终答案存储在dp[N][W]。代码实现def knapsack(W: int, wt: List[int], val: List[int], N: int) - int: # 初始化dp表尺寸为 (N1) x (W1)全部为0 dp [[0] * (W 1) for _ in range(N 1)] # 状态转移 for i in range(1, N 1): # 遍历物品 for w in range(1, W 1): # 遍历容量 if w wt[i-1]: # 当前背包容量装不下第i个物品 dp[i][w] dp[i-1][w] else: # 能装下进行决策 dp[i][w] max( dp[i-1][w], # 不装 val[i-1] dp[i-1][w - wt[i-1]] # 装 ) return dp[N][W]空间优化滚动数组 仔细观察状态转移方程dp[i][w]只依赖于上一行dp[i-1][...]的数据。这意味着我们不需要保存整个N行的表格只需要保存一行代表上一行即可。但内层循环遍历容量w时如果从左到右遍历在计算dp[w]新行时它用到的dp[w - wt[i-1]]可能已经被更新成新行的值了即dp[i][w-wt[i-1]]而我们需要的是旧行的值dp[i-1][w-wt[i-1]]。 解决方法内层循环从右向左遍历。这样在计算新dp[w]时它右侧的dp[w]是新值但左侧的dp[w]都还是旧值保证了状态转移的正确性。def knapsack_opt(W: int, wt: List[int], val: List[int], N: int) - int: dp [0] * (W 1) # 一维数组dp[w]表示容量w下的最大价值 for i in range(1, N 1): # 必须倒序遍历确保每个物品只被计算一次0-1背包特性 for w in range(W, wt[i-1] - 1, -1): dp[w] max(dp[w], val[i-1] dp[w - wt[i-1]]) return dp[W]核心要点0-1背包的内层循环必须倒序这是为了保证每个物品最多被放入一次。如果是完全背包物品无限内层循环就需要正序。这个细节是面试常考点务必理解其背后的原因。3.3 案例三最长公共子序列LeetCode 1143—— 字符串DP的经典LCS问题是二维DP的另一个里程碑它教会我们如何对两个序列进行“对齐”比较。问题描述给定两个字符串text1和text2返回这两个字符串的最长公共子序列Longest Common Subsequence的长度。子序列是指在不改变字符相对顺序的情况下通过删除某些字符后形成的新字符串。第一步定义状态变化量是两个字符串的前缀长度。定义dp[i][j]表示text1的前i个字符即text1[0:i]和text2的前j个字符即text2[0:j]的最长公共子序列的长度。这里i和j的范围分别是[0, len(text1)]和[0, len(text2)]。第二步推导状态转移方程我们考虑如何从已知的小问题推到dp[i][j]。比较text1的第i个字符text1[i-1]和text2的第j个字符text2[j-1]如果两个字符相等那么这个字符一定在LCS中。LCS的长度就等于“text1前i-1个字符和text2前j-1个字符的LCS长度”加1。即dp[i][j] dp[i-1][j-1] 1。如果两个字符不相等那么text1[i-1]和text2[j-1]不可能同时出现在LCS中。LCS的长度有两种可能它可能是text1前i-1个字符和text2前j个字符的LCS即不考虑text1[i-1]。也可能是text1前i个字符和text2前j-1个字符的LCS即不考虑text2[j-1]。 我们取两者的最大值因为我们要找的是最长的。即dp[i][j] max(dp[i-1][j], dp[i][j-1])。第三步初始化基础状态当一个字符串的前缀长度为0时即空字符串它与任何字符串的LCS长度都是0。所以dp[i][0] 0对于所有idp[0][j] 0对于所有j同样在代码中初始化全0数组即可满足。第四步计算顺序与输出我们需要两层循环外层i从1到len(text1)内层j从1到len(text2)。这样能保证在计算dp[i][j]时dp[i-1][j-1],dp[i-1][j],dp[i][j-1]都已就绪。 最终答案在dp[m][n]其中m len(text1),n len(text2)。代码实现def longestCommonSubsequence(text1: str, text2: str) - int: m, n len(text1), len(text2) dp [[0] * (n 1) for _ in range(m 1)] # 创建 (m1) x (n1) 的矩阵 for i in range(1, m 1): for j in range(1, n 1): if text1[i-1] text2[j-1]: dp[i][j] dp[i-1][j-1] 1 else: dp[i][j] max(dp[i-1][j], dp[i][j-1]) return dp[m][n]重构LCS序列进阶 DP表不仅存储了长度还隐含了路径信息。要输出具体的LCS字符串我们可以从dp[m][n]开始反向追踪def getLCS(text1: str, text2: str, dp: List[List[int]]) - str: i, j len(text1), len(text2) lcs_chars [] while i 0 and j 0: if text1[i-1] text2[j-1]: # 字符相等属于LCS记录并跳向左上角 lcs_chars.append(text1[i-1]) i - 1 j - 1 elif dp[i-1][j] dp[i][j-1]: # 来自上方说明text1[i-1]不在LCS中 i - 1 else: # 来自左方说明text2[j-1]不在LCS中 j - 1 # 因为我们是从后往前记录的需要反转 return .join(reversed(lcs_chars))经验之谈LCS的DP表是一个非常好的教学工具。在面试白板 coding 时画出一个简单的例子比如text1abcde,text2ace的DP表填充过程能非常清晰地向面试官展示你的思路。表格的左上角到右下角的数字增长路径直观地展示了状态是如何转移的。4. 动态规划的解题模板与高阶技巧通过上面三个案例你应该已经感受到了DP的套路。下面我总结一个更通用的解题模板和一些应对复杂情况的高阶技巧。4.1 通用解题四步法模板无论题目怎么变以下四步是思考的主轴状态定义思考“影响问题结果的、变化的参数是什么”常见形式一维DPdp[i]常表示以位置i结尾的某种最优解如最大子数组和、最长递增子序列。二维DPdp[i][j]常表示涉及两个序列/维度的问题如LCS、编辑距离或者带状态的单序列问题如股票买卖问题中的dp[i][0/1]表示第i天持有/不持有股票。带额外状态的DP有时需要增加一维来表示状态比如dp[i][k]表示使用k次机会到达i状态的最优解。状态转移方程思考“当前状态如何由之前的状态推导而来” 这是最考验思维的一步。方法论想象你站在状态dp[x]上看看“最后一步”可能做了什么操作从而到达这个状态。然后枚举所有可能的“前一个状态”并取最优。书写用数学公式或伪代码清晰地写出dp[新状态] F(dp[状态1], dp[状态2], ...)。初始化思考“最小、最基础的情况边界条件的解是什么”技巧通常dp[0],dp[0][0]这类下标为0的状态需要手动初始化。有时为了代码简洁会将dp数组整体初始化为一个边界值如0或无穷大但要确保这个初始值不会影响后续递推的正确性例如求最小值时初始化为无穷大。计算顺序与答案思考“以什么顺序填表才能保证计算当前状态时它所依赖的状态都已经计算好了”常见顺序一维DP通常从左到右二维DP通常是双重循环根据依赖关系决定i和j的遍历方向多数是从左上到右下。答案位置最终答案不一定在dp[n]可能是max(dp)或dp[m][n]需要根据问题具体分析。4.2 状态压缩技巧空间优化这是面试中的高频考点展示了你对算法效率的追求。滚动数组当状态转移只依赖于有限的前几个状态时如斐波那契、爬楼梯可以用几个变量滚动更新将空间复杂度从 O(n) 降到 O(1)。降维打击在二维DP中如0-1背包如果当前行只依赖于上一行则可以将二维数组压缩成一维数组通过逆序更新来保证每个状态只被计算一次。这是必须掌握的技巧。技巧核心分析状态转移方程找出真正的依赖关系。在更新一维数组时问自己如果正序更新会不会覆盖掉后续计算还需要用到的“旧值”如果是就改用逆序。4.3 记忆化搜索自顶向下 vs. 制表法自底向上我们之前讲的都是“制表法”Tabulation即从小问题开始迭代填表。还有一种思路是“记忆化搜索”Memoization它更贴近我们自然的递归思维。记忆化搜索从大问题开始递归地解决子问题但在每次求解子问题前先查表看是否已经计算过。如果计算过直接返回如果没有则计算并存入表中。它写起来像递归但通过“记忆化”避免了重复计算。对比与选择制表法迭代通常更高效避免了递归的函数调用开销。思考顺序是“从小到大的递推”。记忆化搜索递归思维更直观尤其是对于状态转移不那么规则的问题。代码更简洁但可能有栈溢出风险对于深度很大的递归。建议在面试中如果问题有明显的递推顺序优先用制表法。如果状态转移关系复杂或者你想先向面试官展示递归思路可以先用记忆化搜索并说明可以优化为迭代。记忆化搜索示例斐波那契def fib_memo(n: int) - int: memo [-1] * (n 1) # 记忆化数组-1表示未计算 def helper(x): if x 1: return x if memo[x] ! -1: # 已经计算过 return memo[x] memo[x] helper(x-1) helper(x-2) # 计算并存储 return memo[x] return helper(n)5. 实战避坑指南与高频问题解析掌握了框架和经典案例我们来看看实际解题和面试中那些容易踩的坑。5.1 如何识别一道题可以用动态规划我总结了一个快速判断 checklist问题求的是“最值”最大值、最小值、最长、最短、最多、最少。问题可以被分解为相似的子问题。子问题之间是重叠的通过画递归树或直觉判断。子问题的最优解能构成原问题的最优解最优子结构。如果满足以上几点尤其是1和2就可以强烈考虑动态规划。常见的DP问题类型包括背包问题、序列问题LIS, LCS、字符串编辑距离、区间DP、状态机DP如股票买卖、路径规划等。5.2 状态定义常见陷阱与纠正陷阱一状态定义不完整。比如在“打家劫舍”问题中如果只定义dp[i]为偷前i个房子的最大金额无法处理“不能偷相邻房子”的约束。更优的定义是dp[i]表示考虑前i个房子且第i个房子可能偷也可能不偷时的最大金额。或者更精妙地定义dp[i][0/1]表示第i个房子不偷/偷时的最大金额。陷阱二状态维度不够。比如在“股票买卖”系列问题中除了天数i通常还需要一维来表示当前是否持有股票k0/1甚至还需要一维来表示交易次数限制。忽略这些维度就无法正确描述状态。纠正方法在定义状态时问自己“这个状态定义是否包含了做出决策所需要的所有信息” 如果发现无法仅凭dp[i]决定下一步就需要增加状态维度。5.3 初始化与边界处理的魔鬼细节下标偏移在字符串或序列问题中我们经常定义dp[i][j]表示前i个和前j个字符此时dp[0][j]和dp[i][0]对应空串需要初始化为0。但访问字符时要用text1[i-1]务必注意下标对应关系这是最常见的 off-by-one 错误。特殊值初始化求最小值问题时通常将dp数组初始化为一个很大的数如float(inf)但dp[0]这类起点状态要初始化为0或其他合法值。求最大值问题时有时需要初始化为很小的数或0。依赖检查在写循环时要确保dp[i]所依赖的所有状态如dp[i-1],dp[i-2]在计算dp[i]时都是有效的、已计算的。这决定了循环的起始点。5.4 高频面试题思路速览这里列举几个LeetCode上经典的DP问题及其核心思路你可以作为练习最长递增子序列 (LIS, LeetCode 300)状态dp[i]表示以nums[i]结尾的最长递增子序列长度。转移dp[i] max(dp[j]) 1对于所有j i且nums[j] nums[i]。优化可以用贪心二分查找将时间复杂度优化到 O(n log n)。编辑距离 (LeetCode 72)状态dp[i][j]表示将word1的前i个字符转换成word2的前j个字符所需的最少操作数。转移考虑对word1的最后一步操作插入、删除、替换取最小值。如果word1[i-1] word2[j-1]dp[i][j] dp[i-1][j-1]否则dp[i][j] min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) 1分别对应删除、插入、替换打家劫舍系列 (LeetCode 198, 213)状态dp[i]表示偷窃前i个房屋能获得的最高金额。核心约束不能偷相邻的。转移dp[i] max(dp[i-1], dp[i-2] nums[i-1])。即要么不偷第i个房子继承dp[i-1]要么偷第i个房子则不能偷第i-1个继承dp[i-2]并加上当前房子的钱。环形变种将环拆成两个线性问题——不考虑第一个房子和不考虑最后一个房子取两者最大值。零钱兑换 (LeetCode 322)状态dp[amount]表示凑成总金额amount所需的最少硬币个数。转移dp[amount] min(dp[amount - coin] 1)对于所有coin in coins且amount coin。初始化dp[0] 0其他初始化为一个很大的数如amount1。注意这是完全背包问题硬币无限所以内层循环遍历金额时需要正序。动态规划的学习曲线确实比较陡峭但一旦你突破了那个“顿悟”的点就会发现很多问题都豁然开朗。我的建议是先把上面几个经典案例反复琢磨透理解每一步为什么这么做。然后集中刷一个专题比如先把LeetCode上DP标签下的简单和中等题刷完在实战中不断应用和巩固四步法。遇到难题时不要急着看答案先自己画图、定义状态、尝试推导方程哪怕想不出来这个过程也能极大提升你的DP思维。最后别忘了和他人讨论或者看看高质量的题解吸收不同的思路。坚持下去动态规划终将成为你算法武器库中最锋利的一把剑。
返回列表