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]
应该插入的位置。
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
的长度。
这个方法区别于官方题解稍微难懂一些。官方题解在理解和实现上更加直观和简单
整个算法的贪心策略是尽可能地延长上升子序列。通过在 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
数组中的正确位置,从而可以通过尽可能小的更新来延长上升子序列的长度。