300:最长递增子序列

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

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

示例 1:

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

示例 2:

ini 复制代码
输入: nums = [0,1,0,3,2,3]
输出: 4

示例 3:

ini 复制代码
输入: nums = [7,7,7,7,7,7,7]
输出: 1

提示:

  • 1 <= nums.length <= 2500
  • -104 <= nums[i] <= 104

进阶:

  • 你能将算法的时间复杂度降低到 O(n log(n)) 吗? 好,我们来做一个完整对比:

🔹方法一:动态规划 O(n²)

思路

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

  • 转移方程:

    css 复制代码
    dp[i] = max(dp[j] + 1)   (0 <= j < i, 且 nums[j] < nums[i])
  • 最终答案是 max(dp)

代码

ini 复制代码
class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        int[] dp = new int[n];
        Arrays.fill(dp, 1); // 每个数本身至少构成长度为1的子序列
        int ans = 1;

        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}
  • 时间复杂度:O(n²)
  • 空间复杂度:O(n)

🔹方法二:贪心 + 二分 O(n log n)

思路

维护一个数组 tailstails[k] 表示长度为 k+1 的递增子序列中 最小的结尾值 。遍历每个数 x,用二分在 tails 中找到第一个 >= x 的位置 pos(即 lower_bound),把 tails[pos] = x;若 pos 恰好等于当前有效长度 size,则把 size++。最终 size 就是 LIS 的长度。

为什么这个想法可行(直觉)

  • 我们希望维护"每个长度下最容易被后续扩展的结尾值"。如果长度为 k 的递增子序列的最小结尾较小,后面更容易接上更大的数,去构成更长的序列。

  • 所以对每个长度 k 只保留一个结尾值:能把结尾做得尽可能小(贪心)。

  • 当遇到新数 x

    • 如果 x 比所有 tails 都大,就能把最长长度 +1(append)。
    • 否则把某个 tails[pos] 替换为 x(更小的结尾),不会降低任何已有长度子序列向更长序列扩展的能力,反而可能增强(因为更小的结尾更易接大数)。

关键细节:二分找的位置

  • 我们寻找的是第一个 >= x 的位置(lower_bound),不是 > x
  • 这样保证序列是严格递增 :相等的元素不会被视为可以接在后面(如果需要非严格非降序列,可改为找第一个 > x)。

代码

ini 复制代码
import java.util.Arrays;

class Solution {
    public int lengthOfLIS(int[] nums) {
        if (nums == null || nums.length == 0) return 0;
        int n = nums.length;
        int[] tails = new int[n]; // tails[k] = 最小的结尾值,长度为 k+1 的递增子序列
        int size = 0; // tails 的有效长度

        for (int x : nums) {
            // 在 tails[0..size-1] 中二分查找第一个 >= x 的位置
            int left = 0, right = size; // 注意 right = size(开区间)
            while (left < right) {
                int mid = (left + right) >>> 1;
                if (tails[mid] < x) {
                    left = mid + 1;
                } else {
                    right = mid;
                }
            }
            // left 是第一个 >= x 的位置(或等于 size,表示 append)
            tails[left] = x;
            if (left == size) size++;
        }
        return size;
    }
}
  • 时间复杂度:O(n log n)(二分查找)
  • 空间复杂度:O(n)

🔹对比总结

方法 思路 时间复杂度 空间复杂度 适用情况
DP (O(n²)) 遍历所有前驱,逐一更新 O(n²) O(n) n ≤ 2500 时可用
贪心 + 二分 (O(n log n)) 用数组维护最小结尾,二分替换 O(n log n) O(n) n 很大(如 10^5)时必用

📌 小例子推演(nums = [10,9,2,5,3,7,101,18])

  • sub = [10]
  • 遇到 9 → 替换 → [9]
  • 遇到 2 → 替换 → [2]
  • 遇到 5 → 追加 → [2,5]
  • 遇到 3 → 替换 → [2,3]
  • 遇到 7 → 追加 → [2,3,7]
  • 遇到 101 → 追加 → [2,3,7,101]
  • 遇到 18 → 替换 → [2,3,7,18]
    最终长度 = 4 ✅

为什么while循环是left<right?

结论先给出

你在代码中用的是

ini 复制代码
int left = 0, right = size;   // right = size(开区间)
while (left < right) {
    int mid = (left + right) >>> 1;
    if (tails[mid] < x) left = mid + 1;
    else right = mid;
}

这是 开区间 / 半开区间 的二分模板,right 表示 不包含 的上界,循环结束时 left == right 即为插入位置。

如果把条件直接改成 while (left <= right)在配合 right = size 的情况下就会出错(可能越界或死循环)


原因与细节解释

1) 你现在用的模板是"半开区间" [left, right)

  • 初始:left = 0, right = sizeright 不包含)

  • 不变量(invariant):目标插入位置始终在区间 [left, right) 内。

  • mid = (left+right)/2 属于 [left, right-1](不会等于 right),因此访问 tails[mid] 安全(不会越界)。

  • 更新:

    • tails[mid] < x → 插入位置不在 <= mid 的范围,所以 left = mid+1(缩到 [mid+1, right)
    • 否则 → 插入位置可能在 mid 或更左边,right = mid(缩到 [left, mid)
  • 循环用 while (left < right),每次区间长度都严格缩小,最终 left == right,即插入点。

这是非常常用且简洁的 lower_bound 模板。


2) 如果使用 while (left <= right),通常配合"闭区间" [left, right]

那就需要把 right 设为 size-1(最后可访问的下标),并用不同的更新方式。例如下面是一个等价且正确的闭区间写法:

ini 复制代码
int left = 0, right = size - 1;
int pos = size; // 如果没找到则插入到末尾
while (left <= right) {
    int mid = (left + right) >>> 1;
    if (tails[mid] >= x) {
        pos = mid;       // 记录候选位置(第一个 >= x)
        right = mid - 1; // 缩小到左半区间(注意是 mid-1)
    } else {
        left = mid + 1;
    }
}
// pos 即插入位置(可能为 size,表示 append)

注意关键点:在闭区间写法中,当发现 mid 可作为答案时,要把 right = mid - 1 ,而不是 right = mid,否则区间不会收缩(会造成死循环)。


为什么不能把 while (left <= right)right = size 混用?

举两个问题说明:

  1. 越界风险

    如果 right = size(相当于半开区间的右端),而你用 while (left <= right),当 mid 被算成 size 时会访问 tails[size],越界(因为有效下标是 0..size-1)。在半开区间模板中,mid 永远不会等于 right,但在 left <= right 的循环里 mid 有可能取到 right,所以不安全。

  2. 死循环风险 (混用闭区间更新规则时)

    如果你在闭区间 left<=right 的循环里又写 right = mid(而不是 mid-1),会出现 leftmidright 不变的情况导致无限循环。例:

    • 初始 left=0, right=1mid=0。如果执行 right = midright 仍是 0;下一次 mid 仍是 0,循环不收缩 → 死循环。
      因此在闭区间要用 right = mid - 1 保证区间收缩。

举个完整例子说明 while(left < right) 的运行(你代码里的场景)

假设 tails = [2,3,7,101]size = 4,现在 x = 18,我们想找第一个 >= 18 的位置。

半开区间写法(你的写法):

scss 复制代码
left=0, right=4
mid=(0+4)/2=2 -> tails[2]=7 < 18 -> left = 3
left=3, right=4
mid=(3+4)/2=3 -> tails[3]=101 >=18 -> right = 3
now left == right == 3 -> loop ends, pos=3

结果正确,替换 tails[3] = 18

如果盲目改成 while(left <= right) 且仍保持 right=4

  • 第一次 mid = 2 ok
  • 之后可能出现 mid=4 的情况,会导致 tails[4] 越界。

所以总结一下(面试/写代码时的建议)

  • 两套二分模板都可以用,但不要混用边界/更新规则

    • 半开区间:[left, right) 用 while (left < right)right = sizeright = mid(不减一)。
    • 闭区间: [left, right] 用 while (left <= right)right = size-1right = mid - 1
  • 半开区间模板更简洁且不容易犯 off-by-one,很多人用它实现 lower_bound。

  • 使用 (left + right) >>> 1left + (right - left) / 2 避免溢出。


代码对比(两种合法写法)

半开区间(推荐与你当前代码匹配):

ini 复制代码
int left = 0, right = size; // right = size (开区间)
while (left < right) {
    int mid = (left + right) >>> 1;
    if (tails[mid] < x) left = mid + 1;
    else right = mid;
}
int pos = left; // 插入位置

闭区间(也可用):

ini 复制代码
int left = 0, right = size - 1;
int pos = size;
while (left <= right) {
    int mid = (left + right) >>> 1;
    if (tails[mid] >= x) {
        pos = mid;
        right = mid - 1;
    } else {
        left = mid + 1;
    }
}
// pos 是插入位置
相关推荐
CoovallyAIHub6 小时前
港大&字节重磅发布DanceGRPO:突破视觉生成RLHF瓶颈,多项任务性能提升超180%!
深度学习·算法·计算机视觉
CoovallyAIHub7 小时前
英伟达ViPE重磅发布!解决3D感知难题,SLAM+深度学习完美融合(附带数据集下载地址)
深度学习·算法·计算机视觉
聚客AI1 天前
🙋‍♀️Transformer训练与推理全流程:从输入处理到输出生成
人工智能·算法·llm
大怪v1 天前
前端:人工智能?我也会啊!来个花活,😎😎😎“自动驾驶”整起!
前端·javascript·算法
惯导马工1 天前
【论文导读】ORB-SLAM3:An Accurate Open-Source Library for Visual, Visual-Inertial and
深度学习·算法
骑自行车的码农1 天前
【React用到的一些算法】游标和栈
算法·react.js
博笙困了1 天前
AcWing学习——双指针算法
c++·算法
moonlifesudo1 天前
322:零钱兑换(三种方法)
算法
NAGNIP2 天前
大模型框架性能优化策略:延迟、吞吐量与成本权衡
算法