多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

动态规划核心思想与实战:从LIS到01背包的解题框架

动态规划核心思想与实战:从LIS到01背包的解题框架 1. 项目概述从一道作业题到动态规划思维的建立最近在辅导学生做数学建模作业时发现“动态规划”这个专题让不少人感到头疼。题目可能叫“资源分配最优解”或者“最短路径规划”本质上都是希望我们找到一系列决策过程中的最优策略。这听起来很理论但其实它无处不在——从你规划一个月的生活费到物流公司设计全国配送路线背后都可能藏着动态规划的思想。很多人第一次接触会觉得它抽象公式复杂状态转移方程让人眼花缭乱。但我想说动态规划的核心魅力恰恰在于它把一个大难题拆解成一系列有规律的小问题然后像搭积木一样从最简单的情况开始一步步构建出最终答案。这次我们就以一道典型的动态规划作业题为引子不堆砌公式而是聊聊怎么“想明白”和“做出来”。这道作业题通常不会直接告诉你“请用动态规划法求解”而是包装在一个具体的场景里比如“某项目有n个阶段每个阶段有若干决策可选收益不同如何选择总收益最大”或者“给定一个数列找出其中最长的严格递增子序列的长度”。面对这样的问题新手容易一头扎进去试图枚举所有可能结果发现组合爆炸根本算不完。动态规划就是来解决这个“算不完”的问题的。它适合解决具有“最优子结构”和“重叠子问题”特性的问题。简单说“最优子结构”就是整个问题的最优解能由它的子问题的最优解组合出来“重叠子问题”就是在递归求解时会反复计算相同的子问题。动态规划通过“记忆化”把算过的结果存起来避免了重复计算从而极大提升了效率。无论你是正在完成作业的学生还是对算法优化感兴趣的开发者理解动态规划都能让你多一种强大的问题分析工具。它不仅仅是写出一个正确的程序更是训练一种“分阶段决策”和“空间换时间”的思维方式。接下来我会拆解动态规划解题的通用思路并用两个最经典的问题——“最长上升子序列”和“01背包问题”——作为手把手的案例把每一步为什么这么做讲清楚最后再分享一些调试和验证的心得帮你把这道作业题做得漂亮更把这种方法学得扎实。2. 动态规划解题的核心思路拆解2.1 识别问题特征什么时候该用动态规划拿到一个问题首先不是想状态方程怎么写而是判断它是否适合用动态规划。我一般会问自己两个问题。第一问题能否被分解为多个阶段或步骤每个阶段是否需要做出一个决策比如在“最长上升子序列”问题中我们依次考察原序列中的每一个数决定“以这个数结尾”的最长上升子序列长度这就是一个阶段一个阶段地推进。在“01背包问题”中我们依次决定是否将每一件物品放入背包这也是分阶段的决策过程。第二子问题是否大量重叠一个简单的检验方法是如果你在脑子里模拟暴力递归穷举所有可能决策路径会不会发现很多路径在中途就“撞车”了计算了完全相同的东西。例如在计算从A点到E点的最短路径时无论之前从哪条路来到B点从B点到E点的最短路径都是固定的这就是重叠子问题。如果一个问题同时满足“多阶段决策”和“子问题重叠”那么动态规划就很可能派上用场。这里有一个常见的误区把“分治”和“动态规划”搞混。像归并排序虽然把大问题分成了小问题但子问题排序左半部分和排序右半部分是独立的不重叠所以用的是分治法。而像计算斐波那契数列F(n)F(n-1)F(n-2)用递归会重复计算无数次F(3)、F(4)这就是典型的重叠子问题必须用动态规划记忆化搜索来优化。2.2 构建解题框架四步法拆解动态规划一旦确定适用动态规划我会遵循一个清晰的四步框架来解题。这个框架就像一份检查清单能确保思考的完整性。第一步定义状态State这是最关键也最难的一步。状态的定义必须包含足够的信息能够描述在某个“阶段”我们所处的情况并且这个情况足以让我们做出后续的决策。通常状态可以用一个数组dp[i]或dp[i][j]来表示。一维状态例如在“最长上升子序列”中我们定义dp[i]表示“以第i个数字结尾的最长上升子序列的长度”。这个状态抓住了“结尾位置”这个关键特征。二维状态例如在“01背包问题”中我们定义dp[i][j]表示“考虑前i件物品在背包容量为j的情况下所能获得的最大价值”。这里“考虑的物品范围”和“当前可用容量”共同构成了一个完整的状态描述。第二步确定状态转移方程State Transition Equation这是动态规划的灵魂它描述了如何从一个或多个已知的“较小”状态推导出当前状态。本质上是在表达决策过程。对于dp[i]我们需要思考dp[i]的值可以从哪些之前的状态dp[k](k i) 推导出来推导的条件是什么对于dp[i][j]我们需要思考在处理第i件物品、容量为j时我们有几种选择比如放或不放每种选择会对应哪个之前的状态然后取最优。第三步确定初始状态Initialization这是递推的起点。如果初始状态设错了整个递推结果都会出错。通常我们需要考虑“边界情况”也就是规模最小、最平凡的子问题的解。在“最长上升子序列”中最小的子问题就是“只包含第一个数”显然dp[0] 1下标从0开始或dp[1] 1下标从1开始。在“01背包问题”中初始状态是“考虑0件物品”或“背包容量为0”此时最大价值自然都是0即dp[0][...] 0和dp[...][0] 0。第四步确定计算顺序与最终答案状态转移方程决定了状态的依赖关系。我们必须按照一种顺序来计算状态确保在计算当前状态时它所依赖的那些“更小”的状态都已经被计算出来了。“最长上升子序列”中计算dp[i]需要用到所有dp[0...i-1]所以自然是从前到后i从0到n-1顺序计算。“01背包问题”的经典二维写法中计算dp[i][j]需要用到上一行dp[i-1][...]的数据所以i和j通常都采用从小到大的顺序遍历。 最终答案往往不是最后一个状态值而是所有状态中的最优值。例如“最长上升子序列”的答案是max(dp[0], dp[1], ..., dp[n-1])因为最长子序列不一定以最后一个数结尾。3. 经典案例深度剖析最长上升子序列LIS3.1 问题重述与状态定义最长上升子序列Longest Increasing Subsequence, LIS问题是动态规划入门的最佳试金石。题目通常这样描述给定一个无序的整数数组nums找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列它不一定是连续的但必须保持元素间的相对顺序。例如对于数组[10, 9, 2, 5, 3, 7, 101, 18]最长的上升子序列是[2, 5, 7, 101]或[2, 5, 7, 18]长度是4。我们如何定义状态关键在于一个上升子序列是由它的“最后一个元素”来标志的。不同的结尾元素对应着不同的子序列。因此我们定义状态数组dp其中dp[i]表示以nums[i]这个数字结尾的所有上升子序列中最长的那个的长度。注意这个定义非常重要。dp[i]不是“前i个数字中的LIS长度”而是必须“以第i个数字结尾”。前者是一个全局最优解很难直接由子问题推导后者是一个局部约束更强的状态其递推关系更清晰。这正是定义状态的艺术增加约束以简化转移。3.2 状态转移方程的推导与理解现在我们要求dp[i]。既然子序列必须以nums[i]结尾那么nums[i]的前一个数倒数第二个数只能是i之前的某个位置j(0 j i) 上的数nums[j]并且必须满足nums[j] nums[i]保证递增。对于所有满足nums[j] nums[i]的j我们都面临一个选择要不要把以nums[j]结尾的最长子序列其长度为dp[j]后面接上nums[i]形成一个新的、以nums[i]结尾的子序列这个新子序列的长度就是dp[j] 1。我们的目标是找到最长的那个所以dp[i]应该等于所有可能的dp[j] 1中的最大值。如果前面没有比nums[i]小的数呢那么nums[i]自己就构成一个长度为1的子序列。因此状态转移方程为dp[i] max(dp[j] 1) 对于所有0 j i且nums[j] nums[i]同时至少为1即dp[i] max(1, max(dp[j] 1))。用上面的例子[10, 9, 2, 5, 3, 7, 101, 18]来手动模拟一下i0, nums[0]10: 前面无数dp[0]1。i1, nums[1]9: 看j0109不满足递增所以dp[1]1。i2, nums[2]2:102,92都不满足dp[2]1。i3, nums[3]5: 看j0,1,2。nums[2]2 5且dp[2]1所以dp[3] dp[2]1 2。i4, nums[4]3: 只有nums[2]2 3dp[4] dp[2]1 2。i5, nums[5]7: 满足nums[j] 7的有2,5,3(j2,3,4)。对应的dp值为1,2,2。最大值是2所以dp[5] 21 3。i6, nums[6]101: 前面所有数都小于101找最大的dp[j]是dp[5]3所以dp[6]4。i7, nums[7]18: 前面小于18的数中最大的dp[j]也是dp[5]3所以dp[7]4。最终dp数组为[1,1,1,2,2,3,4,4]最大值是4即LIS长度为4。3.3 代码实现与初始化细节根据上面的分析代码实现就非常直接了。我们需要注意数组下标的起始和循环范围。def length_of_lis(nums): if not nums: return 0 n len(nums) # 1. 定义并初始化状态数组 dp [1] * n # 每个位置初始长度至少为1即它自身 # 2. 按顺序计算状态 for i in range(n): # 计算每一个dp[i] for j in range(i): # 遍历i之前的所有位置j if nums[j] nums[i]: # 满足递增条件 # 3. 状态转移尝试更新dp[i] dp[i] max(dp[i], dp[j] 1) # 4. 确定最终答案dp数组中的最大值 return max(dp) # 测试 nums [10, 9, 2, 5, 3, 7, 101, 18] print(length_of_lis(nums)) # 输出4初始化细节dp [1] * n这一步很精妙。它同时完成了两件事第一设置了边界条件每个元素自身就是一个长度为1的子序列第二为后续的max比较提供了初始值。在状态转移时dp[i]可能被多个j更新我们需要保留最大值所以初始化为1是合适的。复杂度分析这个算法有两层循环时间复杂度是 O(n²)其中 n 是数组长度。空间复杂度是 O(n)用于存储dp数组。对于作业题或一般面试这个解法已经足够。但值得注意的是存在一种利用“贪心二分查找”将时间复杂度优化到 O(n log n) 的更优解法其核心是维护一个“最小尾部元素数组”这在数据量很大时非常有用。不过对于理解动态规划基础掌握 O(n²) 的解法是第一步。4. 经典案例深度剖析01背包问题4.1 问题重述与状态定义01背包问题是动态规划领域另一个里程碑式的问题。它描述的情景非常直观你有一个容量为W的背包和N件物品。第i件物品的重量是weight[i]价值是value[i]。每件物品只有一件你可以选择放1或者不放0进背包。目标是在不超过背包容量的前提下让装入背包的物品总价值最大。例如背包容量W4物品如下物品编号重量 (weight)价值 (value)011513202430我们如何定义状态回想动态规划的状态需要描述“阶段”和“局面”。在这里“阶段”就是我们一件一件地考虑物品的过程。“局面”由两个因素决定1. 已经考虑了哪些物品2. 当前背包还剩多少容量。因此最自然的状态定义是一个二维数组dpdp[i][j]表示从前i件物品物品编号0到i-1中进行选择当背包容量为j时所能获得的最大价值。这里i的范围是[0, N]i0表示一件物品都不考虑。j的范围是[0, W]表示背包容量从0到最大容量。4.2 状态转移方程的推导放与不放的抉择现在我们来推导dp[i][j]。我们正站在“考虑第i件物品注意这里是第i件对应weight[i-1]和value[i-1]且背包容量为j”这个十字路口。我们只有两种选择不放入第 i 件物品那么问题就退化成了“从前i-1件物品中选容量为j的最大价值”。这个值我们已经算过了就是dp[i-1][j]。放入第 i 件物品前提是背包容量j必须大于等于这件物品的重量weight[i-1]。如果放入背包会消耗掉weight[i-1]的容量价值增加value[i-1]。那么放入之后的最大价值就等于“在前i-1件物品中用剩下的容量j - weight[i-1]所能获得的最大价值”再加上当前物品的价值。即dp[i-1][j - weight[i-1]] value[i-1]。我们的目标是总价值最大所以在这两种选择中取最大值。因此状态转移方程为dp[i][j] max(dp[i-1][j], dp[i-1][j - weight[i-1]] value[i-1]) 其中当j weight[i-1]时只能选择不放入即dp[i][j] dp[i-1][j]。让我们用上面的例子手动填一下dp表N3, W4。为了清晰我们增加一行i0没有物品和一列j0容量为0。dp[i][j]j0j1j2j3j4i0(无物品)00000i1(物品0:重1价15)0max(0, 015)15max(0, 015)15max(0, 015)15max(0, 015)15i2(物品1:重3价20)01515max(15, 020)20max(15, 1520)35i3(物品2:重4价30)0151520max(35, 1530)35dp[1][1]: 容量1可以放物品0(重1)。放dp[0][0]1515不放dp[0][1]0。取max得15。dp[2][3]: 容量3考虑物品1(重3)。放dp[1][0]2020不放dp[1][3]15。取max得20。dp[2][4]: 容量4考虑物品1(重3)。放dp[1][1]20152035不放dp[1][4]15。取max得35。dp[3][4]: 容量4考虑物品2(重4)。放dp[2][0]3030不放dp[2][4]35。取max得35。最终dp[3][4] 35就是最大价值。对应的方案是放入物品0和物品1总重134价值152035。4.3 空间优化滚动数组技巧观察状态转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j - weight[i-1]] value[i-1])我们发现计算第i行的数据时只依赖于第i-1行的数据。也就是说我们并不需要保存整个N x W的二维表格只需要保存“上一行”和“当前行”就足够了。这就是“滚动数组”的思想。我们可以将二维数组压缩成一维数组dp[j]其含义是在当前的物品考虑范围内容量为j的背包所能获得的最大价值。在计算时我们需要逆序枚举背包容量j从W到0。为什么要逆序因为根据方程dp[i][j]依赖于dp[i-1][j]和dp[i-1][j - weight[i-1]]。如果我们用一维数组并正序更新j从0到W那么在计算dp[j]时dp[j - weight[i-1]]可能已经被当前第i轮的更新值覆盖了即变成了dp[i][j - weight[i-1]]而不是我们需要的dp[i-1][j - weight[i-1]]。逆序更新可以保证在计算dp[j]时dp[j - weight[i-1]]还是上一轮i-1的结果。优化后的核心代码如下def knapsack_01(W, weight, value): N len(weight) # 初始化一维dp数组长度为 W1初始值均为0 dp [0] * (W 1) # 遍历每一件物品 for i in range(N): # 逆序遍历背包容量 for j in range(W, weight[i] - 1, -1): # 从W开始到当前物品重量止 # 状态转移dp[j] 旧值相当于 dp[i-1][j] dp[j - weight[i]] 相当于 dp[i-1][j - weight[i]] dp[j] max(dp[j], dp[j - weight[i]] value[i]) return dp[W] # 测试 W 4 weight [1, 3, 4] value [15, 20, 30] print(knapsack_01(W, weight, value)) # 输出35提示空间优化是动态规划题目中常见的考点和优化点。理解一维优化的关键在于想清楚状态依赖关系并明白为什么必须逆序更新。在作业或考试中如果对优化没有把握先写出清晰的二维DP解法是更稳妥的选择通常也能获得大部分分数。5. 动态规划作业的实战技巧与调试心得5.1 从问题描述到状态定义的思维转换很多同学卡在第一步怎么把一段文字描述转化成dp数组的定义我的经验是多问自己几个问题并尝试用自然语言描述“状态”。问题的“阶段”是什么是处理的物品顺序、走过的步数、字符串的位置还是时间点在某个阶段我们需要记录什么信息才能做出后续决策这个信息就是状态变量。常见的有位置、容量、次数、状态如是否持有股票、差值等。尝试用一句话定义dp[x][y]例如“dp[i][j]表示走到第i行第j列格子时的最大收益”或者“dp[i]表示兑换金额i所需的最少硬币数”。如果一种定义方式导致状态转移方程非常复杂或写不出来不要死磕换个角度。比如在“最长上升子序列”中定义“以i结尾”就比定义“前i个元素中”要好得多。5.2 验证与调试如何确保你的DP是正确的动态规划的代码往往不长但逻辑环环相扣一个地方出错全盘皆输。以下是我常用的调试方法小数据手工模拟这是最有效的方法。像前面那样画一个小的dp表格用笔和纸一步步推导填上数值。然后将你程序打印出来的dp数组可以在关键步骤后打印整个数组与手工结果对比。不一致的地方就是bug所在。打印关键变量在状态转移方程的核心max/min比较处打印出参与比较的各个值。例如在01背包中打印出每一轮i, j, dp[j]的旧值, dp[j - weight[i]] value[i]看看是谁更大为什么。检查初始化和边界数组大小开对了吗通常是n1或W1。初始值设对了吗dp[0]或dp[0][...]是否代表了合理的空状态循环的起止范围对吗特别是当状态转移涉及i-1,j-weight时要确保索引不会越界小于0。验证最终答案最终答案是从dp数组里直接取某个值还是需要遍历求max这需要回到你对状态的定义去理解。5.3 常见错误与避坑指南根据我批改作业和面试的经验以下是几个高频错误点错误类型典型表现原因分析与修正状态定义模糊写转移方程时逻辑混乱无法自洽。回到起点重新用一句清晰的话定义dp数组的含义。确保这个定义能唯一确定一个“局面”。初始化错误结果比正确答案小或者出现负数等异常值。仔细考虑“原点”状态。对于求最大值问题常初始化为0或负无穷表示不可达对于求最小值问题常初始化为0或正无穷。循环顺序错误结果不正确特别是空间优化后。画图分析状态依赖关系。计算dp[i][j]需要哪些已计算的状态确保循环顺序能先算出它们。一维优化时务必注意逆序。索引偏移错误数组越界或答案对应不上物品。明确你的i代表什么。如果i从0开始代表第一件物品那么重量是weight[i]如果i从1开始代表“前i件”重量就是weight[i-1]。保持统一。输出答案错误dp数组算对了但最后返回的值不对。再次审视状态定义。dp[N][W]一定是答案吗在LIS中答案是max(dp)在某些问题中答案可能是dp[N][0]或某个特定状态。最后动态规划是一种需要大量练习来培养直觉的算法思想。从这两个经典问题出发尝试去解决“最大子数组和”、“硬币兑换”、“编辑距离”等问题每一步都坚持“定义状态 - 推导方程 - 确定初值 - 计算顺序”的四步法并勤于动手画表调试。慢慢地你会发现很多新问题不过是旧问题的“马甲”而你也将真正掌握这把解决复杂优化问题的利器。
返回列表