资讯动态

最长递增子序列(LIS)问题:从O(n²)动态规划到O(nlogn)贪心+二分优化

发布时间:2026/8/28 6:16:48 来源:尧图企业网站定制
1. 项目概述从一道“签到题”看算法竞赛的思维陷阱刚拿到蓝桥杯国赛的题目列表看到“递增序列”这个标题再配上“签到题”的标签很多选手的第一反应可能是松一口气。心里大概会想“总算有个能快速拿分的题目了估计就是遍历或者简单动态规划吧。”但如果你真这么想可能已经掉进了出题人精心设置的第一个思维陷阱里。我参加过也辅导过不少算法竞赛深知这类标榜“简单”的国赛题往往藏着对基础功和思维严谨性的极致考察。这道“递增序列”题核心是给定一个整数序列要求找出其中最长的严格递增子序列的长度。听起来是不是和经典的“最长递增子序列”LIS问题一模一样如果你的知识库只储备了O(n²)的动态规划解法并且不加思考地套用那么在国赛的舞台上面对可能长达10^5甚至10^6的数据规模等待你的将是“时间超限”的审判。这道“签到题”的真正签到方式绝不是让你签到入场而是签到进入“高效算法”的殿堂。它明面上考的是序列处理底层里测的是选手对算法复杂度敏感度、对经典模型及其优化变种的掌握程度以及能否在紧张的赛场环境下迅速识别问题本质并选择正确工具的能力。所以无论你是正在备赛蓝桥杯的选手还是希望巩固动态规划与二分搜索算法的开发者通过深度拆解这道题我们不仅能学会如何ACAccepted一道题更能掌握一种“透过简单描述看复杂需求”的解题思维。接下来我会带你从暴力解法的低效根源开始逐步推导到最优解法并分享一些在竞赛编码中绝对实用的调试技巧和避坑指南。2. 问题深潜理解“最长递增子序列”的真正约束在动手写任何代码之前我们必须像侦探一样把题目说明的每一个字都审清楚。任何一点歧义或疏忽都会导致南辕北辙。虽然原题描述需要从官方渠道获取但根据“递增序列”这个通用模型和竞赛惯例我们可以准确地还原出问题场景和所有隐含的边界条件。2.1 关键定义与边界条件解析首先我们要明确几个核心概念这直接决定了算法的正确性子序列 (Subsequence) 这是最容易混淆的点。子序列不等于子数组Subarray。子序列是从原序列中删除一些元素也可以不删除后保持剩余元素原有顺序所形成的新序列。例如对于序列[10, 9, 2, 5, 3, 7, 101, 18][2, 3, 7, 18]是一个合法的子序列但[2, 7, 3, 18]就不是因为它改变了原序列中7和3的顺序。严格递增 (Strictly Increasing) 这意味着序列中的每一个元素都必须严格大于其前一个元素。即对于子序列a[i1], a[i2], ..., a[ik]必须满足i1 i2 ... ik且a[i1] a[i2] ... a[ik]。“严格”二字排除了相等的情况这是和“非递减”序列的根本区别。目标 我们需要找到所有可能的严格递增子序列中长度最长的那一个的长度。注意题目通常只要求输出长度而非序列本身这为我们优化算法提供了空间。基于竞赛常识我们还需要明确输入输出的格式和数据的边界这是设计算法时选择数据结构的依据输入 通常第一行是一个整数n代表序列的长度。第二行是n个用空格隔开的整数代表序列本身。输出 一个整数即最长严格递增子序列的长度。数据规模 这是决定算法生死的关键在蓝桥杯国赛难度n的范围很可能达到1 n 10^5甚至更大。这意味着 O(n²) 的算法运算次数约10^10在时间限制通常1秒或2秒内是绝对无法通过的。2.2 从暴力枚举到认知升级最直观的想法是暴力枚举所有可能的子序列检查其是否递增并记录最大长度。一个长度为n的序列其子序列总数高达2^n个每个元素都有“选”或“不选”两种状态。当n30时子序列数量已超过10亿。这显然是不可行的。暴力法给我们的启示是不能枚举所有可能性必须利用“递增”这一性质进行智能的、按阶段推进的计算。这自然引出了动态规划DP的思路。经典的 DP 定义是令dp[i]表示以第i个元素下标从1开始结尾的最长递增子序列的长度。那么dp[i]怎么求呢既然子序列要以nums[i]结尾那么它的前一个元素一定是原序列中在i之前、且值小于nums[i]的某个nums[j]。所以我们需要遍历所有j i且nums[j] nums[i]的情况找到其中dp[j]最大的那个然后加1。状态转移方程为dp[i] max(dp[j]) 1, 其中 j i 且 nums[j] nums[i]初始条件对于任意i至少可以以自己开头所以dp[i] 1。 最终答案就是max(dp[1...n])。这个算法需要两层循环时间复杂度是 O(n²)。对于n10^5运算量是100亿远超承受能力。所以我们必须找到 O(nlogn) 的优化方法。这里的优化瓶颈在于为每个i寻找“在它之前、且值小于它的元素中dp值最大的那个”这个过程太慢了。我们需要一种数据结构或策略来加速这个“查找最大值”的过程。注意这里有一个初学者常见的误区认为dp[i]表示的是“前 i 个元素中的最长递增子序列长度”。这种定义会导致状态转移困难因为新来的元素nums[i]不一定能接在之前的最长子序列后面。而以i结尾的定义确保了状态转移的可行性。3. 核心算法解析贪心与二分搜索的巧妙结合O(nlogn) 的算法是这道题在竞赛中的标准答案。它非常巧妙融合了贪心思想和二分搜索。理解这个算法不能只背模板更要明白其背后的“为什么”。3.1 算法核心维护一个“潜力最小末尾数组”我们不再直接计算以每个位置结尾的长度而是换一个角度思考对于相同长度的递增子序列我们只关心那个结尾数字最小的。为什么因为结尾数字越小未来能够接在它后面、构成更长序列的可能性就越大。基于这个贪心思想我们维护一个数组tail。tail[len]的定义是长度为len的严格递增子序列中最小的末尾元素值。 这个数组本身一定是严格递增的。可以用反证法证明如果存在tail[4] tail[5]那么长度为5的序列的倒数第二个元素一定小于tail[5]也就小于等于tail[4]这意味着我们找到了一个以更小值结尾的长度为4的序列与tail[4]的定义矛盾。3.2 算法流程与二分搜索的引入我们遍历原序列nums中的每一个数字x如果x比tail数组最后一个元素即当前最长子序列的末尾还要大那么恭喜我们可以直接把x接在后面得到更长的子序列。即tail[currentLen] x。否则x不能直接扩展长度。但是它有可能用来更新某个现有长度的tail值。我们需要在tail数组中找到第一个大于或等于x的位置pos然后将tail[pos]更新为x。这个操作的含义是我们发现了一个以更小的值x结尾的长度为pos的递增子序列它比之前记录的tail[pos]更有潜力。由于tail数组严格递增查找“第一个大于等于x的位置”可以使用二分搜索这正是将复杂度降至 O(nlogn) 的关键。为什么是“第一个大于或等于x的位置”对于严格递增序列我们希望每个位置的末尾值尽可能小。如果tail[pos] x更新与否不影响长度但为了逻辑统一可以更新。如果tail[pos] x说明我们找到了一个更小的值来作为长度为pos的序列的结尾这能提高未来构造更长序列的机会所以必须更新。查找“第一个大于等于”保证了我们更新的是最早遇到的可优化位置保持了算法的正确性。3.3 手算模拟与感性理解让我们用例子nums [10, 9, 2, 5, 3, 7, 101, 18]来模拟这个过程这是理解算法的绝佳方式初始化tail []当前长度L 0x10tail为空直接添加。tail [10],L1x9 在tail[10]中找到第一个9的是10(下标0)。更新tail[0]9。tail [9],L1。(用9结尾的长度1序列比10结尾的更有潜力)x2 在tail[9]中找到第一个2的是9。更新tail[0]2。tail [2],L1。x55 tail[last]2可以扩展。tail [2, 5],L2。(序列[2,5])x3 在tail[2,5]中找到第一个3的是5(下标1)。更新tail[1]3。tail [2, 3],L2。(序列[2,3]比[2,5]更好)x77 3可以扩展。tail [2, 3, 7],L3。(序列[2,3,7])x101101 7可以扩展。tail [2, 3, 7, 101],L4。(序列[2,3,7,101])x18 在tail[2,3,7,101]中找到第一个18的是101(下标3)。更新tail[3]18。tail [2, 3, 7, 18],L4。(序列[2,3,7,18])最终L4就是答案。请注意tail数组存储的并不一定是真实的最长递增子序列它存储的是各种长度下的“最佳末尾值”。在这个例子里tail最后是[2,3,7,18]它恰好对应了一个解[2,5,7,101]或[2,3,7,101]或[2,3,7,18]。算法只保证了长度的正确性。4. 代码实现与逐行精讲理解了算法我们来看Java实现。我会提供两个版本的代码标准版和竞赛优化版并解释每一行代码的意图和容易出错的地方。4.1 标准清晰实现版import java.util.Scanner; public class LongestIncreasingSubsequence { public static void main(String[] args) { Scanner scanner new Scanner(System.in); int n scanner.nextInt(); int[] nums new int[n]; for (int i 0; i n; i) { nums[i] scanner.nextInt(); } scanner.close(); // tail数组tail[i]表示长度为i1的LIS的最小末尾值 int[] tail new int[n]; int len 0; // 当前tail数组的有效长度也即当前找到的LIS长度 for (int num : nums) { // 二分查找在tail[0...len-1]中第一个大于等于num的位置 int left 0, right len; // 注意right初始是len搜索区间为[left, right) while (left right) { int mid left (right - left) / 2; // 防止溢出 if (tail[mid] num) { left mid 1; } else { right mid; } } // 循环结束时left right且指向第一个num的位置 int pos left; if (pos len) { // 如果位置等于当前长度说明num比所有末尾都大可以扩展 tail[len] num; } else { // 否则更新该位置的末尾值使其更小 tail[pos] num; } } System.out.println(len); } }关键点精讲输入处理使用Scanner是竞赛中常见的做法。注意在读取完数据后调用scanner.close()是好习惯。tail数组定义tail[i]存储长度为i1的LIS的最小末尾值。数组大小设为n足以应对最坏情况整个序列递增。二分查找细节right len 搜索区间是左闭右开[left, right)。因为len是当前有效长度tail[len]是未使用的空间。while (left right) 这是二分查找处理左闭右开区间的标准写法。循环终止时left right。if (tail[mid] num) left mid 1; 如果中间值小于目标说明目标在右侧且mid位置肯定不是答案所以left移到mid1。else right mid; 如果中间值大于等于目标说明mid可能是答案第一个num的搜索区间缩小到[left, mid)。这种写法找到的left就是第一个 num的位置。更新逻辑pos len是扩展条件否则是替换条件。len变量同时充当了有效长度和最终答案。4.2 竞赛优化与防坑指南在紧张竞赛中我们还可以做一些微优化并特别注意一些坑点。import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.StringTokenizer; public class Main { // 蓝桥杯通常要求类名为Main public static void main(String[] args) throws IOException { // 使用BufferedReader StringTokenizer 比 Scanner 快得多应对大数据输入 BufferedReader br new BufferedReader(new InputStreamReader(System.in)); int n Integer.parseInt(br.readLine().trim()); int[] nums new int[n]; StringTokenizer st new StringTokenizer(br.readLine()); for (int i 0; i n; i) { nums[i] Integer.parseInt(st.nextToken()); } int[] tail new int[n]; int len 0; for (int x : nums) { // 手动二分查找避免调用Arrays.binarySearch可能带来的细微逻辑差异和自动装箱开销 int l 0, r len; while (l r) { int m l (r - l) / 2; if (tail[m] x) { l m 1; } else { r m; } } int p l; tail[p] x; if (p len) { len; } // 可以添加调试输出在本地验证提交前删除 // System.err.println(x x , tail Arrays.toString(Arrays.copyOf(tail, len))); } System.out.println(len); } }优化与避坑要点输入输出加速这是竞赛编程的基本功。BufferedReader比Scanner快一个数量级。StringTokenizer用于快速分割字符串。对于超过10^5量级的输入这个优化至关重要。二分查找的实现我们坚持使用手写的二分查找。虽然Arrays.binarySearch很方便但它返回的是(-(插入点) - 1)需要额外处理且对于基础类型数组它使用二分查找算法但多了一层方法调用。手写可以保证绝对的控制和效率也更容易根据题目微调比如我们这里找的是第一个x的而非等于x的。边界条件测试全递增序列如[1,2,3,4,5]算法会一直走扩展分支len最终等于n。全递减序列如[5,4,3,2,1]每个元素都会更新tail[0]len始终为1。有重复元素的非严格递增序列题目要求“严格递增”所以重复元素不会延长序列。例如[2,2]第二个2会尝试更新tail[0]因为tail[0]2是第一个2的位置但值不变长度仍为1。我们的算法正确处理了这一点。单元素序列len初始为0第一个元素直接扩展输出1。空间复杂度O(n)用于存储tail数组。这是无法优化的下限因为最坏情况需要存储整个序列的信息。一个常见的错误错误地维护tail数组的单调性。确保你的二分查找逻辑真的能找到“第一个x”的位置。如果写成查找“第一个x”的位置对于x等于tail中某个值的情况更新位置会错后一位可能导致结果错误尽管对于严格递增更新相等值不影响长度但逻辑不清晰。实操心得在竞赛中对于这类经典问题我建议直接在代码模板里准备好这个LIS函数。把它当作像“快速排序”一样的基础工具来记忆和调用。但记忆的同时一定要理解其tail数组的含义和二分查找的细节因为有些变种题如求最长非递减子序列需要微调二分查找的条件将tail[mid] num改为tail[mid] num。5. 算法变种与思维拓展掌握了标准的 LIS O(nlogn) 解法我们才算拿到了解决此类问题的钥匙。蓝桥杯等竞赛中问题往往不会这么直接而是会披上各种外衣。这里列举几个常见的变种和对应的思考方向5.1 变种一求最长非递减子序列允许相等这是最常见的变种。只需要修改两个地方算法思想tail数组的定义变为“长度为len的非递减子序列的最小末尾元素”。此时tail数组是**非严格递增单调不减**的。二分查找条件在查找更新位置时我们要找的是第一个大于x的位置对于严格递增我们找的是第一个大于等于x的位置。因为允许相等当x等于tail中某个值时我们可以用它来接在相同长度的序列后面但结尾值不变或者更常见的是我们需要用它来更新后面第一个比它大的位置以保持“最小末尾”的性质。实际上更简单且不易错的方法是将二分查找中的判断条件if (tail[mid] x)改为if (tail[mid] x)。这样查找的就是第一个 x的位置。// 在非递减序列版本中二分查找部分修改如下 while (l r) { int m l (r - l) / 2; if (tail[m] x) { // 将 改为 l m 1; } else { r m; } } // 此时 l 指向第一个 x 的位置5.2 变种二求具体的最长递增子序列而不仅是长度标准算法只求长度。如果要求输出一个具体的序列就需要额外的记录。 我们可以在更新tail数组的同时用一个prev数组记录路径。prev[i]存储在原序列中以nums[i]结尾的LIS中nums[i]的前一个元素的下标。 但注意O(nlogn) 算法中的tail数组是“潜力”数组并不直接对应一个真实的、连贯的子序列。为了重建序列我们通常需要结合 O(n²) 的DP方法或者使用更复杂的记录方式如在二分查找更新时记录每个元素在tail数组中的位置。在竞赛中如果只要求输出一个可能的序列而不一定是字典序最小等特定要求有时可以用 O(n²) DP 来求解前提是数据规模允许。5.3 变种三二维偏序问题如“俄罗斯套娃信封问题”这是一个经典的LIS应用变种给定一些信封的宽度和高度当另一个信封的宽度和高度都大于某个信封时才能套进去。问最多能套多少层。 解题技巧是排序LIS先对信封按宽度w升序排序。对于宽度相同的信封按高度h降序排序。这一步非常关键为什么因为宽度相同是无法嵌套的如果我们对高度也升序那么宽度相同的信封可能会被错误地计入LIS因为算法只看高度。按高度降序保证了在寻找高度的LIS时宽度相同的信封不会互相嵌套。排序后忽略宽度直接在高度数组上求 LIS得到的就是答案。这展示了LIS算法强大的泛化能力它能解决任何可以转化为“在一维序列上寻找最长递增子序列”的二维偏序问题。5.4 思维拓展为什么贪心二分是有效的这可能是这个算法最让人困惑的地方。我们维护的tail数组其每个元素tail[i]可能来自原序列中完全不同的、时间上交错的子序列。例如之前的模拟中tail[2]从5变成了3它们分别来自子序列[2,5]和[2,3]。这看起来破坏了子序列的连续性。关键在于理解这个算法的目的不是维护一个真实的子序列而是维护所有可能长度的“最佳结尾门槛”。tail数组的单调递增性质是一个不变量。当我们用更小的值更新某个tail[i]时我们并没有改变已经找到的长度为i1的子序列的存在性只是降低了未来扩展这个长度的难度。这个“降低门槛”的操作保证了我们总能找到以当前元素结尾的、尽可能长的递增子序列的“潜在长度”。算法的正确性证明通常使用数学归纳法核心就在于这个“最小末尾”的贪心选择策略保证了无后效性。6. 实战调试与常见“坑点”实录即便理解了算法在竞赛高压环境下手误、边界条件考虑不周等情况仍会导致失分。以下是我从大量练习和教学中总结出的常见问题。6.1 常见错误类型与排查表错误现象可能原因排查与修复方法输出结果比预期小1. 二分查找逻辑错误找到了“第一个大于x”而不是“第一个大于等于x”的位置。2. 在pos len时忘记执行len。3. 输入处理错误如n读取后序列元素少读或多读。1. 用简单例子如[2,2]测试看对于相等元素的处理是否正确。2. 检查更新len的代码分支。3. 打印读取到的nums数组确认与输入一致。输出结果比预期大1. 误将“非递减”算法用于“严格递增”问题即二分条件用了。2.tail数组初始化或边界错误导致错误扩展。1. 确认题目要求是“严格递增”还是“非递减”严格对照修改二分条件。2. 检查tail数组初始值应为空以及right的初始值应为len不是n或len-1。运行超时 (TLE)1. 使用了O(n²)的DP解法数据规模大。2. 输入输出未优化如用了Scanner处理大数据。3. 二分查找写成了死循环。1. 确认数据规模必须使用O(nlogn)算法。2. 更换为BufferedReader和StringTokenizer。3. 检查二分循环条件(while (left right))和边界更新(left mid 1,right mid)确保区间在缩小。数组越界1.tail数组访问下标pos可能等于len此时直接tail[pos]会越界如果pos先用于赋值。2. 输入序列长度n为0时未做处理。1. 确保先判断pos与len的关系再决定是扩展还是替换。我们的代码顺序是安全的。2. 考虑边界情况如果n0应直接输出0。6.2 调试技巧构造极端测试数据在本地验证时不要只用手算方便的小例子。构造以下几类数据能有效发现隐藏问题边界数据n1输入1和[100]输出应为1。n0如果题目允许检查程序是否崩溃。极大值/极小值输入包含Integer.MIN_VALUE和Integer.MAX_VALUE的序列。单调数据[1,2,3,...,100000](全递增)应输出100000。[100000, 99999, ..., 1](全递减)应输出1。重复数据[5,5,5,5,5]严格递增应输出1非递减应输出5。[1,2,2,3,3,4]严格递增输出3 ([1,2,3]或[1,2,4]等)非递减输出6。随机大数据写一个生成随机序列的程序用O(n²)的DP暴力算法小规模时或你的逻辑计算结果做对比验证。6.3 竞赛中的时间分配与策略这道题作为“签到题”目标是在短时间内稳稳拿下。读题阶段 (1-2分钟)快速识别这是LIS问题。确认数据规模看题目描述或根据经验推断国赛难度立刻决定使用O(nlogn)算法。编码阶段 (5-7分钟)如果你已经将标准算法封装成模板函数直接调用并稍作修改主要是输入输出处理即可。如果没有模板按照我们上面讲解的步骤稳健书写特别注意二分查找的细节。测试阶段 (2-3分钟)用题目给的样例测试。在脑中或草稿纸上过一遍我们提到的边界数据全增、全减、有重复。如果时间允许用代码生成一个小的随机数据与暴力DP对拍。心态即使它是签到题也要保持谨慎。一次写对远比快速写完但提交错误要节省时间。清晰的思路和准确的代码是应对任何题目的不二法门。这道“递增序列”题就像算法竞赛世界里的一个经典路标。它告诉你通往高级算法的道路始于对基础问题的深刻理解与高效求解。掌握它不仅是掌握了一个算法模板更是掌握了一种优化思维如何从暴力枚举到动态规划再到利用数据结构的性质单调性结合二分搜索进行优化。这种“识别问题-应用模型-优化实现”的思维链条在解决更复杂的图论、字符串、数论问题时同样适用。下次再看到“签到题”不妨多想一想它到底在考察什么。真正的“签到”是签下你扎实的功底和灵活的思维。

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

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

免费获取报价