资讯动态

动态规划核心思想与实战:从LIS到0-1背包的算法设计精讲

发布时间:2026/8/29 14:14:19 来源:尧图企业网站定制
1. 项目概述从“暴力穷举”到“聪明记忆”的思维跃迁搞算法的人绕不开“动态规划”这四个字。我第一次接触它是在啃《算法导论》里那个经典的“钢条切割”问题时当时的感觉是这玩意儿不就是把递归的重复计算给记下来吗有什么难的直到后来在面试和实际项目中面对更复杂的场景比如字符串编辑距离、股票买卖时机、资源调度优化我才真正体会到动态规划Dynamic Programming DP远不止是“缓存”那么简单。它是一种将复杂问题分解为重叠子问题并通过存储子问题的解来避免重复计算从而高效解决原问题的算法设计范式。简单说它教会计算机“吃一堑长一智”别在同一个坑里摔两次跟头。对于开发者而言无论你是准备技术面试动态规划是必考重灾区还是优化后端服务的核心逻辑比如路径规划、成本计算或是处理一些有重叠子结构的数据分析任务动态规划都是一把利器。它不像贪心算法那样“目光短浅”也不像分治法那样“老死不相往来”子问题不重叠。DP的核心魅力在于它用空间换时间通过一种系统化的填表或记忆过程优雅地解决了那些看似需要指数级时间的问题。今天我们就抛开教科书上那些抽象的数学符号从一个一线工程师的视角掰开揉碎了聊聊动态规划到底怎么“设计”又该如何“分析”。我们会从最朴素的想法出发一步步推导到最优解并结合最长上升子序列LIS和0-1背包这两个经典到不能再经典的问题把原理、实现和坑都讲明白。2. 核心思想拆解为什么“记住答案”如此强大动态规划听起来高大上但其思想内核非常朴素甚至可以说是一种“懒惰的聪明”。我们用一个生活化的场景来理解你要计算从1加到100的和。最笨的方法是123...100要算99次加法。但高斯告诉我们可以用公式(1100)*100/2一次搞定。动态规划的思路类似但它解决的是没有现成公式的问题。它的聪明之处在于发现计算1加到100时1加到99的结果会被反复用到如果你用递归思路sum(100) sum(99) 100。与其每次需要sum(99)时都重算一遍不如第一次算完就把它记在小本本数组上下次直接用。2.1 动态规划适用的三大特征不是所有问题都适合上DP。在你决定抄起DP这把锤子之前先看看眼前的问题是不是符合下面这三个“钉子”的特征最优子结构一个问题的最优解包含其子问题的最优解。换句话说大问题的最优解可以由小问题的最优解推导出来。这是DP能成立的基础。比如在“最短路径”问题中从A到C的最短路径如果经过B那么这条路径中A到B的段落也必须是A到B的最短路径B到C的段落也必须是B到C的最短路径。重叠子问题在递归求解的过程中不同的递归路径会反复遇到完全相同的子问题。如果子问题都是全新的没有重叠那更适合用分治法如归并排序。动态规划的价值就在于解决这些重复出现的子问题。斐波那契数列F(n) F(n-1) F(n-2)就是最典型的例子递归树里充满了大量重复计算。无后效性“未来与过去无关”。一旦某个阶段的状态确定后后续决策的演变就不再受这个状态之前决策路径的影响。也就是说当前状态是过去历史的完整总结未来的发展只依赖于当前的状态。比如在背包问题中当你决定到第i件物品、剩余容量为j时能获得的最大价值只取决于这个(i, j)状态而不需要知道你之前具体选了哪几件物品才达到这个容量。如果一个问题同时满足这三点那么恭喜你它很可能就是动态规划的“菜”。接下来我们要做的就是把这种思想转化成具体的解题步骤。2.2 自顶向下 vs. 自底向上两种实现哲学动态规划有两种主流的实现方式它们对应着两种不同的思考路径自顶向下记忆化搜索Memoization这是最符合人类直觉的方式。我们直接从原问题开始思考试图把它分解成子问题并用递归函数去解决。为了避免重复计算我们加入一个“备忘录”通常是一个数组或哈希表在计算子问题前先查备忘录如果算过就直接返回结果没算过再递归计算并保存结果。这种方式写起来直观尤其适合状态转移方程不那么直观的问题。# 斐波那契数列的记忆化搜索示例 memo {} def fib(n): if n 1: return n if n not in memo: # 查备忘录 memo[n] fib(n-1) fib(n-2) # 算完存备忘录 return memo[n]自底向上制表法Tabulation这是一种更“工程化”的思维方式。我们先解决所有最小、最基本的子问题通常是边界情况然后利用这些基础解逐步构建更大规模问题的解直到解决原问题。这个过程通常用一个多维数组DP表来显式地存储所有状态并通过循环来填充这个表。# 斐波那契数列的制表法示例 def fib(n): if n 1: return n dp [0] * (n1) dp[1] 1 # 基础解 for i in range(2, n1): # 逐步构建 dp[i] dp[i-1] dp[i-2] return dp[n]选择哪种记忆化搜索思考负担小但递归有栈深度限制对于状态空间极大的问题可能不适用。制表法通常效率更高没有递归开销且能清晰地展现所有状态是面试和竞赛中的主流写法。我个人的习惯是先尝试用记忆化搜索理清思路和状态转移最终代码实现时除非状态转移特别复杂否则优先采用制表法。3. 经典案例实战最长上升子序列LIS理论说再多不如看实际案例。最长上升子序列Longest Increasing Subsequence是动态规划入门必刷题它完美体现了DP的核心思想。问题定义给定一个无序的整数数组nums找到其中最长严格递增子序列的长度。子序列不要求连续。例如[10, 9, 2, 5, 3, 7, 101, 18]的最长上升子序列是[2, 3, 7, 101]或[2, 3, 7, 18]长度是4。3.1 状态定义与转移方程推导这是DP最核心也最考验功力的步骤。定义错了满盘皆输。定义状态我们定义dp[i]表示以第i个数字nums[i]结尾的最长上升子序列的长度。为什么这么定义这是解决子序列问题的常见技巧。如果定义成“前i个元素中的LIS长度”状态转移会非常困难因为你不知道LIS的最后一个元素是谁无法判断nums[i]是否能接在后面。而以nums[i]结尾则明确了子序列的终点便于进行状态转移。状态转移方程如何计算dp[i]既然dp[i]是以nums[i]结尾的LIS长度那么nums[i]必须在这个子序列里。我们只需要关心在i之前的位置j0 j i哪些nums[j]比nums[i]小。如果nums[j] nums[i]那么nums[i]就可以接在以nums[j]结尾的LIS后面形成一个更长的、以nums[i]结尾的上升子序列。因此我们需要遍历所有j找到那个能形成最长链的j。状态转移方程dp[i] max(dp[j]) 1, 对于所有 0 j i 且 nums[j] nums[i]如果不存在这样的j即nums[i]是前i1个数里最小的那么以它结尾的LIS就是它自己长度为1。所以我们可以初始化所有dp[i] 1。最终答案原问题的答案并不是dp[n-1]因为最长上升子序列不一定以最后一个元素结尾。答案是所有dp[i]中的最大值max(dp[0], dp[1], ..., dp[n-1])。3.2 代码实现与逐行解析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. 自底向上填充dp表 for i in range(n): # 计算每一个dp[i] for j in range(i): # 遍历i之前的所有位置j if nums[j] nums[i]: # 如果nums[j] nums[i]说明nums[i]可以接在j后面 # 状态转移尝试用 dp[j] 1 来更新 dp[i] dp[i] max(dp[i], dp[j] 1) # 3. 最终结果是dp数组中的最大值 return max(dp) # 测试 nums [10, 9, 2, 5, 3, 7, 101, 18] print(lengthOfLIS(nums)) # 输出4逐行解析与注意事项初始化dp [1] * n这是边界条件。每个位置单独作为一个子序列时长度就是1。这一步千万不能漏。双重循环外层循环i遍历每个位置计算以它为结尾的LIS。内层循环j遍历i之前的所有位置寻找可以接在后面的、更小的数。时间复杂度是 O(n²)。max(dp[i], dp[j] 1)这是关键。对于每个满足条件的j我们计算dp[j] 1接上nums[i]后的新长度然后和当前dp[i]比较取最大值。因为可能有多个j满足条件我们要的是能形成最长链的那个。返回max(dp)牢记最终答案不是最后一个状态而是所有状态中的最大值。注意这个O(n²)的解法是标准DP解法易于理解。实际上存在一种利用“贪心二分查找”将时间复杂度优化到O(n log n)的更优解法维护一个tails数组这在处理大规模数据时至关重要。但作为DP教学我们先掌握这个基础版本。4. 核心案例进阶0-1背包问题如果说LIS是序列型DP的代表那么0-1背包就是划分型DP的基石。它描述的场景非常实用你有一个容量有限的背包和一堆各有重量和价值的物品每个物品只能选一次0-1如何选择物品使得背包内物品总价值最大问题形式化有N件物品和一个容量为V的背包。第i件物品的重量是weight[i]价值是value[i]。求解将哪些物品装入背包可使这些物品的总重量不超过背包容量且总价值最大。4.1 状态定义与转移的经典思路定义状态这是最经典的定义方式。我们定义dp[i][j]表示从前i件物品中选择并且背包容量为j时可以获得的最大价值。这里i从1开始计数对应物品列表的索引通常我们会有一个0号物品作为哨兵表示没有物品。状态转移方程对于第i件物品我们只有两种选择放或者不放。不放如果不放第i件物品那么问题就等价于“从前i-1件物品中选择容量为j时的最大价值”即dp[i][j] dp[i-1][j]。放如果放第i件物品那么首先需要背包容量j必须大于等于该物品的重量weight[i]。放了之后背包剩余容量为j - weight[i]我们需要在这个剩余容量下从前i-1件物品中挑选最优解来填充。因此此时的最大价值是dp[i-1][j - weight[i]] value[i]。我们的目标是价值最大所以在这两种选择中取最大值dp[i][j] max(dp[i-1][j], dp[i-1][j - weight[i]] value[i]) 其中要求j weight[i]否则只能选择不放。初始化dp[0][j] 0表示没有物品可选时任何容量的最大价值都是0。dp[i][0] 0表示背包容量为0时无法装任何物品价值为0。最终答案dp[N][V]即考虑所有N件物品背包容量为V时的最大价值。4.2 空间优化滚动数组的艺术直接使用二维DP表空间复杂度是O(N*V)。我们观察状态转移方程dp[i][j]只依赖于dp[i-1][...]即上一行的数据。这意味着我们并不需要保存整个二维表只需要保存“上一行”和“当前行”即可。更进一步我们可以只用一个一维数组dp[j]来表示“容量为j时的最大价值”然后逆序更新这个数组。优化后的状态定义与转移定义状态dp[j]表示背包容量为j时能获得的最大价值。状态转移dp[j] max(dp[j], dp[j - weight[i]] value[i])。为什么必须逆序从V遍历到0这是关键因为dp[j]更新时需要用到dp[j - weight[i]]这个值是“旧”的、对应上一轮即考虑前i-1件物品时的值。如果正序更新当更新dp[j]时dp[j - weight[i]]可能已经在同一轮中被更新过了即考虑了第i件物品这就相当于第i件物品被重复选取了多次变成了“完全背包”问题违背了0-1背包的规则。逆序更新保证了在计算dp[j]时dp[j - weight[i]]存储的还是上一轮未考虑物品i的结果。def knapsack_01(N, V, weight, value): 0-1背包问题空间优化版一维数组 :param N: 物品数量 :param V: 背包容量 :param weight: 物品重量列表长度N1weight[0]无意义 :param value: 物品价值列表长度N1value[0]无意义 :return: 最大价值 # 初始化dp数组dp[j]表示容量为j时的最大价值 dp [0] * (V 1) # 遍历每个物品 for i in range(1, N 1): # 逆序遍历背包容量这是0-1背包的核心 for j in range(V, weight[i] - 1, -1): # 状态转移选择不放dp[j]或放dp[j-weight[i]] value[i] dp[j] max(dp[j], dp[j - weight[i]] value[i]) return dp[V] # 示例数据 N 4 # 物品数量 V 5 # 背包容量 weight [0, 1, 2, 3, 4] # 重量0号位置不用 value [0, 2, 4, 4, 5] # 价值0号位置不用 max_value knapsack_01(N, V, weight, value) print(f背包能装的最大价值为: {max_value}) # 输出应为 8 (选择物品2和4重量235价值448)实操心得逆序是灵魂写0-1背包的一维DP时把“逆序更新容量”这句话刻在脑子里。这是最容易出错的地方。下标处理物品列表通常从1开始编号这样更符合dp[i][j]中i的含义。代码中weight和value数组的0号元素可以设为0或任意值仅占位。循环条件for j in range(V, weight[i] - 1, -1)当背包容量j小于当前物品重量weight[i]时根本放不进去所以直接从V遍历到weight[i]即可j weight[i]的部分保持原值即不放该物品的状态。5. 动态规划解题的通用框架与心法通过上面两个例子我们可以总结出一套解决动态规划问题的通用思考框架。下次遇到新问题可以按这个步骤来判断问题是否适用DP回顾那三大特征——最优子结构、重叠子问题、无后效性。先做一个快速判断。定义状态这是最关键也是最难的一步。状态的定义要能描述一个问题阶段的“情形”。通常状态参数就是问题中会变化的量。常见套路单序列问题像LIS状态常定义为dp[i]表示以第i个位置为结尾的某种最优解。双序列问题如最长公共子序列状态定义为dp[i][j]表示第一个序列前i个和第二个序列前j个元素的某种关系。背包问题状态定义为dp[i][j]表示前i个物品在容量/限制j下的最优解。区间问题状态定义为dp[i][j]表示区间[i, j]上的最优解。状态压缩如果状态参数中有小范围整数如20可以考虑用位掩码来表示状态集合将多维状态压缩成一维。推导状态转移方程找出状态dp[xxx]与之前状态dp[yyy]之间的关系。思考要达到当前状态上一步可能处于哪些状态这些状态如何转移到当前状态通常对应着“选择”或“决策”。用数学方程把这个关系写出来。确定初始状态边界条件最小的、不可再分的子问题的解是什么比如dp[0],dp[0][j],dp[i][0]的值。正确的初始化是填表正确的基础。确定计算顺序应该以什么顺序来填充DP表要保证在计算dp[xxx]时它所依赖的dp[yyy]都已经被计算出来了。对于自底向上的制表法这通常意味着循环的顺序。考虑空间优化分析状态转移方程看是否能用滚动数组或一维数组来降低空间复杂度。像0-1背包的逆序更新就是经典案例。返回最终答案最终答案不一定就是dp[n]或dp[n][m]可能是DP表中的某个最大值、最小值、总和或一个布尔值。仔细审题。6. 常见陷阱、调试技巧与复杂度分析动态规划代码写出来结果不对怎么办或者担心效率不行这里分享一些我踩过的坑和调试方法。6.1 常见错误排查表错误现象可能原因检查点与解决方法结果输出0或初始值1. 状态转移方程逻辑错误根本没更新。2. 初始值设置不当覆盖了有效结果。3. 最终答案取错了位置。1. 打印整个DP表看数据是如何填充的。检查转移方程的条件和计算是否正确。2. 确认初始化是否合理。例如求最大值时初始化为0求最小值时初始化为一个很大的数。3. 确认return的是不是dp数组的正确位置如最大值、最后一个值等。结果比预期小1. 状态定义不完整漏掉了某些可能的最优解。2. 状态转移时max/min的比较对象有遗漏。3. 初始化值设得太小对于求最大值问题。1. 重新审视状态定义是否涵盖了所有可能的情况。2. 仔细推导状态转移方程确保考虑了所有可能的“前驱状态”。3. 对于求最大值确保初始化为0或负无穷视情况而定不会影响max操作。结果比预期大1. 状态转移导致重复计数。2. 初始化值设得太大对于求最小值问题。3. 在0-1背包问题中使用了正序更新一维数组变成了完全背包。1. 检查状态转移是否无意中让同一个元素被使用了多次。2. 对于求最小值初始化为一个很大的数但不要大到溢出。3.重点检查0-1背包的容量循环是否为逆序超时TLE1. 时间复杂度太高通常是O(n²)或O(n³)对于大数据量不行。2. 存在不必要的重复计算记忆化搜索未命中。1. 分析问题是否有更优的DP定义或转移方程能否优化到O(n log n)。2. 检查记忆化搜索的“备忘录”查找和存储是否正确、高效。内存超限MLEDP表开得太大。例如N和V都是10^5开二维数组[10^5][10^5]肯定爆内存。1. 优先考虑空间优化使用滚动数组或一维数组。2. 如果状态定义中有一维是布尔值或小范围值可以考虑用位运算压缩。6.2 复杂度分析要点时间复杂度通常由状态数量×每个状态转移的代价决定。状态数量由状态定义的维度决定。例如dp[i][j]i范围是0~Nj范围是0~V状态数就是 O(N*V)。转移代价计算一个dp[i][j]需要进行的操作次数。例如LIS中计算每个dp[i]需要遍历所有j i代价是O(i)所以总时间是O(∑i) O(n²)。0-1背包中计算每个状态是O(1)的常数时间。总复杂度LIS为 O(n²)0-1背包为 O(N*V)。空间复杂度优化前就是DP表的大小优化后取决于使用的数组维度。一维0-1背包的空间复杂度是 O(V)。6.3 调试技巧打印DP表这是最直观的调试方法。在代码关键位置如每次外层循环结束后打印出整个DP表或数组观察数据的填充过程是否符合你的预期。这能帮你快速定位是初始化问题、转移方程问题还是循环顺序问题。# 以LIS为例添加调试信息 def lengthOfLIS_debug(nums): n len(nums) dp [1] * n print(f初始dp: {dp}) for i in range(n): for j in range(i): if nums[j] nums[i]: old dp[i] dp[i] max(dp[i], dp[j] 1) if dp[i] ! old: print(f 更新 dp[{i}] 因为 nums[{j}]{nums[j]} nums[{i}]{nums[i]}, 用 dp[{j}]1{dp[j]1} 更新为 {dp[i]}) print(fi{i} 循环结束当前dp: {dp}) return max(dp)动态规划就像搭积木定义状态是选择积木的形状转移方程是拼接的规则初始化和计算顺序是搭建的起点和方法。多练习多总结从经典模型LIS LCS 背包 编辑距离 股票问题 打家劫舍入手理解其本质慢慢就能培养出“DP直觉”。当遇到新问题时能快速识别出它背后是哪个经典模型的变种或者如何定义新的状态。这个过程没有捷径就是思考、实现、调试、再思考。我在学习初期会把每个经典问题的状态定义、转移方程、初始化、代码模板都整理在笔记里反复看直到内化成自己的思维模式。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价