QA模块关键
原题链接:300. 最长递增子序列 - 力扣(LeetCode)
解题思路
为了构造尽可能长的上升子序列,我们采取的策略是让子序列的增长尽可能慢,即在相同长度的子序列中,选择末尾数最小的一个。这种方法的核心在于维护一个数组 tails
,其中 tails[i]
表示所有长度为 i+1
的上升子序列中末尾元素的最小值。这样,tails
数组保持单调递增,使得我们可以用二分查找来优化搜索过程。
关键性质
-
性质一:在所有长度相同的递增子序列中,末尾元素越小,为后续元素提供加入递增子序列的可能性越大,从而可能形成更长的递增子序列。
-
性质二:由于子序列是递增的,对于任意一个元素
a[i]
,如果它能接在某个递增子序列的后面形成更长的递增子序列,那么这个递增子序列的末尾元素必定小于a[i]
。
基于上述性质,我们维护一个数组 tails
,其中 tails[i]
表示所有长度为 i+1
的递增子序列中末尾元素的最小值。tails
数组具有单调递增的特性,这使得我们可以使用二分查找来优化查找过程。
解题步骤
-
初始化
tails
数组,并将tails[0]
设置为nums[0]
。设置变量len
为 1,表示当前最长上升子序列的长度。 -
从
nums[1]
开始遍历输入数组nums
:-
使用二分查找在
tails
数组中找到第一个大于等于nums[i]
的元素的位置。 -
如果找到这样的位置,用
nums[i]
更新相应的tails
元素;如果nums[i]
大于tails
数组中所有元素,将其添加到tails
末尾,并更新len
。
-
-
遍历结束后,
len
即为最长上升子序列的长度。
题解①
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
if (n == 0) return 0; // 空数组特判
int[] tails = new int[n];
tails[0] = nums[0]; // 初始化tails数组
int len = 1; // 初始化最长上升子序列长度为1
for (int i = 1; i < n; i++) {
int left = 0, right = len - 1; // 使用二分查找的左右边界
// 二分查找,找到第一个大于等于nums[i]的元素的位置
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[i] > tails[mid]) left = mid + 1;
else right = mid - 1;
}
tails[left] = nums[i]; // 更新tails数组
if (left == len) len++; // 如果nums[i]添加到了tails的末尾,更新len
}
return len; // 返回最长上升子序列的长度
}
}
注释说明
-
tails[left] = nums[i]
:这一步是算法的关键,它保证了tails
数组的定义不变,即tails[i]
仍然表示所有长度为i+1
的递增子序列中末尾元素的最小值。 -
二分查找:
left <= right
确保了查找范围包括所有可能的位置,left = mid + 1
和right = mid - 1
用于缩小查找范围,最终left
指向了nums[i]
应该插入的位置。
题解② 直接写在main中
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
int N = 100010; // 定义最大数组长度
int[] nums = new int[N]; // 输入数组
int[] tails = new int[N]; // tails数组,用于存储当前找到的最长递增子序列的最小末尾元素
int n = scanner.nextInt(); // 读取数组长度
for (int i = 0; i < n; i++) {
nums[i] = scanner.nextInt(); // 读取数组元素
}
int len = 0; // 初始化最长递增子序列长度为0
tails[0] = Integer.MIN_VALUE; // 初始化tails数组的第一个元素为最小值,以便任何nums[i]都大于它
for (int i = 0; i < n; i++) {
int left = 0, right = len;
// 二分查找在tails数组中找到插入nums[i]的位置
while (left <= right) {
int mid = (left + right) >>> 1; // 使用无符号右移来防止溢出
if (tails[mid] < nums[i]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
len = Math.max(len, right + 1);
// 更新tails数组,并可能增加len
tails[right + 1] = nums[i];
}
System.out.println(len); // 输出最长递增子序列的长度
}
}
代码解释:
-
这个版本使用了一个
tails
数组,其含义与之前题解中的一致:tails[i]
存储的是所有长度为i+1
的递增子序列中末尾元素的最小值。 -
初始化
tails[0]
为Integer.MIN_VALUE
是为了确保任何正整数都大于它,从而使得算法可以正确地处理第一个元素。 -
在遍历
nums
数组的过程中,对于每个元素nums[i]
,使用二分查找确定它在tails
数组中的位置,然后根据情况更新tails
数组和最长递增子序列的长度len
。 -
最终,变量
len
存储了最长递增子序列的长度,最后打印出来作为结果。
代码解释:
官方题解:
class Solution {
public int lengthOfLIS(int[] nums) {
int len = 1, n = nums.length;
if (n == 0) {
return 0;
}
int[] d = new int[n + 1];
d[len] = nums[0];
for (int i = 1; i < n; ++i) {
if (nums[i] > d[len]) {
d[++len] = nums[i];
} else {
int l = 1, r = len, pos = 0;
// 如果找不到说明所有的数都比 nums[i] 大,此时要更新 d[1],所以这里将 pos 设为 0
while (l <= r) {
int mid = (l + r) >> 1;
if (d[mid] < nums[i]) {
pos = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
d[pos + 1] = nums[i];
}
}
return len;
}
}
算法复杂度
通过维护 tails
数组和利用二分查找,该算法有效地解决了最长上升子序列问题,时间复杂度为 O(n log n)
,其中 n
是输入数组 nums
的长度。
QA
这个方法区别于官方题解稍微难懂一些。官方题解在理解和实现上更加直观和简单
理解贪心策略
整个算法的贪心策略是尽可能地延长上升子序列。通过在 tails
数组中尽可能地使用较小的值,我们为后续的元素留出了更多的上升空间,从而使得整个上升子序列尽可能地长。
理解 tails
数组的作用
首先,理解 tails
数组的作用是关键。tails[i]
存储的是所有长度为 i+1
的上升子序列中末尾元素的最小值。这个定义是非常重要的,因为它保证了 tails
数组是单调递增的,这使得我们可以使用二分查找来优化搜索过程。
更新 tails
数组
在二分查找后,我们用 nums[i]
更新 tails[left]
,这里 left
是二分查找结束后的索引。这一步的直观理解是:我们要么在 tails
数组的末尾添加一个新元素(从而增加上升子序列的长度),要么用一个较小的值替换 tails
数组中的某个元素(以便为后续可能出现的更大元素腾出空间)。
二分查找的目的
二分查找在这个解法中的目的是找到 nums[i]
应该插入 tails
数组的位置。如果 nums[i]
大于 tails
数组中的所有元素,这意味着我们找到了一个更长的上升子序列,因此我们需要扩展 tails
数组。如果 nums[i]
小于或等于 tails
数组中的一些元素,这意味着我们需要在 tails
数组中找到第一个大于 nums[i]
的元素,并用 nums[i]
替换它,以保持 tails
数组的定义不变。
理解二分查找中 left
和 right
范围的更新逻辑是理解整个解法的关键。二分查找的目标是找到一个位置,这个位置指示了新元素 nums[i]
应该插入 tails
数组的哪里。这里有几个关键点需要注意:
left
和 right
的初始值
left
初始化为0
,因为tails
数组中可能需要更新的位置可以从数组的最开始即索引0
处开始。right
初始化为len - 1
,因为在当前最长上升子序列中,nums[i]
可能会替换的位置最远只能到达当前序列的末尾,即len - 1
。这里len
是到目前为止发现的最长上升子序列的长度。
更新 left
和 right
在二分查找的每一步中,我们计算中点 mid = left + (right - left) / 2
,然后根据 nums[i]
与 tails[mid]
的比较结果更新 left
或 right
:
- 如果
nums[i] > tails[mid]
,这意味着nums[i]
可以放在mid
之后而不破坏上升序列的性质。因此,我们应该在mid
右侧继续查找,所以更新left = mid + 1
。 - 如果
nums[i] <= tails[mid]
,这意味着nums[i]
应该替换mid
位置或mid
位置之前的某个元素(为了使末尾元素尽可能小),因此我们在mid
左侧继续查找,所以更新right = mid - 1
。
为什么 left = mid + 1
当我们发现 nums[i]
大于 tails[mid]
时,mid
位置不是 nums[i]
应该插入的位置,因为 nums[i]
需要放在一个更大的索引处以保持上升序列的性质。这就是为什么我们设置 left = mid + 1
,即我们排除了 mid
及其左侧的所有位置,将搜索范围缩小到 mid
的右侧。
为什么 right = mid - 1
当 nums[i]
小于或等于 tails[mid]
时,mid
位置或其左侧可能是 nums[i]
的合适位置。因此,我们需要在 mid
的左侧继续搜索。通过设置 right = mid - 1
,我们排除了 mid
及其右侧的所有位置,将搜索范围缩小到 mid
的左侧。
循环结束条件
循环继续执行直到 left > right
,此时 left
指示了 nums[i]
应该插入的位置。循环结束时,left
指向的是 tails
数组中第一个大于或等于 nums[i]
的元素的位置(如果所有元素都小于 nums[i]
,则 left
指向 len
,即 nums[i]
应该添加到 tails
的末尾)。
通过这种方式,二分查找帮助我们高效地找到了 nums[i]
在 tails
数组中的正确位置,从而可以通过尽可能小的更新来延长上升子序列的长度。