ARTICLE DETAIL

资讯详情

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

最长递增子序列(LIS)问题详解:从动态规划到贪心+二分的高效解法

最长递增子序列(LIS)问题详解:从动态规划到贪心+二分的高效解法 1. 项目概述从一道国赛真题看算法竞赛的思维跃迁最近在整理蓝桥杯国赛的历年真题发现“递增序列”这道题出现的频率不低而且它非常典型——题目描述看似简单直白但背后考察的算法思维却相当有深度。很多刚接触算法竞赛的朋友一看到“序列”、“递增”这些字眼可能下意识就想用暴力枚举结果一运行不是超时就是内存爆炸。这道题恰恰是检验你是否真正理解如何将问题抽象、转化并运用高效算法工具解决的试金石。今天我就以Python解法为例带大家完整拆解这道题不仅告诉你代码怎么写更重要的是分享解题的完整思考链路以及那些在标准题解里不会写的调试心得和性能优化技巧。无论你是正在备赛蓝桥杯的选手还是想提升自己算法能力的开发者相信这篇从实战中沉淀下来的经验都能让你有所收获。简单来说“递增序列”问题的核心是给定一个整数序列我们需要从中找出一个最长的子序列使得这个子序列是严格递增的。注意这里的“子序列”和“子串”不同它不要求元素在原序列中连续只要保持原有的相对顺序即可。这立刻让我们联想到经典的“最长递增子序列”Longest Increasing Subsequence, LIS问题。国赛真题往往会在经典模型上增加一些约束或变化比如序列长度范围n可能高达10^5、对时间复杂度O(n^2)的DP解法必然超时的严苛要求或者需要你输出具体的序列而不仅仅是长度。我们今天讨论的解法将聚焦于应对大规模数据的高效算法。2. 核心思路解析为什么动态规划不是最优解面对“最长递增子序列”问题初学者最自然的想法就是动态规划DP。我们定义一个数组dp其中dp[i]表示以第i个元素结尾的最长递增子序列的长度。状态转移方程也很直观dp[i] max(dp[j]) 1其中j i且nums[j] nums[i]。最后答案就是dp数组中的最大值。这个思路正确吗完全正确。代码写起来也不复杂一个双重循环就能搞定。但是它的时间复杂度是 O(n^2)。当序列长度 n 达到 10^5 时计算量就是 10^10 这个级别在竞赛常见的1秒或2秒时限内是绝对无法完成的。这就是蓝桥杯国赛题目的典型风格它允许你轻松想到一种解法但会设置数据规模来“卡掉”这种低效的解法逼迫你去寻找更优的算法。那么更优的算法是什么这里就需要引入“贪心 二分查找”的优化策略。这个算法的核心思想非常巧妙我们并不直接维护所有可能的递增子序列而是维护一个“潜力列表”tails。tails[k]的值代表长度为 k1 的所有递增子序列中结尾元素的最小值。为什么维护最小值因为对于相同长度的子序列结尾元素越小未来“接纳”一个新元素使其继续保持递增的可能性就越大潜力也就越大。整个算法的过程可以这样理解我们遍历原序列中的每个数x然后用二分查找在tails数组中找到第一个大于或等于x的元素的位置i。如果找到了即tails[i] x我们就用x去替换tails[i]。这意味着我们发现了一个结尾更小的、长度为i1的递增子序列。如果没找到即x比tails中所有元素都大那么我们就把x追加到tails的末尾。这意味着我们找到了一个更长的递增子序列长度增加了1。这个算法的时间复杂度是 O(n log n)其中遍历是 O(n)二分查找是 O(log n)。对于 10^5 的数据规模这完全在可接受范围内。空间复杂度是 O(n)。这就是应对国赛级别数据量的标准答案。注意这个算法得到的是最长递增子序列的长度并且tails数组本身并不一定是最长递增子序列本身。tails是一个用于辅助计算长度的“工具数组”。如果需要还原出具体的序列还需要额外的记录和回溯操作这通常会增加一些编码复杂度。3. 算法实现细节与Python代码精讲理解了核心思想我们来看具体的Python实现。这里我会给出两个版本的代码第一个是标准的求长度版本第二个是稍微复杂一点、可以还原出其中一个最长递增子序列的版本。我会对每一行关键代码进行注释并解释其中的细微之处。3.1 标准解法计算最长递增子序列的长度这是最简洁、最常用的版本直接应用上述的“贪心二分”算法。def length_of_lis(nums): 计算给定列表 nums 的最长严格递增子序列的长度。 :param nums: List[int] 输入整数序列 :return: int 最长递增子序列的长度 if not nums: return 0 tails [] # 潜力数组tails[i] 存储长度为 i1 的递增子序列的最小结尾值 for num in nums: # 使用二分查找在 tails 中寻找第一个 num 的元素位置 left, right 0, len(tails) while left right: mid (left right) // 2 if tails[mid] num: left mid 1 else: right mid # 二分查找结束后left 指向第一个 num 的位置或者 len(tails)即所有元素都 num if left len(tails): # 如果 num 比所有结尾都大说明可以延长子序列 tails.append(num) else: # 否则用 num 替换掉那个位置的元素使得该长度的子序列结尾更小 tails[left] num # tails 的长度就是最长递增子序列的长度 return len(tails) # 示例 if __name__ __main__: test_nums [10, 9, 2, 5, 3, 7, 101, 18] result length_of_lis(test_nums) print(f序列 {test_nums} 的最长递增子序列长度是: {result}) # 输出应为 4代码精讲与避坑点二分查找的写法这里使用的是“左闭右开”区间[left, right)的二分查找模板。while left right和right mid的搭配是经典写法可以有效避免死循环。判断条件tails[mid] num决定了我们找的是第一个大于等于num的位置。如果我们要找的是“最后一个小于num的位置”条件则需要反过来。为什么是tails[mid] num我们的目标是找到tails中第一个 num的位置。如果tails[mid] num说明mid及其左边的元素都小于num目标位置肯定在右边所以left mid 1。否则tails[mid] num说明mid可能就是目标位置或者目标在左边所以right mid。bisect模块Python标准库的bisect模块提供了高效的二分查找。我们可以用bisect_left(tails, num)直接替代手写的二分查找循环代码会更简洁。bisect_left返回的i就是第一个 num的索引。下面的代码是等效的import bisect def length_of_lis_bisect(nums): tails [] for num in nums: i bisect.bisect_left(tails, num) if i len(tails): tails.append(num) else: tails[i] num return len(tails)在竞赛中使用bisect是更推荐的做法既快又不易出错。3.2 进阶解法还原最长递增子序列之一有时候题目不仅要求长度还要求输出这个子序列本身。由于tails数组在构建过程中会被不断替换它最终存储的并不是一个合法的子序列。我们需要在构建过程中额外记录信息来还原。一种常见的方法是使用一个parent数组。parent[i]记录在最终的最长递增子序列中排在nums[i]前面的那个元素在原数组中的索引。同时我们还需要维护一个tails_idx数组tails_idx[k]记录当前tails[k]对应的元素在原数组nums中的索引。def lis_with_sequence(nums): 计算最长递增子序列的长度并返回其中一个这样的子序列。 :param nums: List[int] :return: tuple (长度, 子序列列表) if not nums: return 0, [] n len(nums) tails [] # 存储长度为 k1 的 LIS 的最小结尾值 tails_idx [] # 存储 tails 中每个值对应的原数组索引 parent [-1] * n # parent[i] 指向在 LIS 中位于 nums[i] 之前的元素索引 for i, num in enumerate(nums): # 二分查找插入位置 left, right 0, len(tails) while left right: mid (left right) // 2 if tails[mid] num: left mid 1 else: right mid # 记录当前元素的“前驱” if left 0: parent[i] tails_idx[left - 1] # 当前元素接在长度为 left 的 LIS 之后 if left len(tails): tails.append(num) tails_idx.append(i) else: tails[left] num tails_idx[left] i # 通过 tails_idx 最后一个元素最长 LIS 的最后一个元素的索引来回溯 lis_length len(tails) seq [0] * lis_length k tails_idx[-1] # 最长 LIS 最后一个元素的索引 for j in range(lis_length - 1, -1, -1): seq[j] nums[k] k parent[k] # 回溯到前一个元素 return lis_length, seq # 示例 if __name__ __main__: test_nums [10, 9, 2, 5, 3, 7, 101, 18] length, sequence lis_with_sequence(test_nums) print(f长度: {length}) # 输出: 4 print(f一个可能的子序列: {sequence}) # 输出: [2, 5, 7, 101] 或 [2, 3, 7, 101] 等还原原理详解parent数组当我们用nums[i]去更新tails[left]时意味着我们找到了一个以nums[i]结尾的、长度为left1的递增子序列。这个子序列的前一个元素就是之前构成长度为left的子序列的结尾元素其索引存储在tails_idx[left-1]中。我们将这个索引记录到parent[i]。tails_idx数组它与tails同步更新始终记录当前tails[k]这个值来源于原数组的哪个位置索引i。回溯构造算法结束后tails的长度就是 LIS 的长度。tails_idx的最后一个元素tails_idx[-1]就是整个找到的最长递增子序列中最后一个元素在原数组中的索引。我们从这里开始利用parent数组不断向前回溯就能把整个子序列找出来。因为我们是倒着回溯的所以构造seq时也需要从后往前填充。实操心得在竞赛中如果题目只要求长度务必使用最简单的bisect版本代码短、速度快、不易错。只有明确要求输出序列时才实现这个带parent的版本。在时间紧迫的赛场清晰的思路比华丽的代码更重要。4. 蓝桥杯真题实战与变种分析掌握了标准解法我们来看看它如何应用到具体的蓝桥杯真题环境中。国赛题目往往不会直接问你“求最长递增子序列”而是会把这个模型嵌入到一个更复杂的场景里。假设一道真题描述如下“给定一个长度为 N 的整数序列 A。你可以进行最多 K 次操作每次操作可以选择序列中的一个元素将其值增加 1每个元素可以被多次操作。请问在操作后序列 A 的最长严格递增子序列的长度最大可能是多少”思路拆解问题转化这不再是单纯的 LIS 问题因为我们可以通过“增加元素值”的操作来“创造”递增关系。这实际上是一个“带修改成本的最长递增子序列”问题。关键洞察对于最终选定的最长递增子序列中的两个相邻元素A[i]和A[j](i j)我们必须有A[i] 增加量_i A[j] 增加量_j。我们的操作次数 K 是有限的。动态规划结合单纯的 O(n log n) 贪心算法无法处理“操作次数”这个约束。我们需要引入动态规划来记录状态。可以定义dp[i][k]表示考虑前 i 个元素并且总操作次数恰好为 k 时所能形成的最长递增子序列的长度。但这样的状态数是 O(N*K)如果 N 和 K 很大比如 10^3O(N^2 * K) 的转移可能会超时。优化思路一个常见的优化是我们并不关心具体对哪个元素操作了多少次我们只关心为了使得某个元素能够接在某个子序列后面所需要的最小操作代价。我们可以将“元素值操作次数”视为一个新的值。问题可以转化为寻找一个子序列使得其对应的“新值”序列是严格递增的并且总操作次数不超过 K。这仍然是一个复杂的问题可能需要结合二分答案二分最终可能的LIS长度和贪心检查来求解。这道变种题说明了竞赛真题往往考察的是对基础模型的灵活运用和组合创新能力。单纯的背模板是行不通的必须真正理解 LIS 算法的本质才能将其作为工具来解决新问题。另一个常见变种非严格递增子序列如果题目要求的是“非严格递增”即允许相等那么算法只需要做微小的调整。在二分查找时我们寻找的不再是第一个 num的位置而是第一个 num的位置。使用bisect模块的话就是把bisect_left换成bisect_right。import bisect def length_of_non_decreasing_subsequence(nums): tails [] for num in nums: i bisect.bisect_right(tails, num) # 关键变化寻找 num 的位置 if i len(tails): tails.append(num) else: tails[i] num return len(tails)5. 调试技巧与性能优化实录在竞赛中实现算法一次写对并且高效运行是关键。下面分享几个我在实战中总结的针对LIS问题的调试和优化技巧。5.1 调试验证算法正确性对于LIS这种有多种可能解的题目如何验证自己的算法输出长度是正确的小数据暴力验证写一个 O(2^n) 的暴力枚举算法使用位运算或DFS用于验证 n 20 左右的小数据。将你的高效算法和暴力算法的结果进行对比。def brute_force_lis(nums): n len(nums) max_len 0 # 枚举所有子序列 for mask in range(1 n): seq [] valid True for i in range(n): if mask i 1: if seq and nums[i] seq[-1]: valid False break seq.append(nums[i]) if valid: max_len max(max_len, len(seq)) return max_len用随机生成的小数组同时运行brute_force_lis和你的length_of_lis多次测试确保结果一致。可视化tails数组在算法运行时打印出tails数组的变化过程有助于理解其工作原理。例如对于输入[3, 1, 4, 1, 5, 9, 2, 6]观察tails如何一步步变为[1, 2, 5, 6]最终长度4。5.2 性能优化让Python飞起来虽然 O(n log n) 的算法已经很快但在 Python 中处理 10^5 甚至 10^6 的数据时细节优化依然很重要。使用bisect替代手写二分bisect模块是用 C 实现的比手写的 Python 循环二分查找快得多。这是最立竿见影的优化。局部变量加速在循环开始前将频繁使用的函数如len,bisect_left或全局变量赋值给局部变量。Python 访问局部变量的速度比访问全局变量或模块属性快。def length_of_lis_fast(nums): import bisect if not nums: return 0 tails [] _append tails.append _bisect_left bisect.bisect_left for num in nums: i _bisect_left(tails, num) if i len(tails): _append(num) else: tails[i] num return len(tails)使用array或list预分配空间效果有限对于已知最大长度的tails可以预先分配一个足够大的列表然后通过索引赋值而非append。但在LIS问题中tails的长度是未知的这种优化意义不大有时反而会因为初始化大列表而变慢。输入优化蓝桥杯的 Python 题目输入数据量往往很大。务必使用sys.stdin.read()或sys.stdin.buffer.read()进行一次性读取然后分割处理这比循环调用input()快一个数量级。import sys, bisect def solve(): data sys.stdin.buffer.read().split() # 假设第一个数是 n后面是 n 个整数 n int(data[0]) nums list(map(int, data[1:1n])) # ... 调用 LIS 算法 ... print(length_of_lis(nums)) if __name__ __main__: solve()5.3 常见错误排查表错误现象可能原因解决方案结果比预期小二分查找条件写反找到了“最后一个小于num”的位置而非“第一个大于等于num”。检查二分循环中的if tails[mid] num条件。确保它寻找的是插入点以维持序列有序。使用bisect_left可避免此错误。结果比预期大非严格递增时在处理“非严格递增”时错误地使用了bisect_left。bisect_left在遇到相等值时会在其左侧插入这可能导致序列中连续出现相等值但题目可能要求严格递增。确认题目要求。严格递增用bisect_left非严格递增用bisect_right。超时 (Time Limit Exceeded)仍然使用了 O(n^2) 的动态规划解法。切换到“贪心二分”的 O(n log n) 算法。检查数据范围如果 n 5000O(n^2) 通常就危险了。内存超限 (Memory Limit Exceeded)在还原序列的版本中parent数组是 O(n) 的通常没问题。但如果使用了错误的多维DP如dp[i][j]且维度很大就会爆内存。优化状态定义减少维度。对于LIS长度问题O(n) 的tails数组足矣。还原的序列不正确parent数组更新逻辑有误特别是在tails中被替换的元素其parent关系需要正确转移。仔细理解tails_idx和parent的更新时机。在替换tails[left]时tails_idx[left]要更新为当前索引i而parent[i]应指向tails_idx[left-1]如果 left0。6. 从LIS延伸到更广阔的算法世界解一道题掌握一类方法。最长递增子序列问题及其高效解法其思想可以迁移到许多其他问题中。1. 二维问题信封嵌套俄罗斯套娃问题给定一些信封的宽度和高度对(w, h)如果一个信封的宽度和高度都大于另一个信封那么它可以套住另一个信封。请问最多可以套多少层信封解法先按宽度升序排序宽度相同的按高度降序排序。然后对高度数组求最长递增子序列。为什么排序后宽度维度已经满足“递增”条件宽度相等时高度降序保证了同一宽度的信封不会相互嵌套问题就转化为了在高度维度上找LIS。这是一个典型的“二维降维”技巧。2. 最大上升子序列和给定一个序列找出一个上升子序列使得其元素和最大。解法此时无法用贪心因为结尾最小的子序列其和不一定最小。需要回归动态规划但状态转移dp[i] max(dp[j]) nums[i] (j i 且 nums[j] nums[i])可以用数据结构如树状数组或线段树优化到 O(n log n)其思想与维护“前缀最大值”类似。3. 构造满足LIS长度的序列给定两个整数 n 和 k构造一个 1~n 的排列使得其最长递增子序列的长度恰好为 k。解法这需要逆向思维。一种构造方法是将序列分成 k 组每组内部是递减的组间是递增的。例如 n9, k3可以构造[3,2,1, 6,5,4, 9,8,7]这个序列的LIS长度就是3从每组选一个最小的。通过这些延伸我们可以看到掌握LIS的核心在于理解其“维护一个具有潜力的有序序列”这一贪心思想。这种思想以及二分查找在这一过程中的关键作用是许多优化算法的共性。在蓝桥杯乃至更高级别的算法竞赛中这种将问题转化、归约到经典模型的能力远比记忆更多的模板重要。
返回列表