1. 问题理解与核心挑战
LeetCode 128题要求我们从一个未排序的整数数组中找出最长的连续数字序列的长度。这里的"连续"指的是数字值连续(如1,2,3,4),而不是在数组中的位置连续。这个问题看似简单,但要在O(n)时间复杂度内解决却需要巧妙的算法设计。
关键点:连续序列的定义是数值上连续递增的序列,与元素在原数组中的排列顺序无关。
举个例子,对于数组[100,4,200,1,3,2],最长的连续序列是[1,2,3,4],长度为4。而数字100和200虽然数值很大,但它们没有形成足够长的连续序列。
2. 暴力解法与优化思路
2.1 直观的暴力解法
最直观的解法是对每个数字,检查其+1、+2...是否存在于数组中,记录最长的连续序列。这种方法的时间复杂度是O(n³),因为:
- 外层循环遍历每个数字:O(n)
- 对每个数字,检查连续序列:最坏O(n)
- 每次检查数字是否存在:O(n)(如果用线性查找)
显然,这种解法在数据量较大时完全不可行。
2.2 优化方向
为了优化时间复杂度,我们需要解决两个关键问题:
- 快速查找:如何快速判断一个数字是否存在于数组中?
- 避免重复计算:如何确保每个连续序列只被计算一次?
哈希表(HashSet)可以完美解决第一个问题,提供O(1)时间复杂度的查找操作。对于第二个问题,我们需要找到一种方法,只从每个连续序列的最小值(起点)开始扩展。
3. 最优解法详解
3.1 算法核心思想
最优解法的核心在于:
- 将所有数字存入哈希集合,实现O(1)时间的查找
- 遍历数组,对于每个数字x:
- 如果x-1不存在于集合中,说明x是一个连续序列的起点
- 从x开始,不断检查x+1、x+2...是否在集合中,统计序列长度
- 同时删除已访问的数字,避免重复处理
- 记录遇到的最长序列长度
3.2 为什么这样高效?
这个算法的高效性体现在:
- 哈希集合的O(1)操作:插入、查找、删除都是O(1)时间
- 避免重复计算:通过只从序列起点扩展,确保每个连续序列只被处理一次
- 及时删除已访问元素:虽然遍历的是原数组,但通过删除集合中的元素,避免了重复处理
3.3 时间复杂度分析
每个数字最多被:
- 插入集合一次:O(1)
- 查找一次:O(1)
- 删除一次:O(1)
因此总时间复杂度是O(n),满足题目要求。
4. 代码实现与逐行解析
以下是C++的完整实现,附带详细注释:
cpp复制class Solution {
public:
int longestConsecutive(vector<int>& nums) {
unordered_set<int> numSet;
// 将所有数字存入哈希集合,自动去重
for (int num : nums) {
numSet.insert(num);
}
int maxLength = 0;
for (int num : nums) {
// 只有当当前数字是序列起点时才处理
if (numSet.find(num - 1) == numSet.end()) {
int currentNum = num;
int currentLength = 1;
// 向后扩展序列
while (numSet.find(currentNum + 1) != numSet.end()) {
currentNum++;
currentLength++;
numSet.erase(currentNum); // 删除已访问元素,避免重复处理
}
// 更新最大长度
maxLength = max(maxLength, currentLength);
}
}
return maxLength;
}
};
4.1 关键代码解析
-
哈希集合初始化:
cpp复制unordered_set<int> numSet; for (int num : nums) { numSet.insert(num); }将所有数字存入哈希集合,自动去重,为后续O(1)查找做准备。
-
序列起点判断:
cpp复制if (numSet.find(num - 1) == numSet.end())只有当num-1不存在时,num才是序列起点,避免重复计算。
-
序列扩展:
cpp复制while (numSet.find(currentNum + 1) != numSet.end()) { currentNum++; currentLength++; numSet.erase(currentNum); }从起点向后扩展,统计序列长度,同时删除已访问元素。
5. 示例演示与逐步推演
让我们用示例nums = [100,4,200,1,3,2]来逐步推演算法执行过程:
-
初始化阶段:
- 哈希集合:
-
遍历处理:
- 处理100:
- 检查99是否存在?否 → 100是起点
- 向后扩展:101不存在
- 序列长度=1,maxLength=1
- 处理4:
- 检查3是否存在?是 → 跳过
- 处理200:
- 检查199不存在?是 → 200是起点
- 向后扩展:201不存在
- 序列长度=1,maxLength保持1
- 处理1:
- 检查0不存在?是 → 1是起点
- 向后扩展:
- 2存在 → currentNum=2, length=2
- 3存在 → currentNum=3, length=3
- 4存在 → currentNum=4, length=4
- 5不存在 → 停止
- maxLength更新为4
- 处理3和2:
- 由于它们已经在处理1时被删除,跳过
- 处理100:
-
最终结果:
- 最长连续序列长度为4
6. 边界情况与注意事项
6.1 边界情况处理
-
空数组输入:
- 应返回0
- 代码中maxLength初始化为0,自动处理这种情况
-
所有数字相同:
- 如[1,1,1],应返回1
- 哈希集合自动去重,会正确处理
-
连续序列跨越负数:
- 如[-1,0,1],应返回3
- 算法不依赖数字大小,能正确处理
6.2 注意事项
-
删除已访问元素:
- 这是保证O(n)时间复杂度的关键
- 虽然遍历原数组,但通过删除集合元素避免重复处理
-
哈希集合的选择:
- C++中使用unordered_set保证O(1)操作
- 其他语言类似,如Python的set,Java的HashSet
-
替代实现:
- 可以不删除元素,而是用一个visited集合记录已访问元素
- 但这样会增加空间复杂度
7. 算法变种与扩展思考
7.1 不修改原始集合的实现
如果不希望修改原始集合,可以使用额外的visited集合来记录已访问元素:
cpp复制int longestConsecutive(vector<int>& nums) {
unordered_set<int> numSet(nums.begin(), nums.end());
unordered_set<int> visited;
int maxLength = 0;
for (int num : nums) {
if (visited.find(num) != visited.end()) continue;
if (numSet.find(num - 1) == numSet.end()) {
int current = num;
int length = 1;
while (numSet.find(current + 1) != numSet.end()) {
current++;
length++;
visited.insert(current);
}
maxLength = max(maxLength, length);
}
}
return maxLength;
}
这种实现空间复杂度略高,但保持了原始集合不变。
7.2 并查集解法
这个问题也可以用并查集(Union-Find)来解决:
- 将每个数字视为一个独立集合
- 对于每个数字,如果num+1存在,合并这两个集合
- 最后找出最大的集合
虽然时间复杂度接近O(n),但实现比哈希法复杂,常数因子较大。
7.3 实际应用场景
这种算法思想可以应用于:
- 社交网络中的连续活跃天数统计
- 日志分析中的连续事件检测
- 基因组序列中的连续模式识别
8. 不同语言实现对比
8.1 Python实现
python复制def longestConsecutive(nums):
num_set = set(nums)
max_length = 0
for num in num_set:
if num - 1 not in num_set: # 检查是否是序列起点
current_num = num
current_length = 1
while current_num + 1 in num_set:
current_num += 1
current_length += 1
max_length = max(max_length, current_length)
return max_length
Python的实现更加简洁,利用了set的特性。
8.2 Java实现
java复制class Solution {
public int longestConsecutive(int[] nums) {
Set<Integer> numSet = new HashSet<>();
for (int num : nums) {
numSet.add(num);
}
int maxLength = 0;
for (int num : nums) {
if (!numSet.contains(num - 1)) {
int currentNum = num;
int currentLength = 1;
while (numSet.contains(currentNum + 1)) {
currentNum++;
currentLength++;
}
maxLength = Math.max(maxLength, currentLength);
}
}
return maxLength;
}
}
Java实现与C++类似,注意使用HashSet和自动装箱。
9. 常见错误与调试技巧
9.1 常见错误
-
忽略时间复杂度要求:
- 使用排序后扫描的方法(O(nlogn)),不符合题目要求
-
重复计算:
- 没有正确判断序列起点,导致多次处理同一序列
-
边界条件处理不当:
- 空数组、全相同数字、负数等情况未考虑
9.2 调试技巧
-
打印中间状态:
- 在序列扩展时打印当前数字和长度
- 观察哈希集合的变化
-
小规模测试用例:
- 先用简单例子验证,如[1,2,0,1]
- 再逐步增加复杂度
-
性能测试:
- 对于大数组(如10^5个元素),验证算法是否真的O(n)
10. 算法优化与进阶思考
10.1 进一步优化
虽然当前算法已经是O(n),但可以做一些微优化:
-
遍历集合而非原数组:
- 原数组可能有重复,遍历集合可以避免重复判断
- 但需要额外空间存储集合
-
提前终止:
- 当剩余未处理数字不可能超过当前maxLength时,可以提前终止
10.2 相关题目拓展
掌握这个算法后,可以解决一系列类似问题:
- 最长递增子序列(非连续,更复杂)
- 数组中的连续元素求和问题
- 寻找缺失的最小正整数
10.3 实际工程应用
在实际工程中,这种算法思想可以用于:
- 用户行为分析:找出连续活跃天数
- 日志分析:检测连续的错误事件
- 时间序列处理:寻找连续的时间段
这个问题的解法展示了如何利用合适的数据结构(哈希集合)将看似O(n²)的问题优化到O(n),是算法设计中空间换时间的经典案例。掌握这种思想对解决其他复杂问题大有裨益。