动态规划4:最长递增子序列问题
引言
在动态规划系列的前三篇文章中,我们分别学习了爬楼梯问题、最长公共子序列和01背包问题。今天我们将探讨另一个动态规划的经典问题——最长递增子序列(Longest Increasing Subsequence,LIS)。
最长递增子序列问题不仅是动态规划的核心问题之一,更是算法优化思想的绝佳体现。从朴素的O(n²)动态规划解法,到巧妙的O(nlogn)优化算法,这个问题展现了算法设计的精妙之处。
问题定义
什么是递增子序列?
递增子序列:从原数组中按照原有顺序选取若干元素,使得这些元素严格递增。
注意几个关键点:
- 保持原有顺序:不能改变元素在原数组中的相对位置
- 严格递增:后面的元素必须大于前面的元素
- 不要求连续:子序列的元素在原数组中可以不相邻
最长递增子序列问题
给定一个整数数组,找到其中最长递增子序列的长度。
1 | 示例 1: |
解法一:动态规划(O(n²))
1. 状态定义
定义状态 dp[i]
表示:以 nums[i]
结尾的最长递增子序列的长度。
这个状态定义很关键!注意是”以 nums[i]
结尾”,这意味着:
nums[i]
必须被包含在子序列中- 子序列的最后一个元素就是
nums[i]
2. 状态转移方程
对于每个位置 i
,我们需要考虑所有在它之前且值小于 nums[i]
的位置 j
:
1 | dp[i] = max(dp[j] + 1) for all j where 0 ≤ j < i and nums[j] < nums[i] |
如果没有这样的 j
,则 dp[i] = 1
(只包含 nums[i]
自己)。
3. 边界条件
dp[0] = 1
:第一个元素单独构成长度为1的递增子序列- 实际上,
dp[i]
的初始值都可以设为1(每个元素都能单独构成长度为1的子序列)
4. 代码实现
1 | public int lengthOfLIS(int[] nums) { |
5. 算法分析
- 时间复杂度:O(n²) - 双重循环
- 空间复杂度:O(n) - dp数组
- 优点:思路清晰,易于理解和实现
- 缺点:对于大数据量效率不高
解法二:动态规划 + 二分查找(O(nlogn))
1. 优化思路
O(n²)解法的瓶颈在于:对于每个位置,我们需要遍历前面所有位置来找到可以接在后面的最优位置。
关键观察:如果我们维护一个数组 tails
,其中 tails[i]
表示长度为 i+1
的递增子序列的最小尾部元素,那么:
tails
数组本身也是递增的- 我们可以用二分查找来快速找到插入位置
2. 算法思想
维护数组 tails
:
tails[i]
= 长度为i+1
的所有递增子序列中,尾部元素的最小值- 遍历原数组,对于每个元素
num
:- 如果
num
大于tails
中所有元素,直接追加到末尾 - 否则,用二分查找找到第一个大于等于
num
的位置,并替换它
- 如果
3. 为什么这样做是正确的?
核心思想:我们希望递增子序列的尾部元素尽可能小,这样后续更容易接上新的元素。
例如,对于数组 [1, 3, 2, 4]
:
- 处理到元素3时:
tails = [1, 3]
- 处理到元素2时:发现2可以替换3,得到
tails = [1, 2]
- 为什么要替换?因为尾部为2的长度为2的子序列,比尾部为3的更有潜力
4. 代码实现(O(nlogn)解法)
1 | public int lengthOfLIS(int[] nums) { |
5. 优化版本(使用Arrays.binarySearch)
1 | public int lengthOfLIS(int[] nums) { |
6. 算法分析
- 时间复杂度:O(nlogn) - n次遍历,每次二分查找O(logn)
- 空间复杂度:O(n) - tails数组
- 优点:效率高,适合大数据量
- 注意:tails数组不是实际的最长递增子序列,只是用于计算长度
进阶:构造实际的最长递增子序列
上面的解法只能求出长度,如果要构造出实际的最长递增子序列,需要额外记录路径信息。
基于DP的构造方法
1 | public List<Integer> findLIS(int[] nums) { |
相关变形问题
掌握了最长递增子序列后,可以尝试以下相关问题:
-
- 要求子序列连续
-
- 求最长递增子序列的数量
-
- 二维的LIS问题
最长递减子序列
- 只需将比较条件改为
nums[j] > nums[i]
- 只需将比较条件改为
算法选择建议
何时使用O(n²)解法?
- 数据量较小(n < 1000)
- 需要记录完整的状态转移路径
- 对空间复杂度有严格要求
何时使用O(nlogn)解法?
- 数据量较大(n > 1000)
- 只需要求长度,不需要构造实际序列
- 对时间复杂度有严格要求
总结
最长递增子序列问题是动态规划的经典问题,它展现了算法优化的精妙思想:
- 状态定义的艺术:选择合适的状态定义是解决DP问题的关键
- 优化的思路:从暴力搜索到动态规划,再到贪心+二分查找
- 算法工程化:不同场景下选择不同的算法实现
通过这个问题,我们不仅学会了解决具体问题的方法,更重要的是理解了算法优化的思维过程。
系列文章回顾
推荐练习
- 在LeetCode上练习相关的序列问题
- 尝试实现构造实际序列的版本
- 思考其他可以用类似优化思想的DP问题
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 DevGobang!