一、引言:一个看似简单的「找不同」问题
今天遇到一道有趣的算法题:给定一个含 n
个整数的数组 nums
,其中每个元素都在 [1, n]
范围内,要求找出所有在 [1, n]
中但未出现在数组中的数字。
这让我想起小时候玩的「找错题」游戏 ------ 看似简单,却暗藏玄机。本文将结合具体代码,分享解题思路、关键知识点及容易踩坑的细节,方便日后复习。
二、解题思路:用「标记法」揪出消失的数字
核心逻辑:给出现的数字「贴标签」
我们可以想象有一个「花名册」(标记数组 arr
),记录 1
到 n
每个数字是否出现过。
遍历原数组时,给出现的数字在花名册中打勾(标记为 1
)。最后扫描花名册,没打勾的数字就是消失的数字。
代码实现步骤
动态创建花名册 :根据数组长度 numsSize
创建一个大小为 numsSize + 1
的数组(索引从 0
到 numsSize
,覆盖 1
到 numsSize
的数字范围)。
标记出现的数字 :遍历原数组,将出现的数字在花名册中标记为 1
。
统计消失的数字个数 :再次遍历花名册,统计值为 0
的索引数量(即消失的数字个数)。
收集结果 :根据统计的个数创建结果数组,将值为 0
的索引存入结果数组。
三、代码逐行解析:细节决定成败
int* findDisappearedNumbers(int* nums, int numsSize, int* returnSize) {
// 1. 动态创建标记数组(花名册),大小为 numsSize+1,初始化为0
int* arr = (int*)calloc(numsSize + 1, sizeof(int));
if (!arr) { // 内存分配失败处理
*returnSize = 0;
return NULL;
}
// 2. 遍历原数组,标记出现的数字(给数字贴标签)
for (int i = 0; i < numsSize; i++) {
arr[nums[i]] = 1; // nums[i]是实际出现的数字,将其对应索引标记为1
}
// 3. 统计消失的数字个数(数花名册中没打勾的项)
int count = 0;
for (int i = 1; i <= numsSize; i++) { // 从1开始,因为数字范围是[1, n]
if (arr[i] == 0) { // 未标记的数字就是消失的
count++;
}
}
// 4. 创建结果数组,存储消失的数字
int* ret = (int*)malloc(count * sizeof(int));
if (!ret) { // 再次检查内存分配
*returnSize = 0;
free(arr); // 释放标记数组内存,避免泄漏
return NULL;
}
// 5. 收集结果(把没打勾的索引转化为数字)
int index = 0;
for (int i = 1; i <= numsSize; i++) {
if (arr[i] == 0) {
ret[index++] = i; // 索引i就是消失的数字
}
}
// 6. 返回结果前清理战场
free(arr); // 标记数组用完就释放,好习惯!
*returnSize = count; // 通过指针传递结果数组长度
return ret;
}
四、知识点总结:从算法到内存管理的必备技能
1. 动态内存分配:calloc
vs malloc
calloc(n, size)
:分配 n
个大小为 size
的连续内存块,并初始化为 0
。
✅ 优势:无需手动初始化数组,适合标记数组这种需要清零的场景。
malloc(size)
:仅分配内存,不初始化,内容为随机值。
⚠️ 注意:若用 malloc
创建标记数组,需手动循环赋值为 0
,否则会出错。
2. 数组越界:为什么 numsSize+1
是关键?
题目中数字范围是 [1, n]
,而数组索引从 0
开始。若数组大小为 numsSize
,最大索引是 numsSize-1
,无法容纳数字 numsSize
(如 n=5
时,数字 5
对应索引 5
,数组大小需至少为 6
)。
公式推导 :数组大小 = 最大数字(n
) + 1(索引从 0
开始)→ numsSize + 1
。
3. 内存泄漏:free
的正确姿势
动态分配的内存(calloc
/malloc
)必须手动释放,否则会导致内存泄漏。
原则:谁申请,谁释放,且只释放一次。
free(arr); // 标记数组用完释放
// free(ret); // 不需要在这里释放,由调用者负责
4. 边界条件处理:空指针与特殊输入
空指针检查 :每次 calloc
/malloc
后立即检查返回值是否为 NULL
,避免后续操作崩溃。
特殊输入 :当 nums
为空数组时(虽然题目中 n≥1
),代码仍能正确处理(count=0
,返回空数组)。
五、常见错误与调试经验
错误 1:固定大小数组导致越界
int arr[100000]; // 错误示范:硬编码数组大小
❌ 问题:当 n>100000
时,访问 arr[n]
会越界,触发「runtime error」。
✅ 解决方案:永远根据输入动态分配数组大小,避免写死数值。
错误 2:忘记初始化标记数组
c
int* arr = (int*)malloc((numsSize+1)*sizeof(int)); // 未初始化
❌ 问题:malloc
分配的内存内容是随机的,可能包含非零值,导致误判数字未出现。
✅ 解决方案:用 calloc
替代 malloc
,或手动循环赋值为 0
。
错误 3:遗漏释放内存
// 省略 free(arr) // 错误示范:内存泄漏!
❌ 后果:程序运行后占用的内存不会自动释放,长时间运行可能导致内存不足。
✅ 习惯:养成「先分配,后释放」的成对编写习惯,可在代码中用注释标记释放位置。
六、扩展思考:空间优化与进阶解法
1. 空间优化:原地标记法(不使用额外数组)
如果不允许使用额外空间,可以利用原数组「原地标记」:
遍历数组时,将 nums[i]-1
位置的元素标记为负数,最后正数索引 + 1 即为消失的数字。
// 伪代码思路
for (int i = 0; i < numsSize; i++) {
int index = abs(nums[i]) - 1;
nums[index] = -abs(nums[index]);
}
// 收集正数索引+1
2. 数学方法:利用等差数列求和
计算 1+2+...+n
的和 sum_n
,减去数组元素总和 sum_nums
,差值即为消失数字的和。但此方法仅适用于单个消失数字的场景。
七、结语:小问题,大收获
这道题看似简单,却考察了动态内存管理、边界条件处理、算法优化等多个核心知识点。通过这次练习,我深刻体会到:
细节决定成败:数组大小、初始化、内存释放等细节稍有不慎就会导致错误。
算法思维的灵活性:同一问题可以有多种解法(标记法、原地修改、数学法),需根据场景选择最优方案。
未来复习时,要重点关注动态内存分配的流程、越界问题的排查思路,以及不同解法的适用场景。编程之路,就是在不断踩坑与填坑中成长的!💪