Files
leetcode-go/笔试/微派 Test1/01-LIS返回子序列.md
T

6.9 KiB
Raw Blame History

tags, create time
tags create time
笔试
微派
DP
贪心
二分查找
Go
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 值最大的位置倒推,找到前驱元素即可恢复完整子序列。

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 数组记录的路径。

算法步骤

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] &gt; 所有 tails" --> C
    B -- "tails[j-1] &lt; 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] ✓

代码提示

// 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

代码

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
}