文章目录
1. 题目描述
给定一个未排序的整数数组 nums
,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。
请你设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例 1:
输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。
示例 2:
输入:nums = [0,3,7,2,5,8,4,6,0,1]
输出:9
解释:最长数字连续序列是 [0,1,2,3,4,5,6,7,8]。它的长度为 9。
提示:
- 0 <= nums.length <= 10^5
- -10^9 <= nums[i] <= 10^9
2. 理解题目
这道题要求我们从一个未排序的数组中找出最长的连续数字序列的长度。注意,这里的"连续"是指数值上连续(如1,2,3,4),而非原数组中的位置连续。
关键点:
- 数组未排序
- 连续是指数值上连续(相邻数字之差为1)
- 要求时间复杂度为O(n)
- 原数组中可能包含重复元素
3. 解法一:排序法
3.1 思路
最直观的方法是先对数组进行排序,然后遍历排序后的数组,计算连续序列的长度:
- 对数组进行排序
- 初始化当前连续序列长度为1,最长连续序列长度为0(空数组情况)
- 遍历排序后的数组:
- 如果当前元素与前一个元素相同,跳过(去重)
- 如果当前元素与前一个元素差值为1,连续序列长度加1
- 否则,重置当前连续序列长度为1
- 更新最长连续序列长度
3.2 Java代码实现
import java.util.Arrays;
public class Solution {
public int longestConsecutive(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// 对数组进行排序
Arrays.sort(nums);
int currentStreak = 1; // 当前连续序列长度
int longestStreak = 1; // 最长连续序列长度
for (int i = 1; i < nums.length; i++) {
// 跳过重复元素
if (nums[i] == nums[i - 1]) {
continue;
}
// 检查是否连续
if (nums[i] == nums[i - 1] + 1) {
currentStreak++;
} else {
// 重置当前连续序列长度
currentStreak = 1;
}
// 更新最长连续序列长度
longestStreak = Math.max(longestStreak, currentStreak);
}
return longestStreak;
}
}
3.3 代码详解
- 首先检查输入是否为空,如果是,返回0(没有连续序列)
- 使用
Arrays.sort()
对数组进行排序 - 初始化
currentStreak
为1(当前遍历的连续序列长度) - 初始化
longestStreak
为1(最长连续序列长度) - 从第二个元素开始遍历排序后的数组:
- 如果当前元素与前一个元素相同,跳过(去重)
- 如果当前元素正好比前一个元素大1,表示连续序列继续,
currentStreak
加1 - 否则,连续序列中断,重置
currentStreak
为1 - 每次迭代后,更新
longestStreak
为当前找到的最长序列长度
- 最后返回
longestStreak
3.4 复杂度分析
- 时间复杂度:O(n log n),其中n是数组长度。排序需要O(n log n)时间,遍历排序后的数组需要O(n)时间。
- 空间复杂度:O(1),仅使用了常数级别的额外空间。(注意:某些排序算法可能使用O(n)的额外空间)
3.5 适用场景
排序法简单直观,易于理解和实现。当数组长度不是特别大,且对时间复杂度要求不太严格时,可以考虑使用此方法。
3.6 优缺点
优点:
- 代码简洁,易于理解
- 实现简单,不需要额外的数据结构
缺点:
- 时间复杂度为O(n log n),不满足题目要求的O(n)
- 排序会改变原数组的结构(如果要求保持原数组不变,需要额外空间复制)
4. 解法二:哈希集合法
4.1 思路
为了达到O(n)的时间复杂度,我们可以使用哈希集合(HashSet)来优化查找过程:
- 将所有元素放入哈希集合,去除重复元素
- 对于每个元素x,检查x-1是否在集合中
- 如果x-1不在集合中,说明x可能是连续序列的起点
- 从x开始,查找连续的序列x, x+1, x+2, …, x+y
- 更新最长连续序列的长度
这种方法的核心思想是只从可能的序列起点开始查找,避免重复计算。
4.2 Java代码实现
import java.util.HashSet;
import java.util.Set;
public class Solution {
public int longestConsecutive(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// 将所有元素加入集合,去除重复元素
Set<Integer> numSet = new HashSet<>();
for (int num : nums) {
numSet.add(num);
}
int longestStreak = 0;
// 遍历集合中的每个元素
for (int num : numSet) {
// 如果num-1不在集合中,num可能是连续序列的起点
if (!numSet.contains(num - 1)) {
int currentNum = num;
int currentStreak = 1;
// 查找以num开始的连续序列
while (numSet.contains(currentNum + 1)) {
currentNum++;
currentStreak++;
}
// 更新最长连续序列长度
longestStreak = Math.max(longestStreak, currentStreak);
}
}
return longestStreak;
}
}
4.3 代码详解
- 首先检查输入是否为空,如果是,返回0
- 创建一个
HashSet
并将数组中所有元素添加到集合中(自动去除重复元素) - 初始化
longestStreak
为0 - 遍历
HashSet
中的每个元素:- 对于每个元素
num
,检查num-1
是否在集合中 - 如果
num-1
不在集合中,说明num
可能是一个连续序列的起点 - 从
num
开始,不断检查num+1
,num+2
, … 是否在集合中 - 计算从
num
开始的连续序列长度currentStreak
- 更新
longestStreak
为当前找到的最长序列长度
- 对于每个元素
- 最后返回
longestStreak
4.4 复杂度分析
- 时间复杂度:O(n),其中n是数组长度。
- 将元素加入哈希集合需要O(n)时间
- 虽然有嵌套循环,但内层while循环的总执行次数不会超过n,因为每个数字只会被访问一次
- 所以总时间复杂度为O(n)
- 空间复杂度:O(n),哈希集合中存储了所有元素。
4.5 正确性证明
为什么这种方法的时间复杂度是O(n)?
乍看起来,算法中有嵌套循环,可能会被误认为是O(n²)。但实际上:
- 外层循环最多执行n次(哈希集合的大小)
- 对于每个元素,内层的while循环只有在该元素是连续序列的起点时才执行
- 每个元素最多只会被访问一次(在某个连续序列中)
- 因此,所有元素总共最多被访问2次,时间复杂度为O(n)
4.6 适用场景
哈希集合法满足题目要求的O(n)时间复杂度,适用于各种规模的输入数据。这是解决此问题的最优方法。
5. 解法三:哈希表记忆法
5.1 思路
我们还可以使用哈希表来存储每个元素作为端点的连续序列长度,并通过合并相邻序列来计算最长连续序列:
- 使用哈希表,键为数字,值为以该数字为端点的连续序列长度
- 遍历数组,对于每个元素:
- 检查它是否已存在于哈希表中,如果是,跳过
- 查找左右相邻数字(x-1和x+1)的序列长度
- 计算包含当前数字的连续序列长度
- 更新当前数字及其序列端点的长度
- 更新最长连续序列长度
5.2 Java代码实现
import java.util.HashMap;
import java.util.Map;
public class Solution {
public int longestConsecutive(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// 哈希表:键为数字,值为以该数字为端点的连续序列长度
Map<Integer, Integer> map = new HashMap<>();
int longest = 0;
for (int num : nums) {
// 如果该数字已经存在于哈希表中,跳过
if (map.containsKey(num)) {
continue;
}
// 查找左侧相邻数字的序列长度
int left = map.getOrDefault(num - 1, 0);
// 查找右侧相邻数字的序列长度
int right = map.getOrDefault(num + 1, 0);
// 当前数字所在连续序列的总长度
int sum = left + right + 1;
// 更新最长连续序列长度
longest = Math.max(longest, sum);
// 更新当前数字在哈希表中的值
map.put(num, sum);
// 更新当前序列左右端点的值
// 注意:中间的数字不需要更新,因为它们不会再被作为新序列的起点或终点
map.put(num - left, sum);
map.put(num + right, sum);
}
return longest;
}
}
5.3 代码详解
- 首先检查输入是否为空,如果是,返回0
- 创建一个
HashMap
,键为数字,值为以该数字为端点的连续序列长度 - 初始化
longest
为0 - 遍历数组中的每个元素:
- 如果该数字已存在于哈希表中,跳过(避免重复处理)
- 获取左侧相邻数字(num-1)的序列长度
- 获取右侧相邻数字(num+1)的序列长度
- 计算包含当前数字的连续序列总长度
sum = left + right + 1
- 更新最长连续序列长度
longest
- 更新当前数字在哈希表中的值为
sum
- 更新当前序列左右端点在哈希表中的值为
sum
- 最后返回
longest
5.4 复杂度分析
- 时间复杂度:O(n),其中n是数组长度。遍历数组一次,每次哈希表操作的时间复杂度为O(1)。
- 空间复杂度:O(n),哈希表最多存储n个元素。
5.5 适用场景
哈希表记忆法也能达到O(n)的时间复杂度,适用于大规模输入数据。与哈希集合法相比,实现稍复杂,但理论上可以减少某些场景下的重复计算。
6. 解法四:并查集法
6.1 思路
并查集(Union-Find)是一种处理元素分组的高效数据结构,我们可以用它来解决此问题:
- 创建并查集,每个数字初始为独立的集合
- 遍历数组,将相邻的数字合并到同一个集合中
- 最后,找出最大集合的大小,即为最长连续序列的长度
6.2 Java代码实现
import java.util.*;
public class Solution {
public int longestConsecutive(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
// 创建并查集
Map<Integer, Integer> parent = new HashMap<>();
Map<Integer, Integer> size = new HashMap<>();
Set<Integer> numSet = new HashSet<>();
// 初始化并查集
for (int num : nums) {
if (!numSet.contains(num)) {
parent.put(num, num); // 每个元素的父节点初始为自己
size.put(num, 1); // 每个集合的初始大小为1
numSet.add(num); // 添加到集合中去重
}
}
// 合并相邻的集合
for (int num : numSet) {
// 检查num+1是否存在,如果存在则合并
if (numSet.contains(num + 1)) {
union(parent, size, num, num + 1);
}
}
// 查找最大集合的大小
int maxSize = 0;
for (int s : size.values()) {
maxSize = Math.max(maxSize, s);
}
return maxSize;
}
// 查找操作,返回元素x的根节点
private int find(Map<Integer, Integer> parent, int x) {
if (parent.get(x) != x) {
// 路径压缩
parent.put(x, find(parent, parent.get(x)));
}
return parent.get(x);
}
// 合并操作,将元素x和元素y所在的集合合并
private void union(Map<Integer, Integer> parent, Map<Integer, Integer> size, int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX != rootY) {
// 合并两个集合,将较小的集合合并到较大的集合
if (size.get(rootX) < size.get(rootY)) {
parent.put(rootX, rootY);
size.put(rootY, size.get(rootX) + size.get(rootY));
} else {
parent.put(rootY, rootX);
size.put(rootX, size.get(rootX) + size.get(rootY));
}
}
}
}
6.3 代码详解
- 首先检查输入是否为空,如果是,返回0
- 创建三个映射表:
parent
:存储每个元素的父节点size
:存储以每个元素为根的集合的大小numSet
:用于去除重复元素
- 初始化并查集:
- 每个元素的父节点初始为自己
- 每个集合的初始大小为1
- 遍历去重后的元素集合,合并相邻的集合:
- 如果num+1存在,则将num和num+1所在的集合合并
- 查找最大集合的大小
find
方法用于查找元素的根节点,采用路径压缩优化union
方法用于合并两个集合,采用按大小合并的优化
6.4 复杂度分析
- 时间复杂度:O(n),其中n是数组长度。
- 初始化并查集需要O(n)时间
- 合并操作总共执行不超过n次
- 查找操作的平均时间复杂度接近O(1)(使用路径压缩后)
- 总时间复杂度为O(n)
- 空间复杂度:O(n),需要三个大小为O(n)的映射表。
6.5 适用场景
并查集法适用于需要动态连接元素的场景,尤其是当问题可以转化为"将相邻元素合并到同一组"的形式时。这种方法在处理大规模数据时也很高效。
7. 特殊情况和边界处理
在实现解决方案时,我们需要考虑以下特殊情况:
-
空数组:当输入为null或空数组时,应返回0。
if (nums == null || nums.length == 0) { return 0; }
-
数组只有一个元素:此时最长连续序列长度为1。
if (nums.length == 1) { return 1; }
-
数组中有重复元素:需要去除重复元素再计算。哈希集合法和哈希表法已经考虑了这一点。
-
数值溢出:输入范围是[-10^9, 10^9],可能需要考虑整数溢出问题。不过在常见的编程语言中,如Java的整数范围足够处理这些数值。
-
极长序列:如果连续序列非常长,接近数组大小,各种方法的性能可能会有差异。哈希集合法和哈希表法在这种情况下都能有效处理。
8. 性能优化与改进
8.1 哈希集合法的预分配优化
如果知道数组大小,可以预分配哈希集合的容量,减少哈希冲突和再哈希操作:
Set<Integer> numSet = new HashSet<>(nums.length);
8.2 减少不必要的检查
在哈希集合法中,我们可以提前检查是否需要继续查找:
int longestStreak = 0;
for (int num : numSet) {
if (!numSet.contains(num - 1)) {
int currentNum = num;
int currentStreak = 1;
// 提前检查可能的最大长度
if (longestStreak > nums.length - numSet.indexOf(num)) {
continue; // 不可能产生更长的序列了
}
while (numSet.contains(currentNum + 1)) {
currentNum++;
currentStreak++;
}
longestStreak = Math.max(longestStreak, currentStreak);
}
}
注意:上述优化在实际中可能无法实现,因为HashSet不支持indexOf操作。这只是一个思路示例。
8.3 并行计算
对于非常大的数组,可以考虑使用并行计算:
// 使用Java 8的并行流
public int longestConsecutive(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
Set<Integer> numSet = Arrays.stream(nums).boxed().collect(Collectors.toSet());
return numSet.parallelStream()
.filter(num -> !numSet.contains(num - 1))
.mapToInt(num -> {
int currentStreak = 1;
while (numSet.contains(num + currentStreak)) {
currentStreak++;
}
return currentStreak;
})
.max()
.orElse(0);
}
9. 完整的 Java 解决方案
以下是整合了各种优化的哈希集合法完整解决方案:
import java.util.HashSet;
import java.util.Set;
class Solution {
public int longestConsecutive(int[] nums) {
// 处理边界情况
if (nums == null || nums.length == 0) {
return 0;
}
if (nums.length == 1) {
return 1;
}
// 将所有元素加入集合,去除重复元素
Set<Integer> numSet = new HashSet<>(nums.length);
for (int num : nums) {
numSet.add(num);
}
int longestStreak = 0;
// 遍历集合中的每个元素
for (int num : numSet) {
// 如果num-1不在集合中,num是连续序列的起点
if (!numSet.contains(num - 1)) {
int currentNum = num;
int currentStreak = 1;
// 查找以num开始的连续序列
while (numSet.contains(currentNum + 1)) {
currentNum++;
currentStreak++;
}
// 更新最长连续序列长度
longestStreak = Math.max(longestStreak, currentStreak);
// 优化:如果找到的序列长度等于集合大小,不可能有更长的序列
if (currentStreak == numSet.size()) {
break;
}
}
}
return longestStreak;
}
}
10. 实际应用与扩展
10.1 应用场景
最长连续序列问题在以下场景中有实际应用:
- 数据分析:查找数据集中的连续区间
- 网络分析:查找连续的网络节点或IP地址
- 游戏开发:检测连续的游戏元素(如连珠游戏)
- 时间序列分析:查找连续的时间点或事件
10.2 扩展问题
-
最长连续递增序列:查找数组中最长的严格递增连续子序列
// 例如:[1,3,5,4,7] 中最长连续递增序列是 [1,3,5],长度为3 public int findLengthOfLCIS(int[] nums) { if (nums == null || nums.length == 0) return 0; int maxLength = 1; int currentLength = 1; for (int i = 1; i < nums.length; i++) { if (nums[i] > nums[i - 1]) { currentLength++; maxLength = Math.max(maxLength, currentLength); } else { currentLength = 1; } } return maxLength; }
-
连续序列的范围:不仅返回长度,还返回序列的起点和终点
public int[] longestConsecutiveRange(int[] nums) { if (nums == null || nums.length == 0) return new int[]{0, 0, 0}; Set<Integer> numSet = new HashSet<>(); for (int num : nums) numSet.add(num); int longestStreak = 0; int start = 0; int end = 0; for (int num : numSet) { if (!numSet.contains(num - 1)) { int currentNum = num; int currentStreak = 1; while (numSet.contains(currentNum + 1)) { currentNum++; currentStreak++; } if (currentStreak > longestStreak) { longestStreak = currentStreak; start = num; end = currentNum; } } } return new int[]{longestStreak, start, end}; }
11. 常见问题与解答
11.1 为什么不能用排序法?
排序法虽然简单,但时间复杂度为O(n log n),不满足题目要求的O(n)。当数据量大时,性能差异会很明显。
11.2 哈希集合法为什么是O(n)时间复杂度?
虽然有嵌套循环,但内层循环对每个元素最多只执行一次,所以总操作次数不超过2n,时间复杂度为O(n)。
11.3 如何处理大规模数据?
对于非常大的数据集:
- 使用哈希集合法或并查集法,它们都是O(n)复杂度
- 考虑并行计算
- 如果数据无法一次加载到内存,可以分批处理
11.4 如何优化空间使用?
如果空间有限,可以:
- 使用原地排序法,虽然时间复杂度为O(n log n)
- 使用位图(Bitmap)表示元素是否存在,适用于元素范围有限的情况
- 使用基本类型数组而不是对象集合
12. 测试用例
为了验证解决方案的正确性,以下是一些测试用例:
public class LongestConsecutiveSequenceTest {
public static void main(String[] args) {
Solution solution = new Solution();
// 测试用例1:基本测试
int[] nums1 = {100, 4, 200, 1, 3, 2};
testAndPrint(solution, nums1, "测试用例1");
// 测试用例2:更长的序列
int[] nums2 = {0, 3, 7, 2, 5, 8, 4, 6, 0, 1};
testAndPrint(solution, nums2, "测试用例2");
// 测试用例3:空数组
int[] nums3 = {};
testAndPrint(solution, nums3, "测试用例3");
// 测试用例4:只有一个元素
int[] nums4 = {1};
testAndPrint(solution, nums4, "测试用例4");
// 测试用例5:重复元素
int[] nums5 = {1, 1, 2, 2, 3, 3, 4, 4};
testAndPrint(solution, nums5, "测试用例5");
// 测试用例6:负数元素
int[] nums6 = {-7, -6, -5, -4, -3, -2, -1, 0, 1};
testAndPrint(solution, nums6, "测试用例6");
// 测试用例7:不连续元素
int[] nums7 = {5, 10, 15, 20, 25};
testAndPrint(solution, nums7, "测试用例7");
}
private static void testAndPrint(Solution solution, int[] nums, String caseName) {
int result = solution.longestConsecutive(nums);
System.out.println(caseName + ":");
System.out.println("输入: " + Arrays.toString(nums));
System.out.println("输出: " + result);
System.out.println();
}
}
13. 总结与技巧
13.1 解题要点
- 理解连续序列的定义:数值上连续,而非位置上连续
- 时间复杂度要求:需要O(n)算法,排除排序等O(n log n)的方法
- 使用哈希结构:利用哈希表/集合O(1)的查找特性
- 避免重复计算:只从可能的起点开始查找
- 正确处理特殊情况:空数组、单元素数组、重复元素等
13.2 常用技巧
- 哈希查找:将元素放入哈希集合,实现O(1)时间查找
- 序列端点识别:通过检查num-1是否存在来识别序列起点
- 增量扩展:从起点开始,不断检查下一个数是否存在
- 数据去重:使用集合自动去除重复元素
- 路径压缩:在并查集中优化查找操作
13.3 面试技巧
在面试中遇到此类问题时:
- 先讨论简单解法(如排序法),说明你理解问题
- 分析简单解法的时间复杂度,指出不满足O(n)的要求
- 提出使用哈希集合的优化方法
- 解释为什么哈希集合法是O(n)而非O(n²)
- 考虑并讨论特殊情况和边界条件
- 如果有时间,可以提及并查集等其他解法