1. 题目拆解:这道题到底在考什么
第一次看到"128 最长连续序列"这个题目的时候,很多人会以为它是个简单的数组遍历题,其实不然。题目要求在一个未排序的整数数组里,找出数字连续的最长序列长度,而且时间复杂度要控制在O(n)。什么叫数字连续?比如数组 [100, 4, 200, 1, 3, 2],最长连续序列是 [1, 2, 3, 4],长度是 4。注意,这里的序列不要求元素在数组里是连续存放的,只要求数值上是连续的。
这个O(n)的时间复杂度约束,是这道题和普通数组题最大的分水岭。如果你上来就想排序,那思路就错了,因为排序的时间复杂度是O(n log n),达不到要求。所以这道题考察的核心,是你对哈希表这一数据结构的理解和灵活运用能力。哈希表能在O(1)时间内完成插入和查找,这才让O(n)的解法成为可能。
从面试角度来讲,这道题出现频率极高,因为它能一次性考察几个点:你对时间复杂度的敏感度、你能否想到用哈希表去重、你是否能设计出每个元素只被访问一次的遍历策略。很多人第一次做这道题会卡在"如何跳过重复判断"这个环节,后面我会详细说。
这道题适合谁来看?如果你正在准备算法面试,或者刷LeetCode想提升哈希表相关的解题手感,这篇文章值得读完。我会从最直观的解法讲起,逐步过渡到最优解,再把并查集的进阶思路也聊一聊,最后整理一些我实际刷题过程中踩过的坑。
2. 从暴力到优化:为什么排序不是答案
2.1 最常见的错误思路:先排序再说
不少人拿到这道题的第一反应是:排序一下,然后遍历一遍,看相邻两个数差是不是1,统计最长的连续段长度。这个思路本身没问题,逻辑完全正确,但致命伤是复杂度不符合要求。排序的时间复杂度是O(n log n),题目明确要求O(n),所以这条路从一开始就堵死了。
不过我得说句公道话,排序法并不是一无是处。如果你在笔试或面试中实在想不出O(n)的解法,先写出排序法至少能保证正确,拿到部分分数,然后再说明自己知道如何优化,这比完全卡死要好得多。我在实际刷题中也会先把暴力解法写出来验证思路,再逐步优化。
排序法的实现思路很简单:先对数组排序,然后从左到右遍历,用两个变量记录当前连续长度和最大连续长度。如果当前元素等于前一个元素加1,则当前长度加1;如果相等,则跳过(处理重复元素);否则重置当前长度为1。这个解法能通过大多数测试用例,只是时间复杂度不达标。
2.2 为什么O(n)是硬性要求
有些朋友可能会问:O(n)和O(n log n)差别有那么大吗?在数据量小的时候确实差距不明显,但当数据规模达到百万级甚至千万级时,O(n log n)比O(n)多出来的时间可能是几倍甚至十几倍。算法面试中,面试官特别看重这种复杂度意识,因为实际工程场景里,数据规模往往远超你的想象。
那有没有办法在不排序的情况下找到连续序列?答案是用哈希表。哈希表的神奇之处在于,它能让"查找某个数是否存在"这个操作变成O(1)的平均时间复杂度。有了这个基础,我们就能设计出一种每个元素只被访问常数次的遍历方案,整体复杂度自然就降到了O(n)。
2.3 暴力解法的演进过程
如果你想彻底理解这道题,建议先自己推导一遍暴力解法。最暴力的版本是:枚举数组中的每个数作为序列起点,然后依次检查x+1、x+2、x+3是否存在,直到找不到为止。每次检查都要在数组中线性查找,时间复杂度是O(n^3)。显然是灾难性的。
第二步优化是把数组放进哈希集合里,这样每次检查从O(n)降到O(1),整个算法变成O(n^2)。虽然还是不够好,但思路方向已经对了。
第三步优化,也是最关键的一步:在遍历时,如果发现x-1已经在集合里,就直接跳过x。为什么?因为如果x-1存在,那么从x-1开始的连续序列一定长于从x开始的序列,x不可能是序列起点。这一步优化让每个元素只被检查"作为起点"一次,整体降到O(n)。这个"只从起点开始数"的思维,就是这道题的核心解法。
3. 哈希集合最优解:实现与原理拆解
3.1 完整的算法流程
直接上代码,我用Python写一版最清晰易懂的实现,然后再逐步解释每一行的意义。
def longestConsecutive(nums): # 第一步:放入哈希集合去重 num_set = set(nums) max_length = 0 # 第二步:遍历集合中的每个数字 for num in num_set: # 第三步:关键判断,如果 num-1 存在,说明 num 不是序列起点 if num - 1 not in num_set: current_num = num current_length = 1 # 第四步:从起点开始往后扩展 while current_num + 1 in num_set: current_num += 1 current_length += 1 # 第五步:更新最大值 max_length = max(max_length, current_length) return max_length别小看这段代码,里面藏了两个细节:一是为什么遍历的是集合而不是原数组,二是为什么num-1的判断能保证时间复杂度是O(n)。下面我逐一解释。
3.2 为什么要用集合而不是列表
用set直接去掉重复元素,有两个好处。第一个好处很直接:重复数字不会影响连续序列的长度计算。比如数组[1, 2, 2, 3],最长连续序列长度是3,如果你拿原数组去判断,遍历到第二个2时可能会重复计算,再加上其他边界处理,很容易出bug。用集合去重后,问题简化成纯粹的数字集合操作。
第二个好处是性能层面的:Python的set底层是哈希表,in操作的均摊时间复杂度是O(1)。而列表的in操作是O(n)的线性扫描。如果拿列表去逐个判断,哪怕逻辑和最优解完全一致,时间复杂度也会退化成O(n^2)。这是很多初学者容易忽视的细节,我在代码审查中见过几次这样的写法,都能正确运行但性能惨不忍睹。
3.3 每个元素只被访问一次的奥秘
这一步是整个算法的灵魂。当我们在遍历集合时,只有num-1不在集合里的情况下,才把num当作序列起点进行后续扩展。这意味着什么?只有当num是一个连续序列中最小的那个数时,我们才会从它开始往后数。
举个例子,数组是[3, 2, 1, 4, 100, 200]。遍历集合时,1这个数字的num-1即0不在集合里,所以从1开始数,得到连续序列[1,2,3,4],长度4。遍历2时,因为1在集合里,直接跳过2,不启动任何内层循环。同样地,3和4也都因为各自的前驱在集合里而被跳过。100和200中,只有100会启动内层循环,因为99不在集合里。
这样一算,整个算法里,内层while循环真正执行的总次数,恰好是所有连续序列的长度之和,而每个元素只会被某个序列的起点扩展时访问一次,加上外层遍历一次,总体还是O(n)的访问次数。这就是这个算法为什么能满足O(n)复杂度的核心原因。很多人在面试时能写出这段代码,但被问到"为什么是O(n)"时支支吾吾,就是因为没有理解到这一层。
3.4 从代码到思维的转化
这道题做完后,我最大的收获是形成了一个思维定式:凡是在无序数组中找连续、找最长、找某类子结构的问题,都可以优先考虑哈希集合去重搭配"只从起点遍历"的策略。这个套路在后续刷题中反复出现,比如判断数独是否有效要查重、找最长子串要查重、判断一个数组是否是另一个数组的子集等场景里,哈希表的这种空间换时间思路都是万金油。
我还喜欢在本地多跑几个用例来验证自己的理解是否到位,比如空数组、全重复数组、负数和正数混合的数组、超长连续序列等。跑完这些边界用例,基本就能确认代码的鲁棒性。
4. 并查集思路:同一道题的另一种解法
4.1 为什么会有并查集解法
哈希集合解法已经达到O(n)了,为什么还要讨论并查集?两个原因:一是面试中如果主动展示第二种解法,能给面试官留下知识面广、思考全面的印象;二是并查集在处理"元素合并、查找连通关系"这类问题时是标配工具,而这道题恰好可以抽象成"相邻数字合并进同一个集合,求最大集合大小"的问题。
我第一次看到并查集解法时,第一反应是"没必要",但仔细琢磨后发现,这种解法强调的是另一种抽象能力——把数组问题转化成连通性问题。这种抽象能力在后续各种图论、动态连通性题目中都非常重要。
4.2 并查集解法的实现
思路大概是:每个数字作为并查集中的一个节点,如果num+1存在,就把num和num+1合并;如果num-1存在,就把num和num-1合并。合并时维护每个集合的大小,最终返回最大集合大小。
class DSU: def __init__(self, nums): self.parent = {num: num for num in nums} self.size = {num: 1 for num in nums} def find(self, x): if self.parent[x] != x: self.parent[x] = self.find(self.parent[x]) # 路径压缩 return self.parent[x] def union(self, x, y): root_x = self.find(x) root_y = self.find(y) if root_x == root_y: return if self.size[root_x] < self.size[root_y]: root_x, root_y = root_y, root_x self.parent[root_y] = root_x self.size[root_x] += self.size[root_y] def longestConsecutive(nums): if not nums: return 0 dsu = DSU(nums) num_set = set(nums) for num in num_set: if num + 1 in num_set: dsu.union(num, num + 1) return max(dsu.size.values())这段代码比哈希集合解法要长,理解起来也更费劲。但如果你熟悉并查集,会发现这个解法非常自然:每个连续序列就是一个连通分量,合并所有相邻数字后,最大的size值就是答案。
4.3 两种解法怎么选
哈希集合解法在面试中的优势很明显:代码短、好解释、不容易写错。我给你的建议是,优先掌握哈希集合解法,并查集作为进阶了解即可。如果你面试中已经用哈希集合解法做出来了,可以主动提一句:"这道题也可以用并查集做,思路是把相邻数字合并,维护集合大小",然后简单描述一下,通常面试官会眼前一亮。
这里有个注意点:并查集解法如果每个数字都单独建节点,遇到海量数据时内存消耗会比哈希集合稍大,因为每个数字要存两份数据(parent和size)。而且路径压缩的递归写法如果处理不好也容易造成栈溢出,所以面试时我更推荐用迭代版本的find函数。
5. 真实刷题记录与踩坑实录
5.1 空数组和边界输入的坑
我第一次提交这道题时,第一个测试用例就挂了,因为没处理空数组。longestConsecutive([])直接返回0是合理预期,但我的第一版代码忘了这个分支。为了不再犯低级错误,后来我养成了一个习惯:写主逻辑前先处理空输入。这不是这道题特有的问题,而是所有算法题都需要注意的通用边界。
5.2 重复元素导致的死循环问题
再分享一个我自己挣扎了挺久的坑。我最早写的一版代码,是在原数组nums上遍历,而不是在set上遍历,然后判断if num + 1 in num_set时用了while循环。看起来没什么问题,但一旦数组里有重复元素,比如[1, 1, 1, 2],遍历到第一个1时启动内层循环,扩展到2,得到长度2;遍历到第二个1时,这个1又被当作序列起点重新扩展了一遍。
更严重的情况是,如果某个数在数组中出现多次,且它恰好是序列起点,内层循环会重复执行多遍,虽然逻辑上不会死循环,但复杂度会退化。正确做法是直接遍历set(nums),从根本上避免重复访问。这个教训让我明白:算法的正确性不仅要靠逻辑推理,还需要考虑数据本身的特征,重复元素就是数组题里最常见的坑。
5.3 负数和大数值的处理
还有一个容易忽略的细节是负数和超大数值。比如[-1, 0, 1, 2],最长连续序列是[-1, 0, 1, 2],长度4。这里的num - 1判断完全没有问题,set本身支持任意整数。Python的set对负数没有特殊限制,不像数组下标那样必须是非负整数,所以放心用。
超大数值方面,Python的int是无限精度的,不会像C++或Java那样出现int溢出。但如果你用其他语言写这道题,比如C++,就要特别小心num + 1的溢出问题,尤其是当num等于INT_MAX时。这种极端值的担忧在面试中属于加分项,如果你能在解释代码时主动提一句"在C++里需要注意INT_MAX的溢出",面试官会认为你考虑问题很周全。
5.4 一个真实的调试现场
我记得有一次在本地调试,输入是[0, 3, 7, 2, 5, 8, 4, 6, 0, 1],我的第一版代码输出是3,正确答案应该是9。当时怎么也想不通哪里错了,后来一行一行打日志才发现,问题出在我用的是数组而不是集合遍历,导致重复的0干扰了判断流程。
具体是这样的:数组里有两个0,遍历到第一个0时,0-1=-1不在集合里,所以0被当成起点,往右扩展,得到了1、2、3、4、5、6、7、8,长度9。但遍历到第二个0时,又把它当成起点重新扩展了一遍,最终虽然结果是9,但中间多执行了一次循环。如果数组里有一个重复的序列中间值,比如[1, 2, 2, 3],那么遍历到第二个2时,因为1在集合里,2不会被当成起点,这反而没问题。但如果你遍历的是数组而不是集合,很难保证每个元素只被处理一次。这让我彻底理解了"遍历集合"不是可有可无的写法优化,而是保证O(n)的关键步骤。
5.5 常见问题速查表
| 问题类型 | 表现现象 | 解决方法 |
|---|---|---|
| 空数组 | 返回0或报错 | 开头加if not nums: return 0 |
| 重复元素 | 复杂度退化或结果异常 | 遍历set(nums)而不是nums |
| 负数 | 部分语言下标越界 | Python无此问题,其他语言注意判断边界 |
| 超大整数 | C++中num+1溢出 | 使用long long或提前判断num是否为INT_MAX |
| 内层循环过多 | 时间超时 | 用num-1 not in num_set跳过非起点元素 |
6. 实战经验与面试答题策略
6.1 从题目到答案的完整思考路径
如果面试遇到这道题,我建议按下面的思路现场推导,不仅让面试官看到你的思考过程,还能在推导中自然展示多种解法:
第一步,先和面试官确认需求:数组是否可能为空,重复元素怎么处理,数字范围有没有限制。这些问题不是废话,能体现你的沟通能力。
第二步,说出暴力思路:枚举每个数,检查后续连续数字是否存在,复杂度O(n^2)或O(n^3)。面试官通常会点头,然后等你优化。
第三步,引出哈希表:用集合去重后,每个元素只从起点扩展一次,整体O(n)。这是核心解法。
第四步,扩展并查集解法:如果面试官追问"还有没有其他思路",可以补充并查集的思路。
我建议在第三部就写代码,因为这是最优解。写完代码后,主动使用示例数据走一遍流程,让面试官看到代码的执行过程。
6.2 关于时间复杂度的深度解读
这道题很多讲解文章都直接说"时间复杂度O(n)",但面试时你如果能主动解释清楚为什么是O(n),印象分会高很多。完整解释是这样的:外层循环遍历集合中的每个元素;内层的while循环只在num是序列起点时启动,而每个序列只被它的起点启动一次,所以内层循环的总执行次数等于所有连续序列的长度之和,不会超过n。因此总体是O(n)。
空间复杂度方面,需要一个哈希集合存储所有数字,所以是O(n)。有些面试官会追问:"能不能用O(1)的空间?"这是个难度陡增的问题,通常需要修改原数组或利用其他技巧,一般面试不会要求到这个深度。你可以坦诚说这在常规解法中做不到。
6.3 我偏爱的几种联想场景
刷完这道题后,我经常把"从起点开始扩展"的思路迁移到其他题目中。比如最长递增子序列问题、矩阵中的最长递增路径问题,都有类似的反向思考。矩阵中的最长递增路径,本质上也是从局部最小值开始DFS,避免重复计算。这和本道题的"从序列起点开始扩展"异曲同工。
这种联想能力不是天生的,而是大量刷题后的自然结果。我的建议是,每做完一道题,花两分钟想一想:这道题的核心技巧能不能用在其他场景?如果每次刷题都有这种反思,你的解题敏感度会提升得很快。
7. 聊聊我对这道题的体会
这道题我前前后后刷了不下十遍,每次看都有新的体会。最开始我只是机械记忆解法,到后来理解了"用哈希集合去重、从序列起点扩展"的思路,再后来能把并查集解法也做到熟练,这个过程其实就是算法思维逐步成型的过程。
我个人感触最深的一点是,好的解法往往是反直觉的。想快速找到最长序列,第一反应是先排序,但正确答案偏偏是用哈希表做O(n)扫描;想统计连续序列的长度,第一步居然是先判断前驱是否存在,而不是直接往后找。这种"先判断能不能出发,再出发"的思路,在解决很多"看似需要全局信息但实际可以局部决策"的问题时非常管用。
最后分享一个小技巧:刷题时准备一个本地题库,每道题记录三种内容——最优解代码、时间复杂度分析、以及你第一次提交时差点写错的点。时间久了,这本笔记就是你最宝贵的面试备考资料。我就是靠这种笨办法,把哈希表相关的题目练得特别扎实,面试时遇到联想题基本都能快速命中思路。
如果你正在准备面试,我建议把这题放进你的必刷清单。没有太复杂的数学推导,没有各种难懂的模板,只要吃透哈希集合加起点判断的核心逻辑,你就能在面试中稳稳拿下这道高频题。