1. 问题定义
给定一个数字序列(数组),找到其一个最长的子序列,要求这个子序列中的所有元素都是单调递增的。我们通常要求的是这个最长递增子序列的长度。
例如,给定序列 [10, 9, 2, 5, 3, 7, 101, 18]
- 它的一些递增子序列有:
[2, 5, 7, 101],[2, 3, 7, 18],[9, 101],[10, 101]等。 - 其中最长的递增子序列是
[2, 5, 7, 101]或[2, 3, 7, 101]或[2, 3, 7, 18]等,它们的长度都是 4。 - 所以,对于这个输入,问题的解是 4。
2. 核心概念辨析:子序列 vs. 子数组
这是一个非常关键的区别,也是初学者容易混淆的地方。
- 子数组 (Subarray) / 子串 (Substring):必须是原序列中连续的一部分。
- 对于
[10, 9, 2, 5],[9, 2]是一个子数组,但[10, 2]不是。
- 对于
- 子序列 (Subsequence):可以不连续,但必须保持元素在原序列中的相对顺序。
- 对于
[10, 9, 2, 5],[10, 2, 5]是一个子序列。我们从原序列中“挑选”了第1、3、4个元素,它们的相对顺序没有改变。
- 对于
LIS 问题研究的是子序列。
3. 解法一:动态规划 (O(n²))
这是解决 LIS 问题最经典、最容易理解的方法。
核心思想
定义一个 dp 数组,dp[i] 表示以 nums[i] 这个元素结尾的最长递增子序列的长度。
为了计算 dp[i],我们需要回头看 i 之前的所有元素 nums[j] (其中 j < i)。
- 如果
nums[i] > nums[j],这意味着nums[i]可以接在以nums[j]结尾的递增子序列的后面,形成一个更长的递增子序列。 - 这个新的长度就是
dp[j] + 1。 - 由于可能有多个
j满足nums[i] > nums[j],我们要选择能使新序列最长的那一个。
状态转移方程
dp[i] = max(dp[j] + 1),其中 0 <= j < i 且 nums[i] > nums[j]。
如果找不到任何 j 满足条件,说明 nums[i] 比前面的所有数都小,它只能自己构成一个长度为 1 的递增子序列。所以 dp[i] 的初始值(或基础情况)为 1。
最终的答案是整个 dp 数组中的最大值,因为最长递增子序列可能在任何位置结束。
步骤详解
以 [10, 9, 2, 5, 3, 7, 101, 18] 为例:
i = 0,nums[0] = 10dp[0] = 1(自身构成长度为1的序列)
i = 1,nums[1] = 9j=0:nums[1](9) 不大于nums[0](10)。dp[1] = 1
i = 2,nums[2] = 2j=0,1:nums[2](2) 都不大于nums[0](10) 和nums[1](9)。dp[2] = 1
i = 3,nums[3] = 5j=0:nums[3](5) <nums[0](10)。j=1:nums[3](5) <nums[1](9)。j=2:nums[3](5) >nums[2](2)。可以接在后面,长度为dp[2] + 1 = 1 + 1 = 2。dp[3] = 2
i = 4,nums[4] = 3j=2:nums[4](3) >nums[2](2)。长度为dp[2] + 1 = 2。dp[4] = 2
i = 5,nums[5] = 7j=2:nums[5](7) >nums[2](2) ->dp[2]+1=2j=3:nums[5](7) >nums[3](5) ->dp[3]+1=3j=4:nums[5](7) >nums[4](3) ->dp[4]+1=3- 取最大值,
dp[5] = 3
- … 以此类推 …
最后得到的 dp 数组会是 [1, 1, 1, 2, 2, 3, 4, 4]。
这个数组的最大值是 4,即为 LIS 的长度。
复杂度分析
- 时间复杂度: O(n²)。外层循环
i从 0 到 n-1,内层循环j从 0 到 i-1,是两层嵌套循环。 - 空间复杂度: O(n)。需要一个
dp数组来存储中间状态。
4. 解法二:贪心 + 二分查找 (O(n log n))
这个解法更巧妙,也更高效。
核心思想
我们维护一个数组 tails (或叫 min_end),其中 tails[i] 存储的是所有长度为 i+1 的递增子序列中,结尾元素的最小值。
这个定义非常关键。举个例子,假设我们有两个长度为 3 的递增子序列 [2, 4, 8] 和 [1, 5, 6]。tails[2] (长度为3) 应该存储 6 而不是 8。为什么?因为结尾元素越小,后面能接上新元素的可能性就越大,这个序列就“更有潜力”。
基于这个思想,我们遍历原数组 nums:
-
如果
num大于tails数组中所有元素(即num > tails.back()),说明num可以接在目前最长的递增子序列后面,形成一个更长的序列。我们就把num添加到tails的末尾,LIS 的长度加一。 -
如果
num不大于tails数组中所有元素,说明它不能让 LIS 变得更长。但是,它有可能“优化”现有的tails数组。我们在tails数组中找到第一个大于或等于num的元素,然后用num替换它。- 这步操作的意义是:我们找到了一个可以构成同样长度的递增子序列的、但结尾更小的元素。这使得未来的扩展可能性更大。
由于 tails 数组在整个过程中始终是有序的,所以“查找第一个大于或等于 num 的元素”这个操作可以用二分查找来完成,从而将时间复杂度降低。
最终,tails 数组的长度就是 LIS 的长度。注意:tails 数组本身并不是一个 LIS 序列!
步骤详解
以 [10, 9, 2, 5, 3, 7, 101, 18] 为例:
num = 10:tails为空,直接加入。tails = [10]num = 9:9不大于10。在tails中找到第一个大于等于9的数是10,替换它。tails = [9]num = 2:2不大于9。替换9。tails = [2]num = 5:5大于tails的最后一个元素2。直接加入。tails = [2, 5]num = 3:3不大于5。在tails中找到第一个大于等于3的数是5,替换它。tails = [2, 3]num = 7:7大于tails的最后一个元素3。直接加入。tails = [2, 3, 7]num = 101:101大于7。直接加入。tails = [2, 3, 7, 101]num = 18:18不大于101。在tails中找到第一个大于等于18的数是101,替换它。tails = [2, 3, 7, 18]
遍历结束,tails 数组的长度为 4。这就是 LIS 的长度。
复杂度分析
- 时间复杂度: O(n log n)。遍历
nums数组需要 O(n),每次遍历都在tails数组上执行一次二分查找,tails的长度最多为n,所以二分查找的复杂度是 O(log n)。 - 空间复杂度: O(n)。需要
tails数组存储状态。
5. 两种解法的对比总结
| 特性 | 动态规划 (O(n²)) | 贪心 + 二分查找 (O(n log n)) |
|---|---|---|
| 核心思想 | dp[i] = 以 nums[i] 结尾的LIS长度 | tails[k] = 长度为 k+1 的LIS的最小结尾元素 |
| 时间复杂度 | O(n²) | O(n log n) |
| 空间复杂度 | O(n) | O(n) |
| 优点 | 思路直观,容易理解和实现,易于扩展(如输出序列) | 效率高,是解决此问题的最优解法 |
| 缺点 | 时间复杂度较高,无法通过大数据量的测试用例 | 思路相对不直观,tails数组不直接代表LIS本身 |
6. 如何输出最长递增子序列本身?
我们上面的讨论主要集中在求长度。如果要求输出序列本身,可以对 O(n²) 的 DP 解法进行扩展。
方法:
在计算 dp 数组的同时,再维护一个 parent 数组。parent[i] 记录的是构成以 nums[i] 结尾的 LIS 时,nums[i] 的前一个元素的索引。
- 在计算
dp[i]时,当我们找到一个j使得dp[i] = dp[j] + 1,我们就记录parent[i] = j。 - 计算完所有
dp值后,找到dp数组中最大值的索引,设为end_index。这个nums[end_index]就是 LIS 的最后一个元素。 - 通过
parent数组向前回溯:从end_index开始,不断地访问parent[end_index],再parent[parent[end_index]]… 直到找到一个没有父节点的元素。将这个路径上的所有元素收集起来,再反转,就是 LIS 之一。
对于 O(n log n) 的解法,要输出序列会更复杂一些,通常需要额外记录每个数字插入 tails 数组时的前驱信息,不如 DP 方法来得直接。因此,如果需要输出序列,使用 O(n²) 的 DP 方法并加以改造是更常见的选择。