给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]
是数组 [0,3,1,6,2,2,7]
的子序列。
示例 1:
ini
输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列是 [2,3,7,101],因此长度为 4 。
示例 2:
ini
输入: nums = [0,1,0,3,2,3]
输出: 4
示例 3:
ini
输入: nums = [7,7,7,7,7,7,7]
输出: 1
提示:
1 <= nums.length <= 2500
-104 <= nums[i] <= 104
进阶:
- 你能将算法的时间复杂度降低到
O(n log(n))
吗? 好,我们来做一个完整对比:
🔹方法一:动态规划 O(n²)
思路
-
定义
dp[i]
:表示 以nums[i] 结尾
的最长递增子序列长度 -
转移方程:
cssdp[i] = max(dp[j] + 1) (0 <= j < i, 且 nums[j] < nums[i])
-
最终答案是
max(dp)
代码
ini
class Solution {
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 每个数本身至少构成长度为1的子序列
int ans = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}
}
- 时间复杂度:O(n²)
- 空间复杂度:O(n)
🔹方法二:贪心 + 二分 O(n log n)
思路
维护一个数组 tails
:tails[k]
表示长度为 k+1
的递增子序列中 最小的结尾值 。遍历每个数 x
,用二分在 tails
中找到第一个 >= x
的位置 pos
(即 lower_bound),把 tails[pos] = x
;若 pos
恰好等于当前有效长度 size
,则把 size++
。最终 size
就是 LIS 的长度。
为什么这个想法可行(直觉)
-
我们希望维护"每个长度下最容易被后续扩展的结尾值"。如果长度为
k
的递增子序列的最小结尾较小,后面更容易接上更大的数,去构成更长的序列。 -
所以对每个长度
k
只保留一个结尾值:能把结尾做得尽可能小(贪心)。 -
当遇到新数
x
:- 如果
x
比所有tails
都大,就能把最长长度 +1(append)。 - 否则把某个
tails[pos]
替换为x
(更小的结尾),不会降低任何已有长度子序列向更长序列扩展的能力,反而可能增强(因为更小的结尾更易接大数)。
- 如果
关键细节:二分找的位置
- 我们寻找的是第一个
>= x
的位置(lower_bound),不是> x
。 - 这样保证序列是严格递增 :相等的元素不会被视为可以接在后面(如果需要非严格非降序列,可改为找第一个
> x
)。
代码
ini
import java.util.Arrays;
class Solution {
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
int[] tails = new int[n]; // tails[k] = 最小的结尾值,长度为 k+1 的递增子序列
int size = 0; // tails 的有效长度
for (int x : nums) {
// 在 tails[0..size-1] 中二分查找第一个 >= x 的位置
int left = 0, right = size; // 注意 right = size(开区间)
while (left < right) {
int mid = (left + right) >>> 1;
if (tails[mid] < x) {
left = mid + 1;
} else {
right = mid;
}
}
// left 是第一个 >= x 的位置(或等于 size,表示 append)
tails[left] = x;
if (left == size) size++;
}
return size;
}
}
- 时间复杂度:O(n log n)(二分查找)
- 空间复杂度:O(n)
🔹对比总结
方法 | 思路 | 时间复杂度 | 空间复杂度 | 适用情况 |
---|---|---|---|---|
DP (O(n²)) | 遍历所有前驱,逐一更新 | O(n²) | O(n) | n ≤ 2500 时可用 |
贪心 + 二分 (O(n log n)) | 用数组维护最小结尾,二分替换 | O(n log n) | O(n) | n 很大(如 10^5)时必用 |
📌 小例子推演(nums = [10,9,2,5,3,7,101,18])
sub = [10]
- 遇到 9 → 替换 →
[9]
- 遇到 2 → 替换 →
[2]
- 遇到 5 → 追加 →
[2,5]
- 遇到 3 → 替换 →
[2,3]
- 遇到 7 → 追加 →
[2,3,7]
- 遇到 101 → 追加 →
[2,3,7,101]
- 遇到 18 → 替换 →
[2,3,7,18]
最终长度 = 4 ✅
为什么while循环是left<right?
结论先给出
你在代码中用的是
ini
int left = 0, right = size; // right = size(开区间)
while (left < right) {
int mid = (left + right) >>> 1;
if (tails[mid] < x) left = mid + 1;
else right = mid;
}
这是 开区间 / 半开区间 的二分模板,right
表示 不包含 的上界,循环结束时 left == right
即为插入位置。
如果把条件直接改成 while (left <= right)
,在配合 right = size
的情况下就会出错(可能越界或死循环) 。
原因与细节解释
1) 你现在用的模板是"半开区间" [left, right)
-
初始:
left = 0, right = size
(right
不包含) -
不变量(invariant):目标插入位置始终在区间
[left, right)
内。 -
mid = (left+right)/2
属于[left, right-1]
(不会等于right
),因此访问tails[mid]
安全(不会越界)。 -
更新:
tails[mid] < x
→ 插入位置不在<= mid
的范围,所以left = mid+1
(缩到[mid+1, right)
)- 否则 → 插入位置可能在
mid
或更左边,right = mid
(缩到[left, mid)
)
-
循环用
while (left < right)
,每次区间长度都严格缩小,最终left == right
,即插入点。
这是非常常用且简洁的 lower_bound 模板。
2) 如果使用 while (left <= right)
,通常配合"闭区间" [left, right]
那就需要把 right
设为 size-1
(最后可访问的下标),并用不同的更新方式。例如下面是一个等价且正确的闭区间写法:
ini
int left = 0, right = size - 1;
int pos = size; // 如果没找到则插入到末尾
while (left <= right) {
int mid = (left + right) >>> 1;
if (tails[mid] >= x) {
pos = mid; // 记录候选位置(第一个 >= x)
right = mid - 1; // 缩小到左半区间(注意是 mid-1)
} else {
left = mid + 1;
}
}
// pos 即插入位置(可能为 size,表示 append)
注意关键点:在闭区间写法中,当发现 mid 可作为答案时,要把 right = mid - 1 ,而不是 right = mid
,否则区间不会收缩(会造成死循环)。
为什么不能把 while (left <= right)
和 right = size
混用?
举两个问题说明:
-
越界风险
如果
right = size
(相当于半开区间的右端),而你用while (left <= right)
,当mid
被算成size
时会访问tails[size]
,越界(因为有效下标是0..size-1
)。在半开区间模板中,mid
永远不会等于right
,但在left <= right
的循环里mid
有可能取到right
,所以不安全。 -
死循环风险 (混用闭区间更新规则时)
如果你在闭区间
left<=right
的循环里又写right = mid
(而不是mid-1
),会出现left
、mid
、right
不变的情况导致无限循环。例:- 初始
left=0, right=1
,mid=0
。如果执行right = mid
,right
仍是 0;下一次mid
仍是 0,循环不收缩 → 死循环。
因此在闭区间要用right = mid - 1
保证区间收缩。
- 初始
举个完整例子说明 while(left < right)
的运行(你代码里的场景)
假设 tails = [2,3,7,101]
,size = 4
,现在 x = 18
,我们想找第一个 >= 18
的位置。
半开区间写法(你的写法):
scss
left=0, right=4
mid=(0+4)/2=2 -> tails[2]=7 < 18 -> left = 3
left=3, right=4
mid=(3+4)/2=3 -> tails[3]=101 >=18 -> right = 3
now left == right == 3 -> loop ends, pos=3
结果正确,替换 tails[3] = 18
。
如果盲目改成 while(left <= right)
且仍保持 right=4
:
- 第一次 mid = 2 ok
- 之后可能出现 mid=4 的情况,会导致
tails[4]
越界。
所以总结一下(面试/写代码时的建议)
-
两套二分模板都可以用,但不要混用边界/更新规则:
- 半开区间:[left, right) 用
while (left < right)
,right = size
,right = mid
(不减一)。 - 闭区间: [left, right] 用
while (left <= right)
,right = size-1
,right = mid - 1
。
- 半开区间:[left, right) 用
-
半开区间模板更简洁且不容易犯 off-by-one,很多人用它实现 lower_bound。
-
使用
(left + right) >>> 1
或left + (right - left) / 2
避免溢出。
代码对比(两种合法写法)
半开区间(推荐与你当前代码匹配):
ini
int left = 0, right = size; // right = size (开区间)
while (left < right) {
int mid = (left + right) >>> 1;
if (tails[mid] < x) left = mid + 1;
else right = mid;
}
int pos = left; // 插入位置
闭区间(也可用):
ini
int left = 0, right = size - 1;
int pos = size;
while (left <= right) {
int mid = (left + right) >>> 1;
if (tails[mid] >= x) {
pos = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
// pos 是插入位置