1. 问题背景与解题思路
LeetCode 128题"最长连续序列"是一个经典的算法问题,它要求我们找到一个无序整数数组中最长的连续数字序列的长度。这里的"连续"指的是数字本身连续(如[100,4,200,1,3,2]的最长连续序列是[1,2,3,4],长度为4),而不是数组中的位置连续。
这个问题看似简单,但要在O(n)时间复杂度内解决却需要一些巧妙的思路。最直观的暴力解法是对每个数字都检查它是否能作为某个连续序列的起点,然后向后查找连续的数字,但这种解法的时间复杂度是O(n²)。我们需要更高效的算法。
在C语言中实现这个算法,我们需要特别注意指针操作、内存管理和数据结构的选择。C语言不像高级语言那样有现成的哈希表实现,这增加了问题的挑战性。
2. 哈希表解法与C语言实现
2.1 算法核心思路
最优解法使用哈希表来存储所有数字,这样我们可以在O(1)时间内检查一个数字是否存在。算法的基本步骤如下:
- 将所有数字存入哈希表
- 对于每个数字,检查它是否是某个连续序列的起点(即num-1不在哈希表中)
- 如果是起点,则向后查找连续的数字,统计序列长度
- 记录遇到的最大长度
2.2 C语言中的哈希表实现
由于C标准库没有内置哈希表,我们需要自己实现或使用第三方库。这里我们展示一个简单的哈希表实现:
c复制#define HASH_SIZE 10000
typedef struct HashNode {
int key;
UT_hash_handle hh;
} HashNode;
HashNode* hashTable = NULL;
void addToHash(int num) {
HashNode* node = (HashNode*)malloc(sizeof(HashNode));
node->key = num;
HASH_ADD_INT(hashTable, key, node);
}
bool isInHash(int num) {
HashNode* node;
HASH_FIND_INT(hashTable, &num, node);
return node != NULL;
}
void freeHash() {
HashNode *current, *tmp;
HASH_ITER(hh, hashTable, current, tmp) {
HASH_DEL(hashTable, current);
free(current);
}
}
2.3 完整解法实现
c复制#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include "uthash.h"
int longestConsecutive(int* nums, int numsSize) {
if (numsSize == 0) return 0;
// 构建哈希表
for (int i = 0; i < numsSize; i++) {
addToHash(nums[i]);
}
int maxLength = 0;
for (int i = 0; i < numsSize; i++) {
int currentNum = nums[i];
// 检查是否是序列起点
if (!isInHash(currentNum - 1)) {
int currentLength = 1;
while (isInHash(currentNum + 1)) {
currentNum++;
currentLength++;
}
maxLength = currentLength > maxLength ? currentLength : maxLength;
}
}
freeHash();
return maxLength;
}
3. 算法优化与边界条件处理
3.1 时间复杂度分析
这个算法的时间复杂度是O(n),因为每个数字最多被访问两次:一次是加入哈希表,一次是在寻找连续序列时。虽然有一个嵌套的while循环,但整体上每个元素只会被访问固定次数。
3.2 边界条件处理
在实际实现中,我们需要特别注意以下边界条件:
- 空数组输入:直接返回0
- 数组中包含重复元素:哈希表会自动去重
- 整数溢出:当处理INT_MAX和INT_MIN时需要特别注意
- 内存管理:确保正确释放哈希表内存
3.3 空间复杂度优化
哈希表的空间复杂度是O(n),这是无法避免的。但在实际实现中,我们可以考虑:
- 使用更紧凑的哈希表实现
- 对于已知范围的数字,可以使用位图代替哈希表
- 在内存受限的环境中,可以考虑外部排序后再处理
4. 替代解法与性能比较
4.1 排序解法
如果不考虑O(n)时间复杂度的要求,我们可以先排序数组,然后寻找最长连续序列:
c复制int compare(const void* a, const void* b) {
return (*(int*)a - *(int*)b);
}
int longestConsecutiveSort(int* nums, int numsSize) {
if (numsSize == 0) return 0;
qsort(nums, numsSize, sizeof(int), compare);
int maxLength = 1;
int currentLength = 1;
for (int i = 1; i < numsSize; i++) {
if (nums[i] == nums[i-1]) continue; // 跳过重复
if (nums[i] == nums[i-1] + 1) {
currentLength++;
} else {
maxLength = currentLength > maxLength ? currentLength : maxLength;
currentLength = 1;
}
}
return currentLength > maxLength ? currentLength : maxLength;
}
这种解法的时间复杂度是O(nlogn),空间复杂度是O(1)(如果忽略排序需要的栈空间)。虽然不如哈希表解法高效,但在某些情况下可能是更实际的选择。
4.2 并查集解法
另一种思路是使用并查集(Union-Find)数据结构,将连续的数字合并到同一个集合中:
c复制typedef struct {
int parent;
int size;
} UnionFind;
UnionFind* createUnionFind(int size) {
UnionFind* uf = (UnionFind*)malloc(size * sizeof(UnionFind));
for (int i = 0; i < size; i++) {
uf[i].parent = i;
uf[i].size = 1;
}
return uf;
}
int find(UnionFind* uf, int x) {
if (uf[x].parent != x) {
uf[x].parent = find(uf, uf[x].parent); // 路径压缩
}
return uf[x].parent;
}
void unionSets(UnionFind* uf, int x, int y) {
int rootX = find(uf, x);
int rootY = find(uf, y);
if (rootX != rootY) {
if (uf[rootX].size < uf[rootY].size) {
uf[rootX].parent = rootY;
uf[rootY].size += uf[rootX].size;
} else {
uf[rootY].parent = rootX;
uf[rootX].size += uf[rootY].size;
}
}
}
int longestConsecutiveUnionFind(int* nums, int numsSize) {
if (numsSize == 0) return 0;
// 创建并查集和哈希表
UnionFind* uf = createUnionFind(numsSize);
HashNode* hashTable = NULL;
// 第一遍:建立数字到索引的映射
for (int i = 0; i < numsSize; i++) {
HashNode* node = (HashNode*)malloc(sizeof(HashNode));
node->key = nums[i];
HASH_ADD_INT(hashTable, key, node);
}
// 第二遍:合并连续数字
for (int i = 0; i < numsSize; i++) {
int num = nums[i];
HashNode* node;
// 检查num+1是否存在
int nextNum = num + 1;
HASH_FIND_INT(hashTable, &nextNum, node);
if (node != NULL) {
// 找到对应的索引
int j;
for (j = 0; j < numsSize; j++) {
if (nums[j] == nextNum) break;
}
unionSets(uf, i, j);
}
}
// 查找最大集合
int maxSize = 0;
for (int i = 0; i < numsSize; i++) {
if (uf[i].size > maxSize) {
maxSize = uf[i].size;
}
}
// 清理
free(uf);
HashNode *current, *tmp;
HASH_ITER(hh, hashTable, current, tmp) {
HASH_DEL(hashTable, current);
free(current);
}
return maxSize;
}
并查集解法的时间复杂度接近O(nα(n)),其中α是反阿克曼函数,在实际应用中几乎可以认为是常数。这种解法在某些特定场景下可能更有优势。
5. 实际应用中的注意事项
5.1 内存管理技巧
在C语言实现中,内存管理是一个重要考虑因素:
- 使用uthash等成熟的哈希表实现可以避免内存泄漏
- 确保在所有退出路径上正确释放内存
- 对于大型数组,考虑分批处理以减少内存峰值使用
5.2 性能优化实践
在实际编码中,我们可以采取以下优化措施:
- 使用更紧凑的数据结构存储数字
- 对于已知范围的数字,使用位图代替哈希表
- 并行处理:将数组分成多个块,分别处理后再合并结果
- 缓存友好访问:尽量顺序访问内存,提高缓存命中率
5.3 测试用例设计
全面的测试用例应该包括:
c复制void testLongestConsecutive() {
// 普通情况
int nums1[] = {100,4,200,1,3,2};
assert(longestConsecutive(nums1, 6) == 4);
// 空数组
int* nums2 = NULL;
assert(longestConsecutive(nums2, 0) == 0);
// 所有元素相同
int nums3[] = {1,1,1};
assert(longestConsecutive(nums3, 3) == 1);
// 包含负数
int nums4[] = {0,-1};
assert(longestConsecutive(nums4, 2) == 2);
// 大数测试
int nums5[] = {2147483647,-2147483648};
assert(longestConsecutive(nums5, 2) == 1);
// 随机测试
int nums6[] = {1,2,0,1};
assert(longestConsecutive(nums6, 4) == 3);
printf("All tests passed!\n");
}
5.4 常见错误与调试技巧
在实现过程中,开发者常会遇到以下问题:
- 忘记处理空输入情况
- 哈希表未正确初始化或释放
- 整数溢出未处理
- 重复元素导致错误计数
- 未正确识别序列起点
调试时可以:
- 打印哈希表内容验证正确性
- 使用小规模测试用例逐步验证
- 检查内存泄漏工具(如Valgrind)的报告
- 添加详细的日志输出跟踪算法执行过程
