十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

动态规划核心思想与经典案例解析:从LIS到01背包

动态规划核心思想与经典案例解析:从LIS到01背包 1. 项目概述从“暴力穷举”到“优雅决策”的思维跃迁如果你曾被“最长上升子序列”、“01背包”这类问题折磨得焦头烂额尝试用循环嵌套暴力求解却总是超时那么“动态规划”就是你一直在寻找的那把钥匙。它不是什么高深莫测的数学理论而是一种将复杂问题分解成简单子问题并通过存储子问题的解来避免重复计算的编程思想与算法范式。简单来说动态规划教会计算机“记住过去”从而在未来做决策时更加“聪明”。无论是规划投资组合、设计机器人路径还是优化广告投放策略其核心都是在一系列前后关联的决策中找到全局最优的那条路径。本文将彻底拆解动态规划不仅让你理解其“状态”、“转移方程”这些核心概念更会通过“最长上升子序列”和“01背包”这两个经典得不能再经典的案例手把手带你写出高效代码并分享那些只有踩过坑才知道的调试心法与优化技巧。2. 动态规划核心思想与解题框架全解析2.1 核心思想最优子结构与重叠子问题动态规划能有效解决问题的两个基石是“最优子结构”和“重叠子问题”。这两个词听起来学术但理解起来并不难。最优子结构指的是一个问题的最优解包含了其子问题的最优解。举个例子你要从北京到上海的最短路径如果这条路径经过南京那么从北京到南京、从南京到上海的这两段路径也必然分别是各自起终点间的最短路径。如果子问题的最优解无法组合成原问题的最优解那么动态规划就失效了。重叠子问题是指在递归求解过程中相同的子问题会被反复计算多次。比如在计算斐波那契数列F(5)时需要计算F(4)和F(3)计算F(4)时又需要计算F(3)和F(2)。这里的F(3)就被重复计算了。动态规划通过“记忆化”将子问题的解存起来避免了这种重复劳动这是它比纯递归高效的本质原因。注意区分“分治法”。分治法如归并排序也分解子问题但子问题通常不重叠。动态规划的子问题则是高度重叠的这是使用DP动态规划标识符的关键判断点。2.2 四步解题法一套通用的思考框架面对一个新问题如何判断能否用动态规划解决又该如何入手我总结了一套四步法亲测有效定义状态这是最关键也最难的一步。状态就是描述问题某个阶段情况的变量。通常我们需要用一个或多个维度的数组DP表来存储状态dp[i]或dp[i][j]代表了在某种限定条件下的最优解。例如在“最长上升子序列”中我们可以定义dp[i]为“以第i个数字结尾的最长上升子序列的长度”。确定状态转移方程这是动态规划的灵魂描述了状态之间如何递推。即如何通过已知的、更小的子问题的解dp[0...i-1]来推导出当前问题的解dp[i]。找到这个方程问题就解决了一大半。通常形式是dp[i] F(dp[i-1], dp[i-2], ...)。初始化给最小的、不可再分的子问题边界条件赋值。比如dp[0]或dp[0][0]通常需要直接给出。初始化不正确整个递推就会像多米诺骨牌一样全部倒掉。确定计算顺序与输出确定填DP表的顺序是正序、倒序还是斜着填以及最终我们要的答案存储在DP表的哪个位置例如max(dp)或dp[n][m]。为了更直观地对比我将动态规划与常见的暴力搜索回溯进行对比特性动态规划 (DP)暴力搜索/回溯核心思想空间换时间记忆化搜索自底向上递推枚举所有可能递归尝试自顶向下时间复杂度通常为多项式级别如O(n²), O(n*m)通常为指数级别如O(2^n), O(n!)空间复杂度需要额外的DP数组存储状态通常O(n)或O(n*m)递归栈开销通常O(n)适用问题具有最优子结构和重叠子问题的优化问题组合、排列、路径探索等所有解问题编码难度思考状态定义和转移方程难但代码通常简洁思考框架直接但代码容易复杂剪枝优化难3. 经典案例深度剖析最长上升子序列 (LIS)3.1 问题定义与状态设计最长上升子序列是动态规划最经典的入门题。问题描述给定一个无序的整数数组nums找到其中最长严格递增子序列的长度。子序列不要求连续。为什么定义dp[i]为“以nums[i]结尾的最长上升子序列长度”这是一种非常经典且有效的状态定义方式。它保证了我们考虑的子序列一定包含nums[i]这样我们就有了一个固定的“终点”。当我们计算dp[i]时我们只需要关注在i之前的所有位置j (0 j i)检查nums[j]是否小于nums[i]。如果是那么nums[i]就可以接在nums[j]结尾的子序列后面形成一个更长的子序列。这种定义方式将全局问题“整个数组的LIS”分解为了n个以每个位置结尾的局部最优子问题完美符合最优子结构。3.2 状态转移方程推导与代码实现根据上述状态定义状态转移方程就呼之欲出了dp[i] max(dp[j]) 1对于所有满足0 j i且nums[j] nums[i]的j。 如果不存在这样的j那么dp[i] 1它自身构成一个长度为1的子序列。初始化每个位置至少可以以自己结尾所以初始时dp数组全部置为1。 输出整个数组的LIS长度就是dp数组中的最大值。下面给出Python代码实现并附上详细注释def lengthOfLIS(nums): 计算最长上升子序列的长度 :type nums: List[int] :rtype: int if not nums: return 0 n len(nums) # 1. 定义dp数组并初始化 # dp[i] 表示以 nums[i] 结尾的最长上升子序列长度 dp [1] * n # 每个元素自身至少是一个长度为1的子序列 # 2. 状态转移 for i in range(n): # 计算每个dp[i] for j in range(i): # 遍历i之前的所有元素 if nums[j] nums[i]: # 如果nums[j] nums[i]则nums[i]可以接在nums[j]结尾的子序列后面 # 我们选择能构成最长序列的那个j dp[i] max(dp[i], dp[j] 1) # 3. 输出结果 return max(dp) # 测试用例 print(lengthOfLIS([10, 9, 2, 5, 3, 7, 101, 18])) # 输出4 (子序列 [2, 5, 7, 101] 或 [2, 3, 7, 101]) print(lengthOfLIS([0, 1, 0, 3, 2, 3])) # 输出4 print(lengthOfLIS([7, 7, 7, 7, 7])) # 输出1时间复杂度分析两层循环时间复杂度为 O(n²)。对于n10^4的数据量勉强可以接受但n10^5就会超时。这就引出了优化方法。3.3 优化技巧贪心二分查找O(n²)的解法在数据量大时效率低下。我们可以采用一种更巧妙的“耐心排序”思想将时间复杂度优化到 O(n log n)。核心思路维护一个数组tails其中tails[k]存储长度为k1的所有上升子序列中末尾元素最小的那个值。这个数组一定是严格递增的为什么因为长度更长的子序列其末尾元素不可能比长度短的小。遍历原数组nums中的每个数x如果x大于tails中所有元素即大于最后一个元素则将其追加到后面表示发现了更长的上升子序列。否则在tails中找到第一个大于等于x的元素用x替换它。这一步很关键它保证了在长度不变的情况下让该长度的子序列的末尾元素尽可能小为后续接上更大的数创造机会。查找过程可以用二分法。最终tails的长度就是最长上升子序列的长度。def lengthOfLIS_optimized(nums): 优化版贪心 二分查找时间复杂度 O(n log n) tails [] # tails[i] 表示长度为 i1 的LIS的末尾元素的最小值 for num in nums: # 二分查找 left 为第一个大于等于 num 的位置 left, right 0, len(tails) while left right: mid (left right) // 2 if tails[mid] num: left mid 1 else: right mid # 如果 left 等于 tails 的长度说明 num 比所有末尾都大 if left len(tails): tails.append(num) else: tails[left] num # 替换使该长度的末尾元素最小化 return len(tails)实操心得这个优化版本理解起来有门槛但一旦掌握威力巨大。它无法直接给出具体的子序列是什么tails数组本身并不是一个合法的LIS但能高效求出长度。在竞赛或面试中如果只要求长度一定要优先考虑此方法。4. 经典案例深度剖析01背包问题4.1 问题定义与基础解法01背包问题是动态规划的另一个基石。问题描述有N件物品和一个容量为V的背包。第i件物品的重量是weight[i]价值是value[i]。每件物品只能选择0次或1次这就是“01”的由来。求解将哪些物品装入背包可使总价值最大且不超过背包容量。状态定义定义二维DP数组dp[i][j]其含义是从前i件物品物品编号从1开始中任意选取放入容量为j的背包中所能获得的最大价值。状态转移方程对于第i件物品我们有两种选择不放入背包那么问题转化为“前i-1件物品放入容量为j的背包”即dp[i][j] dp[i-1][j]。放入背包前提是j weight[i-1]放入后背包剩余容量为j - weight[i-1]价值增加value[i-1]。问题转化为“前i-1件物品放入剩余容量为j - weight[i-1]的背包”即dp[i][j] 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[0][...] 0表示前0件物品无论背包容量多大价值都是0。dp[...][0] 0表示背包容量为0无论有多少物品价值都是0。def knapsack_01_basic(V, weight, value): 01背包基础二维DP解法 :param V: 背包容量 :param weight: 物品重量列表 :param value: 物品价值列表 :return: 最大总价值 N len(weight) # 创建DP表多一行一列用于简化初始化 dp [[0] * (V 1) for _ in range(N 1)] # 状态转移 for i in range(1, N 1): # 遍历物品 w_i, v_i weight[i-1], value[i-1] for j in range(1, V 1): # 遍历背包容量 if j w_i: # 当前背包容量装不下第i件物品 dp[i][j] dp[i-1][j] else: # 装得下取“不装”和“装”的最大值 dp[i][j] max(dp[i-1][j], dp[i-1][j - w_i] v_i) return dp[N][V] # 测试 V 4 weight [1, 3, 4] value [15, 20, 30] print(knapsack_01_basic(V, weight, value)) # 输出35 (选择物品0和物品2)4.2 空间优化滚动数组与一维DP观察状态转移方程dp[i][j] max(dp[i-1][j], dp[i-1][j - w_i] v_i)可以发现当前状态dp[i][...]只依赖于上一行dp[i-1][...]的状态。因此我们完全可以用一个一维数组dp[j]来替代二维数组表示容量为j的背包所能获得的最大价值。但这里有一个极其关键的细节内层循环遍历背包容量j必须从大到小遍历。为什么必须倒序因为dp[j]依赖于dp[j - w_i]而这个dp[j - w_i]必须是“上一轮”即考虑前i-1件物品时的值。如果从小到大遍历当计算dp[j]时dp[j - w_i]可能已经在同一轮循环中被更新为考虑过第i件物品后的值了这就相当于同一件物品被重复放入多次变成了“完全背包”问题违背了01背包的规则。倒序遍历保证了在计算dp[j]时dp[j - w_i]保存的还是未被“当前物品”污染过的旧值。def knapsack_01_optimized(V, weight, value): 01背包空间优化版一维DP N len(weight) dp [0] * (V 1) # 一维DP数组 for i in range(N): # 遍历物品 w_i, v_i weight[i], value[i] # 关键背包容量从大到小遍历 for j in range(V, w_i - 1, -1): dp[j] max(dp[j], dp[j - w_i] v_i) return dp[V] # 测试结果应与基础版一致 print(knapsack_01_optimized(V, weight, value)) # 输出35避坑指南一维DP写法简洁高效但“内层循环倒序”这个点堪称新手杀手。我最初几次写错都是因为这里。务必理解其原理正序会导致物品被重复计算。你可以用一个小例子如V3, weight[1,1], value[1,2]手动模拟一下正序和倒序的过程印象会非常深刻。5. 动态规划解题的进阶心法与调试技巧5.1 如何识别与设计状态对于陌生问题设计状态是最大的挑战。我的经验是从问题描述中找变量通常问题的限制条件如背包容量、序列位置、步数、金额和待优化目标最大价值、最长长度、最小成本就是状态的维度。01背包的状态是(前i个物品 容量j)LIS的状态是(以第i个元素结尾)。尝试最直观的定义先定义dp[i]为“考虑前i个元素时的答案”。如果发现无法转移再考虑增加维度比如dp[i][j]其中j可以代表另一个限制条件如容量、状态、差值等。类比经典模型很多问题可以转化为经典模型。例如“分割等和子集”可以转化为背包问题目标和为总和的二分之一“零钱兑换”是完全背包问题。5.2 调试如何验证DP表的正确性动态规划的代码往往不长但逻辑错误很难一眼看出。我的调试三板斧是打印DP表对于二维DP在循环结束后将整个dp数组打印出来。对照手动计算的前几行、前几列检查数据是否正确。这是最直接有效的方法。小数据量手动模拟不要一上来就用复杂用例。用最小的、能体现问题的例子比如2-3个物品的背包长度为3-4的序列在纸上画出DP表一步步推导再与程序输出对比。关注边界与初始化很多错误出在i0或j0的边界上。仔细检查你的DP数组大小是N还是N1以及初始值是否合理。5.3 常见变种与问题形态掌握了基础你需要知道动态规划有哪些常见的“马甲”线性DP状态沿着一个维度线性推进如LIS、最大子数组和。区间DP状态定义与区间[i, j]相关如石子合并、最长回文子串。通常循环是先枚举区间长度再枚举起点。树形DP在树结构上进行动态规划状态常与节点相关需要后序遍历。如二叉树中的最大路径和。状态压缩DP当状态中的某个维度是“集合”或“排列”时可以用二进制位来压缩表示常用于旅行商问题。计数型DP不是求最优值而是求方案数。转移方程通常是累加 (dp[i] dp[j])初始化dp[0]1。存在型DP判断可行性如背包能否恰好装满。dp[j]是布尔值。6. 从理论到实践综合例题演练让我们用一个稍复杂的问题来串联所学知识零钱兑换 IILeetCode 518。题目给定不同面额的硬币和一个总金额写出函数来计算可以凑成总金额的硬币组合数。假设每一种面额的硬币有无限个。分析这本质上是“完全背包”的计数问题。背包容量是总金额amount物品是硬币每种物品有无限个。状态定义dp[j]表示凑成金额j的硬币组合数。状态转移对于每一枚面额为coin的硬币当我们考虑用它来凑金额j时组合数可以加上凑出金额j - coin的组合数。即dp[j] dp[j - coin]。初始化dp[0] 1表示凑成金额0只有一种组合什么也不选。遍历顺序这是本题的关键点也是与01背包的区别所在。外层循环遍历硬币物品保证在考虑一种硬币时不会受到后面硬币的影响从而区分出组合的顺序。如果先遍历金额再遍历硬币就会把(1,2)和(2,1)算作不同排列。内层循环遍历金额背包容量从小到大因为每种硬币无限个所以允许在本次硬币的考虑中重复使用。从小到大的遍历保证了dp[j - coin]已经包含了使用当前硬币coin的情况。def change(amount, coins): :type amount: int :type coins: List[int] :rtype: int dp [0] * (amount 1) dp[0] 1 # 初始化 for coin in coins: # 外层遍历物品硬币 for j in range(coin, amount 1): # 内层正序遍历容量金额 dp[j] dp[j - coin] return dp[amount] # 测试 print(change(5, [1, 2, 5])) # 输出4 (55, 5221, 52111, 511111) print(change(3, [2])) # 输出0 print(change(0, [1, 2, 5])) # 输出1为什么遍历顺序如此重要先物品后容量正序求的是组合数。对于硬币[1,2]凑金额3只会计算{1,1,1}, {1,2}这两种组合。{2,1}不会被重复计算。先容量后物品或任何其他顺序求的是排列数。会把{1,2}和{2,1}当作两种不同的方案。这个例子深刻地说明了在动态规划中循环的顺序是状态转移方程的一部分它定义了状态更新的逻辑语义。理解不了顺序就等于没有真正理解状态转移。动态规划的魅力在于一旦你突破了“状态定义”和“转移方程”这两个思维关卡并熟练掌握了背包问题的遍历顺序、空间优化等技巧很多看似复杂的题目都会在你面前变得清晰起来。它锻炼的是一种将复杂问题分解、定义状态、寻找最优子结构的系统性思维能力。这种能力远比背下几个算法模板重要得多。多动手画表多思考“为什么这样定义状态”多总结不同问题类型的共性你会发现自己解决复杂问题的能力在潜移默化中得到了质的提升。
返回列表