当前位置: 首页 > news >正文

LeetCode Hot 100 - 最长递增子序列完全题解

题目描述

给你一个整数数组nums,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]是数组[0,3,1,6,2,2,7]的子序列。

示例 1:

text

输入:nums = [10,9,2,5,3,7,101,18] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为 4。

示例 2:

text

输入:nums = [0,1,0,3,2,3] 输出:4 解释:最长递增子序列是 [0,1,2,3] 或 [0,1,3,?] 等,长度为 4。

示例 3:

text

输入:nums = [7,7,7,7,7,7,7] 输出:1 解释:严格递增,相同元素不算递增,所以最长长度为 1。

示例 4:

text

输入:nums = [1,3,6,7,9,4,10,5,6] 输出:6

约束条件:

  • 1 <= nums.length <= 2500

  • -10^4 <= nums[i] <= 10^4


解题思路

核心思想:这是经典的最长递增子序列(LIS)问题,有两种主流解法:

  1. 动态规划:O(n²) 时间,O(n) 空间

  2. 贪心 + 二分查找:O(n log n) 时间,O(n) 空间


方法一:动态规划(基础解法)

思路

定义dp[i]表示以nums[i]结尾的最长递增子序列的长度。

状态转移方程:

text

dp[i] = max(dp[j]) + 1, 其中 0 ≤ j < i 且 nums[j] < nums[i]

如果前面没有比nums[i]小的数,则dp[i] = 1

最终答案:max(dp[0], dp[1], ..., dp[n-1])

代码实现

java

public int lengthOfLIS(int[] nums) { int n = nums.length; if (n == 0) return 0; int[] dp = new int[n]; int maxLength = 1; // 初始化:每个元素至少可以自己构成一个长度为1的子序列 Arrays.fill(dp, 1); for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } } maxLength = Math.max(maxLength, dp[i]); } return maxLength; }

手动模拟

nums = [10, 9, 2, 5, 3, 7, 101, 18]为例:

inums[i]比较前面所有 jdp[i]计算过程
0101初始值
1910>9 ❌1没有比9小的
2210>2❌, 9>2❌1没有比2小的
352<5✅ → dp[2]+1=22前面只有2比5小
432<3✅ → dp[2]+1=22前面只有2比3小
572<7✅→2, 5<7✅→3, 3<7✅→33max(2,3,3)=3
6101前面所有都小于101,取最大dp+14max(1,1,1,2,2,3)+1=4
718前面小于18的有多个,最大dp[6]=4?等等,101>18,不能用4需要仔细计算...

详细计算 i=7(nums[7]=18):

  • j=0: 10<18? 是 → dp[0]+1=2

  • j=1: 9<18? 是 → dp[1]+1=2

  • j=2: 2<18? 是 → dp[2]+1=2

  • j=3: 5<18? 是 → dp[3]+1=3

  • j=4: 3<18? 是 → dp[4]+1=3

  • j=5: 7<18? 是 → dp[5]+1=4

  • j=6: 101<18? 否 → 跳过

  • max = 4,所以 dp[7] = 4

最终 dp 数组:[1, 1, 1, 2, 2, 3, 4, 4]
最大长度 = 4

复杂度分析

  • 时间复杂度:O(n²) — 双重循环

  • 空间复杂度:O(n) — dp 数组

优缺点

  • ✅ 思路直观,容易理解

  • ✅ 可以记录具体的子序列(稍作修改)

  • ❌ 时间复杂度较高,n=2500 时勉强可过


方法二:贪心 + 二分查找(最优解)⭐

核心思想

维护一个数组tails,其中tails[i]表示长度为 i+1 的递增子序列的末尾元素的最小值

贪心策略:为了让子序列尽可能长,我们要让末尾元素尽可能小,这样更有利于后面接上更大的数。

算法步骤:

  1. 遍历每个数num

  2. tails中找到第一个 ≥num的位置(二分查找)

  3. 如果找到,替换该位置的值为num(因为更小的末尾更优)

  4. 如果没找到(num大于所有tails中的数),则追加到末尾

  5. 最终tails的长度就是 LIS 的长度

为什么贪心成立?

  • tails数组是严格递增的

  • 对于相同的长度,我们保留更小的末尾值,这样后面的数更容易接上

  • 替换操作不会影响最终长度,但可能改变子序列的内容

代码实现

java

public int lengthOfLIS(int[] nums) { int n = nums.length; if (n == 0) return 0; int[] tails = new int[n]; int size = 0; // 当前 tails 的有效长度 for (int num : nums) { // 二分查找第一个 >= num 的位置 int left = 0, right = size; while (left < right) { int mid = left + (right - left) / 2; if (tails[mid] < num) { left = mid + 1; } else { right = mid; } } // left 是要替换的位置 tails[left] = num; // 如果 left == size,说明 num 大于所有已有值,长度增加 if (left == size) { size++; } } return size; }

使用库函数的简化版

java

public int lengthOfLIS(int[] nums) { int[] tails = new int[nums.length]; int size = 0; for (int num : nums) { int pos = Arrays.binarySearch(tails, 0, size, num); if (pos < 0) { pos = -pos - 1; // 获取插入点 } tails[pos] = num; if (pos == size) { size++; } } return size; }

手动模拟(关键!)

nums = [10, 9, 2, 5, 3, 7, 101, 18]

步骤numtails 数组size操作说明
110[10]1初始,直接加入
29[9]19 < 10,替换 tails[0]=9
32[2]12 < 9,替换 tails[0]=2
45[2, 5]25 > 2,追加到末尾
53[2, 3]23 在 2 和 5 之间,替换 tails[1]=3
67[2, 3, 7]37 > 3,追加到末尾
7101[2, 3, 7, 101]4101 > 7,追加到末尾
818[2, 3, 7, 18]418 在 7 和 101 之间,替换 tails[3]=18

最终 size = 4

重要说明:tails数组不是真正的 LIS,只是长度相同。例如最后tails = [2, 3, 7, 18],但原数组中没有[2,3,7,18]这个子序列(7 在 3 后面但 18 在 101 后面?)。它只是维护了每个长度的最小末尾值。

复杂度分析

  • 时间复杂度:O(n log n) — 每个数二分查找 O(log n)

  • 空间复杂度:O(n) — tails 数组

优缺点

  • ✅ 时间复杂度最优 O(n log n)

  • ✅ 空间复杂度 O(n)

  • ❌ 无法直接得到具体的子序列(需要额外处理)

  • ⭐ 面试推荐写法


方法三:线段树(进阶,了解即可)

思路

将数值离散化后,使用线段树维护以某个值结尾的 LIS 长度。

代码实现(简化版)

java

public int lengthOfLIS(int[] nums) { // 离散化 int[] sorted = nums.clone(); Arrays.sort(sorted); Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < sorted.length; i++) { map.put(sorted[i], i + 1); } int n = nums.length; int[] tree = new int[4 * n + 5]; for (int num : nums) { int idx = map.get(num); // 查询 [1, idx-1] 的最大值 int maxLen = query(tree, 1, 1, n, 1, idx - 1); // 更新 idx 位置为 maxLen + 1 update(tree, 1, 1, n, idx, maxLen + 1); } return query(tree, 1, 1, n, 1, n); }

复杂度分析

  • 时间复杂度:O(n log n)

  • 空间复杂度:O(n)

优缺点

  • ✅ 时间复杂度 O(n log n)

  • ✅ 可以处理更复杂的问题(如带权重的 LIS)

  • ❌ 代码复杂,面试不常用


方法四:打印具体的 LIS(扩展)

思路

在 DP 的基础上,记录前驱节点,最后回溯。

代码实现

java

public List<Integer> lengthOfLISWithSequence(int[] nums) { int n = nums.length; int[] dp = new int[n]; int[] prev = new int[n]; // 记录前驱索引 Arrays.fill(prev, -1); Arrays.fill(dp, 1); int maxLen = 1; int maxIdx = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < i; j++) { if (nums[j] < nums[i] && dp[j] + 1 > dp[i]) { dp[i] = dp[j] + 1; prev[i] = j; } } if (dp[i] > maxLen) { maxLen = dp[i]; maxIdx = i; } } // 回溯构建序列 List<Integer> sequence = new ArrayList<>(); while (maxIdx != -1) { sequence.add(0, nums[maxIdx]); maxIdx = prev[maxIdx]; } return sequence; }

方法对比总结

方法时间复杂度空间复杂度能否得到具体序列推荐度
动态规划O(n²)O(n)✅ 可以⭐⭐⭐
贪心+二分O(n log n)O(n)❌ 需要额外处理⭐⭐⭐⭐⭐
线段树O(n log n)O(n)✅ 可以⭐⭐

图文详解

动态规划过程可视化

text

nums: 10 9 2 5 3 7 101 18 dp: 1 1 1 2 2 3 4 4 状态转移图(箭头表示可以从前面转移过来): 10 ← 无 9 ← 无 2 ← 无 5 ← 2 3 ← 2 7 ← 5, 3 101 ← 7, 5, 3, 2, ... 18 ← 7, 5, 3, 2, ...

贪心 + 二分过程可视化

text

tails 数组的变化(每个位置表示长度为 i+1 的 LIS 的最小末尾): 步骤1: [10] → 长度1的最小末尾:10 步骤2: [9] → 长度1的最小末尾:9(更优) 步骤3: [2] → 长度1的最小末尾:2(更优) 步骤4: [2, 5] → 长度2的最小末尾:5 步骤5: [2, 3] → 长度2的最小末尾:3(更优) 步骤6: [2, 3, 7] → 长度3的最小末尾:7 步骤7: [2, 3, 7, 101] → 长度4的最小末尾:101 步骤8: [2, 3, 7, 18] → 长度4的最小末尾:18(更优) 最终 tails = [2, 3, 7, 18],长度 = 4

常见问题 Q&A

Q1:为什么贪心+二分是正确的?

A:维护tails[i]表示长度为 i+1 的递增子序列的最小末尾。对于新的数x

  • 如果x大于所有末尾,说明可以扩展最长长度

  • 否则,找到第一个≥ x的位置并替换,这样不会让长度变短,但让后面的数更容易接上
    这是一个经典的贪心证明问题。

Q2:贪心法能得到正确的 LIS 长度,但 tails 数组就是 LIS 吗?

A:不一定是。tails只保证长度正确,但元素可能不是原数组中的连续子序列。例如[2, 3, 7, 18]在原数组中并非递增子序列(18 在 7 前面?需要验证)。它只是每个长度的"潜力"值。

Q3:如果要求返回具体的 LIS,该怎么办?

A:使用 O(n²) 的 DP 并记录前驱,或者对贪心法做额外处理(维护每个位置的前驱信息)。

Q4:如何处理非严格递增(允许相等)?

A:将判断条件从nums[j] < nums[i]改为nums[j] <= nums[i],二分查找时找第一个> num的位置。

Q5:n=2500 时 O(n²) 能过吗?

A:2500² = 6,250,000 ≈ 6 百万,在 Java 中约 0.1 秒,可以过。但如果是 n=10^5 就不能了。


扩展:最长递增子序列的变种

1. 最长非递减子序列

java

// 只需将 < 改为 ≤ if (nums[j] <= nums[i]) { dp[i] = Math.max(dp[i], dp[j] + 1); } // 贪心+二分:找第一个 > num 的位置 int pos = Arrays.binarySearch(tails, 0, size, num + 1);

2. 最长递增子序列的个数(LeetCode 673)

需要同时维护长度和个数,用两个 DP 数组。

3. 俄罗斯套娃信封问题(LeetCode 354)

先按宽度排序,再对高度求 LIS。

4. 最长数对链(LeetCode 646)

排序后求 LIS 或贪心。


总结

最长递增子序列是 LeetCode Hot 100 中的经典动态规划题目,也是面试高频题。

面试建议:

  1. 先说 O(n²) 的 DP 解法(基础)

  2. 再优化到 O(n log n) 的贪心+二分(进阶)

  3. 解释贪心法的核心思想:维护每个长度的最小末尾

  4. 如果问具体序列,可以说用 O(n²) 记录前驱

核心要点:

  • 理解 DP 状态定义:dp[i]是以nums[i]结尾的 LIS 长度

  • 理解贪心+二分的核心:tails[i]是长度为 i+1 的 LIS 的最小末尾

  • 掌握二分查找的边界处理

一句话总结:

最长递增子序列可以用 O(n²) 的动态规划求解,但更优的是 O(n log n) 的贪心+二分:维护一个 tails 数组,每个新数通过二分找到合适位置替换或追加,最终 tails 的长度就是答案。


参考链接

  • LeetCode 300. 最长递增子序列 题目链接

  • LeetCode 300. 最长递增子序列 官方题解

http://www.cnnetsun.cn/news/2418266.html

相关文章:

  • 从零到一:ESP32 蓝牙 SPP 配对连接实战指南
  • 从零到一:Nextcloud私有云部署实战与性能调优指南
  • 告别内网穿透:用动态IPv6与云解析打造永在线的家庭服务器
  • 绿色与成本对比:电商物流碳减排的优化方案模拟
  • 番茄小说下载器:跨平台免费小说下载终极指南
  • 从宝可梦训练师到AI专家:聊聊李宏毅课程里提到的4种ML/DL职业发展路径(附学习地图)
  • VOFA+上位机三大协议实战:从FireWater到JustFloat的C语言实现与选型指南
  • 深度学习概率建模:生成模型理论
  • 2026届学术党必备的五大降AI率工具解析与推荐
  • 从零到一:手把手教你完成IDM的官网下载与系统安装
  • 019、神经网络基础:感知机、激活函数与多层网络
  • 【Midjourney针孔相机风格终极指南】:20年AI影像专家亲授5大参数黄金配比与3种不可逆质感增强技巧
  • 【ElevenLabs旁遮普文语音合成实战指南】:零基础30分钟接入Gurmukhi语音API并优化自然度至92.7%(实测数据)
  • Zynq SoC核心板在电动赛车实时控制系统中的工程实践
  • 创业团队如何统一管理多个AI工具配置以提升协作效率
  • 一套鸿蒙 App,如何跑在手机 / 平板 / TV?
  • JavaScript逆向工程的架构演进:Jsxer如何重新定义二进制脚本反编译
  • 对比按量计费与Token Plan套餐的实际成本感受
  • 儿童语音合成不是降级版成人模型!拆解ElevenLabs Child-Voice架构中的3层神经注意力掩码机制(含PyTorch可复现代码片段)
  • 如何通过智能模组管理器彻底解决Beat Saber模组安装的复杂性问题
  • 3步快速上手WebPlotDigitizer:从图表图像到数据表格的终极转换指南
  • AI教材写作神器!低查重AI工具,一键生成符合标准的专业教材!
  • Path of Building PoE2:如何轻松规划流放之路2最强BD?
  • 明日方舟自动化助手终极指南:一键解放双手的完整解决方案
  • ComfyUI-WanVideoWrapper:你的AI视频创作伙伴,让想象力动起来
  • 企业数据采集的技术困境与架构演进:company-crawler的深度技术解构
  • 量子误差抑制技术VD在离子阱系统中的实现与优化
  • Win11Debloat终极优化指南:4步让你的Windows 11重获新生
  • 实验室里的“学霸”与街头上的“全才”:深度解析 PaLM 与 ChatGPT
  • 毕业季实用指南:论文降AI率全攻略,轻松过审技巧汇总