1. 动态规划专题核心概念回顾动态规划作为算法领域的核心方法论在各类编程竞赛和面试中占据着重要地位。这个专题我们将深入探讨动态规划中那些看似简单却暗藏玄机的经典题型。记得去年我在准备算法面试时曾经花了整整两周时间专门研究动态规划的各种变种那段经历让我深刻理解了看似简单的题目背后往往隐藏着精妙的设计思路。动态规划本质上是一种通过将原问题分解为相对简单的子问题的方式来求解复杂问题的方法。它特别适用于具有重叠子问题和最优子结构性质的问题。在实际应用中我们通常会遇到两类典型的动态规划场景一类是求最优解的问题比如背包问题、最短路径问题另一类是计数类问题比如爬楼梯的不同方式计算。2. 经典题型深度解析2.1 背包问题的变种与优化背包问题可以说是动态规划中最具代表性的问题了。标准的0-1背包问题大家应该都很熟悉给定一组物品每种物品都有自己的重量和价值在限定的总重量内如何选择物品使得总价值最大。但面试和竞赛中往往会出现各种变种这才是真正考验理解深度的地方。比如完全背包问题与0-1背包不同的是每种物品可以选取无限次。这个变化看似简单却需要我们对状态转移方程进行重新思考。在代码实现上只需要将0-1背包的内层循环从倒序改为正序即可但这个改动背后的原理值得深思def complete_knapsack(W, wt, val, n): dp [0]*(W1) for i in range(n): for j in range(wt[i], W1): dp[j] max(dp[j], dp[j - wt[i]] val[i]) return dp[W]另一个有趣的变种是多重背包问题它介于0-1背包和完全背包之间每种物品有固定的数量限制。这类问题通常可以通过二进制拆分的方法转化为0-1背包问题来处理。2.2 股票买卖系列问题这个系列的问题在面试中出现的频率极高它完美展现了动态规划如何应用于实际场景。问题的基本形式是给定一个数组表示股票每天的价格设计算法计算可以获得的最大利润。根据交易次数的限制一次、多次、两次、k次以及是否有冷冻期、手续费等条件可以衍生出多种变体。以最复杂的最多完成k笔交易为例我们需要定义一个三维DP数组其中三个维度分别表示天数、交易次数、持有状态。状态转移方程需要考虑买入、卖出、持有三种操作def maxProfit(k, prices): if not prices: return 0 n len(prices) if k n//2: return sum(max(prices[i1]-prices[i],0) for i in range(n-1)) dp [[[0]*2 for _ in range(k1)] for __ in range(n)] for i in range(n): for j in range(k,0,-1): if i 0: dp[i][j][0] 0 dp[i][j][1] -prices[i] else: dp[i][j][0] max(dp[i-1][j][0], dp[i-1][j][1]prices[i]) dp[i][j][1] max(dp[i-1][j][1], dp[i-1][j-1][0]-prices[i]) return dp[-1][k][0]这类问题的关键在于理清所有可能的状态以及状态之间的转移关系。在实际面试中面试官往往会从简单的一次交易开始逐步增加限制条件来考察候选人对动态规划的理解深度。3. 动态规划的优化技巧3.1 状态压缩与空间优化很多动态规划问题在初步解决时我们会使用二维甚至三维数组来存储中间状态。但实际上很多问题的状态转移只依赖于有限的几个前驱状态这就为空间优化提供了可能。以经典的最长公共子序列问题为例原始解法需要O(mn)的空间def LCS(text1, text2): m, n len(text1), len(text2) dp [[0]*(n1) for _ in range(m1)] for i in range(1, m1): for j in range(1, n1): 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]通过观察可以发现当前行的计算只依赖于上一行和当前行已计算的部分因此可以将空间优化到O(min(m,n))def LCS_optimized(text1, text2): if len(text1) len(text2): text1, text2 text2, text1 m, n len(text1), len(text2) prev [0]*(n1) for i in range(1, m1): curr [0]*(n1) for j in range(1, n1): if text1[i-1] text2[j-1]: curr[j] prev[j-1] 1 else: curr[j] max(prev[j], curr[j-1]) prev curr return prev[n]3.2 单调队列优化对于一些特殊形式的动态规划问题如滑动窗口最大值、特定形式的状态转移方程等可以使用单调队列来优化时间复杂度。这类优化技巧在竞赛中较为常见但在面试中也可能遇到。以滑动窗口最大值问题为例常规解法是使用最大堆时间复杂度为O(nlogk)。而使用单调队列可以将时间复杂度优化到O(n)def maxSlidingWindow(nums, k): from collections import deque q deque() res [] for i, num in enumerate(nums): while q and nums[q[-1]] num: q.pop() q.append(i) if q[0] i - k: q.popleft() if i k - 1: res.append(nums[q[0]]) return res这种优化技巧的关键在于维护一个单调递减的队列队列中存储的是可能成为窗口最大值的元素索引。每次窗口滑动时移除不在窗口范围内的元素并保持队列的单调性。4. 动态规划在字符串处理中的应用4.1 编辑距离问题编辑距离Levenshtein距离是衡量两个字符串相似度的重要指标它表示将一个字符串转换成另一个字符串所需的最少单字符编辑操作插入、删除、替换次数。这个问题在自然语言处理、DNA序列比对等领域有广泛应用。标准的动态规划解法使用一个二维DP数组其中dp[i][j]表示将word1的前i个字符转换为word2的前j个字符所需的最小操作数def minDistance(word1, word2): m, n len(word1), len(word2) dp [[0]*(n1) for _ in range(m1)] for i in range(m1): dp[i][0] i for j in range(n1): dp[0][j] j for i in range(1, m1): for j in range(1, n1): if word1[i-1] word2[j-1]: dp[i][j] dp[i-1][j-1] else: dp[i][j] 1 min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) return dp[m][n]这个问题有几个值得注意的细节初始化时空字符串到任意长度字符串的编辑距离就是该字符串的长度当字符匹配时直接继承左上角的值当字符不匹配时考虑三种操作中的最小值加一4.2 正则表达式匹配这是一个难度较大的动态规划问题题目要求实现支持.和的正则表达式匹配。其中.匹配任意单个字符匹配零个或多个前面的元素。这个问题的关键在于正确处理*的各种情况包括匹配零次、匹配一次或多次。我们可以定义dp[i][j]表示s的前i个字符和p的前j个字符是否匹配def isMatch(s, p): m, n len(s), len(p) dp [[False]*(n1) for _ in range(m1)] dp[0][0] True for j in range(1, n1): if p[j-1] *: dp[0][j] dp[0][j-2] for i in range(1, m1): for j in range(1, n1): if p[j-1] s[i-1] or p[j-1] .: dp[i][j] dp[i-1][j-1] elif p[j-1] *: dp[i][j] dp[i][j-2] if p[j-2] s[i-1] or p[j-2] .: dp[i][j] | dp[i-1][j] return dp[m][n]这个问题的状态转移需要考虑多种情况当前字符匹配或遇到.时继承左上角的状态遇到时考虑匹配零次的情况即跳过和前一个字符如果*前的字符匹配还可以考虑匹配一次或多次的情况5. 动态规划在路径问题中的应用5.1 最小路径和给定一个包含非负整数的m×n网格找出一条从左上角到右下角的路径使得路径上的数字总和为最小。这个问题是理解动态规划在网格类问题中应用的经典案例。我们可以使用一个二维DP数组其中dp[i][j]表示从起点到(i,j)位置的最小路径和def minPathSum(grid): m, n len(grid), len(grid[0]) dp [[0]*n for _ in range(m)] dp[0][0] grid[0][0] for i in range(1, m): dp[i][0] dp[i-1][0] grid[i][0] for j in range(1, n): dp[0][j] dp[0][j-1] grid[0][j] for i in range(1, m): for j in range(1, n): dp[i][j] min(dp[i-1][j], dp[i][j-1]) grid[i][j] return dp[-1][-1]这个问题可以进行空间优化因为每次计算只依赖于左边和上边的值所以可以将空间复杂度从O(mn)优化到O(n)def minPathSum_optimized(grid): m, n len(grid), len(grid[0]) dp [0]*n dp[0] grid[0][0] for j in range(1, n): dp[j] dp[j-1] grid[0][j] for i in range(1, m): dp[0] grid[i][0] for j in range(1, n): dp[j] min(dp[j], dp[j-1]) grid[i][j] return dp[-1]5.2 不同路径问题这个问题要求计算从m×n网格的左上角到右下角共有多少条不同的路径每次只能向右或向下移动。这是一个典型的组合数学问题也可以用动态规划来解决。基础解法是使用二维DP数组其中dp[i][j]表示到达(i,j)位置的不同路径数def uniquePaths(m, n): dp [[1]*n for _ in range(m)] for i in range(1, m): for j in range(1, n): dp[i][j] dp[i-1][j] dp[i][j-1] return dp[-1][-1]这个问题实际上可以通过组合数学直接计算因为从起点到终点需要移动(m-1)(n-1)步其中(m-1)步向下(n-1)步向右所以总路径数为C((m-1)(n-1), (m-1))。不过动态规划的解法更容易扩展到有障碍物的情况。当网格中存在障碍物时我们需要调整状态转移方程def uniquePathsWithObstacles(obstacleGrid): m, n len(obstacleGrid), len(obstacleGrid[0]) dp [[0]*n for _ in range(m)] dp[0][0] 1 if obstacleGrid[0][0] 0 else 0 for i in range(1, m): dp[i][0] dp[i-1][0] if obstacleGrid[i][0] 0 else 0 for j in range(1, n): dp[0][j] dp[0][j-1] if obstacleGrid[0][j] 0 else 0 for i in range(1, m): for j in range(1, n): if obstacleGrid[i][j] 0: dp[i][j] dp[i-1][j] dp[i][j-1] else: dp[i][j] 0 return dp[-1][-1]6. 动态规划在子序列问题中的应用6.1 最长递增子序列最长递增子序列LIS问题是动态规划的经典问题要求找出给定序列中最长的严格递增子序列的长度。这个问题有多种解法包括O(n²)的动态规划解法和O(nlogn)的优化解法。标准动态规划解法定义dp[i]为以nums[i]结尾的最长递增子序列的长度def lengthOfLIS(nums): if not nums: return 0 dp [1]*len(nums) for i in range(1, len(nums)): for j in range(i): if nums[i] nums[j]: dp[i] max(dp[i], dp[j]1) return max(dp)更高效的解法是维护一个tails数组其中tails[i]表示长度为i1的所有递增子序列的最小末尾元素。这种方法可以将时间复杂度优化到O(nlogn)def lengthOfLIS_optimized(nums): tails [] for num in nums: 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)6.2 最大子数组和这个问题要求找出一个整数数组中和最大的连续子数组。Kadane算法是解决这个问题的经典动态规划方法可以在O(n)时间内解决问题。基本思路是维护两个变量current_max表示以当前元素结尾的最大子数组和global_max表示全局最大子数组和def maxSubArray(nums): current_max global_max nums[0] for num in nums[1:]: current_max max(num, current_max num) global_max max(global_max, current_max) return global_max这个问题的变种包括返回最大子数组的起始和结束索引或者处理环形数组的情况。对于环形数组可以考虑两种情况一种是最大子数组不跨越数组首尾这种情况就是普通的Kadane算法另一种是最大子数组跨越首尾这种情况可以转化为求数组总和减去最小子数组和。7. 动态规划在游戏问题中的应用7.1 打家劫舍系列这个系列的问题考察如何在不触发警报的情况下获得最大收益。基础版本是直线排列的房屋进阶版本包括环形排列的房屋和二叉树结构的房屋。对于直线排列的房屋我们可以定义dp[i]表示抢劫前i个房屋能获得的最大金额def rob(nums): if not nums: return 0 if len(nums) 1: return nums[0] dp [0]*len(nums) dp[0] nums[0] dp[1] max(nums[0], nums[1]) for i in range(2, len(nums)): dp[i] max(dp[i-1], dp[i-2] nums[i]) return dp[-1]这个解法可以进行空间优化因为每次计算只需要前两个状态def rob_optimized(nums): prev, curr 0, 0 for num in nums: prev, curr curr, max(curr, prev num) return curr对于环形排列的房屋我们可以将问题分解为两个子问题抢劫第一个房屋但不抢劫最后一个房屋或者不抢劫第一个房屋但可以抢劫最后一个房屋def rob_circle(nums): if not nums: return 0 if len(nums) 1: return nums[0] return max(rob_optimized(nums[:-1]), rob_optimized(nums[1:]))7.2 鸡蛋掉落问题这是一个经典的动态规划难题题目描述是给定k个鸡蛋和n层楼找到最坏情况下确定临界楼层所需的最少试验次数。临界楼层是指从该楼层或更高楼层扔下鸡蛋会碎而从更低楼层扔下不会碎。我们可以定义dp[m][k]表示用k个鸡蛋进行m次试验最多可以检查多少层楼def superEggDrop(k, n): dp [[0]*(k1) for _ in range(n1)] m 0 while dp[m][k] n: m 1 for i in range(1, k1): dp[m][i] dp[m-1][i-1] dp[m-1][i] 1 return m这个问题的另一种解法是定义dp[k][n]为k个鸡蛋和n层楼时的最小试验次数但这种方法的时间复杂度较高。上述解法利用了逆向思维将问题转化为给定m次试验和k个鸡蛋最多能检查多少层楼从而将时间复杂度优化到O(kn)。8. 动态规划在数学问题中的应用8.1 整数拆分这个问题要求将一个正整数n拆分为至少两个正整数的和并使这些整数的乘积最大化。例如对于n10可以拆分为334乘积为3×3×436。我们可以定义dp[i]表示整数i拆分后的最大乘积def integerBreak(n): dp [0]*(n1) dp[1] 1 for i in range(2, n1): for j in range(1, i): dp[i] max(dp[i], max(j, dp[j]) * max(i-j, dp[i-j])) return dp[n]这个问题有一个数学上的优化解法当n≥4时尽可能多地拆分出3如果剩下4则拆分为22而不是31def integerBreak_math(n): if n 2: return 1 if n 3: return 2 product 1 while n 4: product * 3 n - 3 product * n return product8.2 完全平方数这个问题要求找出组成给定正整数n的最少的完全平方数的数量。例如n12可以表示为444最少需要3个完全平方数。动态规划解法定义dp[i]表示组成整数i所需的最少完全平方数def numSquares(n): dp [float(inf)]*(n1) dp[0] 0 for i in range(1, n1): j 1 while j*j i: dp[i] min(dp[i], dp[i - j*j] 1) j 1 return dp[n]这个问题还可以用数学中的四平方和定理来优化但动态规划的解法更加通用易于理解和实现。9. 动态规划在树形结构中的应用9.1 二叉树中的最大路径和这个问题要求在二叉树中找出一条路径使得路径上节点值的和最大。路径的起点和终点可以是树中的任意节点。我们可以使用后序遍历的方法对于每个节点计算以该节点为根的最大路径和def maxPathSum(root): def helper(node): nonlocal max_sum if not node: return 0 left max(helper(node.left), 0) right max(helper(node.right), 0) current_sum node.val left right max_sum max(max_sum, current_sum) return node.val max(left, right) max_sum float(-inf) helper(root) return max_sum这个问题的关键在于区分两种情况一种是当前节点作为路径的根节点可以同时包含左右子树另一种是当前节点作为路径的一部分只能选择左或右子树中的一个。9.2 打家劫舍III这是打家劫舍系列问题的树形版本要求在不触动警报的情况下不能同时抢劫直接相连的两个节点从二叉树结构的房屋中获得最大收益。我们可以使用递归加记忆化的方法对于每个节点计算抢劫该节点和不抢劫该节点两种情况下的最大收益def rob(root): def helper(node): if not node: return (0, 0) left helper(node.left) right helper(node.right) rob_current node.val left[1] right[1] not_rob_current max(left) max(right) return (rob_current, not_rob_current) return max(helper(root))这个解法的时间复杂度是O(n)因为每个节点只被访问一次。关键在于返回一个元组分别表示抢劫当前节点和不抢劫当前节点时的最大收益然后在父节点处根据这两种情况进行决策。10. 动态规划的常见陷阱与优化策略10.1 如何识别动态规划问题在实际应用中识别一个问题是否适合用动态规划解决是关键的第一步。通常具有以下特征的问题可以考虑使用动态规划问题可以分解为重叠的子问题问题具有最优子结构性质需要做出系列决策每个决策会影响后续状态常见的动态规划问题模式包括最值问题最大/最小/最长/最短计数问题多少种方式/方法存在性问题是否可行/能否实现10.2 动态规划的实现技巧状态定义的艺术好的状态定义可以大大简化问题。通常状态应该包含足够的信息来做出后续决策但又不能过于复杂。常见的状态定义方式包括dp[i]前i个元素的状态dp[i][j]涉及两个维度如字符串、矩阵的状态dp[i][j][k]涉及多个条件或限制的状态边界条件的处理动态规划的边界条件往往容易被忽视但却是正确解题的关键。常见的边界包括空序列或空集合的情况初始状态的设置索引越界的处理遍历顺序的选择不同的遍历顺序可能导致不同的计算复杂度或正确性问题。例如背包问题中物品和容量的遍历顺序字符串处理中从前向后还是从后向前遍历树形结构中后序遍历的使用10.3 动态规划的调试技巧打印DP表格对于二维DP问题打印出完整的DP表格可以帮助理解状态转移过程发现逻辑错误。小规模测试用例先用小的、手工可以验证的测试用例来验证算法的正确性。可视化工具对于复杂的动态规划问题可以使用可视化工具来展示状态转移过程。对比暴力解法对于中等规模的问题可以先用暴力解法得到正确结果然后与动态规划解法的结果进行对比。10.4 动态规划的空间优化策略滚动数组当状态转移只依赖于有限的几个前驱状态时可以使用滚动数组来减少空间复杂度。状态压缩对于某些问题可以使用位运算等技巧来压缩状态表示。降维处理分析状态转移方程看是否可以将二维DP降为一维或将三维降为二维。改变计算顺序有时改变计算顺序可以复用存储空间减少空间使用。11. 动态规划问题的解题框架基于多年的解题经验我总结了一个解决动态规划问题的通用框架可以帮助系统性地思考和解决各类动态规划问题定义状态明确dp数组或变量的含义确定需要存储哪些信息才能做出后续决策。确定状态转移方程找出状态之间的关系明确如何从已知状态推导出未知状态。初始化边界条件确定最简单子问题的解通常是最小的或边界的情况。确定计算顺序决定是从上到下还是从下到上计算确保在计算每个状态时它所依赖的状态已经被计算过。考虑空间优化分析是否可以减少空间复杂度使用更少的变量或数组来存储必要信息。返回最终结果确定dp数组或变量的哪个部分包含最终问题的解。以经典的最长公共子序列问题为例应用这个框架定义状态dp[i][j]表示text1前i个字符和text2前j个字符的最长公共子序列长度。状态转移方程如果text1[i-1] text2[j-1]则dp[i][j] dp[i-1][j-1] 1否则dp[i][j] max(dp[i-1][j], dp[i][j-1])初始化dp[0][j] 0和dp[i][0] 0表示空字符串与任何字符串的LCS长度为0。计算顺序从上到下从左到右填充二维表格。空间优化可以使用一维数组加一个临时变量来优化空间。返回结果dp[m][n]即为最终结果。12. 动态规划进阶练习建议为了真正掌握动态规划仅仅理解概念是不够的还需要大量的练习。以下是我推荐的练习路径基础阶段斐波那契数列爬楼梯问题最小路径和最大子数组和提高阶段背包问题及其变种股票买卖系列字符串编辑距离最长递增子序列进阶阶段鸡蛋掉落问题正则表达式匹配树形结构上的动态规划状态压缩动态规划竞赛级别数位动态规划概率动态规划动态规划优化斜率优化、四边形不等式等练习时建议先自己思考尝试写出状态定义和转移方程对比优秀解法学习更好的状态设计和优化方法对于每道题尝试找出至少两种不同的解法总结各类问题的共性和解题模式13. 动态规划在实际工程中的应用虽然动态规划常被视为算法面试的考点但它在实际工程中也有广泛应用文本处理拼写检查和自动更正文本相似度计算DNA序列比对资源分配任务调度带宽分配库存管理路径规划导航系统的最短路径计算机器人运动规划网络路由优化游戏AI棋类游戏的决策树评估游戏角色的行为规划游戏中的资源管理金融领域投资组合优化期权定价风险管理理解动态规划不仅可以帮助我们通过技术面试更能提升我们解决实际工程问题的能力。在实际应用中我们常常需要根据具体问题调整和优化标准的动态规划算法这需要对算法本质有深刻的理解。14. 动态规划与其他算法的关系动态规划不是孤立存在的它与许多其他算法有着密切的联系分治算法动态规划和分治都通过分解问题来解决但动态规划的子问题通常是重叠的而分治的子问题通常是独立的。贪心算法贪心算法可以看作是动态规划的一种特例它通过局部最优选择来达到全局最优不需要存储子问题的解。回溯算法回溯法通过尝试所有可能性来解决问题而动态规划通过存储子问题的解来避免重复计算。图算法许多图算法如Dijkstra最短路径算法、Floyd-Warshall算法本质上都是动态规划的应用。理解这些算法之间的关系有助于我们在面对问题时选择最合适的解决方法。动态规划特别适合那些具有最优子结构和重叠子问题特性的问题当问题满足这些特性时动态规划通常能提供高效的解决方案。15. 动态规划学习资源推荐经典教材《算法导论》动态规划章节《算法设计手册》相关章节在线课程MIT 6.006 Introduction to AlgorithmsStanford CS97SI: Introduction to Competitive Programming在线练习平台LeetCode动态规划专题Codeforces动态规划标签题目AtCoder动态规划分类题目可视化工具VisuAlgo的动态规划可视化Algorithm Visualizer的动态规划部分竞赛资源Codeforces动态规划教程TopCoder动态规划教程学习动态规划是一个循序渐进的过程建议从简单的题目开始逐步挑战更复杂的问题。每解决一个问题后尝试总结其中的模式和技巧久而久之就能培养出对动态规划问题的敏锐直觉。
阅读完成 · 觉得有帮助?