Java版LeetCode热题100之最长递增子序列:从O(n²)动态规划到O(n log n)贪心+二分的深度剖析
本文全面解析 LeetCode 第300题「最长递增子序列」(Longest Increasing Subsequence, LIS),涵盖题目理解、两种经典解法(DP与贪心+二分)、代码实现、复杂度分析、优化思路、数据结构基础、面试高频问题、实际应用场景及延伸思考,是一篇面向中高级开发者的高质量技术博客。
一、原题回顾
题目编号:LeetCode 300
题目名称:最长递增子序列(Longest Increasing Subsequence)
难度等级:中等(Medium)
题目描述
给你一个整数数组nums,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,
[3,6,2,7]是数组[0,3,1,6,2,2,7]的子序列。
示例
示例 1: 输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4。 示例 2: 输入:nums = [0,1,0,3,2,3] 输出:4 解释:可能的 LIS 有 [0,1,2,3] 或 [0,1,3,3](注意:严格递增,故后者非法),正确应为 [0,1,2,3] 示例 3: 输入:nums = [7,7,7,7,7,7,7] 输出:1 解释:所有元素相等,无法构成严格递增序列,最长长度为 1。约束条件
1 <= nums.length <= 2500-10⁴ <= nums[i] <= 10⁴
进阶要求
你能将算法的时间复杂度降低到O(n log n)吗?
二、原题分析
这道题的核心在于:在不改变元素相对顺序的前提下,找出最长的严格递增子序列的长度。
注意几个关键词:
- 子序列 ≠ 子数组:不要求连续;
- 严格递增:
a < b < c,不允许相等; - 只需返回长度:不要求输出具体序列(若要求,则需额外记录路径)。
直观思路
最朴素的想法是:枚举所有子序列,检查是否递增,记录最大长度。但子序列总数为2 n 2^n2n,显然不可行。
于是我们转向更高效的策略:
- 动态规划(DP):利用“以某元素结尾的LIS”这一状态,自底向上构建解;
- 贪心 + 二分查找:维护一个“潜在最优序列”,通过贪心策略和二分优化,达到 O(n log n)。
这两种方法代表了算法设计中的两大范式:状态转移 vs 贪心构造。
三、答案构思:方法一 —— 动态规划(O(n²))
3.1 状态定义
定义dp[i]表示:以nums[i]结尾的最长严格递增子序列的长度。
关键点:
nums[i]必须被选中。
3.2 状态转移方程
对于每个i,我们遍历所有j < i:
- 如果
nums[j] < nums[i],说明可以将nums[i]接在以nums[j]结尾的 LIS 后面; - 此时
dp[i] = max(dp[i], dp[j] + 1)
形式化表达:
d p [ i ] = max 0 ≤ j < i n u m s [ j ] < n u m s [ i ] ( d p [ j ] + 1 ) dp[i] = \max_{\substack{0 \leq j < i \\ nums[j] < nums[i]}} (dp[j] + 1)dp[i]=0≤j<inums[j]<nums[i]max(dp[j]+1)
若不存在满足条件的j,则dp[i] = 1(仅包含自身)。
3.3 初始条件与结果
- 初始:
dp[0] = 1 - 最终答案:
max(dp[0], dp[1], ..., dp[n-1])
四、完整答案(方法一:动态规划)
publicclassSolution{publicintlengthOfLIS(int[]nums){if(nums==null||nums.length==0){return0;}intn=nums.length;int[]dp=newint[n];Arrays.fill(dp,1);// 每个元素至少构成长度为1的序列intmaxLength=1;for(inti=1;i<n;i++){for(intj=0;j<i;j++){if(nums[j]<nums[i]){dp[i]=Math.max(dp[i],dp[j]+1);}}maxLength=Math.max(maxLength,dp[i]);}returnmaxLength;}}代码说明
- 使用
Arrays.fill(dp, 1)初始化,避免显式写dp[i] = 1; - 双重循环实现状态转移;
- 实时更新
maxLength,避免最后再遍历一次dp数组。
五、代码分析(方法一)
5.1 正确性验证
以nums = [10,9,2,5,3,7,101,18]为例:
| i | nums[i] | dp[i] 计算过程 | dp[i] 值 |
|---|---|---|---|
| 0 | 10 | - | 1 |
| 1 | 9 | 无 j 满足 9>10 | 1 |
| 2 | 2 | 无 j 满足 2>… | 1 |
| 3 | 5 | j=2: 2<5 → dp[3]=dp[2]+1=2 | 2 |
| 4 | 3 | j=2: 2<3 → dp[4]=2 | 2 |
| 5 | 7 | j=2→1+1=2; j=3→2+1=3; j=4→2+1=3 → max=3 | 3 |
| 6 | 101 | j=5: 7<101 → dp[6]=3+1=4 | 4 |
| 7 | 18 | j=5: 7<18 → dp[7]=3+1=4 | 4 |
最终maxLength = 4✅
5.2 边界处理
- 空数组:返回 0;
- 单元素:返回 1;
- 全相等:所有
dp[i]=1,返回 1; - 严格递增:
dp[i]=i+1,返回 n;
六、时间复杂度与空间复杂度分析(方法一)
时间复杂度:O(n²)
- 外层循环:n 次;
- 内层循环:最多 n 次;
- 每次比较和赋值:O(1);
- 总计:O(n²)
对于n=2500,操作次数约为 625 万,在 Java 中通常可接受(LeetCode 通过)。
空间复杂度:O(n)
dp数组占用 O(n) 空间;- 无其他额外结构;
- 总计:O(n)
七、答案构思:方法二 —— 贪心 + 二分查找(O(n log n))
7.1 核心思想
要使 LIS 尽可能长,就要让序列“增长得尽可能慢”。
换句话说:在相同长度下,末尾元素越小,后续扩展的可能性越大。
7.2 数据结构设计
维护一个数组d,其中:
d[len]表示:长度为len的所有递增子序列中,末尾元素的最小值。
注意:
d数组本身不是原数组的子序列,但它记录了“最优潜力”。
7.3 关键性质:d数组单调递增
证明(反证法):
假设存在i < j但d[i] ≥ d[j]。
考虑一个长度为j的 LIS,其末尾为d[j]。
从中删除j - i个元素,得到一个长度为i的递增子序列,其末尾< d[j] ≤ d[i],
这与d[i]是“长度为 i 的最小末尾”矛盾。
故d严格递增。
✅ 因此,d是严格递增数组,可使用二分查找!
7.4 算法流程
初始化len = 1,d[1] = nums[0]
对每个nums[i](i 从 1 到 n-1):
- 若
nums[i] > d[len]:
→ 可扩展最长序列,d[++len] = nums[i] - 否则:
→ 在d[1..len]中找到最大的 k 使得 d[k] < nums[i]
→ 更新d[k+1] = nums[i](用更小的值替代,保持“最小末尾”)
最终len即为 LIS 长度。
7.5 示例演示
nums = [0,8,4,12,2]
| 步骤 | nums[i] | d 数组变化 | len |
|---|---|---|---|
| 初始 | - | d = [_, 0] | 1 |
| i=1 | 8 | 8 > 0 → d=[_,0,8] | 2 |
| i=2 | 4 | 4 4 → d=[_,0,4,12] | 3 |
| i=4 | 2 | 2<12 → 找 d[k]<2 → k=1 → d[2]=2 → d=[_,0,2,12] | 3 |
✅ 最终len = 3
八、完整答案(方法二:贪心 + 二分)
publicclassSolution{publicintlengthOfLIS(int[]nums){if(nums==null||nums.length==0){return0;}intn=nums.length;int[]d=newint[n+1];// d[1..len] 有效intlen=1;d[1]=nums[0];for(inti=1;i<n;i++){if(nums[i]>d[len]){d[++len]=nums[i];}else{// 二分查找:找最大的 pos 使得 d[pos] < nums[i]intleft=1,right=len;intpos=0;// 若所有 d[j] >= nums[i],则 pos=0,更新 d[1]while(left<=right){intmid=(left+right)/2;if(d[mid]<nums[i]){pos=mid;left=mid+1;}else{right=mid-1;}}d[pos+1]=nums[i];}}returnlen;}}代码说明
d数组从索引 1 开始使用,便于理解“长度为 len”;pos初始化为 0,确保即使找不到小于nums[i]的元素,也能更新d[1];- 二分查找标准模板,寻找最后一个满足条件的位置。
九、代码分析(方法二)
9.1 正确性再验证
以nums = [0,1,0,3,2,3]为例:
- d = [_, 0] → len=1
- 1 > 0 → d=[_,0,1] → len=2
- 0 < 1 → 二分:d[1]=0 不小于 0?等于,不满足
<,故 pos=0 → d[1]=0(不变) - 3 > 1 → d=[_,0,1,3] → len=3
- 2 2 → d=[_,0,1,2,3] → len=4 ✅
9.2 为何d不是真实子序列却能正确计数?
因为d仅用于维护“长度为 L 的 LIS 的最小末尾”,并不关心具体路径。
只要d[len]被更新,就说明存在一个长度为len的递增序列。
贪心策略保证了:只要有可能形成更长序列,len就会增长。
十、时间复杂度与空间复杂度分析(方法二)
时间复杂度:O(n log n)
- 外层循环:n 次;
- 每次二分查找:O(log n);
- 总计:O(n log n)
对于n=2500,操作次数约为 2500 × log₂(2500) ≈ 2500 × 12 = 30,000,远优于 O(n²)。
空间复杂度:O(n)
d数组长度为 n+1;- 总计:O(n)
十一、常见问题解答(FAQ)
Q1:为什么方法二中d数组不是真实的 LIS,但长度正确?
A:因为我们只关心是否存在长度为len的 LIS,而不关心具体是哪些元素。d[len]的存在本身就证明了这一点。贪心策略确保了:只要能扩展,就扩展;不能扩展,就优化已有长度的末尾值,为未来扩展创造条件。
Q2:二分查找时,为什么找的是“最后一个 d[k] < nums[i]”?
A:因为我们要将nums[i]放在尽可能长的合法序列后面。例如,若d = [0,2,5],nums[i]=3,则d[2]=2 < 3,所以可形成长度为 3 的序列(替换d[3]=5为 3),这样未来遇到 4 时就能继续扩展。
Q3:能否用Arrays.binarySearch()简化代码?
A:可以,但需注意:binarySearch返回的是插入点的负值,且要求严格匹配。我们需要的是“小于 target 的最大值的位置”,因此手动实现二分更清晰可控。
Q4:如果要求输出具体的 LIS 序列,怎么办?
A:方法一(DP)可通过记录前驱节点实现;方法二较难,因d不是真实序列。通常建议用 DP + parent 数组回溯。
十二、优化思路拓展
12.1 使用ArrayList动态维护d
虽然d长度最多为 n,但实际len通常远小于 n。可用List<Integer>替代数组:
List<Integer>d=newArrayList<>();d.add(nums[0]);for(inti=1;i<n;i++){if(nums[i]>d.get(d.size()-1)){d.add(nums[i]);}else{intidx=binarySearch(d,nums[i]);// 找第一个 >= nums[i] 的位置d.set(idx,nums[i]);}}returnd.size();配合自定义二分查找,代码更简洁。
12.2 使用TreeSet或TreeMap?
理论上可行,但TreeSet不支持按“长度”索引,且无法高效找到“小于 x 的最大值”的位置(虽有floor方法,但无法直接用于此场景)。故不推荐。
12.3 并行化?
由于d数组依赖前序状态,难以并行。但若仅需近似解,可分段处理后合并(非精确解)。
十三、数据结构与算法基础知识点回顾
13.1 动态规划(DP)的本质
- 最优子结构:全局最优解包含子问题最优解;
- 重叠子问题:子问题被重复计算;
- 无后效性:当前状态只与过去有关,与未来无关。
本题中,dp[i]仅依赖dp[0..i-1],满足无后效性。
13.2 贪心算法的适用条件
- 局部最优能导致全局最优;
- 具有贪心选择性质。
本题中,“保持末尾最小”是局部最优,且能保证最终len最大。
13.3 二分查找的变种
标准二分用于查找目标值,但更多时候用于:
- 查找第一个 ≥ x 的位置(上界)
- 查找最后一个 ≤ x 的位置(下界)
本题属于后者,需熟练掌握模板。
13.4 单调性的重要性
d数组的单调性是使用二分的前提。在算法设计中,构造单调结构是优化的关键技巧(如单调栈、单调队列)。
十四、面试官提问环节(模拟对话)
面试官:你提到了两种解法,什么时候用 O(n²),什么时候用 O(n log n)?
答:如果n ≤ 1000,O(n²) 代码简单、易调试,优先使用;若n很大(如 10⁵),必须用 O(n log n)。本题n=2500,两者均可,但面试官常期望看到 O(n log n) 解法以展示算法深度。
面试官:方法二中,
d数组的含义是什么?它和真实 LIS 有什么关系?
答:d[i]表示“所有长度为 i 的递增子序列中,末尾元素的最小值”。它不是真实序列,但它的长度等于 LIS 长度。因为每次扩展len,都意味着发现了一个更长的递增序列。
面试官:如果序列中有重复元素,比如 [2,2],你的代码还能工作吗?
答:可以。因为题目要求“严格递增”,所以2 < 2为假,不会更新。d保持[2],返回 1,正确。
面试官:如何修改代码以支持“非严格递增”(即允许相等)?
答:只需将比较条件从nums[j] < nums[i]改为nums[j] <= nums[i],并在方法二中将d[mid] < nums[i]改为d[mid] <= nums[i]。但要注意,此时d数组变为非递减,二分逻辑需相应调整。
十五、这道算法题在实际开发中的应用
15.1 股票交易策略
在股价序列中,寻找最长递增子序列可帮助识别最长的上涨趋势周期,用于制定买入/卖出策略。
15.2 任务调度
若有多个任务,每个任务有开始时间和结束时间,要求选择最多不重叠任务。若按结束时间排序后,问题可转化为 LIS 变种。
15.3 生物信息学
DNA 序列比对中,寻找最长公共子序列(LCS)是核心问题,而 LCS 可通过 LIS 优化(当一个序列无重复元素时)。
15.4 版本控制系统
Git 的 commit 历史是 DAG(有向无环图),在某些分支合并策略中,需找最长递增提交序列以确定合并顺序。
15.5 推荐系统
用户行为序列(如点击商品ID)中,若商品ID按时间递增,LIS 可反映用户兴趣的“连贯性”。
十六、相关题目推荐
| 题号 | 题目 | 关联点 |
|---|---|---|
| 673. 最长递增子序列的个数 | 统计 LIS 的数量 | DP 扩展 |
| 354. 俄罗斯套娃信封问题 | 二维 LIS | 排序 + LIS |
| 1671. 得到山形数组的最少删除次数 | 前后缀 LIS | 正反两次 LIS |
| 1964. 找出到每个位置为止最长的有效障碍赛跑路线 | 每个位置的 LIS 长度 | 输出数组而非单值 |
| 1143. 最长公共子序列 | 经典 DP | 与 LIS 思想相通 |
十七、总结与延伸
17.1 核心收获
- 动态规划适用于具有最优子结构的问题,状态定义是关键;
- 贪心 + 二分是优化 LIS 的经典组合,体现了“构造单调结构”的思想;
- 时间复杂度从 O(n²) 到 O(n log n)的跨越,展示了算法优化的巨大价值;
- 理解数据结构的性质(如
d的单调性)是设计高效算法的基础。
17.2 延伸思考
- LIS 与 LCS 的关系:当一个序列无重复元素时,LCS(A, B) = LIS(B 在 A 中的索引序列);
- 离散化处理:若
nums[i]范围很大(如 10⁹),可先离散化再用树状数组优化至 O(n log n); - 在线 LIS:若数据流式到达,能否实时维护 LIS?答案是肯定的,仍可用
d数组 + 二分。
17.3 给读者的建议
- 先掌握 O(n²) DP,理解状态转移;
- 再攻克 O(n log n) 贪心,体会算法之美;
- 多画图、多举例,尤其是
d数组的变化过程; - 面试中先写 DP,再优化到贪心,展示思考过程。
结语:最长递增子序列不仅是 LeetCode 热题,更是算法思维的试金石。它连接了动态规划、贪心、二分查找、单调性等多个核心概念。掌握它,你就掌握了一把打开算法世界大门的钥匙。希望本文能助你在技术之路上走得更稳、更远!