287 lines
6.9 KiB
Markdown
287 lines
6.9 KiB
Markdown
---
|
||
tags: [笔试, 微派, DP, 贪心, 二分查找, Go]
|
||
create time: 2026-05-16 14:35
|
||
---
|
||
|
||
# 01 - 最长严格递增子序列(返回子序列)
|
||
|
||
## 题面
|
||
|
||
**输入**: 一个整数数组 `nums`
|
||
|
||
**输出**: 一个切片,表示 nums 的**最长严格递增子序列(Longest Increasing Subsequence)**的具体元素
|
||
|
||
**要求**:
|
||
- 子序列不要求连续,但必须保持原顺序
|
||
- **严格递增**:后一项必须**大于**前一项(不能等于)
|
||
- ACM 模式:从 stdin 读取,stdout 输出结果
|
||
- 如有多个答案,返回任意一个即可
|
||
|
||
**示例 1**:
|
||
```
|
||
输入: [10, 9, 2, 5, 3, 7, 101, 18]
|
||
输出: [2, 3, 7, 101] (或 [2, 3, 7, 18])
|
||
```
|
||
|
||
**示例 2**:
|
||
```
|
||
输入: [0, 1, 0, 3, 2, 3]
|
||
输出: [0, 1, 2, 3]
|
||
```
|
||
|
||
**示例 3** (严格递增):
|
||
```
|
||
输入: [3, 3, 3, 3]
|
||
输出: [3] (长度为 1,因为相等不算递增)
|
||
```
|
||
|
||
> [!question] 💡 思考一下
|
||
> 如果题目要求的是**非递减**子序列(可以等于),解法需要怎么调整?
|
||
|
||
---
|
||
|
||
## 思路
|
||
|
||
### 方法一:O(n²) 动态规划(基础)
|
||
|
||
定义 `dp[i] = 以 nums[i] 结尾的最长递增子序列长度`。
|
||
|
||
转移方程:
|
||
|
||
$$dp[i] = \max \{ dp[j] \} + 1, \quad j < i,\; nums[j] < nums[i]$$
|
||
|
||
回溯时,从 dp 值最大的位置倒推,找到前驱元素即可恢复完整子序列。
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["nums = [10, 9, 2, 5, 3, 7, 101, 18]"] --> B["计算 dp[i]"]
|
||
B --> C["dp = [1, 1, 1, 2, 2, 3, 4, 4]"]
|
||
C --> D["找到最大值的最后一个位置"]
|
||
D --> E["倒推前驱还原子序列"]
|
||
E --> F["[2, 3, 7, 101] 或 [2, 3, 7, 18]"]
|
||
```
|
||
|
||
**优点**: 思路直观,容易实现
|
||
**缺点**: O(n²) 时间复杂度,大数据量会超时
|
||
|
||
---
|
||
|
||
### 方法二:O(n log n) 贪心 + 二分 + 路径回溯(推荐)⭐
|
||
|
||
这是本题的**核心考点**。很多人只记得求长度的 O(n log n) 解法,但本题要求**返回具体序列**,需要额外技巧。
|
||
|
||
#### 核心数据结构
|
||
|
||
维护两个数组:
|
||
|
||
| 数组 | 含义 |
|
||
|------|------|
|
||
| `tails[k]` | 长度为 k+1 的所有递增子序列中,**最小尾部元素**的值 |
|
||
| `parent[i]` | 以 `nums[i]` 结尾的 LIS 中,`nums[i]` 的**前驱索引** |
|
||
|
||
#### 关键洞察 🔑
|
||
|
||
> [!warning] ⚠️ 易错点
|
||
> `tails` 数组**不一定**是真实的子序列!它只是帮助我们高效找到更长的子序列。真正恢复子序列要靠 `parent` 数组记录的路径。
|
||
|
||
#### 算法步骤
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
%% 定义所有节点
|
||
A["遍历每个元素 nums[i]"]
|
||
B{"在 tails 中<br/>二分查找"}
|
||
C["追加到 tails 末尾<br/>更新 parent[i]=prevIdx"]
|
||
D["用 nums[i] 替换<br/>tails[j]<br/>parent[i]=j-1<br/>对应的前驱索引"]
|
||
E["i++"]
|
||
F{"是否遍历完?"}
|
||
G["从 tails 末尾开始<br/>回溯 parent"]
|
||
H["得到逆序的子序列"]
|
||
I["翻转得到正序"]
|
||
|
||
%% 连线
|
||
A --> B
|
||
B -- "nums[i] > 所有 tails" --> C
|
||
B -- "tails[j-1] < nums[i]" --> D
|
||
C --> E
|
||
D --> E
|
||
E --> F
|
||
F -- "否" --> A
|
||
F -- "是" --> G
|
||
G --> H
|
||
H --> I
|
||
```
|
||
|
||
**细节**: 为了正确设置 `parent[i]`,我们还需要一个数组 `pos[k]` 记录长度为 k 的子序列当前尾元素的索引。
|
||
|
||
#### 完整流程演示
|
||
|
||
```
|
||
nums = [10, 9, 2, 5, 3, 7, 101, 18]
|
||
|
||
i=0: nums[0]=10, tails=[], pos=[]
|
||
tails=[10], pos=[0], parent[0]=-1
|
||
|
||
i=1: nums[1]=9, 9<10, 替换 tails[0]
|
||
tails=[9], pos=[1], parent[1]=-1
|
||
|
||
i=2: nums[2]=2, 2<9, 替换 tails[0]
|
||
tails=[2], pos=[2], parent[2]=-1
|
||
|
||
i=3: nums[3]=5, 5>2, 追加
|
||
tails=[2,5], pos=[2,3], parent[3]=2
|
||
|
||
i=4: nums[4]=3, 2<3≤5, 替换 tails[1]
|
||
tails=[2,3], pos=[2,4], parent[4]=2
|
||
|
||
i=5: nums[5]=7, 7>3, 追加
|
||
tails=[2,3,7], pos=[2,4,5], parent[5]=4
|
||
|
||
i=6: nums[6]=101, 101>7, 追加
|
||
tails=[2,3,7,101], pos=[2,4,5,6], parent[6]=5
|
||
|
||
i=7: nums[7]=18, 7<18≤101, 替换 tails[3]
|
||
tails=[2,3,7,18], pos=[2,4,5,7], parent[7]=5
|
||
|
||
LIS 长度 = 4, 从 pos[3]=7 开始回溯:
|
||
nums[7]=18 → parent[7]=5 → nums[5]=7 → parent[5]=4 → nums[4]=3 → parent[4]=2 → nums[2]=2 → parent[2]=-1
|
||
逆序: [18, 7, 3, 2] → 翻转 → [2, 3, 7, 18] ✓
|
||
```
|
||
|
||
---
|
||
|
||
## 代码提示
|
||
|
||
```go
|
||
// 1. 初始化 tails []int, pos []int, parent []int{-1}
|
||
// 2. 遍历 i := 0 to n-1:
|
||
// - 二分搜索 tails,找到第一个 >= nums[i] 的位置 j
|
||
// - if j == len(tails): 追加,否则替换 tails[j]
|
||
// - 更新 pos[j] = i, parent[i] = pos[j-1] (if j > 0)
|
||
// 3. 从 pos[len(tails)-1] 沿 parent 倒推
|
||
// 4. 反转结果
|
||
```
|
||
|
||
---
|
||
|
||
## 技巧
|
||
|
||
> [!tip] 小技巧: 二分搜索的使用
|
||
> `sort.SearchInts` 返回的是**第一个 >= target** 的位置,正好符合我们的需求。如果用标准库 `lower_bound` 语义,找的是 `>=`;如果要处理**非严格递增**(允许等于),就改为找 `>`。
|
||
|
||
> [!note] ACM 模式注意事项
|
||
> - 用 `bufio.Scanner` 读行比 `fmt.Scan` 更高效
|
||
> - 输出格式通常是用空格分隔的数字
|
||
> - 记得处理空数组边界情况 `n == 0`
|
||
|
||
---
|
||
|
||
## 代码
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
func main() {
|
||
scanner := bufio.NewScanner(os.Stdin)
|
||
if !scanner.Scan() {
|
||
return
|
||
}
|
||
|
||
// 解析输入: "10 9 2 5 3 7 101 18"
|
||
parts := strings.Fields(scanner.Text())
|
||
n := len(parts)
|
||
if n == 0 {
|
||
fmt.Println("[]")
|
||
return
|
||
}
|
||
|
||
nums := make([]int, n)
|
||
for i, p := range parts {
|
||
v, _ := strconv.Atoi(p)
|
||
nums[i] = v
|
||
}
|
||
|
||
// LIS 返回具体子序列
|
||
result := longestIncreasingSubsequence(nums)
|
||
|
||
// 输出结果
|
||
fmt.Print("[")
|
||
for i, v := range result {
|
||
if i > 0 {
|
||
fmt.Print(" ")
|
||
}
|
||
fmt.Print(v)
|
||
}
|
||
fmt.Println("]")
|
||
}
|
||
|
||
func longestIncreasingSubsequence(nums []int) []int {
|
||
n := len(nums)
|
||
if n == 0 {
|
||
return []int{}
|
||
}
|
||
|
||
// tails[k] = 长度为 k+1 的递增子序列的最小尾部值
|
||
tails := make([]int, 0, n)
|
||
// pos[k] = 该尾部值在原数组中的索引
|
||
pos := make([]int, n)
|
||
// parent[i] = 以 nums[i] 结尾的 LIS 中,前一个元素的索引
|
||
parent := make([]int, n)
|
||
for i := range parent {
|
||
parent[i] = -1
|
||
}
|
||
|
||
for i := 0; i < n; i++ {
|
||
// 二分查找: 在 tails 中找第一个 >= nums[i] 的位置
|
||
j := lowerBound(tails, nums[i])
|
||
|
||
if j == len(tails) {
|
||
// nums[i] 可以接在当前最长子序列后面
|
||
tails = append(tails, nums[i])
|
||
} else {
|
||
// 用较小的 nums[i] 替换 tails[j]
|
||
tails[j] = nums[i]
|
||
}
|
||
pos[j] = i // 记录长度为 j+1 的子序列尾部索引
|
||
|
||
if j > 0 {
|
||
parent[i] = pos[j-1] // 前驱是长度为 j 的子序列尾部
|
||
}
|
||
}
|
||
|
||
// 从最长子序列的尾部开始,沿 parent 回溯
|
||
length := len(tails)
|
||
result := make([]int, length)
|
||
result[length-1] = nums[pos[length-1]]
|
||
k := pos[length-1]
|
||
for i := length - 2; i >= 0; i-- {
|
||
k = parent[k]
|
||
result[i] = nums[k]
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// lowerBound 返回第一个 >= target 的位置
|
||
func lowerBound(a []int, target int) int {
|
||
left, right := 0, len(a)
|
||
for left < right {
|
||
mid := left + (right-left)/2
|
||
if a[mid] < target {
|
||
left = mid + 1
|
||
} else {
|
||
right = mid
|
||
}
|
||
}
|
||
return left
|
||
}
|
||
```
|