LeetCode 128. 最长连续序列:从排序双指针扫描到 HashSet,一文讲透为什么 O(n) 解法要用哈希
摘要
LeetCode 128. 最长连续序列(Longest Consecutive Sequence) 是一道非常经典的数组题。题目本身不难理解,但它真正的考点在于:
- 如何识别"连续序列"不要求在原数组中相邻
- 如何处理重复元素
- 如何把常规排序解法优化到题目要求的
O(n)
很多人第一次做这道题时,都会先想到:
- 先排序
- 再线性扫描连续段
- 统计最长长度
这个思路完全正确,而且很好理解,但时间复杂度是 O(n log n),不满足题目明确要求的 O(n)。
这篇文章会系统梳理两种做法:
- 排序 + 线性扫描:容易理解,适合建立题感
- HashSet 去重 + 只从序列起点出发扩展 :满足题目要求的
O(n)标准解法
文章会重点讲清楚:
- 为什么排序解法能做对,但不达标
- 为什么哈希解法可以做到
O(n) - 为什么哈希解法必须"只从序列起点开始扩展"
- 为什么这样不会漏掉任何答案,也不会重复计算
目录
文章目录
- [LeetCode 128. 最长连续序列:从排序双指针扫描到 HashSet,一文讲透为什么 O(n) 解法要用哈希](#LeetCode 128. 最长连续序列:从排序双指针扫描到 HashSet,一文讲透为什么 O(n) 解法要用哈希)
-
- 摘要
- 目录
- 一、题目描述
- 二、这道题真正的难点是什么
-
- [1. 连续的是"数值",不是"下标"](#1. 连续的是“数值”,不是“下标”)
- [2. 数组里可能有重复元素](#2. 数组里可能有重复元素)
- [3. 题目要求时间复杂度为 `O(n)`](#3. 题目要求时间复杂度为
O(n))
- [三、先从最自然的思路开始:排序 + 线性扫描](#三、先从最自然的思路开始:排序 + 线性扫描)
- 四、排序解法的核心逻辑
- [五、排序 + 线性扫描代码(高质量注释版)](#五、排序 + 线性扫描代码(高质量注释版))
- 六、这段排序代码到底在做什么
- 七、为什么这段代码能正确处理重复元素
- 八、排序解法的优点与缺点
- 九、严格来说,这个做法算不算"双指针"
- [十、为什么这道题可以用哈希做到 O(n)](#十、为什么这道题可以用哈希做到 O(n))
- 十一、哈希解法的核心思路
-
- [第一步:把所有数字放进 `HashSet`](#第一步:把所有数字放进
HashSet) - 第二步:只从"连续序列的起点"开始扩展
- 第三步:从起点不断向后扩展
- [第一步:把所有数字放进 `HashSet`](#第一步:把所有数字放进
- 十二、为什么一定要"只从起点出发"
- [十三、HashSet 标准解法代码(高质量注释版)](#十三、HashSet 标准解法代码(高质量注释版))
- 十四、哈希代码逐步拆解
-
- [1. 先放入集合](#1. 先放入集合)
- [2. 判断是否为起点](#2. 判断是否为起点)
- [3. 从起点向后扩展](#3. 从起点向后扩展)
- [4. 更新答案](#4. 更新答案)
- 十五、用示例完整走一遍哈希过程
- [十六、为什么哈希解法的时间复杂度是 O(n)](#十六、为什么哈希解法的时间复杂度是 O(n))
- 十七、两种解法对比
-
- [1. 排序 + 线性扫描](#1. 排序 + 线性扫描)
- [2. HashSet 解法](#2. HashSet 解法)
- 十八、推荐的面试写法
- 十九、推荐提交版代码
- 二十、面试高频追问总结
-
- [1. 为什么排序解法不满足题意](#1. 为什么排序解法不满足题意)
- [2. 为什么 `HashSet` 能解决这题](#2. 为什么
HashSet能解决这题) - [3. 为什么只从起点开始扩展](#3. 为什么只从起点开始扩展)
- [4. 为什么重复元素不会影响结果](#4. 为什么重复元素不会影响结果)
- [5. 为什么不会漏掉最长序列](#5. 为什么不会漏掉最长序列)
- 二十一、整道题的学习路线总结
-
- 第一步:先接受排序做法
- [第二步:意识到排序不满足 O(n)](#第二步:意识到排序不满足 O(n))
- 第三步:想到用哈希做存在性判断
- 第四步:理解"只从起点开始扩展"
- 二十二、结语
一、题目描述
给定一个未排序的整数数组 nums,找出数字连续的最长序列的长度。
注意:
这里的"连续"只要求数值连续,不要求这些元素在原数组中的下标连续。
示例 1:
java
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4],长度为 4
示例 2:
java
输入:nums = [0,3,7,2,5,8,4,6,0,1]
输出:9
示例 3:
java
输入:nums = [1,0,1,2]
输出:3
二、这道题真正的难点是什么
这道题表面上看是在求"最长连续段",但真正难点主要有三个:
1. 连续的是"数值",不是"下标"
例如:
java
[100,4,200,1,3,2]
虽然 1,2,3,4 在原数组里并不挨着,但它们仍然构成一个长度为 4 的连续序列。
2. 数组里可能有重复元素
例如:
java
[1,0,1,2]
这里 1 出现了两次,计算连续序列时不能被重复计数。
3. 题目要求时间复杂度为 O(n)
这意味着:
- 排序解法虽然能做对
- 但因为排序是
O(n log n),严格来说不符合题意要求
所以真正的重点在于:如何不用排序,直接在线性时间内找到最长连续序列。
三、先从最自然的思路开始:排序 + 线性扫描
很多人第一次想到的做法是:
- 先对数组排序
- 排序后,连续的数字会靠在一起
- 线性扫描数组,统计每一段连续序列的长度
- 遇到重复数字时跳过
- 最后取最大值
这个思路非常自然,而且代码也不难写。
四、排序解法的核心逻辑
假设数组排序后变成:
java
[1,2,3,4,100,200]
那么就可以线性扫描:
1 -> 2,连续,长度加 12 -> 3,连续,长度加 13 -> 4,连续,长度加 14 -> 100,断开,结束这一段
如果有重复元素,比如:
java
[0,1,1,2]
那么:
0 -> 1,连续1 -> 1,重复,不增加长度,但继续往后看1 -> 2,连续
所以重复元素要跳过但不打断当前连续段。
五、排序 + 线性扫描代码(高质量注释版)
下面是整理后的排序解法,并加上详细注释。
java
import java.util.*;
class Solution {
public int longestConsecutive(int[] nums) {
// 先排序
Arrays.sort(nums);
int n = nums.length;
// 记录最长连续序列长度
int res = 0;
// i 用来表示当前连续段的起点
for (int i = 0; i < n; ) {
// 当前连续段至少包含 nums[i] 自己
int count = 1;
// j 用来向后扫描,寻找这一段连续区间的终点
int j = i + 1;
while (j < n) {
// 如果当前数字和前一个数字正好相差 1
// 说明连续段可以继续扩展
if (nums[j] - nums[j - 1] == 1) {
count++;
j++;
}
// 如果当前数字和前一个数字相同
// 说明是重复元素,不增加长度,但也不打断连续段
else if (nums[j] == nums[j - 1]) {
j++;
}
// 否则说明连续段已经断开
else {
break;
}
}
// 更新最长长度
res = Math.max(res, count);
// 直接跳到下一段的起点
i = j;
}
return res;
}
}
六、这段排序代码到底在做什么
这段代码的本质可以概括为一句话:
先把无序问题变成有序问题,再按段扫描连续区间。
其中:
i表示当前这一段连续序列的起点j负责一直向后找,直到这一段不能再延伸count记录当前连续段长度res维护全局最大值
七、为什么这段代码能正确处理重复元素
这是排序解法中最关键的细节之一。
例如数组:
java
[1,0,1,2]
排序后:
java
[0,1,1,2]
如果扫描到:
0 -> 1,连续,count = 21 -> 1,重复,这时不能把count加 1,因为重复元素不应该重复计入序列长度- 但也不能直接
break,因为后面的2仍然可能和当前连续段连上
所以这里正确的做法是:
java
else if (nums[j] == nums[j - 1]) {
j++;
}
也就是:
重复元素跳过,但不打断当前连续段。
这点非常重要。
八、排序解法的优点与缺点
优点
- 思路直观
- 代码容易写
- 容易理解和验证正确性
- 适合第一次建立题感
缺点
- 排序时间复杂度是
O(n log n) - 不满足题目明确要求的
O(n)
这意味着:
这是一种"能做对,但不是题目最优要求"的解法。
九、严格来说,这个做法算不算"双指针"
很多人会把这类写法称为"双指针",因为这里确实用了两个下标 i 和 j。
但更准确地说,它其实更接近:
排序 + 分段扫描
因为:
- 它不是经典的左右夹逼双指针
- 也不是在原数组上做双端收缩
- 它的本质是在排序后,用两个下标扫描一段连续区间
所以在写博客或面试表述时,更推荐叫它:
排序 + 线性扫描
或者
排序后用两个下标维护连续段
这样更准确。
十、为什么这道题可以用哈希做到 O(n)
接下来进入这道题真正的标准解法。
题目要求 O(n),这意味着不能排序。
那么就必须想办法:
- 在不排序的情况下
- 快速判断某个数在不在数组里
- 快速扩展连续序列
这正是 HashSet 最擅长的事情。
因为 HashSet 支持平均 O(1) 的查找。
十一、哈希解法的核心思路
思路分三步:
第一步:把所有数字放进 HashSet
这样就能在平均 O(1) 时间内判断:
java
x 是否存在
x + 1 是否存在
x - 1 是否存在
第二步:只从"连续序列的起点"开始扩展
什么叫起点?
如果某个数 x 的前一个数 x - 1 不存在,那么说明它是一个连续序列的起点。
例如:
java
[100,4,200,1,3,2]
放入集合后:
1没有前驱0,所以1是起点2有前驱1,不是起点3有前驱2,不是起点4有前驱3,不是起点100没有前驱99,是起点200没有前驱199,是起点
第三步:从起点不断向后扩展
如果当前是起点 x,就不断看:
java
x + 1 是否存在
x + 2 是否存在
x + 3 是否存在
...
直到不存在为止,这样就能得到这一段连续序列的长度。
十二、为什么一定要"只从起点出发"
这是哈希解法最关键的优化点。
假设数组中有连续序列:
java
[1,2,3,4,5]
如果不区分起点,而是对每个数都向后扩展:
- 从
1扩展一遍:长度 5 - 从
2扩展一遍:长度 4 - 从
3扩展一遍:长度 3 - 从
4扩展一遍:长度 2 - 从
5扩展一遍:长度 1
这样就会重复计算很多次,复杂度退化。
而如果只从起点 1 出发:
- 一次就能得到整个序列长度 5
其余 2,3,4,5 因为都有前驱,所以全部跳过。
这就是哈希解法能做到 O(n) 的核心原因。
十三、HashSet 标准解法代码(高质量注释版)
下面给出这道题最经典、最推荐掌握的写法。
java
import java.util.*;
class Solution {
public int longestConsecutive(int[] nums) {
// 用 HashSet 去重,并支持 O(1) 平均时间查找
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
int res = 0;
// 遍历集合中的每个数
for (int num : set) {
// 只有当 num - 1 不存在时,num 才是一个连续序列的起点
// 只有起点才需要向后扩展
if (!set.contains(num - 1)) {
int cur = num;
int count = 1;
// 不断检查后继是否存在
while (set.contains(cur + 1)) {
cur++;
count++;
}
// 更新最长长度
res = Math.max(res, count);
}
}
return res;
}
}
十四、哈希代码逐步拆解
1. 先放入集合
java
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
作用有两个:
- 去重
- 支持快速查找
例如数组:
java
[1,0,1,2]
放入集合后就是:
java
{0,1,2}
重复的 1 自动被消掉了。
2. 判断是否为起点
java
if (!set.contains(num - 1))
这句的意思是:
如果
num - 1不存在,那么num才是某个连续序列的起点。
例如:
1前面没有0,那么1是起点2前面有1,那么2不是起点
3. 从起点向后扩展
java
while (set.contains(cur + 1)) {
cur++;
count++;
}
只要后继存在,就说明连续序列还能继续延长。
例如从 1 开始:
- 有
2,长度变 2 - 有
3,长度变 3 - 有
4,长度变 4 - 没有
5,停止
4. 更新答案
java
res = Math.max(res, count);
每找到一段完整连续序列,就更新一次全局最大值。
十五、用示例完整走一遍哈希过程
以:
java
nums = [100,4,200,1,3,2]
为例。
第一步:放入集合
java
set = {100,4,200,1,3,2}
第二步:遍历集合
遍历到 100
99不存在- 所以
100是起点 - 看
101是否存在:不存在 - 当前长度 = 1
遍历到 4
3存在- 所以
4不是起点,跳过
遍历到 200
199不存在- 所以
200是起点 - 看
201是否存在:不存在 - 当前长度 = 1
遍历到 1
0不存在- 所以
1是起点 - 看
2:存在 - 看
3:存在 - 看
4:存在 - 看
5:不存在 - 当前长度 = 4
最终答案为 4。
十六、为什么哈希解法的时间复杂度是 O(n)
很多人看到这里会问:
外层一个循环,内层还有一个
while,为什么不是O(n^2)?
关键点在于:
每个数只会在某一条连续序列扩展中被真正访问一次。
例如序列:
java
[1,2,3,4,5]
只有从 1 这个起点开始,才会一路访问到 2,3,4,5。
而 2,3,4,5 自己不会再作为起点去重复扩展。
所以整体来看,每个元素最多被:
- 外层遍历一次
- 内层扩展参与一次
总复杂度仍是平均 O(n)。
十七、两种解法对比
1. 排序 + 线性扫描
思路:
- 排序
- 扫描连续段
- 跳过重复值
时间复杂度:
java
O(n log n)
空间复杂度:
java
取决于排序实现,通常可视为 O(log n) 或 O(1) 额外空间
特点:
- 易理解
- 易写
- 但不满足题目要求的
O(n)
2. HashSet 解法
思路:
- 放入集合
- 只从起点开始扩展
- 统计每条连续序列长度
时间复杂度:
java
平均 O(n)
空间复杂度:
java
O(n)
特点:
- 满足题目要求
- 去重天然完成
- 是标准最优解
十八、推荐的面试写法
如果是面试中回答这道题,建议这样组织表达:
先说排序解法
可以先说:
最直观的方法是先排序,再线性扫描连续段,遇到重复元素跳过,复杂度是
O(n log n)。
这说明你对题目有基本掌握。
再指出不满足题意
接着说:
但题目要求
O(n),所以排序不能作为最终最优解。
这说明你看懂了题目要求。
最后给出哈希优化
然后说:
可以把所有数放进
HashSet,只从没有前驱的数开始向后扩展,这样每个连续序列只会被统计一次,总复杂度平均O(n)。
这就是完整且高质量的面试回答路径。
十九、推荐提交版代码
下面给出一版更适合正式提交和面试书写的标准写法。
java
import java.util.*;
class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
int res = 0;
for (int num : set) {
// 只有没有前驱的数,才是连续序列的起点
if (!set.contains(num - 1)) {
int cur = num;
int count = 1;
while (set.contains(cur + 1)) {
cur++;
count++;
}
res = Math.max(res, count);
}
}
return res;
}
}
二十、面试高频追问总结
1. 为什么排序解法不满足题意
因为排序本身需要 O(n log n),而题目要求实现 O(n)。
2. 为什么 HashSet 能解决这题
因为它能在平均 O(1) 时间判断某个数是否存在,非常适合做"连续性查询"。
3. 为什么只从起点开始扩展
为了避免对同一条连续序列重复统计,否则复杂度会退化。
4. 为什么重复元素不会影响结果
因为放进 HashSet 后,重复元素会自动去重。
5. 为什么不会漏掉最长序列
因为每条连续序列一定有唯一的起点,也一定会在遍历集合时被访问到。
二十一、整道题的学习路线总结
真正掌握这道题,建议按这个顺序理解:
第一步:先接受排序做法
先通过排序把"数值连续"变成"相邻元素容易判断",建立题感。
第二步:意识到排序不满足 O(n)
这一步很关键,说明开始从"能做对"走向"满足题意最优"。
第三步:想到用哈希做存在性判断
因为连续性本质上就是不断查询某个数在不在。
第四步:理解"只从起点开始扩展"
这是整个 O(n) 解法的灵魂。
二十二、结语
LeetCode 128. 最长连续序列 是一道非常经典的题目,它真正锻炼的不是某个固定模板,而是一种更重要的算法思维:
当排序能让问题变简单时,先用排序建立题感;
当题目进一步要求更优复杂度时,再思考如何用哈希替代排序中的"有序性作用"。
在这道题中:
- 排序提供了最自然的入门解法
- 哈希则提供了满足题意的最优解法
- "只从起点出发"是把复杂度压到
O(n)的关键技巧
真正理解这三层递进关系,这道题就不再只是背答案,而会成为一道非常有代表性的"从朴素解走向最优解"的经典案例。