FEATURED · 精选文章

最长上升子序列(LIS)模型解析:从动态规划到贪心二分优化

发布时间 / 2026/8/28 16:16:37
来源 / 创域科博编辑部
栏目 / 资讯中心
最长上升子序列(LIS)模型解析:从动态规划到贪心二分优化 1. 项目概述从“怪盗基德的滑翔翼”到最长上升子序列最近在重温一些经典的算法题目发现“怪盗基德的滑翔翼”这道题虽然名字听起来像动漫情节但内核却是一个极其经典且应用广泛的算法模型——最长上升子序列。我第一次接触这道题时觉得它就是个简单的动态规划练习题但随着项目经验的积累尤其是在处理一些数据流分析、序列预测和路径规划问题时才深刻体会到LIS模型那“四两拨千斤”的威力。这道题本质上是在考察我们如何从一个看似无序的序列中抽取出最长的、符合某种单调性递增或递减的子序列。对于怪盗基德来说他需要选择一条从一栋建筑到另一栋建筑的滑翔路径而建筑的高度构成了一个序列他只能从高往低滑翔这就转化为了寻找序列中的最长下降子序列问题。今天我就结合自己踩过的坑和实战心得把这个模型的里里外外、前世今生以及那些教科书里不会写的“骚操作”和“暗坑”给大家掰开揉碎了讲清楚。2. 核心思路拆解为什么是动态规划2.1 问题重述与模型抽象题目场景是这样的怪盗基德面前有一排高低错落的建筑他可以选择任意一栋作为起点然后向任意方向左或右滑翔。滑翔时他只能从较高的建筑飞向较低的建筑。目标是找到一条最长的连续滑翔路径即经过最多栋建筑。我们首先需要把生活场景抽象成数学模型。假设有N栋建筑它们的高度用一个数组heights表示heights[i]代表第i栋建筑的高度。基德从第i栋建筑出发如果选择向左滑翔那么他实际上是在寻找序列heights[0...i]的最长下降子序列。如果选择向右滑翔那么他实际上是在寻找序列heights[i...N-1]的最长下降子序列。由于方向是任意的所以对于每个起点i我们需要计算两个值以i为结尾的、向左的最长下降子序列长度以及以i为开头的、向右的最长下降子序列长度。最终的答案就是所有i对应的这两个值的最大值。注意这里有一个关键点题目要求的是“连续”滑翔吗仔细读题会发现并不要求滑翔路径上的建筑在原序列中连续只要求高度单调递减且顺序一致对于向左是逆序对于向右是正序。所以这完全符合“子序列”的定义而非“子数组”。2.2 为什么动态规划是自然的选择面对“最长XX子序列”这类问题动态规划几乎是条件反射般的首选。原因在于这个问题满足DP的两个核心性质最优子结构整个序列的最长下降子序列必然包含其子序列的最长下降子序列。例如如果我们知道了前i-1个建筑中以各个位置结尾的最长下降子序列长度那么要计算以第i个建筑结尾的长度只需要看前面那些比它高的建筑即可。重叠子问题在计算以第i个建筑结尾的长度时我们需要反复查询前面所有比它高的建筑对应的状态。如果使用递归暴力搜索会产生大量重复计算。因此我们定义状态dp_left[i]表示以第i个建筑为终点且方向向左即只看i左边的建筑时能构成的最长下降子序列的长度。状态转移方程就非常直观了dp_left[i] max(dp_left[j]) 1其中j满足0 j i且heights[j] heights[i]。 这个方程的意思是为了找到以i结尾的最长下降子序列我去前面所有比i高的建筑j那里看看接在它们已经形成的最长子序列后面是不是能让我变得更长。我选择能让我变得最长的那个j。同理我们还需要计算向右滑翔的情况。这里可以取巧将原序列反转然后对反转后的序列同样应用上述DP过程得到的结果dp_right_rev[k]对应到原序列的位置i时就是以i为起点向右的最长下降子序列长度。也可以直接定义dp_right[i]表示以i为起点向右的最长下降子序列长度然后从右向左遍历进行状态转移。2.3 算法选择背后的权衡O(N²) DP vs O(N log N) 贪心二分基础的DP解法时间复杂度是 O(N²)空间复杂度是 O(N)。对于题目常见的 N 100 的数据范围这完全够用且代码直观易于理解和调试。这也是面试或笔试中面试官最可能期望你首先写出的解法。但是在实际的工程项目或算法竞赛中如果 N 达到 10^5 甚至更大O(N²) 就无法接受了。这时就需要更优的 O(N log N) 解法。这个解法基于贪心思想并利用二分查找来维护一个“潜力序列”。它虽然不直接求出每个位置i的dp值但能高效地求出整个序列的最长上升或下降子序列的长度。对于“怪盗基德的滑翔翼”这道题由于我们需要对每个点都计算向左和向右两个方向的值相当于求解2N次LIS问题如果N很大O(N²)的DP就会超时。而O(N log N)的算法则能轻松应对。因此理解这两种方法及其适用场景是掌握这个模型的关键。3. 核心细节解析与两种实现方案3.1 方案一经典O(N²)动态规划实现这是最符合直觉的解法。我们分别计算两个方向的dp数组。步骤拆解初始化创建两个数组dp_left和dp_right长度均为 N。初始化每个值为1因为每个建筑自身可以构成一个长度为1的子序列。计算向左滑翔dp_left顺序遍历i从 0 到 N-1。对于每个i再遍历j从 0 到i-1。如果heights[j] heights[i]说明可以从j滑翔到i。那么dp_left[i]就有可能更新为dp_left[j] 1。我们取所有可能中的最大值。状态转移dp_left[i] max(dp_left[i], dp_left[j] 1)。计算向右滑翔dp_right逆序遍历i从 N-1 到 0。对于每个i遍历j从i1到 N-1。如果heights[j] heights[i]说明可以从i滑翔到j注意方向此时i是起点。那么以i为起点的最长下降子序列就可能包含了以j为起点的序列。因此dp_right[i]可能更新为dp_right[j] 1。状态转移dp_right[i] max(dp_right[i], dp_right[j] 1)。合并结果遍历每个建筑i其能完成的最长滑翔路径为dp_left[i] dp_right[i] - 1。因为dp_left[i]包含了建筑i自身dp_right[i]也包含了建筑i自身相加时多算了一次所以要减1。最终答案就是所有i计算结果的最大值。代码示例Python风格伪代码def longest_glide(heights): n len(heights) dp_left [1] * n dp_right [1] * n # 计算向左滑翔 for i in range(n): for j in range(i): if heights[j] heights[i]: dp_left[i] max(dp_left[i], dp_left[j] 1) # 计算向右滑翔 for i in range(n-1, -1, -1): for j in range(i1, n): if heights[j] heights[i]: dp_right[i] max(dp_right[i], dp_right[j] 1) # 合并结果 max_len 0 for i in range(n): max_len max(max_len, dp_left[i] dp_right[i] - 1) return max_len实操心得边界处理初始化dp数组为1是正确且必要的。它代表了最坏情况如果前面或后面没有更低的建筑基德就只能停留在这栋建筑上。遍历顺序计算dp_left必须从左到右因为每个dp_left[i]依赖于它左边所有j的状态。计算dp_right则必须从右到左道理相同。结果合并dp_left[i] dp_right[i] - 1这个公式是这道题的一个小陷阱。一定要想清楚dp_left[i]表示以i结尾向左的最长序列dp_right[i]表示以i开头向右的最长序列。它们都包含了建筑i所以交点i被重复计算了一次。3.2 方案二优化版O(N log N)贪心二分实现当N很大时我们需要更快的算法。这个算法的核心思想是对于一个固定长度的下降子序列其末尾元素的值越大越不“下降”未来可能接上更多新元素、让序列变得更长的潜力就越大。我们维护一个数组tail或者叫d但它并不是直接存储LIS本身而是存储一个“潜力序列”tail[len]表示长度为len的下降子序列的末尾元素的最大可能值对于下降序列我们其实维护的是“最小末尾值”但为了和经典的LIS算法对照我们讨论其逆过程——即寻找最长上升子序列。对于下降序列通常将原序列取反或调整比较逻辑。更通用的做法是我们将“寻找最长下降子序列”转化为“寻找最长上升子序列”。具体有两种转化方式将原序列取负数-heights那么求heights的最长下降子序列就等价于求-heights的最长上升子序列。在贪心二分的过程中将比较符号从改为。这里我们采用第二种更直观地针对下降序列来描述算法。我们维护数组tailtail[len]表示长度为len的下降子序列的最后一个元素即最小的那个元素因为越往后高度越低。我们希望tail数组是单调递减的这样我们可以用二分查找快速定位新元素应该插入的位置。步骤拆解以求整个序列的最长下降子序列长度为例初始化tail为空数组。遍历原序列的每个元素x。在tail数组中二分查找第一个小于x的元素的位置。因为我们要维护下降序列新元素x如果比某个长度为len的序列末尾元素还小它就可以接在后面形成更长的下降序列如果x比所有tail[len]都大或相等那么它只能作为一个新的长度为1的序列的头部。如果找到了这样的位置pos即tail[pos] x那么用x替换tail[pos]。因为x比原来的tail[pos]大对于同样长度pos1的下降子序列用x作为末尾未来的扩展潜力更大允许后面接更小的数。如果没找到即x比当前tail中所有元素都小那么就把x追加到tail末尾。这意味着我们发现了一个更长的下降子序列。遍历结束后tail数组的长度就是最长下降子序列的长度。应用到本题我们需要对每个位置i分别计算left_len[i]: 序列heights[0...i]的最长下降子序列长度以i结尾。right_len[i]: 序列heights[i...N-1]的最长下降子序列长度以i开头。注意贪心二分算法通常直接求出整个序列的LIS长度而不是每个位置结尾的长度。为了得到每个位置的left_len[i]我们需要一个技巧在遍历到i时当前tail数组的长度就是以i结尾的、考虑前i1个元素时的最长下降子序列长度吗不一定。因为tail数组维护的是全局最优潜力其最后一个元素不一定对应heights[i]。一个可靠的方法是对每个前缀子数组heights[0...i]单独运行一次贪心二分算法。这样时间复杂度是 O(N² log N)反而比朴素DP更差。这显然不是我们想要的。因此对于需要每个位置的LIS长度的问题O(N²)的DP仍然是更合适的选择因为它天然地计算出了每个dp[i]。而贪心二分算法的优势在于高效求解全局最长长度。在“怪盗基德的滑翔翼”这道题中如果我们只需要知道全局最优解可以对整个序列和反转序列分别求一次最长下降子序列长度然后取最大值但这忽略了起点可以是任意位置且左右方向独立。所以严格来说要完美解决此题对于大规模N我们需要能高效计算每个位置LIS长度的方法这涉及到更复杂的数据结构如树状数组维护前缀最大值其时间复杂度为 O(N log N)。这超出了本题最常见的考察范围但却是工程实践中可能遇到的优化点。重要提示在面试或笔试中如果N不超过1000优先实现并讲解O(N²)的DP解法它思路清晰代码简洁足以通过。同时可以提及存在O(N log N)的优化算法体现你的知识广度。如果N非常大则需要和面试官沟通确认是否需要实现优化版本。4. 从模型到实战LIS的广泛应用场景最长上升子序列模型绝不仅仅是一道算法题。它的思想在诸多领域都有巧妙的应用。理解这些能让你在遇到相关问题时更快地建立模型。4.1 应用场景一俄罗斯信封问题这是一个经典变种给定一些信封的宽度和高度(w, h)如果一个信封的宽度和高度都大于另一个信封那么它可以套住另一个信封。问最多可以套多少层信封。解法先按宽度升序排序宽度相同的按高度降序排序。排序后问题就转化为了在高度序列上寻找最长上升子序列。为什么宽度相同要按高度降序这是为了避免宽度相同的信封被错误地套在一起因为题目要求宽度和高度都严格大于。4.2 应用场景二堆箱子问题有n个箱子每个箱子有长宽高。只有当箱子的长、宽、高都分别大于另一个箱子时才能堆在上面。求最高能堆多高。解法一个箱子的6种旋转体长宽高排列都可以视为一个独立的箱子。然后按照底面积长*宽排序问题转化为在高度维度上的带权最长上升子序列每个箱子的高度作为权值求最大权值和。4.3 应用场景三调度与安排问题例如给定一些任务每个任务有开始时间s_i和结束时间e_i以及价值v_i。选择一系列不重叠的任务使得总价值最大。如果任务已经按结束时间排序那么选择任务i后下一个能选的任务j必须满足s_j e_i。这可以通过DP解决状态转移类似于LISdp[i] max(dp[j]) v_i其中j是最后一个结束时间不大于s_i的任务。4.4 应用场景四数据流中的最长递增趋势在金融分析或监控系统中我们可能关注一个实时数据流中最长的连续上涨或下跌趋势的长度。这可以看作是一个在线版本的LIS问题可以使用贪心二分的思想结合滑动窗口或数据结构来近似求解。5. 常见“坑点”与调试技巧实录即便理解了算法在实现时还是会遇到一些意想不到的问题。下面是我在多次实现和教学中总结的常见坑点。5.1 坑点一方向混淆与dp数组定义这是最容易出错的地方。dp_left[i]到底表示以i开头还是以i结尾向左滑翔时基德站在i栋建筑上他看向左边的建筑所以i是滑翔的终点。因此dp_left[i]应定义为以第i栋建筑为终点从左边滑翔过来的最长下降子序列长度。计算它时需要遍历i左边的所有j。同理dp_right[i]表示以i为起点向右滑翔的最长下降子序列长度。计算它时需要从右向左遍历对于每个i遍历其右边的j。调试技巧用一个小例子手动模拟。例如建筑高度为[3, 1, 4, 2, 5]。对于i3(高度2)向左看比它高的有3和4。dp_left[0]1,dp_left[2]1所以dp_left[3] max(1, 1) 1 2。序列可以是[4, 2]或[3, 2]。对于i3向右看比它高的只有5。dp_right[4]1所以dp_right[3] 1 1 2。序列是[2, 5]不对注意方向dp_right[3]表示以3为起点向右高度2要高于后面的建筑才能滑翔。2并不高于5所以这里状态转移不应该发生正确的逻辑是在计算dp_right[i]时我们遍历右边的j只有当heights[i] heights[j]时才能从i滑向j。所以对于i3(高度2)j4(高度5)2 5为假因此dp_right[3]保持为1。这个例子提醒我们在写状态转移的条件判断时必须时刻清楚谁是起点、谁是终点以及高度比较的方向。5.2 坑点二结果合并时重复计算建筑i正如前面提到的最终结果不是max(dp_left[i], dp_right[i])也不是dp_left[i] dp_right[i]而是dp_left[i] dp_right[i] - 1。因为建筑i在向左和向右的序列中都被算作了一个节点。你可以想象基德以i为顶点向左滑了一段又向右滑了一段i这个点是左右两段的连接点只应计算一次。调试技巧画图。把建筑画成一排标上高度和计算出的dp_left和dp_right。然后模拟基德从某个建筑i出发先向左滑到最远经过dp_left[i] - 1栋建筑加上i本身是dp_left[i]栋再折返回来向右滑到最远经过dp_right[i] - 1栋建筑加上i本身是dp_right[i]栋。总建筑数就是(dp_left[i] - 1) 1 (dp_right[i] - 1) dp_left[i] dp_right[i] - 1。5.3 坑点三初始化与边界条件dp数组必须初始化为1。因为最短的序列就是建筑本身。在动态规划的双重循环中内层循环可能找不到任何一个满足条件的j即前面没有更高的建筑此时dp[i]应该保持为初始值1。如果初始化为0结果就会出错。调试技巧在代码中显式打印出dp数组的中间结果。对于第一个建筑i0dp_left[0]应该始终为1因为左边没有建筑。检查你的输出是否符合预期。5.4 坑点四贪心二分算法中的比较逻辑当将LIS算法适配到下降序列时二分查找的比较条件很容易写反。对于下降序列我们维护的tail数组应该是一个单调递减的序列因为末尾元素越小序列下降得越厉害。当我们遇到一个新元素x时我们要在tail中找到第一个小于x的数的位置。这是因为如果tail[pos] x说明x比这个长度为pos1的下降序列的末尾元素大那么x可以替换它让这个长度的序列末尾元素变大一点未来更有潜力接更小的数。如果x比tail中所有数都小那么x可以作为一个新的更长的下降序列的末尾因为当前最长的下降序列末尾都比x大x更小可以接在后面形成更长的序列。如果比较逻辑写错例如找第一个大于x的数整个算法就会失效。调试技巧用一个简单序列手动模拟算法过程。例如序列[5, 3, 4, 2, 1]。x5tail[]直接加入 -tail[5]x3在tail[5]中找第一个3的数。5不小于3没找到所以3比所有数都小加入末尾 -tail[5, 3]x4在tail[5,3]中找第一个4的数。5不小于43小于4找到位置1用4替换3-tail[5, 4]x2在tail[5,4]中找第一个2的数。5和4都不小于2没找到加入末尾 -tail[5,4,2]x1在tail[5,4,2]中找第一个1的数。都不小于1加入末尾 -tail[5,4,2,1]最终tail长度为4最长下降子序列为[5,4,2,1]或[5,3,2,1]长度正确。通过一步步跟踪可以验证算法逻辑。6. 性能分析与进阶思考6.1 时间复杂度对比O(N²) DP对于每个位置i需要扫描它之前或之后的所有位置j。计算dp_left和dp_right各需要 O(N²)合并结果需要 O(N)。总时间复杂度 O(N²)。空间复杂度 O(N)。O(N log N) 贪心二分全局长度遍历一次序列每次进行二分查找 O(log N)。总时间复杂度 O(N log N)。空间复杂度 O(N)。但如前所述它不能直接给出每个位置的LIS长度。O(N log N) 获取每个位置LIS长度需要借助树状数组或线段树在遍历过程中维护前缀或后缀最大值信息。对于每个元素用其值作为索引查询小于或大于它的最大值并在对应位置更新。这可以将复杂度从 O(N²) 优化到 O(N log M)其中 M 是值域范围。如果值域很大可能需要离散化。6.2 如何选择算法数据规模这是决定性因素。N 1000O(N²) 完全够用。N 10000就必须考虑 O(N log N) 的算法。问题需求如果只需要全局最长长度贪心二分是最优解。如果需要知道每个位置的LIS长度例如本题需要计算每个建筑作为起点的最优值那么O(N²)的DP是直观解法若N很大则需用树状数组优化。编码与调试成本O(N²) DP逻辑简单不易出错。贪心二分和树状数组的实现需要更小心调试起来也更复杂。6.3 一个常见的思维扩展最长上升子序列的个数有时问题会问最长上升子序列有多少个这需要在动态规划的基础上再维护一个计数数组cnt[i]表示以i结尾的最长上升子序列的个数。在状态转移时如果dp[j] 1 dp[i]则更新dp[i]并重置cnt[i] cnt[j]如果dp[j] 1 dp[i]则累加cnt[i] cnt[j]。最后对所有dp[i]等于最大长度的i累加其cnt[i]即可得到总数。这个变种考察了对DP状态理解的深度。7. 总结与个人体会“怪盗基德的滑翔翼”这道题就像算法世界里的一个经典模版它把生动的场景和抽象的模型完美结合。通过解决它我们不仅学会了一个算法更学会了一种将实际问题转化为已知模型最长上升/下降子序列的思考方式。我个人在刷题和项目中的体会是对于动态规划问题最重要的不是背下状态转移方程而是理解状态的定义。在这道题里为什么dp_left[i]要定义为以i结尾因为这样定义状态转移才是自然的、可计算的。如果定义为以i开头向左那么计算dp_left[i]时就需要知道它右边建筑的状态这不符合DP的无后效性或者说需要逆序计算变得更绕。另一个深刻的教训是关于边界和初始化。很多DP问题的错误都源于此。像这道题里dp数组初始化为1看起来简单却至关重要。它代表了“最平凡的解”是状态转移的基石。在思考任何DP问题时我都养成了先问自己“最简单的情况是什么它的解是多少”的习惯这能帮助我正确初始化。最后关于优化。虽然工作中大部分时候数据规模不会大到必须用 O(N log N) 的算法但知道它的存在和原理是很有价值的。它体现了计算机科学中一个朴素而强大的思想用额外的空间tail数组来存储“潜力”信息从而避免冗余的比较。这种“空间换时间”以及“维护有序结构以加速查找”的思想在数据库索引、缓存系统等众多领域随处可见。所以下次当你看到“最长”、“子序列”、“单调”这些关键词时不妨想想怪盗基德和他的滑翔翼想想那个维护着“最小末尾”的tail数组。这个小小的模型或许就能帮你优雅地解决一个看似复杂的问题。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻