🧩 128.最长连续序列 —— 哈希表 + 双向扩展标记法
💡 题目链接:leetcode.cn/problems/lo…
🔍 难度:中等 | 🏷️ 标签:哈希表、数组、双指针(变种)
⏱️ 目标时间复杂度:O(n)
💾 空间复杂度:O(n)
🧠 题目解析:从“无序”到“连续”的思维跃迁
给定一个未排序的整数数组 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, ... 是否存在于哈希表中且尚未被访问。 -
终止条件有两个:
data.count(val - i)→ 数字不存在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) ✅ |
| 是否最优 | 是,无法进一步优化 |
🔍 详细解释:
-
每个数字最多被访问两次:
- 一次是在构建哈希表时(
data[x] = 1) - 一次是在扩展过程中(无论是作为起点还是中间元素)
- 一次是在构建哈希表时(
-
虽然有嵌套循环,但不会超线性增长:
- 外层遍历哈希表,共 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!
🧠 面试常考点 & 技巧总结
🔹 面试官可能会问的问题:
-
为什么不直接排序?
- 排序是 O(n log n),不符合 O(n) 要求。
- 且排序破坏了原始顺序,无法支持某些高级需求。
-
能不能用 set?
- 可以,但不如 map 方便做标记。
- 如果用
set,你需要额外维护一个集合来记录已访问元素,或者改用unordered_set+ 遍历策略。
-
有没有办法只遍历一次?
- 有的!可以通过“只从最小值开始扩展”来减少重复。
- 优化版本:只在
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移动到末尾,同时保持非零元素的相对顺序不变。
🔹 核心思路:使用双指针技术,一个指针遍历数组,另一个指针记录下一个非零元素应放置的位置。
🔹 考点:双指针、原地修改、空间优化。
🔹 难度:简单,但却是很多“数组重构”类问题的入门基础,务必掌握!
💡 提示:不要用
remove或push_back,那样会增加额外空间开销!
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!