动态规划核心:从最长上升子序列拆解子问题分析与状态转移

发布时间:2026/8/28 15:51:32
动态规划核心:从最长上升子序列拆解子问题分析与状态转移 1. 从“最长上升子序列”说起为什么动态规划是绕不开的坎如果你刷过一些算法题或者正准备踏入这个领域大概率会碰到“最长上升子序列”Longest Increasing Subsequence, LIS这个问题。它太经典了经典到几乎成了动态规划Dynamic Programming, DP的“名片”。题目描述很简单给定一个无序的整数序列找到其中最长的、严格递增的子序列的长度。比如序列[10, 9, 2, 5, 3, 7, 101, 18]最长的上升子序列之一是[2, 3, 7, 101]长度为4。新手看到这个问题第一反应可能是暴力枚举所有子序列然后检查是否递增。但稍微算一下就知道一个长度为n的序列子序列总数是2^n这显然是指数级的灾难。于是你开始寻找更优解然后就会在各种攻略、题解里反复看到一个词动态规划。很多教程会直接甩给你一个状态定义dp[i]表示以第i个元素结尾的最长上升子序列长度然后给出状态转移方程dp[i] max(dp[j]) 1 (其中 j i 且 nums[j] nums[i])。背下来似乎也能解题。但问题来了这个dp[i]是怎么想出来的为什么是“以第i个元素结尾”为什么状态转移要去看前面所有的j这背后隐藏的动态规划核心思想——子问题分析才是真正需要啃下的硬骨头。很多人学动态规划感到吃力就是因为跳过了“定义子问题”这个最关键的思考过程直接去记忆和套用模板。今天我们就以“最长上升子序列”这个经典案例为引子深入Level 2的层面拆解动态规划中“子问题分析”的完整心路历程。这不是一篇教你背公式的文章而是一次思维过程的慢放让你看清高手是如何一步步把一个大问题拆解成可管理、可重复利用的小问题的。2. 动态规划的本质不是算法是方法论在深入案例之前我们必须统一思想动态规划首先是一种方法论其次才体现为具体的算法实现。它的核心目标是通过巧妙地定义子问题和存储子问题的解来避免重复计算从而高效解决那些具有“重叠子问题”和“最优子结构”特性的复杂问题。2.1 重叠子问题与最优子结构两个基石这两个术语听起来很学术我们用最直白的方式解释重叠子问题在解决大问题的过程中你需要反复解决许多一模一样的小问题。比如在计算斐波那契数列F(5)时你需要计算F(4)和F(3)计算F(4)时又需要计算F(3)和F(2)。你看F(3)被计算了多次。这就是重叠子问题。如果傻傻地用递归就会造成巨大的计算浪费。动态规划通过“记笔记”即DP表把算过的F(3)存起来下次直接用。最优子结构一个大问题的最优解可以通过其子问题的最优解组合得到。这是动态规划能够成立的前提。如果子问题的最优解无法构成原问题的最优解那动态规划就无效。例如在“最短路径”问题中从A到C的最短路径如果经过B那么这条路径必然由A到B的最短路径和B到C的最短路径组成。注意很多问题具有“子结构”但不一定是“最优子结构”。比如最长路径问题就不具有最优子结构因为局部最长无法保证全局最长。所以拿到问题第一件事是判断它是否适合用DP而判断的关键往往始于对子问题的分析。2.2 子问题分析动态规划的灵魂步骤子问题分析就是寻找那个“牵一发而动全身”的切入点。一个好的子问题定义应该具备以下特点与原问题同构子问题应该是原问题的一个缩小版形式相同。边界清晰存在一个或多个显而易见的、无需计算就能得出答案的“最小子问题”即初始状态。能推导出原问题通过某种规则可以由子问题的解有效地推导出更大规模子问题乃至原问题的解。这个过程没有固定公式更像是一种艺术。我们回到“最长上升子序列”问题看看这个分析过程是如何发生的。3. 案例深潜最长上升子序列的子问题拆解全记录假设我们面对序列nums [10, 9, 2, 5, 3, 7, 101, 18]。目标是求LIS长度。3.1 第一步暴力搜索的视角与启发最笨的方法是枚举所有子序列。当我们枚举时潜意识里其实在做一种决策对于序列中的每一个数在构造当前子序列时只有两种选择——“选它”或者“不选它”。但这会形成一棵庞大的二叉决策树。我们可以换个角度思考如果我强制规定找出来的最长上升子序列必须以某个特定的数结尾会怎么样比如我必须找一个以7结尾的上升子序列。那么这个子序列的前一个数只能是7前面那些比7小的数2,5,3中的一个。那么以7结尾的最长上升子序列的长度就等于“从前面那些比7小的数里挑一个结尾形成最长序列然后接上7”。这个想法至关重要它把一个“全局自由”的问题转化为了一个“带约束”的问题。约束就是子序列的结尾元素固定。3.2 第二步定义状态子问题基于上面的启发我们自然可以定义一组子问题子问题 dp[i]表示以原序列中第i个位置下标通常从0开始的数字nums[i]作为结尾的最长上升子序列的长度。为什么这么定义同构性每个dp[i]本身就是一个“最长上升子序列”问题只不过定义域缩小到了前缀nums[0...i]且加上了“必须以nums[i]结尾”的约束。边界清晰对于任何一个位置i最短的、以nums[i]结尾的上升子序列就是它自己长度为1。所以初始状态dp[i] 1对所有i都成立。目标关联原序列的LIS长度必然是以其中某个数结尾的。所以原问题的答案就是所有dp[i]中的最大值即max(dp[0], dp[1], ..., dp[n-1])。3.3 第三步推导状态转移方程子问题间的关系这是动态规划最核心的一步也是子问题分析能力的直接体现。我们现在知道了dp[i]的含义那么dp[i]的值应该怎么算出来根据定义dp[i]是以nums[i]结尾的LIS长度。既然序列必须以nums[i]结尾那么nums[i]的前一个数倒数第二个数是谁它可以是nums[i]之前、任何比nums[i]小的数nums[j](其中0 j i且nums[j] nums[i])。如果这个“前一个数”是nums[j]那么以nums[i]结尾的整个序列就可以看作是在“以nums[j]结尾的LIS”后面接上nums[i]。因此这种情况下新的序列长度就是dp[j] 1。nums[i]前面可能有多个符合条件的j多个比它小的数我们应该选哪个因为我们要找的是“最长”的所以应该选择能使得dp[j] 1最大的那个j。如果前面没有比nums[i]小的数那nums[i]就只能自己作为一个序列开头长度为1也就是我们初始化的值。于是状态转移方程就呼之欲出了dp[i] max(1, max{ dp[j] 1 for all j i and nums[j] nums[i] })这个方程完美诠释了“最优子结构”为了求dp[i]这个子问题的最优解我们需要遍历所有更小的子问题dp[j]的最优解并从中选出最好的一个来组合。3.4 第四步模拟计算与填表理论有了我们手动模拟一下感受动态规划“表格”的填充过程这能极大地加深理解。下标 inums[i]dp[i] 计算过程j遍历 0 到 i-1dp[i] 值解释以nums[i]结尾的LIS举例010前面无数初始为11[10]19j0: 109不满足。初始为11[9]22j0:102; j1:92。初始为11[2]35j0:105; j1:95;j2:25, dp[2]12。max(1,2)22[2, 5]43j0,1:不满足j2:23, dp[2]12j3:53。max(1,2)22[2, 3]57j0,1:不满足j2:27, dp[2]12j3:57, dp[3]13j4:37, dp[4]13。max(1,2,3,3)33[2, 5, 7] 或 [2, 3, 7]6101遍历j0到5所有数都小于101。找到最大的dp[j]是dp[5]3。所以dp[6]3144[2, 5, 7, 101] 或 [2, 3, 7, 101]718遍历j0到6比18小的数中最大的dp[j]是dp[5]3对应数字7。所以dp[7]3144[2, 5, 7, 18] 或 [2, 3, 7, 18]最终所有dp[i]中的最大值是4所以原序列的LIS长度是4。实操心得手动填一两遍表胜过看十遍代码。这个过程能让你直观地看到每个子问题的解是如何依赖于更小的子问题的这是理解动态规划不可或缺的一环。很多人在面试时卡壳就是因为只在脑子里想没有动笔把这个依赖关系画清楚。4. 从理论到代码实现与优化理解了子问题分析和状态转移代码实现就是水到渠成的事情。4.1 基础动态规划实现def length_of_lis(nums): if not nums: return 0 n len(nums) # 1. 定义dp数组初始化所有值为1 dp [1] * n # 2. 外层循环计算每一个dp[i] for i in range(n): # 内层循环遍历所有可能的“前一个数” nums[j] for j in range(i): if nums[j] nums[i]: # 3. 状态转移尝试用dp[j]来更新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时间复杂度O(n²)因为有两层嵌套循环。空间复杂度O(n)用于存储dp数组。4.2 优化思路贪心二分查找O(n²)的复杂度在数据量大时比如n10^5依然不够看。有没有更优的方法有其核心在于子问题定义的进一步优化。我们定义一个新的子问题子问题 tail[k]表示长度为k1的所有上升子序列中结尾数字最小的那个子序列的结尾数字。这个定义非常巧妙。我们维护一个数组tail它的长度就是当前找到的最长上升子序列的长度。tail[i]的值代表了在扫描过的数字中能够构成长度为i1的上升子序列时所需的最小结尾数字。维护过程遍历每个数字x。在tail数组中寻找第一个大于等于x的位置。这个查找可以用二分法完成因为tail数组本身是严格递增的可以证明。如果找到说明存在一个更长的子序列可以用更小的结尾数字x来更新我们用x替换掉那个位置原来的数。如果没找到即x比tail中所有数都大说明x可以接在当前最长的子序列后面形成更长的子序列我们将x追加到tail末尾。这个过程保证了tail数组始终是递增的并且它的最终长度就是LIS的长度。import bisect def length_of_lis_optimized(nums): if not nums: return 0 tail [] for num in nums: # 在tail中二分查找第一个 num 的位置 pos bisect.bisect_left(tail, num) if pos len(tail): # num比所有数都大延长子序列 tail.append(num) else: # 用更小的num替换掉pos位置的数 tail[pos] num return len(tail) # 测试 nums [10, 9, 2, 5, 3, 7, 101, 18] print(length_of_lis_optimized(nums)) # 输出4时间复杂度O(n log n)遍历n个元素每个元素进行一次O(log n)的二分查找。空间复杂度O(n)最坏情况下tail数组和原数组等长。注意事项这个优化算法得到的tail数组其内容不一定是一个真实的、合法的LIS但它的长度一定是正确的LIS长度。如果需要输出具体的序列基础DP方法可以通过记录“前驱”节点来回溯而优化方法则不行。这是时间效率和信息完整性之间的一个权衡。5. 举一反三子问题分析在其他经典DP问题中的应用掌握了LIS的分析方法我们可以将其应用到其他经典问题上你会发现套路是相通的。5.1 最大子数组和Kadane算法问题给定一个整数数组找出一个具有最大和的连续子数组。子问题分析暴力搜索枚举所有子数组O(n²)。DP思路如果我们定义dp[i]为“以第i个元素结尾的最大子数组和”会怎么样那么对于dp[i]它有两种选择要么只包含自己 (nums[i])要么接在以i-1结尾的最大子数组后面 (dp[i-1] nums[i])。状态转移方程dp[i] max(nums[i], dp[i-1] nums[i])。原问题的答案是max(dp[0], ..., dp[n-1])。这其实就是Kadane算法的动态规划形式空间可以优化到O(1)。5.2 不同路径网格路径问题问题一个机器人位于一个 m x n 网格的左上角每次只能向下或向右移动一步问到达右下角有多少条不同路径。子问题分析定义dp[i][j]为从起点(0,0)走到格子(i,j)的不同路径数。如何走到(i,j)要么从上面的格子(i-1,j)走下来要么从左边的格子(i,j-1)走过来。这两种方式是互斥且完备的。状态转移方程dp[i][j] dp[i-1][j] dp[i][j-1]。边界条件第一行dp[0][j]和第一列dp[i][0]都只有一种走法直走所以初始化为1。5.3 0-1背包问题问题有N件物品和一个容量为V的背包。第i件物品的体积是v[i]价值是w[i]。求解将哪些物品装入背包可使这些物品的总体积不超过背包容量且总价值最大。子问题分析 这是二维子问题的经典案例。定义dp[i][c]为考虑前i件物品在背包容量为c的情况下可以装入的最大价值。对于第i件物品我们有两种选择不装那么最大价值就是考虑前i-1件物品、容量为c时的最大价值即dp[i-1][c]。装前提是能装下即c v[i]那么最大价值就是“第i件物品的价值w[i]”加上“考虑前i-1件物品、剩余容量为c-v[i]时的最大价值”即w[i] dp[i-1][c-v[i]]。状态转移方程dp[i][c] max(dp[i-1][c], w[i] dp[i-1][c-v[i]])当c v[i]时。边界条件dp[0][...] 0考虑0件物品价值为0。6. 动态规划解题的通用思维框架与避坑指南根据上面的案例分析我们可以总结出一套解决动态规划问题的通用思维框架确定状态定义子问题这是最难也最关键的一步。问自己问题的哪个维度在变化通常状态参数对应着问题规模缩小的维度如序列长度i、背包容量c、坐标(i,j)。一个经典技巧是尝试在问题描述中加上“一定条件下”比如“以...结尾”、“考虑前...个”、“在...容量下”。确定状态转移方程找出子问题之间的关系。思考要得到当前状态需要哪些已经计算出来的子状态它们之间如何组合取最大、最小、求和等这一步是数学建模。确定初始状态边界条件最小的、不可再分的子问题是什么它们的解通常是显而易见的如空序列、容量为0、起点位置。确定计算顺序为了保证在计算一个状态时它所依赖的子状态都已经被计算出来我们需要确定一个正确的填表顺序通常是自底向上从左到右从上到下。代码实现与优化将上述思路转化为代码。考虑空间优化例如滚动数组有时也需要考虑时间优化如斜率优化、四边形不等式等高级技巧。常见问题与排查技巧实录问题1状态定义想不出来怎么办技巧从暴力搜索开始思考。暴力搜索的递归函数通常有哪些参数这些参数往往就是状态定义的维度。例如在递归计算斐波那契数时参数是n在递归枚举子序列时参数可能是当前索引i和前一个数的值prev。prev这个信息如果很多可以想想能否把它“编码”到状态里或者通过定义方式规避掉如LIS中定义为“以i结尾”就自然包含了prev的信息。问题2状态转移方程写错了导致结果不对。排查一定要手动模拟小规模数据画出DP表一步步推导。这是最有效的调试方法。检查边界条件i0,j0,c0等是否处理正确。检查转移条件如背包问题中的容量判断是否遗漏。问题3递归实现超时但改成递推自底向上又很绕。心得优先掌握自底向上的递推写法填表法。它更符合动态规划“利用已计算子问题”的本意而且通常比递归记忆化搜索有更好的常数性能也更容易进行空间优化。把递推过程想象成填满一个表格顺序很重要。问题4空间复杂度太高如何优化技巧观察状态转移方程。如果dp[i][...]只依赖于dp[i-1][...]即上一行那么通常可以用滚动数组将空间从O(mn)降到O(n)或O(m)。如果只依赖于左侧或上方的几个状态甚至可能优化到O(1)。在优化前务必先写出清晰正确的二维DP代码。动态规划的魅力在于一旦你突破了“定义子问题”这个思维屏障很多看似复杂的问题都会变得有迹可循。它锻炼的是一种将复杂问题分解、定义、重组的能力这种能力不仅在算法竞赛中有用在解决实际的工程和系统设计问题时也同样宝贵。从LIS这个经典案例入手仔细体会每一步思考的由来然后尝试去解构其他DP问题你会发现自己对算法的理解正在从“背诵”走向“创造”。