力扣hot100之300:最长递增子序列
力扣hot100之300:最长递增子序列

LeetCode 300. 最长递增子序列

题目描述

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

所谓子序列,是指可以删除数组中的某些元素,也可以不删除,但不能改变剩余元素的相对顺序。

注意,这里要求的是:

  • 递增

  • 而且是 严格递增

  • 求的是最长递增子序列的长度

  • 不要求把具体子序列输出出来


示例分析

示例 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

最长递增子序列之一是:

[0,1,2,3]

所以答案是 4


示例 3

输入: nums = [7,7,7,7,7,7,7]
输出: 1

因为严格递增要求后一个数必须大于前一个数。 这里所有数字都相等,所以最长递增子序列长度只能是 1


解题思路

这道题最经典的做法有两种:

  1. 动态规划,时间复杂度 O(n^2)

  2. 贪心 + 二分查找,时间复杂度 O(n log n)

你给出的代码用的是第二种,也就是这道题最经典、效率最高的写法。

代码如下:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int[] tails = new int[nums.length];
        int res = 0;
        for(int num : nums) {
            int i = 0, j = res;
            while(i < j) {
                int m = (i + j) / 2;
                if(tails[m] < num) i = m + 1;
                else j = m;
            }
            tails[i] = num;
            if(res == j) res++;
        }
        return res;
    }
}

这份代码看起来不长,但第一次看时最容易懵的地方通常有两个:

  1. tails 数组到底表示什么

  2. 为什么每次替换 tails[i] 不会把答案搞错

只要把这两个点想明白,这题就通了。


先说结论:tails 数组表示什么

tails[i] 的含义不是“某个具体最长递增子序列”。

它表示的是:

长度为 i + 1 的递增子序列中,结尾元素最小的那个尾值。

这个定义非常关键。

例如:

  • tails[0]:长度为 1 的递增子序列的最小结尾

  • tails[1]:长度为 2 的递增子序列的最小结尾

  • tails[2]:长度为 3 的递增子序列的最小结尾

为什么要维护“最小结尾”?

因为:

结尾越小,后面就越容易接上更大的数,越有利于形成更长的递增子序列。

这就是这道题里的贪心思想。


为什么维护最小结尾是合理的

假设现在有两个长度都为 3 的递增子序列:

[2,5,9]
[2,5,7]

它们长度一样,但谁更“有前途”?

显然是:

[2,5,7]

因为它的结尾更小。 后面如果来一个 8,它还能继续变成长度 4:

[2,5,7,8]

[2,5,9] 就接不上 8

所以对于同样长度的递增子序列,我们只需要关心:

谁的结尾更小。

这就是 tails 的意义。


整体思路:遍历每个数,更新 tails

对于数组中的每个数字 num,我们要做的事是:

  1. tails 中找到第一个大于等于 num 的位置

  2. num 去替换它

如果 num 比当前所有结尾都大,说明它可以接到最长递增子序列后面,于是答案长度加 1。

如果 num 不能让长度增加,那也没关系,我们就让它去“优化某个长度下的最小结尾”。


为什么要二分查找

因为 tails 数组本身是递增的。

为什么递增?

因为:

  • 长度为 1 的最小结尾

  • 长度为 2 的最小结尾

  • 长度为 3 的最小结尾

它们一定是递增的。

例如:

tails = [2,3,7,18]

不可能出现:

tails = [2,8,5]

因为长度更长的递增子序列,结尾不可能比更短的还小。

既然 tails 有序,那就可以用二分查找去快速定位应该替换的位置,从而把复杂度从 O(n^2) 优化到 O(n log n)


代码解析

1. 定义 tails 数组和结果变量

int[] tails = new int[nums.length];
int res = 0;
  • tails 用来维护不同长度递增子序列的最小结尾

  • res 表示当前最长递增子序列的长度

注意:

  • tails 数组的实际有效部分只有前 res

  • 后面的空间只是预留出来,不一定会用到


2. 遍历数组中的每个数

for(int num : nums) {

依次处理每个元素,尝试更新 tails


3. 在 tails 中二分查找插入位置

int i = 0, j = res;
while(i < j) {
    int m = (i + j) / 2;
    if(tails[m] < num) i = m + 1;
    else j = m;
}

这里做的是一个经典的“找第一个大于等于 num 的位置”。

为什么要找这个位置?

因为:

  • 如果某个位置的结尾值小于 num,说明 num 可以接在它后面

  • 如果某个位置的结尾值大于等于 num,说明 num 可以把它替换掉,让这个长度的结尾变得更小

所以最后得到的 i,就是 num 应该放进去的位置。


4. 更新 tails

tails[i] = num;

表示:

  • 现在找到了一个长度为 i + 1 的递增子序列

  • 它的最小结尾可以更新为 num

即使这里是“替换”,也不会影响最终答案,反而是让这个长度的状态更优。


5. 如果 num 接到了当前最长序列后面,就更新答案长度

if(res == j) res++;

这里其实等价于:

if(i == res) res++;

因为二分结束时 i == j

这表示什么?

表示 num 比当前所有的 tails 都大,它成功接到了当前最长递增子序列的后面,于是最长长度加 1。


6. 返回最终答案

return res;

res 就是最长递增子序列的长度。


示例推演

我们以经典例子:

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

来一步一步模拟。


处理 10

当前 tails 为空,直接放进去:

tails = [10]
res = 1

处理 9

tails 中找到第一个大于等于 9 的位置,是下标 0 9 替换 10

tails = [9]
res = 1

注意,长度没变,但结尾更小了,更有利于后续扩展。


处理 2

同样替换第一个位置:

tails = [2]
res = 1

处理 5

52 大,可以接到后面:

tails = [2,5]
res = 2

处理 3

找到第一个大于等于 3 的位置,是下标 1 替换掉 5

tails = [2,3]
res = 2

这里虽然 [2,5] 被换成了 [2,3],但这不是坏事。 因为长度没变,结尾更小了,更容易接后面的数。


处理 7

比当前所有结尾都大,直接接在后面:

tails = [2,3,7]
res = 3

处理 101

继续接在后面:

tails = [2,3,7,101]
res = 4

处理 18

找到第一个大于等于 18 的位置,是下标 3 替换掉 101

tails = [2,3,7,18]
res = 4

最终答案仍然是:

4

为什么替换不会影响答案

很多同学第一次看这题,最担心的就是:

既然把 101 换成了 18,那原来的子序列不是被破坏了吗?

这里一定要记住:

tails 不是在保存某个真实的完整最长递增子序列,而是在保存“每个长度下最优的结尾值”。

所以替换掉一个更大的结尾,只是在优化状态,让这个长度下的结尾更小、更容易继续扩展。

这不会让答案变差,只会让后面的可能性更大。


和 O(n²) 动态规划写法对比

动态规划写法

定义:

dp[i] = 以 nums[i] 结尾的最长递增子序列长度

转移:

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

这种方法思路清晰,但要双重循环,所以复杂度是:

O(n^2)

当前这份贪心 + 二分写法

它不再显式计算“每个位置结尾时的最长长度”,而是维护:

  • 每种长度的最小结尾

这样就能利用 tails 的有序性做二分查找,把复杂度优化到:

O(n log n)

复杂度分析

设数组长度为 n

时间复杂度

O(n log n)

因为每个元素都要做一次二分查找。


空间复杂度

O(n)

需要一个 tails 数组来保存不同长度递增子序列的最小结尾。


这道题为什么这么经典

这题非常经典,因为它是“贪心 + 二分”思想的代表题之一。

它真正难的不是代码,而是理解:

  1. tails[i] 到底表示什么

  2. 为什么维护最小结尾是合理的

  3. 为什么替换不会丢掉正确答案

  4. 为什么 tails 一定有序,能做二分

只要这几个点想明白,这份代码其实非常优雅。


总结

这道题的核心可以概括成一句话:

tails[i] 维护长度为 i+1 的递增子序列最小结尾,并通过二分查找不断更新它。

具体来说:

  1. 如果当前数比所有结尾都大,就把最长长度加 1

  2. 否则就替换掉第一个大于等于它的结尾

  3. 替换不会让答案变差,反而会让后续扩展更容易

你可以把这题记成一句话:

最长递增子序列的 O(n log n) 解法,本质是“贪心维护最小结尾 + 二分定位替换位置”。

这是一道非常经典、也非常值得反复琢磨的题。 把这题真正吃透之后,很多“贪心 + 二分”的题都会顺很多。


如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇