401 lines
15 KiB
Markdown
401 lines
15 KiB
Markdown
|
|
---
|
|||
|
|
tags: [算法/动态规划, 线性DP, 二分查找, 贪心]
|
|||
|
|
create time: 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`(自身构成子序列)
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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 数组中的最大值**。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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**,即为答案 ✅
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
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²) 动态规划
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 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) 二分 + 贪心(推荐 ✅)
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// 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. 面试中优先展示方法二,同时能够解释方法一
|