Files
leetcode-go/动态规划/87-最长递增子序列.md
T

15 KiB
Raw Blame History

tags, create time
tags create time
算法/动态规划
线性DP
二分查找
贪心
2026-05-16 19:00

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

[!quote] LeetCode 原题 · 中等 给你一个整数数组 nums,找到其中 最长严格递增子序列 的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。

  • 输入范围:1 <= nums.length <= 2500,-10⁴ <= nums[i] <= 10⁴

题面

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

示例 3:

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

思路

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

[!question] 💡 思考 题目要求"子序列"(不要求连续),和"子数组"(必须连续)有什么本质区别?这种区别对 DP 的设计产生了什么影响?

子序列允许跳过元素,这意味着在考虑每个位置时,我们需要回头"查看所有可能的 preceding 状态"——而不是只看不相邻的前一个。这正是 LIS 的 DP 核心。

第一步:定义状态

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

关键理解:这里定义的 LIS 必须以 nums[i] 作为结尾元素。这个约束让状态具有了"最优子结构"。

第二步:推导转移方程

对于位置 i,我们枚举所有 j < i:

  • 如果 nums[j] < nums[i],说明可以把 nums[i] 接到以 nums[j] 结尾的子序列后面,形成长度为 dp[j] + 1 的递增子序列
  • 我们取所有合法 j 中的最大值

dp[i] = max(dp[j] + 1),对所有满足 0 ≤ j < i 且 nums[j] < nums[i] 的 j

若不存在这样的 j(即前面的元素都不比它小),则 dp[i] = 1(自身构成子序列)

flowchart TD
    subgraph "以 nums[5]=7 为例 (nums=[10,9,2,5,3,7,...])"
        A["dp[5]\n= max(dp[j]+1)\nfor nums[j]<7"] --> B["j=2: dp[2]=1\nnums[2]=2 < 7 → 2"]
        A --> C["j=3: dp[3]=2\nnums[3]=5 < 7 → 3"]
        A --> D["j=4: dp[4]=2\nnums[4]=3 < 7 → 3"]
        B --> E["候选值: 2"]
        C --> F["候选值: 3 ★"]
        D --> G["候选值: 3 ★"]
        F --> H["dp[5] = 3\n→ [2,5,7] 或 [2,3,7]"]
        G --> H
    end

    style A fill:#e7f3ff,stroke:#3b82f6
    style H fill:#dcfce7,stroke:#16a34a

第三步:确定最终答案

[!question] 💡 思考 dp[n-1] 就是答案吗?

不一定!因为 dp[i] 表示"以 nums[i] 结尾"的 LIS 长度,但全局最长递增子序列可能不以最后一个元素结尾。所以答案是 dp 数组中的最大值。

flowchart LR
    subgraph "填表完成后扫描最大值"
        A["dp[0]=1"] --> B["dp[1]=1"]
        B --> C["dp[2]=1"]
        C --> D["dp[3]=2"]
        D --> E["dp[4]=2"]
        E --> F["dp[5]=3"]
        F --> G["dp[6]=4 ★★★"]
        G --> H["dp[7]=4"]
    end

    style A fill:#fef3c7,stroke:#f59e0b
    style F fill:#fef3c7,stroke:#f59e0b
    style G fill:#dcfce7,stroke:#16a34a
    style H fill:#fef3c7,stroke:#f59e0b

第四步:自底向上填表演示

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

i nums[i] dp[i] 计算过程 dp[i]
0 10 无前驱 1 → [10]
1 9 无前驱(10 > 9) 1 → [9]
2 2 无前驱 1 → [2]
3 5 nums[2]=2<5 → dp[2]+1 2 → [2,5]
4 3 nums[2]=2<3 → dp[2]+1 2 → [2,3]
5 7 nums[3]=5<7→3, nums[4]=3<7→3 3 → [2,5,7] 或 [2,3,7]
6 101 dp[5]=3 → 4 4 → [2,5,7,101]
7 18 dp[5]=3 → 4 4 → [2,5,7,18]

答案 = max(dp) = 4 ✅

[!summary] 💡 代码结构拆解

  • 外层 for i=1..n-1:逐个计算每个位置结尾的 LIS 长度
  • 内层 for j=0..i-1:回溯检查所有前驱,找最优的那条"接力链"
  • 最终 scan:dp 数组中取最大值,因为全局最优解不一定停在末尾

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

[!abstract] 🔍 核心直觉升级 上面 DP 的思路是"以某个元素结尾",而 O(n log n) 方法的思路完全不同——它不再关心"以谁结尾",而是关注"长度为 k 的递增子序列,最小的末尾元素是什么"。

想象你在打扑克牌——"接龙游戏"(Patience Sorting):从左到右依次处理每张牌,把它放在最左边的、顶部数字 ≥ 它的牌堆上;如果没有这样的牌堆,就新开一堆。最终牌堆的数量就是 LIS 的长度。

用程序语言描述,我们维护一个数组 tails:

tails[k] = 长度为 k+1 的所有递增子序列中,末尾元素的最小值

为什么这个数组能优化?因为它有至关重要的单调性:

[!tip] 🧠 关键引理:tails 严格递增 假设长度为 k 的递增子序列最小末尾是 a,长度为 k+1 的是 b。由于任何长度为 k+1 的子序列都包含一个长度为 k 的前缀,该前缀的末尾一定小于 b,所以 a < b。

结论:tails 严格递增 → 可以用二分查找!

操作规则

对每个 nums[i]:

  1. 在 tails 中二分查找第一个 ≥ nums[i] 的位置
    • 找到了:把这个位置的元素替换成 nums[i](说明存在一个同样长度的子序列,但末尾更小了——贪心选择更有潜力)
    • 没找到(nums[i] 比所有 tails 都大):把 nums[i]追加到尾部,LIS 长度 +1
flowchart TD
    subgraph "核心操作逻辑"
        A["nums[i]"] --> B{"二分查 tails\n首个 ≥ nums[i]?"}
        B -- "找到了" --> C["替换该位置\n→ 更小末尾, 同长度"]
        B -- "没找到" --> D["追加到尾部\n→ 延长 LIS"]
        C --> E["tails 仍严格递增 ✓"]
        D --> E
    end

    style A fill:#e7f3ff,stroke:#3b82f6
    style C fill:#fef3c7,stroke:#f59e0b
    style D fill:#dcfce7,stroke:#16a34a
    style E fill:#d1fae5,stroke:#10b981

完整走一遍

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

步骤 nums[i] tails 变化 说明
初始 — [] 空
1 10 [10] 空,直接追加
2 9 [9] 9 < 10,替换 tails[0]
3 2 [2] 2 < 9,替换 tails[0]
4 5 [2, 5] 5 > 2,追加
5 3 [2, 3] 3 < 5,替换 tails[1]
6 7 [2, 3, 7] 7 > 3,追加
7 101 [2, 3, 7, 101] 101 > 7,追加 → LIS 长度=4
8 18 [2, 3, 7, 18] 18 < 101,替换 tails[3]

最终 tails 长度 = 4,即为答案 ✅

flowchart LR
    subgraph "tails 演变全过程"
        A["[]"] --> B["[10]"]
        B --> C["[9]"]
        C --> D["[2]"]
        D --> E["[2,5]"]
        E --> F["[2,3]"]
        F --> G["[2,3,7]"]
        G --> H["[2,3,7,101]\n★答案=4"]
        H --> I["[2,3,7,18]"]
    end

    style A fill:#f3f4f6,stroke:#6b7280
    style G fill:#fef3c7,stroke:#f59e0b
    style H fill:#dcfce7,stroke:#16a34a
    style I fill:#dbeafe,stroke:#3b82f6

[!warning] ⚠️ 重要澄清:tails ≠ 实际的 LIS tails 记录的是"各长度下最小末尾"的信息,它本身不一定是一个真实的子序列。但它的长度恰好等于 LIS 的真实长度。上面的例子中,tail 经历了 [2,3,7,101] → [2,3,7,18],真实 LIS 可能是 [2,5,7,101] 或 [2,3,7,18]。虽然内容不完全一致,但长度恒等。

代码提示

在动手写代码之前,想一想这些关键决策点:

[!question] 💡 思考 1 Go 标准库中有二分查找函数——你会选哪一个?需要注意什么?

Go 的 sort.SearchInts 返回目标值在有序切片中的插入位置(索引)。如果该索引等于切片长度,说明目标值比所有元素都大——正好对应"追加"的情况。如果索引 < len(tails),说明找到了第一个 ≥ 值的元素——对应"替换"。

[!question] 💡 思考 2 两种方法的空间复杂度分别是多少?有没有办法进一步降低空间?

方法一需要 dp 数组 → O(n);方法二只需要 tails 数组 → O(n)。两者都是 O(n) 空间。不过对于方法一,如果你只需要长度而非具体序列,可以注意到 dp 数组的某些冗余信息其实不需要全部保留——但这涉及更复杂的技巧,面试中答出标准的 O(n) 即可。

技巧

变体速查:边界微调决定不同语义

[!tip] ⚡ 小技巧:非严格递增 / 最长递减子序列

变体 改动点
非严格递增(允许相等) 方法一中 nums[j] < nums[i] 改为 ≤;方法二中二分改为"第一个 > nums[i]"
最长递减子序列 反转数组后求 LIS,或在比较时反过来:> 变 <
求最长非递减子序列 使用 sort.Search 自定义比较函数,找第一个 大于 而不是 大于等于

构造实际子序列

[!abstract] 🔍 进阶:不只是长度

如果需要输出具体的 LIS,方法一更容易扩展:维护一个 parent[i] 数组记录每个 dp[i] 对应的上一个元素下标,最后从 dp 最大处回溯即可。方法二较难还原(可以用额外数据结构实现但不常见)。

何时选哪种方法?

[!question] 💡 面试官追问:为什么要用 O(n log n) 而不是 O(n²)?

关键指标看 n 的范围:

约束 推荐方法 理由
n ≤ 3000 O(n²) DP 约 9×10⁶ 次操作,足够快且代码简单
n ≤ 10⁵ O(n log n) 二分 约 1.7×10⁶ 次操作,差距明显
n ≤ 10⁶+ 必须 O(n log n) O(n²) 超时

本题 n ≤ 2500,两种都能 AC,但 O(n log n) 是这道题的灵魂考点,面试务必掌握。

方法 时间 空间 评价
DP 填表 O(n²) O(n) 直观易懂,容易扩展构造序列
二分 + 贪心(推荐 ✅) O(n log n) O(n) 高效优雅,面试加分项

代码

Go 语言实现

版本一:O(n²) 动态规划

// lengthOfLIS returns the length of the longest strictly increasing subsequence.
func lengthOfLIS(nums []int) int {
	n := len(nums)
	if n == 0 {
		return 0
	}

	// dp[i] = 以 nums[i] 结尾的最长递增子序列的长度
	// 初始化全为 1,因为每个元素自身就是一个长度为 1 的子序列
	dp := make([]int, n)
	for i := range dp {
		dp[i] = 1
	}

	// 自底向上填表:对每个位置 i,遍历所有前驱 j < i
	for i := 1; i < n; i++ {
		for j := 0; j < i; j++ {
			if nums[j] < nums[i] { // 满足递增条件
				if dp[j]+1 > dp[i] {
					dp[i] = dp[j] + 1
				}
			}
		}
	}

	// 答案是 dp 数组中的最大值
	result := 0
	for _, v := range dp {
		if v > result {
			result = v
		}
	}
	return result
}

[!summary] 💡 代码说明

  • 初始化:所有 dp[i] = 1(每个元素单独构成子序列)
  • 外层 i:从左往右,逐个"放置"当前元素
  • 内层 j:回头看所有前驱,找到能把当前元素接上去的"最佳父节点"
  • 最后扫描:最大值即全局 LIS 长度

版本二:O(n log n) 二分 + 贪心(推荐 ✅)

// lengthOfLIS returns the length of the longest strictly increasing subsequence.
// Time complexity: O(n log n), Space complexity: O(n).
func lengthOfLIS(nums []int) int {
	// tails[i] = 长度为 i+1 的所有递增子序列中,最小的末尾元素
	var tails []int

	for _, num := range nums {
		// 二分查找 tails 中第一个 >= num 的位置
		pos := sort.SearchInts(tails, num)

		if pos < len(tails) {
			tails[pos] = num // 替换:让同样长度的子序列末尾更小
		} else {
			tails = append(tails, num) // 追加:发现更长的递增子序列
		}
	}

	return len(tails)
}

[!summary] 💡 代码结构拆解

  • sort.SearchInts:Go 标准库的二分搜索,返回目标值在有序切片中的插入位置(保证左侧元素 < 目标,右侧元素 ≥ 目标)
  • pos < len(tails):找到可替换位置 → 贪心地让末尾更小,增加未来扩展的可能性
  • pos == len(tails):所有 tails 元素都 < num → num 能延长 LIS,长度 +1
  • 核心循环仅 5 行,简洁优雅

执行过程演示(nums = [10,9,2,5,3,7,101,18])

tails = []

num=10: SearchInts([], 10) → 0 (len=0, 追加)
tails = [10]

num=9:  SearchInts([10], 9) → 0 (tails[0]=10 >= 9, 替换)
tails = [9]

num=2:  SearchInts([9], 2) → 0 (tails[0]=9 >= 2, 替换)
tails = [2]

num=5:  SearchInts([2], 5) → 1 (1 == len, 追加)
tails = [2, 5]

num=3:  SearchInts([2,5], 3) → 1 (tails[1]=5 >= 3, 替换)
tails = [2, 3]

num=7:  SearchInts([2,3], 7) → 2 (2 == len, 追加)
tails = [2, 3, 7]

num=101: SearchInts([2,3,7], 101) → 3 (3 == len, 追加)
tails = [2, 3, 7, 101] ← LIS 长度达到 4

num=18: SearchInts([2,3,7,101], 18) → 3 (tails[3]=101 >= 18, 替换)
tails = [2, 3, 7, 18]

结果: len(tails) = 4 ✅

复杂度分析

维度 方法一(DP) 方法二(二分)
时间 O(n²) — 两层嵌套循环,每次常数操作 O(n log n) — n 次迭代,每次二分 O(log L),L ≤ n
空间 O(n) — dp 数组 O(n) — tails 数组

代入本题约束 n ≤ 2500:

  • 方法一:~6.25 × 10⁶ 次操作,轻松通过
  • 方法二:~2500 × 12 ≈ 3 × 10⁴ 次操作,几乎瞬时完成

举一反三

这道题是 线性 DP 与贪心 + 二分 结合的经典范例,也是 LeetCode 中"模式识别"价值极高的一题:

  • 86-单词拆分 — 同样是逐状态递推,但这里的"选择分支"是从所有前驱中选择
  • 84-完全平方数 — 与 LIS 的 DP 写法内核相似(枚举起跳点取极值),但目标函数从"求最大长度"变成了"求最小个数"
  • LeetCode 673. 最长递增子序列的个数 —— 变种:在 DP 基础上多维护一个 count 数组,统计每个位置结尾的方案数
  • LeetCode 354. 俄罗斯套娃信封问题 —— 二维 LIS 模板题:先按一维排序,再对另一维求 LIS
  • LeetCode 392. 判断子序列 —— 简化版:只需判断是否存在(返回 true/false 而非长度),双指针即可
  • LeetCode 674. 最长连续递增序列 —— 降维权:子序列变成连续子数组,直接一次扫描即可

[!summary] 📌 本节要点

  1. 方法一(DP):定义 dp[i] = 以 nums[i] 结尾的 LIS 长度,转移 dp[i] = max(dp[j]+1),总时间 O(n²)
  2. 方法二(二分+贪心):维护 tails 数组——长度为 k+1 的递增子序列的最小末尾,利用单调性做二分,总时间 O(n log n)
  3. tails 严格递增是关键引理,保证了二分查找的正确性
  4. tails 本身不一定是真实 LIS,但其长度一定等于 LIS 长度
  5. 面试中优先展示方法二,同时能够解释方法一