300. 最长递增子序列(Longest Increasing Subsequence)

难度:中等 | 主题:动态规划、二分查找、贪心

题目

给你一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。

示例

 
nums = [10,9,2,5,3,7,101,18] → 输出 4
 
解释:[2,3,7,101] 长度为 4
 

思路

先讲个故事:社团选拔

学校话剧社要选演员,报名表按先后顺序排列。社长要求:不能打乱报名顺序,且后一个人必须比前一个人高——这样站在台上才好看。

问题:在不打乱报名顺序的前提下,最多能选多少人?这就是最长递增子序列。

身高:[10, 9, 2, 5, 3, 7, 101, 18]

选中的:[2, 3, 7, 101] → 4 人


引导式推导:从暴力到最优

第 1 层(暴力枚举):枚举所有子序列(2ⁿ 种),检查是否递增。n=8 时 256 种还行,n=100 时宇宙炸了。

第 2 层(DP O(n²)):思考方式——以每个人为”队尾”,看前面有谁比他矮。

 
dp[i]:以 nums[i] 为结尾的最长递增子序列长度
 
对每个 i,往前看所有 j < i:
 
  如果 nums[j] < nums[i](前一个人更矮),
 
  就可以把 i 接在 j 后面 → dp[i] = max(dp[i], dp[j] + 1)
 

graph LR

    subgraph dp_table["DP 表 <br/>nums=[10,9,2,5,3,7,101,18]"]

        direction LR

        n10["nums[0]=10"] --> d0["dp=1<br/>前面没人"]

        n9["nums[1]=9"] --> d1["dp=1<br/>10>9, 接不了"]

        n2["nums[2]=2"] --> d2["dp=1<br/>9>2, 接不了"]

        n5_1["nums[3]=5"] --> d3["dp=2<br/>← 2+1"]

        n3["nums[4]=3"] --> d4["dp=2<br/>← 2+1"]

        n7["nums[5]=7"] --> d5["dp=3<br/>← 5+1<br/>← 3+1"]

        n101["nums[6]=101"] --> d6["dp=4<br/>← 7+1"]

        n18["nums[7]=18"] --> d7["dp=4<br/>← 7+1"]

    end

    style d6 fill:#f96

    style d5 fill:#ffd700

    style d3 fill:#9cf

问题:O(n²) 在大数据上太慢了。有没有更好的?

第 3 层(贪心 + 二分 O(n log n)):改变思路——我们不关心”具体选了谁”,只关心”每种长度的子序列,最矮的队尾能有多矮”。

 
tails[k]:长度为 k+1 的递增子序列的最小末尾值
 
维护规则:遍历每个人,在 tails 中找第一个 >= 当前身高的位置。
 
  - 没找到(所有人都比他矮)→ 他成为新长度的队尾
 
  - 找到了 → 替换那个位置的队尾(因为用更矮的人做队尾,后面更容易接人)
 

graph TD

    subgraph 贪心过程

        t1["tails = []"] --> t2["10 → [10]"]

        t2 --> t3["9  → [9]<br/>替换10"]

        t3 --> t4["2  → [2]<br/>替换9"]

        t4 --> t5["5  → [2,5]<br/>追加"]

        t5 --> t6["3  → [2,3]<br/>替换5"]

        t6 --> t7["7  → [2,3,7]<br/>追加"]

        t7 --> t8["101 → [2,3,7,101]<br/>追加"]

        t8 --> t9["18 → [2,3,7,18]<br/>替换101"]

    end

    style t9 fill:#ffd700

为什么能替换? 因为 tails 是递增的(长度为 k 的队尾一定 < 长度为 k+1 的队尾),所以二分查找。


graph LR

    subgraph 三层递进

        A["暴力枚举<br/>O(2ⁿ)"]

        B["DP 递推<br/>O(n²)"]

        C["贪心 + 二分<br/>O(n log n)"]

    end

    A -->|发现重复子问题| B

    B -->|发现只关心最小末尾| C


代码

 
import bisect
 
# 贪心 + 二分(推荐)
 
def lengthOfLIS(self, nums):
 
    tails = []                                    # tails[k] = 长度为k+1的LIS的最小末尾值
 
    for num in nums:
 
        idx = bisect.bisect_left(tails, num)      # 在 tails 中找第一个 >= num 的位置
 
        if idx == len(tails):                     # 比所有队尾都大 → 可以接在后面延长LIS
 
            tails.append(num)
 
        else:
 
            tails[idx] = num                      # 替换队尾,让后续更容易接人
 
    return len(tails)
 
# DP 版本(也应掌握)
 
def lengthOfLIS_DP(self, nums):
 
    n = len(nums)
 
    dp = [1] * n                                  # dp[i] = 以 nums[i] 结尾的 LIS 长度
 
    for i in range(1, n):
 
        for j in range(i):
 
            if nums[j] < nums[i]:
 
                dp[i] = max(dp[i], dp[j] + 1)
 
    return max(dp)
 

复杂度

解法时间空间说明
贪心 + 二分(推荐)O(n log n)O(n)tails 数组长度 ≤ n,二分每次 O(log n)
DPO(n²)O(n)双重循环,n=10⁵ 时超时

实战考量

频率分析

出现在:字节/腾讯/阿里 一面到约 40% 的 DP 面会考 LIS。常作为从 O(n²) 到 O(n log n) 优化的经典范例,考察你能否跳出 DP 定式想到贪心。

延伸思考

Q:tails 数组为什么不一定是真实的 LIS 序列?

A:tails 只记录”每种长度的最小末尾值”,不是实际序列。例如 [2,3,7,18] 中的 7 和 18 在原数组中顺序其实是 7→101→18,tails[3]=18 不代表 18 一定跟在 7 后面。要输出真实 LIS 需要用 DP 法回溯。

Q:输出具体的 LIS 怎么做?

A:DP 法可以回溯——额外记录 prev[i] 表示以 i 结尾时前一个元素的下标,最后从 dp 值最大的位置往前追溯。贪心法需要额外维护每个 tails 位置对应的原数组下标和前驱关系。

Q:如果允许相等(非严格递增)呢?

A:把 bisect_left 改成 bisect_right,这样相等的元素会追加到后面而不是替换,保证非递减。

Q:二维 LIS(信封嵌套)怎么做?

A:先按一维升序(相等时另一维降序)排序,再对另一维求 LIS。降序是为了避免同宽度的信封相互嵌套。

Q:如果要求方案数呢?

A:DP 时额外维护 count[i],在更新 dp[i] 时同步累加计数。当 dp[j] + 1 > dp[i]count[i] = count[j],当相等时 count[i] += count[j]

易错点

  • bisect_left(严格递增)和 bisect_right(非严格递增)的区别

  • tails 数组长度 = LIS 长度,但内容不一定等于 LIS 序列

  • DP 法返回 max(dp) 不是 dp[-1](LIS 不一定以最后一个元素结尾)

  • 空数组/单元素数组的边界


生活类比

LIS → 贪心 + 二分

像在整理书架:你只关心”每种厚度的书,最薄的那本放哪”。

越薄的放在外层,后面越容易插进去。

用两个字概括:留底牌——每种长度留最小的”底牌”,后面的数字更容易打出新高度。


相关题目

题目关系
198打家劫舍同为一维 DP 线性递推,但 LIS 需要双循环
322零钱兑换完全背包一维 DP,外层循环和内层循环的先后讲究
416分割等和子集0/1 背包 DP,目标从求最长变求能凑成某值
— 354. 俄罗斯套娃信封问题二维 LIS,先排序再求 LIS
— 673. 最长递增子序列的个数LIS 计数版,同时维护 dp 和 count

→ 返回题单:LeetCode学习路线图 > 十三、动态规划

速记卡(面试闪卡)

Q1:一句话讲清「300. 最长递增子序列(Longest Increasing Subsequence)」到底是什么?

A:在不改顺序的前提下,从数组中挑出最长的严格递增子序列。

Q2:思路:暴力到 DP 到贪心二分 —— 怎么理解?

A:话剧社选演员:报名顺序不能乱、后一个要比前一个高。动态规划(DP, Dynamic Programming)以每个人为队尾往前找更矮的接上;更妙的是贪心(Greedy)+ 二分(Binary Search)。

Q3:代码:tails 数组留最小末尾 —— 怎么理解?

A:维护 tails 数组记「每种长度的最小末尾值」,遍历时用二分查找(Binary Search)找第一个 ≥ 当前的位置,能接就接、不能就替换——贪心留底牌。

Q4:复杂度:两种写法 —— 怎么理解?

A:贪心 + 二分时间复杂度(Time Complexity)O(n log n) 飞起;普通 DP 是 O(n²) 双重循环,n 大了就超时——能优化就别偷懒。

Q5:生活类比:留最薄底牌 —— 怎么理解?

A:最长递增子序列(LIS, Longest Increasing Subsequence)像整理书架:只关心每种厚度最薄的那本放哪,越薄越容易后面插新书。留底牌(Greedy)就是核心心法。

Q6:核心速记主线有哪些?

  • 顺序不变挑最长递增

  • DP 平方、贪心二分快

  • tails 留最小末尾值

  • 返回长度非具体序

口诀

A:递增子序列最长,

DP 平方二分强。

贪心留薄底牌藏,

越薄越能装进仓。

相关链接