【LeetCode Hot100刷题日记(3/100)】128.最长连续序列 —— 哈希表、数组、双指针(变种)+智能遍历优化🚀

170 阅读9分钟

🧩 128.最长连续序列 —— 哈希表 + 双向扩展标记法

💡 题目链接leetcode.cn/problems/lo…

🔍 难度:中等 | 🏷️ 标签:哈希表、数组、双指针(变种)

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(n)


🧠 题目解析:从“无序”到“连续”的思维跃迁

image.png

给定一个未排序的整数数组 nums,要求找出其中数字构成的最长连续序列的长度。例如:

输入: [100, 4, 200, 1, 3, 2]
输出: 4  // 因为 1->2->3->4 构成了长度为4的连续序列

⚠️ 注意:不能排序! 如果排序,最坏情况下需要 O(n log n),不符合题目对 O(n) 时间复杂度的要求。

因此,我们只能通过 哈希表 + 智能遍历 的方式,在不改变原顺序的前提下完成任务。


🔍 核心算法思想:哈希表 + 双向扩展 + 访问标记 ✅

🎯 算法关键词:

哈希表存储 + 仅从起点开始扩展 + 标记已访问避免重复

这道题是 “哈希表 + 动态规划思想” 的经典体现,也是面试中考察你是否真正理解「数据结构优化」与「避免重复计算」能力的经典题型。


🛠️ 算法实现详解

✅ 第一步:数据预处理

unordered_map<int, int> data;
for (int x : nums) data[x] = 1;  // 将所有数字存入哈希表
🧩 补充说明:
  • 为什么用 unordered_map<int, int> 而不是 set

    • 因为我们不仅需要判断是否存在,还需要标记是否已被访问
    • 使用 data[x] = 1 表示该值存在且未被访问;后续设为 0 即表示已处理。
    • 这是一种典型的 “状态标记”技巧,常见于图遍历、并查集、动态规划中。
  • 哈希表的作用是什么?

    • 实现 O(1) 查询某数是否存在 → 是整个算法效率的关键!
    • 若不用哈希表,每次查找需 O(n),总复杂度变为 O(n²),不可接受。

💡 面试提示:当看到“快速查找”、“去重”、“判断存在性”时,优先考虑哈希表!


✅ 第二步:遍历所有数字(只处理未访问的)

for (auto& p : data) if (p.second) {
🧩 补充说明:
  • p.second 是状态标志位:

    • 1:存在且未访问
    • 0:已参与过序列计算,跳过
  • 这是关键优化点!
    如果不加这个判断,会重复计算同一个数字,导致结果错误或性能下降。

🔁 举个反例:假设 [1,2,3],如果不做标记,遍历到 2 时,它会再次尝试向左扩展 1 和向右扩展 3,造成冗余。

  • 如何保证每个数字只被作为“起点”一次?

    • 我们只允许从“没有前驱”的数字出发(即 val - 1 不存在),但这里更简单粗暴:只要没被访问,就当作起点尝试扩展
    • 扩展后全部标记为 0,防止后续重复。

核心思想:把每一个数字都当成可能的起点,但通过标记机制确保不会重复处理。


✅ 第三步:向左扩展序列

for (int i = 1; data.count(val - i) && data[val - i]; i++) {
    length++;
    data[val - i] = 0;  // 标记为已访问
}
🧩 补充说明:
  • 逻辑本质:从当前数字 val 开始,不断检查 val-1, val-2, ... 是否存在于哈希表中且尚未被访问。

  • 终止条件有两个

    1. data.count(val - i) → 数字不存在
    2. data[val - i] == 0 → 已被访问(已在其他序列中处理)
  • 为何要标记为 0?

    • 防止在后续遍历中再次以这些数字为起点展开,避免重复计算。
    • 是一种惰性删除(lazy deletion) 的思想,比真的从 map 中 erase 更高效。

🔄 类比:就像你在森林里找路径,一旦走过一条路,就用石头标记,下次就不走回头路了。


✅ 第四步:向右扩展序列

for (int i = 1; data.count(val + i) && data[val + i]; i++) {
    length++;
    data[val + i] = 0;  // 标记为已访问
}
🧩 补充说明:
  • 完全对称于左侧扩展。
  • 同样使用 count()value 判断存在性和访问状态。
  • 每次找到一个连续数字,就累加长度,并将其标记为已访问。

📌 小技巧:可以将左右扩展合并成一个函数,提升可读性,但在实际面试中,清晰分步更重要!


✅ 第五步:更新最大长度

ret = max(ret, length);
🧩 补充说明:
  • ret 是全局最大值,记录所有可能的连续序列中的最长者。
  • 每次扩展完一个序列,就更新一次。
  • 最终返回的是整体最长连续序列的长度。

🎯 示例回顾:[100,4,200,1,3,2]

  • 100 → 无前后 → 长度1
  • 4 → 向左找到 3,2,1 → 长度4
  • 200 → 无前后 → 长度1
  • 其他数字已被标记 → 跳过
  • 最大值 = 4 ✅

📊 算法分析:为什么它是 O(n)?

维度分析
时间复杂度O(n) ✅
空间复杂度O(n) ✅
是否最优是,无法进一步优化

🔍 详细解释:

  • 每个数字最多被访问两次

    1. 一次是在构建哈希表时(data[x] = 1
    2. 一次是在扩展过程中(无论是作为起点还是中间元素)
  • 虽然有嵌套循环,但不会超线性增长

    • 外层遍历哈希表,共 n 个元素
    • 内层扩展虽然看似 O(n),但由于每个数字只会被访问一次,所以总的扩展操作总数不超过 n

🧮 举个极端例子:[1,2,3,4,5]

  • 当遍历到 1 时,会扩展出 2,3,4,5 并标记它们
  • 后续遍历到 2~5 时都会因 p.second == 0 被跳过
  • 所以总共只执行了一次扩展,总操作次数 ≈ 5(初始)+ 4(扩展)= 9 < 2n

✅ 结论:虽然是两层循环,但内层不会重复处理同一数字,整体仍是 O(n)


🧪 测试用例验证(附运行结果)

// 测试用例1
vector<int> nums1 = { 100, 4, 200, 1, 3, 2 };
cout << "测试用例1 [100,4,200,1,3,2]: " << solution.longestConsecutive(nums1) << " (期望: 4)" << endl;

// 测试用例2
vector<int> nums2 = { 0, 3, 7, 2, 5, 8, 4, 6, 0, 1 };
cout << "测试用例2 [0,3,7,2,5,8,4,6,0,1]: " << solution.longestConsecutive(nums2) << " (期望: 9)" << endl;

// 测试用例3
vector<int> nums3 = { 1, 0, 1, 2 };
cout << "测试用例3 [1,0,1,2]: " << solution.longestConsecutive(nums3) << " (期望: 3)" << endl;

// 测试用例4 - 空数组
vector<int> nums4 = {};
cout << "测试用例4 []: " << solution.longestConsecutive(nums4) << " (期望: 0)" << endl;

✅ 输出结果:

测试用例1 [100,4,200,1,3,2]: 4 (期望: 4)
测试用例2 [0,3,7,2,5,8,4,6,0,1]: 9 (期望: 9)
测试用例3 [1,0,1,2]: 3 (期望: 3)
测试用例4 []: 0 (期望: 0)

💡 特别注意:[0,3,7,2,5,8,4,6,0,1]0→1→2→3→4→5→6→7→8 构成连续序列,长度为9!


🧠 面试常考点 & 技巧总结

🔹 面试官可能会问的问题:

  1. 为什么不直接排序?

    • 排序是 O(n log n),不符合 O(n) 要求。
    • 且排序破坏了原始顺序,无法支持某些高级需求。
  2. 能不能用 set?

    • 可以,但不如 map 方便做标记。
    • 如果用 set,你需要额外维护一个集合来记录已访问元素,或者改用 unordered_set + 遍历策略。
  3. 有没有办法只遍历一次?

    • 有的!可以通过“只从最小值开始扩展”来减少重复。
    • 优化版本:只在 val - 1 不存在时才扩展,这样每个序列只有一个入口。

进阶优化写法(推荐用于面试)

int longestConsecutive(vector<int>& nums) {
    unordered_set<int> s(nums.begin(), nums.end());
    int ret = 0;

    for (int num : s) {
        // 只从序列的起始点开始扩展
        if (s.find(num - 1) == s.end()) {  // num 是某个序列的起点
            int cur = num;
            int length = 1;
            while (s.find(cur + 1) != s.end()) {
                cur++;
                length++;
            }
            ret = max(ret, length);
        }
    }
    return ret;
}

🔥 这种写法更优雅,避免了显式标记,也同样是 O(n) ,并且更容易理解和记忆!


💻 完整代码实现

// Leetcode128. 最长连续序列
#include <bits/stdc++.h>
#include <unordered_map>
using namespace std;
using ll = long long;

class Solution {
public:
    int longestConsecutive(vector<int>& nums) {
        unordered_map<int, int> data;
        for (int x : nums) data[x] = 1;  // 用哈希表存储所有数字,1表示存在且未访问

        int ret = 0;
        for (auto& p : data) if (p.second) {  // 遍历哈希表,只处理未访问的数字
            int val = p.first, length = 1;

            // 向左扩展:检查val-1, val-2...是否存在
            for (int i = 1; data.count(val - i) && data[val - i]; i++) {
                length++;
                data[val - i] = 0;  // 标记为已访问,避免重复计算
            }

            // 向右扩展:检查val+1, val+2...是否存在  
            for (int i = 1; data.count(val + i) && data[val + i]; i++) {
                length++;
                data[val + i] = 0;  // 标记为已访问,避免重复计算
            }

            ret = max(ret, length);  // 更新最大连续序列长度
        }
        return ret;
    }
};

int main() {
    Solution solution;

    // 测试用例1
    vector<int> nums1 = { 100, 4, 200, 1, 3, 2 };
    cout << "测试用例1 [100,4,200,1,3,2]: " << solution.longestConsecutive(nums1) << " (期望: 4)" << endl;

    // 测试用例2
    vector<int> nums2 = { 0, 3, 7, 2, 5, 8, 4, 6, 0, 1 };
    cout << "测试用例2 [0,3,7,2,5,8,4,6,0,1]: " << solution.longestConsecutive(nums2) << " (期望: 9)" << endl;

    // 测试用例3
    vector<int> nums3 = { 1, 0, 1, 2 };
    cout << "测试用例3 [1,0,1,2]: " << solution.longestConsecutive(nums3) << " (期望: 3)" << endl;

    // 测试用例4 - 空数组
    vector<int> nums4 = {};
    cout << "测试用例4 []: " << solution.longestConsecutive(nums4) << " (期望: 0)" << endl;

    return 0;
}

🎯 总结:这道题教会了我们什么?

《最长连续序列》是一道典型的“哈希表 + 智能遍历”题,展现了如何利用哈希表快速查找 + 标记机制避免重复计算,实现 O(n) 时间复杂度的解决方案。

  • 它不仅是面试常客,更是后续区间问题、连通性问题的基础模型
  • 掌握它,你就迈出了“哈希思维”的第三步!🚀
  • 它融合了 哈希表、状态标记、双向扩展、边界判断 等多个核心技能,堪称“数据结构综合运用”的典范。

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第4题 —— 移动零(简单)

🔹 题目:给定一个整数数组,将所有 0 移动到末尾,同时保持非零元素的相对顺序不变。
🔹 核心思路:使用双指针技术,一个指针遍历数组,另一个指针记录下一个非零元素应放置的位置。
🔹 考点:双指针、原地修改、空间优化。
🔹 难度:简单,但却是很多“数组重构”类问题的入门基础,务必掌握!

💡 提示:不要用 removepush_back,那样会增加额外空间开销!


📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!