LeetCode 128题,Longest Consecutive Sequence,中文站一般叫“最长连续序列”。我第一次刷这题已经是很多年前的事了,当时还在用最笨的办法——拿到题先排序,排完序再扫一遍,然后暗暗得意自己用了不到五分钟就AC了。后来在准备面试时重新做这题,才发现自己根本没吃到题目的精髓:题目要求的是O(n)时间复杂度,排序这条路从一开始就是错的。这道题在LeetCode上属于“看似人畜无害、实则暗藏杀机”的代表,不像动态规划那样一眼望不到头,也不像字符串匹配那样公式化,它考的是你对数据结构的理解深度——具体来说,就是能不能想到用哈希表把“存在性查询”的代价降到常数级,并且想明白为什么一个嵌套循环总复杂度依然只有O(n)。
这篇文章不打算只丢一个标准答案,我想把题目拆开揉碎,从暴力解法的复杂度账本,到哈希集合解法的每一步推导,再到各种极端测试用例和面试追问,一条线讲下来。不管你是刚开始刷题的新手,还是准备面试想查漏补缺的老手,这篇应该都能给你点东西。
1. 为什么这道题看似简单,却让不少人翻车
1.1 第一反应排序,但题目在考验你有没有更深的意识
绝大多数人看到“最长连续序列”的第一反应,就是先把数组排个序,然后从头到尾扫一遍,遇到相邻元素差值为1就继续累计,否则重新计数。这个思路确实非常自然,毕竟排序之后“连续”变成了一个局部性质,代码写起来也就十几行。
但题目描述里有一个关键约束:时间复杂度需要是O(n)。排序本身在最优情况下也需要O(n log n),这直接超出了题目允许的复杂度。很多人会说“那我能通过啊,LeetCode又不卡”,这话在Dev时间内确实偶尔能混过去,但在面试场景下,面试官追问一句“你能做到O(n)吗”,如果你只能回答排序,这道题基本就拿不到最高评价了。
这道题隐藏的考察点是:你能否意识到“连续序列”本质上是一个集合性质,而不是顺序性质。我们不需要知道元素在数组里的先后位置,只需要知道“存在哪些数字”。一旦把视角从“数组的线性结构”切换到“集合的查询结构”,自然就会想到哈希表。
1.2 “连续”二字的含义:去重与区间起点
想更深入理解这道题,就要先明确“连续序列”的定义。给定数组 [100, 4, 200, 1, 3, 2],最长的连续序列是 1, 2, 3, 4,长度是4。注意这里如果数组改成 [1, 2, 2, 3, 4],答案依然是4,因为重复的2只在序列中占一个位置。换句话说,这道题要求的是“数字集合中最长的连续整数值区间长度”,而数组中的重复元素不应该对答案产生任何贡献。
这一点直接影响解法设计。如果你用的是排序法,就需要在扫描时跳过重复元素;如果你用哈希集合,去重是天然完成的。从语义上讲,“连续序列”和“去重后的集合中找出最长连续区间”是等价的,理解到这一层,后面选择哈希集合就顺理成章了。
还有个更隐蔽的点:如何高效判断一个连续区间的起点?如果当前数字是 num,而且 num - 1 不存在于集合中,那么 num 必然是一个连续序列的第一个元素。反过来,如果 num - 1 存在,那 num 顶多只能算序列中间的某个元素,从这个元素往两边扩展其实是白白浪费计算。这个“起点检查”是整个算法的灵魂,也是我觉得这道题最值得玩味的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种解法的代价账本:暴力、排序、哈希差在哪
2.1 暴力检查:最直观也最昂贵的O(n²)
先看看暴力解法。对于数组中的每一个元素 num,检查 num+1、num+2……是否在数组中,直到找不到下一个为止。如果你用数组的 contains 或 indexOf 来做判断,每次查询都是O(n),那么总复杂度最坏是O(n³)。哪怕你把存在性查询换成哈希集合的O(1)判断,外层的“每个元素作为起点”加内层“序列长度”的枚举,最坏情况下仍是O(n²)。
什么场景下会触发O(n²)?比如数组是 [1, 2, 3, ..., n],而且你枚举所有元素作为起点,那第一个元素会数到n,第二个元素会数到n-1,所有元素加起来是等差数列求和O(n²)。所以暴力解法的问题不仅在查询开销,更在于同一个连续区间被重复计算了无数次。要优化,就必须消除这种重复计算。
2.2 排序解法:能过,但没吃到题目的精髓
排序解法确实简单,先把数组排序,然后一次线性扫描记录最长连续长度。遇到 nums[i] == nums[i+1] 时跳过重复;遇到 nums[i+1] == nums[i] + 1 时长度加1;否则重置长度。这个解法在LeetCode上完全能AC,代码也好看。
但它的硬伤有两个。第一是复杂度不达标,Array.sort() 在Java里是O(n log n)的双轴快排,Python的 sorted() 也是Timsort,复杂度同样是O(n log n)。第二,排序改变了元素之间的天然关系,你其实是在“用顺序结构强行表达集合语义”,这相当于开着卡车送外卖,能送到,但不够优雅。
我在实际面试中也会拿排序法作为“第一版方案”,然后主动指出它的瓶颈,再引出哈希集合解法。这种“先给一个能跑的版本,再逐步优化”的思路,其实比直接甩出最优解更能体现工程思维。
2.3 哈希解法:用空间换时间,把复杂度压到O(n)
哈希解法的思路是:把所有数组元素放入一个HashSet,然后遍历集合中的每个元素,只从连续序列的“起点”开始向后扩展。核心代码逻辑非常短,但背后的设计思想是整个算法的价值所在。
先说空间代价:额外开辟一个哈希集合,平均空间复杂度O(n)。这是典型的“用空间换时间”。在算法面试中,O(n)的额外空间通常是可接受的,除非题目明确要求O(1)空间。LeetCode 128没有这个限制,所以哈希集合是标准答案。
时间上,外层循环遍历集合的每个元素,内层while循环只会在“起点元素”处触发,并且每个元素最多被内层while访问一次。整体来看,所有内层循环加起来访问的元素总数不会超过n,因此总时间复杂度是O(n)。这个结论反直觉的地方在于“嵌套循环为什么不是O(n²)”,后面我会单独用一节讲清楚。
3. 哈希集合解法的核心逻辑与代码实现
3.1 为什么先全部放进HashSet
先把数组里的所有元素塞进一个HashSet,这一步看似平淡,作用却不小。一是去重,重复数字只保留一个“存在性标记”,和题目语义天然对齐;二是让后续的 contains 查询变成平均O(1)的操作。
我见过有人写 for (int num : nums) set.add(num); 之后,又在循环里把 nums 和 set 混着用,想要什么效果完全没想明白。直接把集合做完整一遍插入,是为了在后面的遍历中,每个元素都只被判断一次“是否存在前驱”,而且遍历集合本身也是O(n)。如果你遍历原始数组而不是集合,重复元素会导致同一个数字被当成候选起点多次检查,既浪费又容易出错。从可读性和正确性两个角度看,遍历 set 都是更稳的选择。
3.2 起点检查:num - 1 才是整个算法的枢纽
这是整个算法的核心判断,也是面试官最爱追问的地方。对于遍历到的某个数字 num,先检查 set.contains(num - 1)。如果不存在,说明 num 前面没有数字接续,它一定是一个连续序列的起点,这个时候才进入内层while循环,让 current 从 num 开始一直向后累加,统计序列长度。如果存在 num - 1,说明当前数字不是起点,直接跳过,不做任何统计。
为什么要这样做?因为任何一个连续序列,比如 [100, 4, 200, 1, 3, 2] 中的 1, 2, 3, 4,只有从 1 开始枚举时,才能一路扩展出完整长度4;如果从 2 开始枚举,最多数到4,长度只有3,还会浪费计算;如果从每个中间节点都做一次完整枚举,整体就是O(n²)。起点检查的本质是“把计算集中到每个序列的唯一起点上”,这样每个序列只被完整统计一次。
3.3 最容易被问到的点:为什么内层while不会让复杂度变回O(n²)
这是我看过几乎所有讨论帖里争议最大的地方。很多人一看到外层for套内层while,就下意识认定复杂度是O(n²)。要破除这个误解,关键在于理解内层while的执行条件。
内层while只会在一个数字没有前驱(即它是某个连续序列的起点)时进入。假设你的集合里有 1, 2, 3, 4, 10, 11, 12 七个数字,遍历到1时,while会一路数到4,访问4个元素;遍历到2时,因为 set.contains(1) 为true,直接跳过;3、4同理都被跳过;遍历到10时,while会数到12,访问3个元素;11、12被跳过。算下来,内层while虽然套在for里面,但每个元素作为“被查询是否存在下一个”的次数,在整个流程中最多只有一次。
这个结论的本质是:连续序列不会重叠。每个连续区间都会被“起点检查”严格划分,计算只从每个区间的左端点发起,向右扫描时访问到的每个元素,之后再也不会被其他序列的扫描所访问。因此所有内层while迭代的总次数等于“所有连续区间的总长度”,而这个总长度不会超过数组元素个数n。所以总复杂度是O(n),而不是表面上看起来的O(n²)。这个分析思路在面试中一定要主动说出来,比直接背答案要有说服力得多。
3.4 Python/Java/C++三版实现与执行细节
Python版本,注意遍历的是 set 而不是原始 nums:
python复制def longestConsecutive(nums):
num_set = set(nums)
longest = 0
for num in num_set:
if num - 1 not in num_set:
current = num
current_len = 1
while current + 1 in num_set:
current += 1
current_len += 1
longest = max(longest, current_len)
return longest
Java版本,使用 HashSet<Integer>,注意遍历集合时用 for (int num : set):
java复制class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> set = new HashSet<>();
for (int num : nums) {
set.add(num);
}
int longest = 0;
for (int num : set) {
if (!set.contains(num - 1)) {
int current = num;
int currentLen = 1;
while (set.contains(current + 1)) {
current++;
currentLen++;
}
longest = Math.max(longest, currentLen);
}
}
return longest;
}
}
C++版本,使用 unordered_set<int>:
cpp复制class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> set(nums.begin(), nums.end());
int longest = 0;
for (int num : set) {
if (!set.count(num - 1)) {
int current = num;
int currentLen = 1;
while (set.count(current + 1)) {
current++;
currentLen++;
}
longest = max(longest, currentLen);
}
}
return longest;
}
};
三个版本逻辑完全一致。细节上有几个值得注意的地方:第一,Java里如果 nums 长度为0,for (int num : set) 不会执行任何循环体,直接返回0,不需要单独处理。第二,C++的 unordered_set 对int类型有内置hash,性能很好,但如果数据量极大,可以考虑 std::unordered_set<int> 的 reserve 预分配,减少rehash开销。第三,Python的 set 在遍历时动态修改会报错,但这题没有修改集合的操作,所以放心遍历即可。
4. 边界条件与隐蔽测试用例:从空数组到超大连续区间
4.1 空数组、单元素、全重复这些“送分”边界
先看空数组 []。数组里没有任何元素,最长的连续序列长度自然是0。上面三版代码里,for 循环不会执行,longest 初始值0直接返回,稳妥。不用单独写if。
单元素数组 [5]。集合里只有一个数字5,遍历它时 set.contains(4) 为false,进入内层while,set.contains(6) 为false,currentLen 为1,返回1。逻辑上完全自洽。
全重复数组 [1, 1, 1, 1]。去重后集合里只有一个1,返回1。这个用例如果不用集合去重,排序解法里就必须处理重复元素,否则会数出长度为4的错误答案。哈希集合天然规避了这个问题,这也是我推荐集合解法的原因之一。
4.2 负数和零:整数数组不是只有正整数
LeetCode的测试用例里会出现负数。比如 [-1, -2, 0, 1],最长连续序列是 -2, -1, 0, 1,长度4。哈希集合解法对负数没有任何特殊处理,因为 num - 1 和 num + 1 的判断对负数同样成立。排序解法在这个用例上也还行,但如果你用了“桶”或“数组下标偏移”这类投机方案,负数就会成为噩梦。
零作为中断值也值得提一下。[0, 3, 7, 2, 5, 8, 4, 6, 0, 1] 这组用例里,最大值可能让你以为是3、4、5、6、7、8那段,实际上从0到8是完整连续序列,长度9。这类用例专门考验你是否漏掉了0和负数的存在。我的建议是,写完之后至少自己测这几组:空数组、单元素、全重复、包含负数、包含0、最大最小值差距很大的数组。
4.3 跑一份压力测试:10万个连续数字耗时多少
我用自己的笔记本做了个简单测试,生成 [0, 1, 2, ..., 99999] 这10万个连续数字,再随机混入5000个无关数字,然后运行哈希集合解法。整个流程跑下来耗时在几十毫秒量级,longest 正确输出100000。这个结果并不意外,因为内层while总共只会做10万次左右的自增和查询,每个数字只被访问一次,O(n)的底子摆在那里。
同样的输入如果跑暴力解法,即使我把存在性查询优化成HashSet,外层从每个起点开始数一遍,总操作次数也会达到等差数列量级,在这个用例上会明显慢很多。虽然LeetCode的测试数据不至于这么极端,但理解这个性能差异,对你回答“为什么必须O(n)”这个问题很有帮助。
5. 面试追问与进阶变形:连续问题的通用思路
5.1 如果要求输出完整序列,怎么改
LeetCode 128只要求返回最大长度,但面试官有可能追问“你能不能把那个最长的连续序列本身返回出来”。改法很简单:在更新 longest 的同时,记住序列起点 num 和终点 current,最后在集合里把从起点到终点的所有数字收集进一个列表返回。因为HashSet的 contains 是O(1),收集过程也是O(序列长度),不会改变整体复杂度。
我在跟朋友讨论时觉得,这种“从长度到序列”的追问,其实是想考察你对数据结构的掌控程度。如果你连 num - 1 的起点判断都没想明白,到了这一步肯定会乱。
5.2 并查集解法:另一种“归组”的姿势
除了哈希集合,这道题还可以用并查集(Union-Find)来做。思路是:把数组中每个数字看成节点,对于每个 num,如果 num + 1 存在,就把 num 和 num + 1 合并到同一组;同时维护每个组的规模,合并时累加。最后遍历所有数字,找规模最大的组。
并查集的复杂度可以做到接近O(n α(n)),其中α(n)是反阿克曼函数,可以认为近似常数。这个解法比哈希集合更“重”,写起来也复杂,但它展示了另一种归类思路:用集合操作把相邻数字连接成连通分量。在面试里主动提一句“这个题还能用并查集做,但哈希集合更简洁”,会给面试官留下不错的印象。
5.3 变体玩法:最长连续等差数列、最长连续相同元素
LeetCode 128的思路可以迁移到不少变体问题。比如“最长连续等差数列”,它要求找出数组中差值为某个固定 k 的最长序列,这时候哈希表的value就不能只是布尔存在性,而要记录“以当前数字结尾的序列长度”,用 map[num] = map[num - k] + 1 来递推。再比如“最长连续相同元素”,可以用滑动窗口或双指针解决,和128题的集合思路不同,但同样是“连续”这个语义下的延伸。
从网络热词里也能看到,大家在刷题时会同时关注“LeetCode热门100题”“洛谷P6175”“东方博宜1071题”这类竞赛题解,说明LeetCode 128不是孤立的题目,它和很多竞赛题的底层思想是相通的。把这道题的哈希集合思路吃透,对后续做区间类、集合类问题有很大帮助。
6. 个人体会:这道题真正教会我的事
这题我前前后后刷了不下五遍。第一遍觉得简单,排序扫描一遍过;第二遍开始认真研究哈希解法,当时看别人代码没看懂,为什么 if (set.contains(num - 1)) continue; 能省这么多事;第三遍自己默写,还是会漏掉起点判断,导致结果偏高;直到第四遍,我才真正从复杂度分析的角度理解到“每个连续序列只会从起点枚举一次”这个本质。
后来我给别人讲题时发现,大多数人第一次看到这个解法都会犯同一个毛病——写完了,AC了,但问一句“内层while为什么不是O(n²)”,就答不上来。如果你也是这个状态,我强烈建议你亲手构造一个 [1, 2, 3, ...] 的极端输入,把内层while每次到底访问了哪些元素画出来,亲眼看一下“每个元素只被访问一次”这个事实。这个过程比看十篇题解都有用。
回到LeetCode 128本身:它用一个O(n)的时间复杂度要求,逼着你跳出“排序线性扫描”的惯性思维,转向“集合存在性判断”的哈希思路。当你下次遇到类似“数组中找连续区间”“判断元素是否存在”的题目时,我希望你能回忆起这道题里那个关键判断——num - 1 存在与否,它决定了你是从一个序列的开头数到底,还是在中间重复造轮子。
如果你准备面试,建议把这道题的复杂度分析、边界用例、还有“为什么不能排序”这三个点都提前组织好语言。面试的时候不慌,一步步把思路摊开讲,比直接背代码要有效得多。
