时间复杂度的最优解)
最长连续序列这道题刷过LeetCode的朋友应该都不陌生。它表面上只是个“数组里找最长连续数字长度”的小问题但真到了面试或者实际业务优化里你会发现它考的是你对哈希结构、边界条件、复杂度取舍的综合感知。我不止一次在模拟面试里拿它当开场题原因很简单它看起来简单但解法从暴力到最优跨越了好几个层级每一层都能暴露一个人的基本功。这篇文章就把我对这道题的理解、实操踩坑和几种解法背后的逻辑一次讲清楚。1. 题目拆解连续序列到底在考什么1.1 题面与核心需求解析先明确一下题目本身。给定一个未排序的整数数组要求找出其中最长连续序列的长度。比如数组[100, 4, 200, 1, 3, 2]最长连续序列是[1, 2, 3, 4]长度是 4。注意这里的“连续”指的是数值上连续相差 1跟元素在原数组中的位置无关。这个题的隐藏约束才是真正的考点要求时间复杂度 O(n)。这意味着你不能先排序再遍历因为大多数排序算法的时间复杂度是 O(n log n)不是题目想要的。这个约束直接把“先排序再说”这条常规思路堵死了逼迫你换一种数据结构来思考。我第一次做这道题的时候第一反应也是排序——把数组排好序然后从头扫一遍记录连续递增的段有多长。逻辑上完全成立代码也能写出来但面试官一定会追问一句“能不能 O(n)”这时候你才意识到排序是思维惯性哈希才是这道题想让你掌握的套路。1.2 暴力解法的思路与痛点在讲最优解之前值得先聊聊暴力法因为理解暴力法的痛点你才能理解哈希解法的巧妙之处。最直接的暴力思路是遍历每个数字x然后依次查看x1、x2、x3……是否在数组里。每遇到一个长度加一不断更新最大值。这个做法的时间复杂度是 O(n^3) 级别的——如果你每次用数组包含判断来做“是否在”的查询那就是 O(n) 的查询成本嵌套在 O(n) 的起点枚举里即便用哈希集合把查询降到 O(1)最坏情况下每个数字都会作为起点尝试向后延伸一整条链整体仍是 O(n^2)。举个例子如果数组本身就是[1, 2, 3, ..., n]的顺序排列那么以 1 为起点向后查 n 次以 2 为起点向后查 n-1 次……总操作次数是 $n (n-1) ... 1 O(n^2)$。这在数据量一大就完全不可接受了。暴力法给我们的教训很直接找到一条思路之后至少要做一次复杂度估算不要被“能跑通示例用例”误导。很多初学者在刷题时只求通过样例这是最危险的习惯因为样例数据往往小到掩盖复杂度问题。2. 哈希集合解法O(n) 的核心原理2.1 为什么哈希集合能打破 O(n log n) 魔咒最优解法的核心思路其实非常朴素先把所有数字放进一个哈希集合HashSet然后只从“连续序列的起点”开始向后查找。关键在于怎么判断一个数字是不是起点——只要x - 1不在集合中那么x就是一个序列的起点。为什么这样能做到 O(n)因为每个数字最多被访问两次一次是外层循环遍历到它时判断它是不是起点另一次是它作为某个序列的后续元素被内层循环访问到。一旦某个数字已经被内层循环数过它就不会再被其他数字作为起点展开因为从它左侧的元素开始统计时序列会覆盖到它而从它本身开始又不是合法起点。整体来看每个元素的操作次数是常数级总复杂度就压到了 O(n)。这个“只从起点开始查”的小判断是整个算法的灵魂。它避免了大量重复的链式查询把暴力法中那个 O(n^2) 的“每个数字都作为起点”的浪费彻底消除了。2.2 空间换时间的经典权衡哈希集合解法使用了额外的 O(n) 空间来存储所有元素这是典型的空间换时间策略。在工程实践中这种权衡无处不在。举个例子处理一个几百万用户的事件流时如果你需要快速判断某个用户 ID 是否出现过用一个哈希集合或者布隆过滤器就是拿内存换查询速度跟这道题的取舍逻辑完全一致。不过也要说清楚 O(n) 空间的代价。如果题目有额外的空间限制比如只能使用 O(1) 额外空间那哈希集合就不是合法解了。不过 LC 原题默认不限制额外空间所以哈希方案是标准答案。实际面试中如果被追问“能不能优化空间”你可以提一下基于排序的解法虽然在时间上是 O(n log n)但空间可以是 O(1)这就体现你对时间和空间trade-off有完整认知。2.3 为什么不能直接排序的深度思考排序解法代码只有几行排完序后一次遍历统计连续递增段的最大长度。为什么题目非要限制 O(n)我认为是因为这道题想考察的是“能否跳出思维定式”排序是最容易想到的方案但也是最无脑的方案。在数据规模达到千万级时O(n log n) 和 O(n) 的差距是秒级和毫秒级的差距。虽然日常开发中我们不一定会刻意把每段逻辑都压到 O(n)但理解这些经典算法的思维模型能帮助你在处理大规模数据时多一种优化视角。排序解法的另一个小坑是处理重复元素比如[0, 0, 1, 2]排序后如果不跳过重复项你会把连续段长度统计错。这其实也为哈希方案提了个醒哈希集合天然去重反而省掉了这层麻烦。3. 实操过程代码实现与逐步推演3.1 核心步骤拆解把哈希集合解法的执行流程拆开看一共只有三步把数组所有元素放入一个 HashSet遍历数组中的每个元素x判断x - 1是否存在于集合中如果x - 1不存在说明x是起点就不断检查x length是否存在统计长度如果x - 1存在说明x不是起点跳过。整个算法的判断逻辑非常干净但有一个细节容易被忽略第二步遍历时用数组本身作为驱动而不是遍历集合。这是因为数组是原始输入遍历它不会发生任何并发修改问题。如果你在遍历集合的同时向集合中添加或删除元素会触发异常。这里我们只做查询不做修改所以问题不大但理清这个点有助于理解语言的集合语义。3.2 参考实现Java 版下面是我在实际编码时比较顺手的 Java 实现import java.util.HashSet; import java.util.Set; public class LongestConsecutiveSequence { public int longestConsecutive(int[] nums) { SetInteger numSet new HashSet(); for (int num : nums) { numSet.add(num); } int longestStreak 0; for (int num : nums) { // 如果 num - 1 在集合中说明 num 不是起点跳过 if (!numSet.contains(num - 1)) { int currentNum num; int currentStreak 1; // 从起点向后扩展 while (numSet.contains(currentNum 1)) { currentNum 1; currentStreak 1; } longestStreak Math.max(longestStreak, currentStreak); } } return longestStreak; } }这个版本是教科书标准写法但我后来实际跑大数组测试时注意到一个可以忽略不计的内存优化点遍历numSet本身会比遍历数组稍微快一点因为集合中的数据是唯一的重复元素多的时候能省掉一些重复的判断。不过时间复杂度不变只是一个常数级的微优化面试时不用过度纠结但提一句能显得你考虑周全。3.3 参考实现Python 版Python 写法更简洁核心逻辑完全一样def longestConsecutive(nums): num_set set(nums) longest_streak 0 for num in num_set: if num - 1 not in num_set: current_num num current_streak 1 while current_num 1 in num_set: current_num 1 current_streak 1 longest_streak max(longest_streak, current_streak) return longest_streak注意一个细节Python 版这里直接遍历num_set而不是nums因为 Python 的集合遍历在去重后能减少循环次数特别是数组重复元素极多的时候性能有小幅提升。而且这样写语义也更准确——序列是由不重复的数字组成的起点只会是集合中某个元素。3.4 用示例数组手工推演全过程拿[100, 4, 200, 1, 3, 2]来走一遍流程集合内元素为{1, 2, 3, 4, 100, 200}遍历到10099不在集合中它是起点向后找101不存在最长长度暂为 1遍历到43在集合中说明 4 不是起点跳过遍历到200199不在集合中它是起点向后找201不存在最长长度仍为 1遍历到10不在集合中它是起点向后找 2、3、4、5……直到 5 不在集合中得到长度 4遍历到32在集合中跳过遍历到21在集合中跳过。最终最长连续序列长度为 4。推演过程清晰地展示了“跳过非起点”这个判断如何避免 3 次无意义的重复扩展。3.5 关于时间复杂度最坏情况的一个关键证明有朋友可能会问就算只从起点扩展如果极端情况下整个数组就是一个从 1 到 n 的完整序列那外层循环访问每个元素内层循环也会把整条链走一遍岂不是 O(n^2)答案是否定的因为内层循环的累计代价是分布式的。数组是[1, 2, ..., n]时只有 1 是起点内层循环从 1 一路查到 n总查询次数是 n外层循环遍历其余元素时全部跳过。所以总操作次数是 O(n n) O(n)。内层循环的总消耗被“只有起点才展开”这个约束限制住了每个元素最多作为后续项被访问一次。4. 常见问题与排查技巧实录4.1 重复元素导致的计数错误这是最典型的问题。如果数组里有重复元素比如[1, 2, 2, 3]排序解法不跳过重复项会统计出奇怪的连续长度哈希解法因为集合去重天然规避了这个问题。但如果你自己实现时用的是 List 而不是 Set并且用contains查询那么重复元素同样不会影响计数因为“存在性查询”本身就是去重的。最容易出错的写法是在遍历时把元素从集合中边查边删——这会把序列切断导致统计长度变短。提示无论使用哪种语言如果你在处理过程中修改了正在遍历的集合一定要先复制一份或者改用迭代器安全的方式。4.2 空数组与单元素数组的边界空数组的最长连续序列长度应该是 0单元素数组是 1。这两个边界在代码里天然成立因为外层循环不会执行空数组或者内层循环不会向后扩展单元素。但正因为“天然成立”很多人会忘记单独测试这两类输入。我建议你写完代码后第一轮测试用例就固定包含这三种空数组、单元素、全相同元素[3, 3, 3, 3]。全相同元素的预期结果是 1因为集合里只有一个数字 3没有连续数字。4.3 负数与较大范围数值的处理哈希集合对负数没有任何特殊处理-1和1在集合里就是两个不同键判断x - 1是否存在时天然能处理负数序列。比如[-3, -2, -1, 0, 1]最长连续长度是 5。用字符串或者排序的方式反而容易出现各种边界错乱哈希集合在这一点上非常省心。我自己实测过一个包含约 100 万元素的随机数组哈希做法在 Java 下的耗时大概在 80~120 毫秒排序做法的耗时在 400~600 毫秒差距明显。虽然通常面试不会让你跑大数据但心里有数能帮你更好地理解为什么要 O(n)。4.4 面试现场最容易被追问的三个问题面试中这道题的高频追问基本集中在三个方向为什么if (!numSet.contains(num - 1))这行能保证每个序列只被统计一次如果数组有序直接遍历是不是更简单你会怎么权衡如果要求额外空间 O(1)有没有替代方案前两个问题在上面已经详细解释过了。第三个问题可以回答使用基数排序Radix Sort或计数排序Counting Sort桶的思想在整数范围有限时能做到近似 O(n) 时间 O(k) 空间但通用性不好。这是一个能体现你知识边界的回应。5. 变式问题与延伸思路从“一维连续”到“多维连通”5.1 二维矩阵中的最长连续序列这道题还有一类常见变体在二维矩阵中找到最长连续数值路径的长度每一步只能移动到上下左右相邻格子且下一个格子的值必须比当前格子大 1。这时候哈希集合的思路依然有用但需要配合记忆化搜索DFS memoization。每个格子出发做深度优先搜索用缓存记录已计算过的路径长度整体复杂度 O(m*n)。这种变式的难点在于把“一维的集合连续性”变成“二维的路径连通性”一旦想明白代码结构其实比一维版本更直观。5.2 使用并查集视角处理连续性问题另一种思考方式是并查集Union-Find。把集合中相邻的数字合并到同一个连通分量最后找到最大的分量大小。具体做法是把数组中的每个元素作为并查集的节点需要可以映射成整数 ID遍历每个数如果x1或x-1在集合中就执行合并。虽然代码复杂度高于哈希解法但并查集的思路在后续许多图论问题里非常有用理解这道题的“合并相邻元素”视角能帮你建立更广的算法知识关联。哈希解法和并查集解法本质都是要解决“连续性”的判断只是一个用查询起点来避免重复统计一个用显式的合并操作来构建连通分量。能说出两种思路的对应关系面试官对你的评价会明显加分。5.3 从刷题到工程连续区间的应用场景这类“找最长连续”的逻辑并不只是在面试题里出现。实际业务中比如判断用户连续打卡天数数据记录里逐日递增的日期、日志系统中找连续的错误码区间、电商场景中识别连续活跃的留存用户本质都是在一个集合里寻找最长的连续子集。不同的只是数据类型从整数换成了日期或时间戳判断条件从“相差 1”变成了“相邻两天”。我处理过一个用户活跃度分析的需求表里有几百万条用户登录记录要算每个用户的最长连续登录天数。一种粗糙的实现是把每个用户的登录日期取出来排序然后线性扫描但如果你对哈希集合解法足够熟悉你会自然想到先对用户 ID 分区再在分区内的日期集合上用“找起点”的思路做统计。数据量大的时候性能差得不是一点半点。6. 写在最后的实操体会这道题是我反复用来检验自己编码基本功的试金石。最初我也写过排序解法也写过 O(n^2) 的暴力查找直到真正理解“只从起点扩展”这句话背后的复杂度逻辑才算是把这道题吃透。我在实际代码里会特别注意一点集合的元素越多内存占用的哈希桶开销越明显如果数组本身有上千万元素你需要评估内存是否够用。虽然题目默认不限制但在真实系统中这个空间换时间的策略是需要谨慎决策的。另外如果你用的是 JavaHashSet的初始容量也可以根据数组大小预估减少扩容带来的开销类似new HashSet(nums.length * 2)这种小细节在工程优化里很实用但在面试中点到即可。最后再分享一个小技巧写完解法后不要只满足于提交通过建议自己构造几个反例测试一下——比如包含重复元素、负数和超大值混合的数组。这种测试习惯能帮你把脑子里的“解法”真正变成手下“可靠的代码”。这道题最大的价值不是让你记住一个套路而是训练你在遇到“去重、连续性、高效查询”这类需求时第一时间想到哈希结构并且能清楚地算清复杂度账。