最长递增子序列(LIS)
最长递增子序列(Longest Increasing Subsequence)是算法中经典的动态规划问题。
问题描述
给定一个无序的整数数组,找到其中最长的严格递增子序列的长度。
示例:
1 | 输入: [10,9,2,5,3,7,101,18] |
方法一:动态规划(O(n²))
解题思路
- 创建 dp 数组,
dp[i]表示以nums[i]结尾的最长递增子序列长度 - 初始时每个元素本身就是一个子序列,所以初始值为 1
- 对于每个元素,检查前面所有比它小的元素,更新 dp 值
- 最后返回 dp 数组中的最大值
代码实现
1 | function lengthOfLIS(nums) { |
复杂度分析
- 时间复杂度:O(n²) - 双重循环
- 空间复杂度:O(n) - dp 数组
方法二:贪心+二分查找(O(nlogn))
解题思路
- 维护一个
tails数组,tails[i]表示长度为 i+1 的递增子序列的最小末尾元素 - 遍历数组,对于每个数字:
- 如果它比
tails最后一个元素大,直接加入数组 - 否则,用二分查找找到第一个 ≥ 它的元素并替换
- 如果它比
tails的长度就是最长递增子序列的长度
代码实现
1 | function lengthOfLIS(nums) { |
复杂度分析
- 时间复杂度:O(nlogn) - 遍历数组 O(n),每个元素二分查找 O(logn)
- 空间复杂度:O(n) - tails 数组
两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 动态规划 | O(n²) | O(n) | 小规模数据,需要知道具体序列 |
| 贪心+二分 | O(nlogn) | O(n) | 大规模数据,只需长度 |
示例测试
1 | console.log(lengthOfLIS([10,9,2,5,3,7,101,18])); // 4 |
进阶:输出具体的最长递增子序列
如果需要输出具体的序列而不仅仅是长度,可以修改动态规划解法:
1 | function getLIS(nums) { |
总结
最长递增子序列问题有两种主要解法:
- 动态规划:直观易懂,适合小规模数据和需要输出具体序列的情况
- 贪心+二分:效率更高,适合大规模数据但实现稍复杂
最长递增子序列(LIS)贪心+二分法详细图解
贪心+二分法是最优的LIS解法,时间复杂度O(nlogn)。下面我用详细的步骤和图解来解释这个算法。
算法核心思想
维护一个tails数组,其中tails[i]表示长度为i+1的所有递增子序列的最小末尾元素。
示例数组
nums = [10, 9, 2, 5, 3, 7, 101, 18]
详细步骤图解
初始状态
tails = [] (空数组)
处理每个元素:
i=0, num=10
- tails为空,直接加入
tails = [10]- 解释:长度为1的子序列最小末尾是10
i=1, num=9
- 二分查找9在tails中的位置
- tails = [10],找到第一个≥9的是10
- 替换10→9
tails = [9]- 解释:长度为1的子序列最小末尾现在是9
i=2, num=2
- tails = [9],找到第一个≥2的是9
- 替换9→2
tails = [2]- 解释:长度为1的子序列最小末尾现在是2
i=3, num=5
- tails = [2],5>2,可以扩展
- 加入5
tails = [2, 5]- 解释:
- 长度为1的最小末尾:2
- 长度为2的最小末尾:5
i=4, num=3
- tails = [2,5],二分查找3
- 找到第一个≥3的是5
- 替换5→3
tails = [2,3]- 解释:
- 长度为1的最小末尾:2
- 长度为2的最小末尾:3
i=5, num=7
- tails = [2,3],7>3,可以扩展
- 加入7
tails = [2,3,7]- 解释:
- 长度1:2
- 长度2:3
- 长度3:7
i=6, num=101
- tails = [2,3,7],101>7,可以扩展
- 加入101
tails = [2,3,7,101]- 解释:
- 长度1:2
- 长度2:3
- 长度3:7
- 长度4:101
i=7, num=18
- tails = [2,3,7,101],二分查找18
- 找到第一个≥18的是101
- 替换101→18
tails = [2,3,7,18]- 解释:
- 长度1:2
- 长度2:3
- 长度3:7
- 长度4:18 (比101更小)
完整过程表格
| 步骤 | 当前数字 | tails数组变化 | 操作说明 |
|---|---|---|---|
| 1 | 10 | [10] | 直接加入 |
| 2 | 9 | [9] | 替换10→9 |
| 3 | 2 | [2] | 替换9→2 |
| 4 | 5 | [2,5] | 5>2,扩展 |
| 5 | 3 | [2,3] | 替换5→3 |
| 6 | 7 | [2,3,7] | 7>3,扩展 |
| 7 | 101 | [2,3,7,101] | 101>7,扩展 |
| 8 | 18 | [2,3,7,18] | 替换101→18 |
为什么这样能得到正确结果?
- 贪心策略:我们总是希望递增子序列的末尾元素尽可能小,这样后面有更多机会扩展更长的序列
- tails数组性质:tails数组一定是严格递增的,这保证了我们可以使用二分查找
- 替换不影响长度:当我们替换某个位置的元素时,不会改变当前的最大长度,只是为未来可能的扩展创造了更好的条件
最终结果
tails数组的最终长度是4,因此最长递增子序列的长度为4。
可能的LIS序列:
- [2,3,7,101]
- [2,3,7,18]
虽然tails数组不直接给出具体的LIS,但它正确记录了LIS的长度。