1. 同样是求“连续”,为什么普通遍历会超时
1.1 题面到底在说什么
“最长连续序列”这道题,刷过 LeetCode 的人应该都不陌生。题目原话大致是:给你一个未排序的整数数组 nums,请你找出数字连续的最长序列的长度,并且要求算法时间复杂度为 O(n)。
这里的“数字连续”有很多人理解错。比如输入 [100, 4, 200, 1, 3, 2],正确答案是 4,也就是 [1, 2, 3, 4]。它并不要求在数组里这些数字是按顺序相邻存在的,只要数值上能连成一条递增的整数链,而且链上的每一个整数都真实出现在原数组里,就算一个连续序列。
再比如 [0, 3, 7, 2, 5, 8, 4, 6, 0, 1] 这个例子,数组里面出现了 0 到 8 的所有整数,虽然原数组顺序打得很散,但答案就是 9。因为从 0 一路数到 8,每个数字都在数组里存在。
这道题看起来像“找最大连续区间”,但本质上是一个集合查询问题。我们需要回答的是:给定一个数字哈希集合,是否存在从某个最小值开始、以步长 1 递增延伸的完整区间,以及这个区间最长能有多长。
我第一次刷到这道题时,第一反应和大多数人一样:排序。排完序后,挨个检查相邻数字是不是差 1,一路数过去就行。这个方案确实能把题目做出来,而且写起来不容易错。但问题也很明显:排序已经让时间复杂度变成了 O(n log n),根本达不到题目要求的 O(n)。
后来我才慢慢理解,面试官问这道题,表面上考的是“你会不会处理数组”,实际考的是三个层次:能不能想到用哈希集合去重,能不能想到从连续区间的最小值开始扩展,以及能不能把时间复杂度证明清楚。最后这一点才是分水岭。
1.2 别急着上set,先理解“盲目扩展”会造成什么后果
很多人知道要用哈希集合后,会写下这样一版代码:先把所有数字塞进 set,然后遍历 set 里的每个数字,用 while 循环不断把当前数字加 1,看它在不在集合里,在的话长度加 1,最后更新最大值。
这个思路方向是对的,但复杂度是错的。如果数组是 [1, 2, 3, ..., n],遍历到 1 的时候,内部 while 会一直加到 n,统计出 n 的长度;遍历到 2 的时候,又会从 2 一直加到 n,又统计出一个 n-1 的长度;遍历到 3 的时候,再重复一次。最终整体耗时接近于 n 的平方。
为什么会这样?因为每个连续区间被重复计算了多次。每个数字都当了一次“开头”,但实际上只有区间里的最小值才有资格当开头。其他数字虽然也能通过 while 找出一段连续序列,但那只是把别人统计过的区间又走了一遍,属于纯浪费。
所以这道题的技术核心,不是“用 set 判断存在性”,而是“如何保证每个区间只被统计一次”。只要这个问题想明白了,代码其实很短。在这个意义下,它又是一道典型的“想清楚思路比写代码更难”的题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从排序版解法入手:能过用例,但没答到题眼上
2.1 排序加单遍扫描的代码
排序版解法是我建议初学者先写一遍的版本,因为它最直观,也最不容易出逻辑漏洞。思路就三步:先对数组排序,排序之后相同或相近的数字会靠到一起;然后从左到右扫描,如果当前数字等于上一个数字,说明是重复项,直接跳过;如果当前数字正好比上一个数字大 1,说明连续段还在延续,计数器加 1;否则说明连续段断了,先把当前长度更新到结果里,再把计数器重置成 1。
Python 实现大概是这样的:
python复制def longestConsecutive_sort(nums):
if not nums:
return 0
nums.sort()
longest = 1
current_len = 1
for i in range(1, len(nums)):
if nums[i] == nums[i - 1]:
continue
elif nums[i] == nums[i - 1] + 1:
current_len += 1
else:
longest = max(longest, current_len)
current_len = 1
return max(longest, current_len)
这段代码在普通测试用例下完全没问题,甚至可以通过 LeetCode 上的绝大多数提交。问题只在于面试官要求的“O(n)”没达到。如果只是自己刷题练手,排序版作为热身是挺好的,它能帮你把“连续”的定义和重复数字的处理先想清楚。
2.2 排序方案的两笔复杂度账
排序版的复杂度要分两部分算。第一部分是排序本身,Python 内置的 sort 用的是 TimSort,平均复杂度和最坏复杂度都是 O(n log n);第二部分是排序后的线性扫描,复杂度是 O(n)。两者相加,整体还是 O(n log n)。
空间复杂度方面则取决于实现方式。如果允许原地排序,那么额外空间可以做到 O(1);但很多编程语言的内置排序并不是严格原地。在算法题里通常不会去抠这一点,因为更大的问题在于时间复杂度不达标。
那为什么 O(n) 和 O(n log n) 的差别值得面试官专门追问?因为这道题的数据规模往往会被放大到“排序不可接受”的程度。假设数组长度是 1 亿,n log n 意味着大约要做 27 亿次基础比较,而 O(n) 只需要 1 亿次左右。在真实的大数据场景里,两者可能在秒级和分钟级之间拉开差距。
2.3 面试官追加那句“不排序”是想听什么
有一次我和朋友复盘面试题时聊到,很多候选人都能写出排序版,但当面试官追问“如果数据量大到没法排序,你怎么做”时,就开始卡壳。这里的核心暗示是:连续序列的“连续性”和数组下标无关,只和值有关。既然只关心值,就可以把数组转化为一个哈希集合,让查询一个数字是否存在的时间降为 O(1)。
接着,为了满足整体 O(n),还需要避免重复扫描。重复扫描的根源在于,区间里的每一个数字都有可能被当作起点。想避免重复,就必须人为规定:只有“没有左邻居”的数字才有资格成为起点。也就是说,遍历到数字 x 时,先检查 x - 1 是否也在集合里,如果 x - 1 存在,说明 x 不是它所在连续段的最小值,直接跳过。只有当 x - 1 不存在时,才从 x 开始向上扩展。
这个“规定”就是整道题的题眼。排序版解法的价值在于,它能帮你确认正确解法的答案是对的;但面试官真正想听的是,你能不能自己推导出这个不必要的起点判断。
3. 哈希集合的正确解法:用“没有左邻居”锁定起点
3.1 为什么先要去重
正确解法第一步是去重。你可以直接用 Python 的 set(nums),它会自动去掉重复元素。
去重有双重好处。第一,如果数组里存在重复数字,不去重会让“连续序列长度”被虚增。举个例子,数组是 [1, 2, 2, 3],如果保留重复的 2,你在遍历时可能把 1、2、2、3 四个元素都当成不同数字,误认为连续长度是 4。但实际数值上只有 1、2、3 三个整数,答案是 3。
第二,去重之后,每个数字只需要处理一次。这能保证后面进行起点判断时,不会因为同一个数字出现多次而反复浪费查询时间。
有些人担心用 set 会改变数字顺序,这完全不用担心,因为我们本来就不需要顺序。我们要的是一个能快速回答“某个整数是否存在”的哈希结构,而不是有序结构。HashSet 的查询平均时间复杂度是 O(1),正好符合要求。
3.2 关键判断:只有 num - 1 不存在时才开始扩展
正确版代码可以这样写:
python复制def longestConsecutive(nums):
num_set = set(nums)
longest = 0
for num in num_set:
# 只有当前数字是连续段的左端点时才处理
if num - 1 not in num_set:
current_num = num
current_len = 1
while current_num + 1 in num_set:
current_num += 1
current_len += 1
longest = max(longest, current_len)
return longest
重点在 if num - 1 not in num_set 这一行。如果 num - 1 存在,说明当前数字并不是连续段的最小值,它所在的区间一定会在更早的时候被一个更小的数字统计到。因此可以直接跳过,不进行内部 while 扩展。
这个方法还有一个常见写法是“从右端点开始判断”,即判断 num + 1 不存在后向左扩展。两种写法的逻辑是对称的,但面试时最好统一成“从最小值开始向右扩展”,因为更容易解释清楚。代码里的变量命名也建议用 current_len 而不是简单的 len,避免和 Python 内置函数混淆,这是很多初学者容易踩的小坑。
3.3 完整实现与一次手推
跑一下题目给的例子:[100, 4, 200, 1, 3, 2]。
去重之后哈希集合里是 {1, 2, 3, 4, 100, 200}。遍历到 1 时,发现 0 不在集合里,于是开始扩展:2 在,长度变 2;3 在,长度变 3;4 在,长度变 4;5 不在,停止,得到长度 4。遍历到 2 时,因为 1 在集合里,所以直接跳过;遍历到 3 时,因为 2 在集合里,也直接跳过;遍历到 4 时同理。遍历到 100 时,因为 99 不在,扩展发现 101 也不在,长度只有 1。遍历到 200 时同理。
如果把这一段手推过程画成图,你会发现只有从 1 开始的那次扩展真正做了大量查询,其余数字基本都只做了一次“左邻居是否存在”的判断。这正是复杂度降低到 O(n) 的原因。
3.4 最容易误解的复杂度:每个元素到底被访问了几次
很多人刚看到这个解法时会觉得,外面有一个 for 循环,里面还有一个 while 循环,不可能还是 O(n)。要解释清楚这一点,不能只停留在“平均情况”上,而要给出一个更直接的理由。
可以这样想:整个哈希集合可以切分成若干条互不相交的连续段。比如集合 {1, 2, 3, 10, 11, 20} ,它有三个连续段:[1, 2, 3]、[10, 11]、[20]。每一条连续段只会被它自己的最小值触发一次内部扩展。扩展时,它会从最小值一直访问到最大值,这一段里的每个元素都只被访问一次;当扩展到最大值后,下一轮 for 遍历到该段的其他元素时,全部被左邻居判断挡住,不再进入 while。
所以每个元素最多被访问两次:一次是在外层 for 中作为判断对象,判断它有没有左邻居;另一次是当它所在的连续段从最小值开始扩展时,它作为段内成员被 while 查询到。总访问次数不会超过 2n,因此时间复杂度是 O(n)。空间复杂度是 O(n),因为需要存哈希集合。
“每个连续段只统计一次,每个元素至多参与一次内部扩展”这句话,值得在面试时主动说出来。它能直接证明你已经理解了解法的本质,而不是背了一个模板。
4. 这道题真正容易翻车的细节,以及面试追问怎么回答
4.1 三个边界条件对照
很多算法题的正确思路想出来了,最后还是因为边界条件没处理好而错。最长连续序列最常见的边界条件有三个:空数组、单元素数组、重复数字集中的数组。
| 输入示例 | 正确结果 | 容易犯的错 |
|---|---|---|
[] |
0 | 返回值初始化为 1,导致返回 1 |
[5] |
1 | 没考虑单元素自身是一个连续段 |
[0, 0] |
1 | 不去重,按两个数字算长度 2 |
[1, 2, 0, 1] |
3 | 重复的 1 把统计结果变成 4 |
解决这些问题的关键有两个:一是 longest 的初始值必须设为 0,不是 1;二是先做集合去重,再去遍历集合而不是遍历原数组。
有些同学在排序版解法里把 longest 初始成 1,也能在空数组时报错,因为空数组直接返回 0,要走一个 if not nums 分支。但哈希集合版里如果 longest 初始为 1,空集合就不会进入循环,最后会返回 1。这种情况我在给同事 review 代码时遇到过不止一次,所以建议一开始就把初始值设为 0,然后在循环里统一用 max 更新。
4.2 负数、大整数和“连续”的另一个错觉
“连续”很容易让人误以为只能处理 0 和正整数。实际上题目里没有这个限制,负数一样可以出现在连续段里。例如数组 [-1, -3, -2],正确答案是 3,也就是 [-3, -2, -1]。哈希集合里查 num - 1 和 num + 1 时,对负数也成立,所以不需要单独处理。
还有一种错觉是把“连续序列”和“等差为 1 的子数组”混在一起。题目只要求数值连续,不要求元素在原数组中的相对顺序连续。所以即使原数组里 1 和 2 隔得很远,只要都出现过,它们就在同一条连续段里。这也是为什么哈希集合比滑动窗口更适合这道题。滑动窗口通常处理“子数组”问题,而这道题处理的是“子集”问题。
4.3 被问到“能不能用并查集”时怎么接
有些面试官会在你写出哈希集合解法后,追问还有没有别的思路。一个比较常见的进阶方案是用并查集。思路是:把每个数字看成图里的一个节点,如果 num + 1 存在,就把 num 和 num + 1 合并到一个集合里;合并时按集合大小维护当前连续段的长度。最终所有节点组成的最大连通分量的大小,就是最长连续序列的长度。
并查集解法的时间复杂度接近 O(n alpha(n)),其中 alpha(n) 是反阿克曼函数,增长非常慢,可以近似看成常数。但它比哈希集合解法要复杂不少,需要额外维护父节点数组和 size 数组。如果面试只是为了验证你懂更多算法结构,口头说说思路就够了,通常不需要完整实现。我更推荐把哈希集合解法作为主力,因为它的代码最简洁,也最容易证明复杂度。
4.4 空间复杂度受限的讨论
有一个容易被追问的问题是:如果面试官要求 O(1) 空间,并且不允许排序来解决,怎么办?这个问题其实没有一个能同时满足 O(n) 时间和 O(1) 空间的通用解法。你可以先坦白说明这一点,然后补充:如果允许把原数组当作哈希表的存储去改造,理论上可以模拟哈希,但会非常复杂,不是这道题期望的答案。
在实际系统里,O(n) 空间通常是可以接受的,因为哈希集合的长度最多也就是原数组的长度。真正要注意的反而是内存占用:如果数字是 64 位整数且数量达到上亿级别,一个 set 可能占用很大内存。这时可以考虑用布隆过滤器做过滤,或者用数据库的分区方式处理。但这些属于工程延伸,面试里点到为止即可,不要为了展示知识而偏离题目主线。
5. 从最长连续序列延伸出去:一类问题的通用打法
5.1 与“最长递增子序列”有一点像,但思路完全不同
很多初学者容易把“最长连续序列”和“最长递增子序列”搞混,因为名字里都有“最长”。但它们的条件很不一样。最长连续序列要求两个数字相差必须是 1,而且中间的每个整数都要出现;最长递增子序列则只要求下标递增、数值严格递增,不要求数值连续。
这两道题放在一起对比,能让你更清楚“题目在考察什么”。最长连续序列考的是集合存在性查询,所以用哈希集合最合适;最长递增子序列考的是基于下标的前后关系推导,所以要用动态规划或贪心加二分。如果你只看结果,会以为是同一个套路,实际上解法方向完全不同。刷题时先判断“限制条件是基于值还是基于下标”,是特别重要的第一步。
5.2 在真实业务场景里找“连续活跃区间”
这类“给一个集合,找最长连续值区间”的算法,并不只是面试题。我在数据团队工作时常遇到类似需求:给定一批用户 ID 集合,想找出连续 7 天都登录过的用户区间,或者给一批交易日期,找出最长的一串连续交易日。
如果把日期按“用户在某天出现过”建模成一个分布式集合,就能用到和这道题一样的思路:先把数据去重,再对每个元素判断它的“前一天”或“前一个时间戳”是否存在,不存在则作为起点向外扩展。只是工程上数据不一定会全部放在一个内存 set 里,可能会用数据库索引或者外部键值存储。但核心思想是一致的。
很多人在面试算法题后会觉得“这些题真实工作中根本用不到”,但当你处理过一次“在千万级 ID 集合中找连续活跃区间”的需求后,就会发现哈希集合加邻居判断的方法非常有用。它可以避免对一个庞大的有序列表做全量排序,尤其是当数据分散在多台机器上时,排序的成本会远高于哈希查询。
5.3 如果题目要求输出具体的序列,怎么改
这道题通常只要求返回长度,所以代码里只需要记录最大长度。但如果业务上需要把最长的那条连续序列本身输出,也就是从哪个数字开始、到哪个数字结束,那也很简单。可以用两个变量记下最长连续段的最小值和最大长度,最后生成一个从起点到终点的整数列表。
python复制def longestConsecutive_with_result(nums):
num_set = set(nums)
longest = 0
best_start = None
for num in num_set:
if num - 1 not in num_set:
current_num = num
current_len = 1
while current_num + 1 in num_set:
current_num += 1
current_len += 1
if current_len > longest:
longest = current_len
best_start = num
return list(range(best_start, best_start + longest)) if longest > 0 else []
这里有一个很实际的小技巧:连续序列开头 best_start 就是触发扩展的那个 num,而不是扩展结束后的 current_num。因为只有左端点才会进入 while 扩展,所以只要更新最大值时把当前的 num 记录下来就好。输出时用 range(best_start, best_start + longest) 能恢复完整序列,普通解法输出数组的时候很容易忽略连续段中重复元素的处理,而基于起点加长度的方法可以避免一切歧义。
5.4 我自己的一个习惯:用暴力正确解法做交叉验证
最后分享一个我平时刷算法题时用的习惯。对于一个新解法,我不会只看它的复杂度证明,还会写一个非常暴力的参考实现,用来跑随机数据做交叉验证。所谓暴力实现,就是枚举所有可能的左端点,从每个端点开始逐个数往后查,哪怕复杂度是 O(n^3) 也没关系,只要它一定正确。
然后把正确解法和暴力解法放在一起,用随机生成的大数组比较答案。这个方法能帮我快速发现边界问题和某些隐蔽的计数器错误。最长连续序列这道题,我用它抓到过 longest 初始值设置错误的问题,也抓到过去重不彻底导致的长度虚增问题。无论一个人刷了多少道题,这种“拿笨办法验证聪明办法”的思路都不过时。
真实面试里,题目本身往往并不是最需要担心的部分。比这更重要的是面对追问时的表达:为什么初始化值要设成 0,为什么每个连续段只从最小元素开始扩展,为什么总复杂度是 O(n)。如果你能把这三个问题讲得清清楚楚,那这道题才算真正拿下了。
