Leetcode-100 最长递增子序列

最长递增子序列(Longest Increasing Subsequence)题解

题目概括

给定一个整数数组 nums,返回其最长严格递增子序列的长度。
子序列定义为:通过删除(或不删除)数组中的元素而不改变剩余元素的顺序得到的序列。
例如:数组 [10,9,2,5,3,7,101,18] 的最长递增子序列是 [2,3,7,101],长度为 4。

算法思想

动态规划(Dynamic Programming)
核心思想:定义 dp[i] 表示以 nums[i] 结尾的最长递增子序列的长度。

  • 对于每个元素 nums[i],遍历其之前的所有元素 nums[j]j < i
  • nums[i] > nums[j],则 dp[i] 可以继承 dp[j] 的最优解
  • 最终结果为所有 dp[i] 中的最大值

算法步骤

  1. 初始化
    创建数组 dp(代码中的 ans),长度与 nums 相同,初始值全为 0。

  2. 双层遍历

    • 外层遍历:逐个处理每个元素 nums[i]
    • 内层遍历:对于当前元素 nums[i],遍历其之前的所有元素 nums[j]j < i
      • nums[i] > nums[j],则更新 dp[i] = max(dp[i], dp[j])(继承前面更优的递增链)
  3. 状态转移
    每个 dp[i] 的值最终为继承后的最大值 +1(表示当前元素自身加入子序列)。

  4. 获取结果
    返回 dp 数组中的最大值。

具体代码

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        dp = [0] * len(nums)  # dp[i] 表示以 nums[i] 结尾的最长递增子序列长度
        for i in range(len(nums)):
            # 遍历 i 之前的所有元素,寻找可以接续的递增序列
            for j in range(i):
                if nums[i] > nums[j]:
                    dp[i] = max(dp[i], dp[j])
            dp[i] += 1  # 当前元素自身构成长度1,或接续前序序列
        return max(dp) if dp else 0

时间复杂度

  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
    外层循环遍历 n n n 个元素,内层循环最坏需遍历 i i i 次( i i i 从 0 到 n − 1 n-1 n1),总操作次数为 1 + 2 + . . . + n − 1 = n ( n − 1 ) 2 1+2+...+n-1 = \frac{n(n-1)}{2} 1+2+...+n1=2n(n1)

  • 空间复杂度: O ( n ) O(n) O(n)
    dp 数组占用与输入数组等长的空间。

动态规划过程示例

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

  • d p [ 0 ] = 1 dp[0] = 1 dp[0]=1(子序列 [10]
  • d p [ 1 ] = 1 dp[1] = 1 dp[1]=1(子序列 [9],无法接续10)
  • d p [ 2 ] = 1 dp[2] = 1 dp[2]=1(子序列 [2]
  • d p [ 3 ] = max ⁡ ( d p [ 0 ] , d p [ 1 ] , d p [ 2 ] ) + 1 = 2 dp[3] = \max(dp[0], dp[1], dp[2]) + 1 = 2 dp[3]=max(dp[0],dp[1],dp[2])+1=2(接续2 → [2, 5]
  • d p [ 4 ] = max ⁡ ( d p [ 2 ] ) + 1 = 2 dp[4] = \max(dp[2]) + 1 = 2 dp[4]=max(dp[2])+1=2(接续2 → [2, 3]
  • d p [ 5 ] = max ⁡ ( d p [ 2 ] , d p [ 3 ] , d p [ 4 ] ) + 1 = 3 dp[5] = \max(dp[2], dp[3], dp[4]) + 1 = 3 dp[5]=max(dp[2],dp[3],dp[4])+1=3(接续3 → [2, 3, 7]
  • d p [ 6 ] = max ⁡ ( d p [ 5 ] ) + 1 = 4 dp[6] = \max(dp[5]) + 1 = 4 dp[6]=max(dp[5])+1=4(接续7 → [2, 3, 7, 101]

最终 max ⁡ ( d p ) = 4 \max(dp) = 4 max(dp)=4

优化方案(贪心 + 二分查找)

优化背景

传统动态规划解法时间复杂度为 O(n²),当 n ≥ 1e4 时效率较低。通过结合贪心策略二分查找,可将时间复杂度优化至 O(n log n),适用于处理大规模数据。


算法思想

核心洞察

  • 贪心策略:要使得递增子序列尽可能长,需让序列增长得尽可能慢。因此,我们希望每次在递增子序列最后添加的元素尽可能小。
  • 维护数组:定义数组 tail,其中 tail[i] 表示长度为 i+1 的递增子序列的最小末尾元素。例如:tail[2] = 5 表示所有长度为 3 的递增子序列中,最小的末尾元素是 5。

操作步骤

  1. 初始化tail 数组为空。
  2. 遍历元素:对每个元素 num 进行如下操作:
    • num > tail[-1],直接加入 tail,表示递增子序列长度增加 1。
    • 否则,在 tail 中找到第一个大于等于 num 的位置 j,将 tail[j] 替换为 num(此操作保证 tail 的单调性不变,但可能使未来更长的子序列更容易形成)。
  3. 结果:最终 tail 的长度即为最长递增子序列的长度。

正确性证明

关键点

  • 替换不影响已有长度:替换 tail[j] 为更小的 num,不会改变当前长度的递增子序列的存在性,但为后续元素提供了更低的“门槛”。
  • 单调性保持tail 数组始终保持严格递增(可用反证法证明)。

时间复杂度分析

  • 遍历元素:外层循环 O(n)
  • 二分查找:每次查找 O(log n)
  • 总时间复杂度:O(n log n)

具体代码实现

class Solution:
    def lengthOfLIS(self, nums: List[int]) -> int:
        tail = []
        for num in nums:
            # 二分查找插入位置
            left, right = 0, len(tail)
            while left < right:
                mid = (left + right) // 2
                if tail[mid] < num:
                    left = mid + 1
                else:
                    right = mid
            # 若 num 可扩展序列长度
            if left == len(tail):  # 说明没有找到>=num的,直接添加
                tail.append(num)
            else:
                tail[left] = num  # 贪心替换,降低后续门槛
        return len(tail)

详解贪心+二分查找的替换过程

我们以 nums = [3, 5, 6, 2, 3, 7] 为例,逐步演示贪心策略中二分查找替换的操作。


初始化

  • tail 数组初始为空:tail = []
  • 目标:维护 tail 数组,使其始终保持严格递增,且 tail[i] 表示长度为 i+1 的递增子序列的最小末尾元素。

处理元素 3

  1. 当前状态tail = []
  2. 操作:直接添加 3
  3. 结果tail = [3]
    • 解释:长度为 1 的子序列末尾元素为 3。

处理元素 5

  1. 当前状态tail = [3]
  2. 比较5 > tail[-1] (3)
  3. 操作:直接添加 5
  4. 结果tail = [3, 5]
    • 解释:长度为 2 的子序列末尾元素为 5。

处理元素 6

  1. 当前状态tail = [3, 5]
  2. 比较6 > tail[-1] (5)
  3. 操作:直接添加 6
  4. 结果tail = [3, 5, 6]
    • 解释:长度为 3 的末尾元素为 6。

处理元素 2(关键步骤)

目标:找到 tail 中第一个大于等于 2 的位置

  1. 当前状态tail = [3, 5, 6]
  2. 二分查找过程
    • 初始范围:left = 0, right = 3
    • 第一次中间点:mid = (0+3)//2 = 1,检查 tail[1] = 5
      • 由于 5 > 2,更新 right = mid = 1
    • 第二次中间点:mid = (0+1)//2 = 0,检查 tail[0] = 3
      • 由于 3 > 2,更新 right = mid = 0
    • 循环结束,left = right = 0
  3. 操作:将 tail[0] 替换为 2
  4. 结果tail = [2, 5, 6]
    • 解释:虽然原序列 [3,5,6] 无法接续 2,但替换后长度为 1 的子序列末尾元素变为更小的 2,为后续元素(如 3)提供了更低的“门槛”。

处理元素 3(关键步骤)

目标:找到 tail 中第一个大于等于 3 的位置

  1. 当前状态tail = [2, 5, 6]
  2. 二分查找过程
    • 初始范围:left = 0, right = 3
    • 第一次中间点:mid = (0+3)//2 = 1,检查 tail[1] = 5
      • 由于 5 > 3,更新 right = mid = 1
    • 第二次中间点:mid = (0+1)//2 = 0,检查 tail[0] = 2
      • 由于 2 < 3,更新 left = mid + 1 = 1
    • 循环结束,left = right = 1
  3. 操作:将 tail[1] 替换为 3
  4. 结果tail = [2, 3, 6]
    • 解释:替换后长度为 2 的子序列末尾元素变为 3,比原 5 更小,使得后续元素(如 7)更容易接续。

处理元素 7

  1. 当前状态tail = [2, 3, 6]
  2. 比较7 > tail[-1] (6)
  3. 操作:直接添加 7
  4. 结果tail = [2, 3, 6, 7]
    • 最终长度为 4,对应子序列 [2, 3, 6, 7]

替换操作的意义

  1. 降低后续门槛
    替换 53 后,后续元素只需大于 3(而非原 5)即可形成更长的子序列。

  2. 保持单调性
    每次替换后,tail 数组始终保持严格递增(例如 [2,3,6] 严格递增),这使得二分查找始终有效。

  3. 不改变最长长度
    替换操作不会减少当前最长子序列的长度,但会优化未来扩展的可能性。


总结

通过二分查找定位替换位置,贪心策略在每一步都选择当前最优的末尾元素,最终通过维护一个严格递增的 tail 数组,高效求得最长递增子序列的长度。此方法将时间复杂度从 O(n²) 优化至 O(n log n),适用于大规模数据场景。

对比动态规划

方案时间复杂度适用场景
经典动态规划 O ( n 2 ) O(n^2) O(n2) n ≤ 1 e 3 n \leq 1e3 n1e3
贪心 + 二分查找 O ( n log ⁡ n ) O(n \log n) O(nlogn) n ≥ 1 e 4 n \geq 1e4 n1e4,大规模数据

总结

通过维护一个单调递增的 tail 数组,并结合二分查找快速定位插入位置,该方案在保证正确性的前提下大幅提升了效率。此优化体现了贪心选择性质与高效搜索的结合,是处理最长递增子序列问题的标准优化方案。

LeetCode 题目 491 - 递增子序列 (Incremental Subsequence) 是一道关于算法设计的中等难度题目。这道题要求你在给定整数数组 nums 中找出所有长度大于等于 1 的递增子序列递增子序列是指数组中的一串连续元素,它们按照顺序严格增大。 解决这个问题的一个常见策略是使用动态规划(Dynamic Programming),特别是哈希表或者单调栈(Monotonic Stack)。你可以维护一个栈,每当遍历到一个比栈顶元素大的数字时,就将它推入栈,并更新当前最长递增子序列的长度。同时,如果遇到一个不大于栈顶元素的数字,就从栈顶开始检查是否存在更长的递增子序列。 以下是 C++ 解决此问题的一种简单实现: ```cpp class Solution { public: vector<int> lengthOfLIS(vector<int>& nums) { int n = nums.size(); if (n == 0) return {}; // 使用单调栈存储当前已知的最大子序列 stack<pair<int, int>> stk; stk.push({nums[0], 1}); for (int i = 1; i < n; ++i) { while (!stk.empty() && nums[i] > stk.top().first) { // 如果新数大于栈顶元素,找到一个更长的递增子序列 int len = stk.top().second + 1; ans.push_back(len); stk.pop(); } // 如果新数不大于栈顶元素,尝试从当前位置开始寻找更长子序列 if (!stk.empty()) { stk.top().second = max(stk.top().second, 1); } else { stk.push({nums[i], 1}); } } return ans; } private: vector<int> ans; }; ``` 在这个解决方案中,`ans` 存储所有的递增子序列长度,最后返回这个结果向量即可。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值