目录

题目描述

LCR 119. 最长连续序列

题意分析

给定一个未排序的整数数组,找出数值上连续(如 1,2,3,4)的最长序列长度——注意是数值连续,与元素在数组中的位置完全无关,这一点和「最长递增子序列」那类看位置顺序的题有本质区别。

题面明确要求 $O(n)$ 时间,这是最强的约束信号:它直接把「先排序再扫一遍」排除在标准答案之外——排序至少 $O(n \log n)$,写出来等于答非所问。反过来想,$O(n)$ 的要求也在提示我们要用能常数时间判存在性的结构。

边界与细节:数组可能为空,此时返回 0;数组中可能有重复元素,重复值对连续长度没有任何贡献([1,2,2,3] 的答案是 3 不是 4);元素范围覆盖 int 的最小值到最大值,涉及 num ± 1 的运算要留意溢出。

解法:哈希集合只从序列起点扩展

核心思路

暴力做法是:对每个数字 num,不断查询 num + 1num + 2 是否在数组里,统计以它开头的连续段长度。就算把「是否存在」的查询用哈希集合降到 $O(1)$,仍有一个致命瓶颈:一段长为 $k$ 的连续序列 [1..k],从 1 开始扩展要走 $k$ 步,从 2 开始要走 $k-1$ 步……同一段被反复扫描,总代价 $O(k^2)$,全 [1..n] 的用例直接退化成 $O(n^2)$。

关键观察是:一段连续序列的完整长度,只需要从它的最小值(起点)量一次,从中间任何位置出发量到的都是残段,纯属浪费。而「是不是起点」有一个 $O(1)$ 的判据:num 是某段的起点,当且仅当 num - 1 不在集合中。

于是得到本解法的核心不变量:只对满足「num - 1 不在集合中」的数字发起扩展。它为什么能把总量压到 $O(n)$?把所有操作分成两类看——第一类是对每个数字做一次「前驱是否存在」的判定,共 $n$ 次;第二类是扩展中的 contains(cur + 1) 命中,每命中一次就有一个数字被纳入某段序列,而每个数字只属于唯一一段、这段又只从唯一的起点被扫描一次,所以命中总数也不超过 $n$。也就是说,每个元素恰好被「起点判定」和「被扩展纳入」这两类操作各碰一次,双重循环的总工作量是 $2n$ 级别,均摊下来就是线性。

具体流程:先把所有数字倒进哈希集合(顺带去重),再遍历集合,跳过所有非起点,只在起点处向后逐一扩展 cur + 1,用得到的段长更新答案。

解题步骤

  • 将数组所有元素加入哈希集合。为什么:后续需要大量「某个值是否存在」的查询,集合把每次查询降到 $O(1)$,同时天然去重,重复元素不会干扰长度统计。
  • 遍历集合(而非原数组)中的每个数字。为什么:遍历集合可以让重复值只被判定一次;遍历原数组也对,但重复元素会做无意义的重复判定。
  • num - 1 存在于集合中,直接跳过。为什么:说明 num 处在某段序列的中间或末尾,它所在段的完整长度会由该段真正的起点负责统计,从这里扩展只能得到残段。
  • 否则 num 是一段序列的起点,用 curnum 出发不断检查 cur + 1 是否在集合中,在则 cur 与长度同步加一。为什么:从起点向后走到断裂处,走过的步数恰好就是这段序列的完整长度。
  • 每段扩展结束后用段长更新答案,遍历完返回。为什么:题目要的是所有段中的最大值,每个起点各贡献一个候选。

[100,4,200,1,3,2] 走一遍:建集合 {100,4,200,1,3,2}。逐个判定起点——10099 不在集合,是起点,101 不在,段长 1ans = 143 在集合中,不是起点,跳过;200199 不在,是起点,201 不在,段长 1ans 仍为 110 不在,是起点,依次发现 234 都在集合中,5 不在,段长 4ans = 432:前驱都在集合中,跳过。整段 1,2,3,4 只被起点 1 完整扫描了一次,最终返回 4

代码实现

class Solution {
    public int longestConsecutive(int[] nums) {
        Set<Integer> set = new HashSet<>();
        for (int num : nums) {
            set.add(num);
        }

        int ans = 0;
        for (int num : set) {
            if (num != Integer.MIN_VALUE && set.contains(num - 1)) {
                continue;
            }

            int cur = num;
            int len = 1;

            // 只从连续段起点扩展,每段序列只会被完整扫描一次。
            while (cur != Integer.MAX_VALUE && set.contains(cur + 1)) {
                cur++;
                len++;
            }
            ans = Math.max(ans, len);
        }

        return ans;
    }
}
func longestConsecutive(nums []int) int {
    set := make(map[int]bool)
    for _, num := range nums {
        set[num] = true
    }

    ans := 0
    for num := range set {
        if set[num-1] {
            continue
        }

        cur := num
        length := 1

        // 只从连续段起点扩展,每段序列只会被完整扫描一次。
        for set[cur+1] {
            cur++
            length++
        }
        if length > ans {
            ans = length
        }
    }

    return ans
}

复杂度分析

  • 时间复杂度:$O(n)$。凭什么:表面上是「遍历套 while」的双重循环,但用均摊论证看——外层对每个不同数字做一次前驱判定,共 $O(n)$;内层 while 每前进一步就把一个数字纳入它所属的唯一段,而每段只从唯一的起点被扫描一次,所以所有 while 步数加起来也不超过 $n$。每个元素恰被「起点判定」与「被扩展纳入」两类操作各碰一次,总量 $2n$,即 $O(n)$。建集合本身也是 $O(n)$。
  • 空间复杂度:$O(n)$。凭什么:哈希集合最多保存 $n$ 个不同数字,除此之外只用常数个变量。

关键点总结

  • 看到「未排序 + 要求 $O(n)$」的组合,第一反应应当是拿空间换时间:哈希结构把存在性查询压到 $O(1)$,这是绕开排序的通用手段。
  • 「只从起点扩展」是一个可迁移的去重思想:当多个元素会重复触发同一段计算时,选一个唯一代表(这里是段的最小值)来承担全部计算,其余元素一律跳过。
  • 双重循环不一定是 $O(n^2)$,要看内层的总步数——均摊分析的核心是「给每步操作找到唯一买单的元素」,这套论证在单调栈、滑动窗口里反复出现。
  • 重复元素放入集合即自然去重,不需要任何特判;分析题意时先确认重复对答案无贡献,能省掉一类边界代码。
  • 面试视角:这题的考点几乎全在复杂度论证上——面试官大概率追问「你这不是两层循环吗,为什么是 $O(n)$」,必须能当场讲清上面的均摊账;先说排序解法再主动指出它不满足要求、引出哈希解法,是稳妥的表达路径。

易错点总结

  • 错误写法:不做起点判定,对每个元素都向后(或向两边)扩展——用例 [1,2,3,...,n],从每个位置都把剩余整段扫一遍,总步数 $1+2+\cdots+n$,退化成 $O(n^2)$,大数据量直接超时;这正是本题设置 $O(n)$ 要求想卡掉的写法。
  • 错误写法:图省事用排序解法当主解——用例上能得到正确答案,但复杂度是 $O(n \log n)$,题目白纸黑字要求 $O(n)$,面试中等于答非所问,会被直接追问或降档。
  • 错误写法:起点判定写反,写成「num + 1 不存在才扩展」再向前找 num - 1——逻辑对称本身能得出正确答案,但若和向后扩展混写(判 num - 1 却仍向 num - 1 方向扩展)——用例 [1,2,3],每个数都被判为非起点或扩展方向错误,答案输出 1
  • 错误写法:忘记处理空数组,直接取第一个元素初始化——用例 [],数组越界或返回 1,正确答案是 0;把 ans 初始化为 0 即可自然覆盖。
  • 错误写法:以为重复元素会拉长序列,用计数而不是集合——用例 [1,2,2,3],答案算成 4,正确答案是 3;连续序列按数值算,重复值无贡献。
  • 错误写法:在 Java 中不设防地计算 num - 1cur + 1——用例含 Integer.MIN_VALUEInteger.MAX_VALUE 时发生回绕,Integer.MAX_VALUE + 1 变成最小值,可能误判存在性甚至死循环;代码中先短路判断 num != Integer.MIN_VALUEcur != Integer.MAX_VALUE 再做加减。
  • 错误写法:内层扩展时只移动 cur 忘记累加 len(或反之)——用例 [1,2,3,4],返回 1 或死循环;curlen 必须同步更新。
  • 错误写法:遍历原数组且不判起点、只用 while 里删除元素来防重扫,但删除时机不对——用例 [3,2,1],先访问 312 还在集合里却从 3 起扫不到它们,若又把 3 删掉,轮到 1 扩展时段被截断,答案偏小。

相似题目

题目 难度 考察点
674. 最长连续递增序列 简单 要求位置连续,一次线性扫描即可
485. 最大连续 1 的个数 简单 统计最长连续段的最简形态,计数器归零技巧
300. 最长递增子序列 中等 保持相对位置但可不连续,需动态规划或贪心加二分
298. 二叉树最长连续序列 中等 连续序列搬到树上,沿父子链递归传递长度
549. 二叉树最长连续序列 II 中等 允许递增递减双向,路径可跨越父节点