1 题目
提示
给你一个含 n
个整数的数组 nums
,其中 nums[i]
在区间 [1, n]
内。请你找出所有在 [1, n]
范围内但没有出现在 nums
中的数字,并以数组的形式返回结果。
示例 1:
输入:nums = [4,3,2,7,8,2,3,1] 输出:[5,6]
示例 2:
输入:nums = [1,1] 输出:[2]
提示:
n == nums.length
1 <= n <= 105
1 <= nums[i] <= n
进阶:你能在不使用额外空间且时间复杂度为 O(n)
的情况下解决这个问题吗? 你可以假定返回的数组不算在额外空间内。
2 我的想法
一共是1到n的区间,从1取到n,长度也刚好是n。
存在消失的数,也就是对应找一下没有在1到n出现的数,直接hash?很笨的办法,循环比对这个数字是否出现过,如果重复出现了就说明有消失的。。。啊,不知道怎么弄!
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
}
}
3 关于我的想法
你的想法 “用 hash 表存数字、再循环比对 1 到 n 里没出现的数”,完全可行! 先肯定你的思路 —— 这是最直接、最容易想明白的办法,一点都不 “笨”,反而很符合正常人解决问题的逻辑。
不过咱们可以拆解开,聊聊它的 “好” 和 “可以优化的地方”,帮你更清楚它的可行性边界:
1. 先讲你的思路具体怎么实现(帮你把想法落地)
你的核心逻辑是 “记录出现过的数,再找没记录的”,用 Java 的HashSet
就能实现,步骤特别简单:
- 第一步:把数组里所有数字都放进
HashSet
(因为HashSet
不存重复值,刚好能自动过滤掉重复的数); - 第二步:从 1 到 n 逐个检查 —— 如果某个数不在
HashSet
里,就说明它是 “消失的数”,把它放进结果里。
比如示例 1:nums = [4,3,2,7,8,2,3,1]
- 第一步放进
HashSet
后,里面是{1,2,3,4,7,8}
; - 第二步从 1 查到 8:发现 5、6 不在集合里,所以结果就是
[5,6]
,和题目要求完全一致。
2. 再聊可行性:优点很明显,也有个小 “代价”
优点(为什么可行):
- 逻辑简单:不用绕弯子,“存出现的→找没存的”,一眼就能看懂,写代码也不容易出错;
- 时间能接受:
HashSet
的 “存” 和 “查” 操作都是 O (1),整个过程要遍历数组(O (n))+ 遍历 1 到 n(O (n)),总时间还是 O (n),满足题目 “时间复杂度 O (n)” 的要求。
小 “代价”(为什么题目会有 “不使用额外空间” 的进阶要求):
- 需要额外的空间:
HashSet
最多要存 n 个数字(如果数组里没有重复),所以空间复杂度是 O (n)—— 这不符合题目 “进阶要求”,但完全符合基础要求,用来解决问题是没问题的。
3. 总结:你的想法 “能解决问题”,但可以更优
- 可行性结论:100% 可行!用
HashSet
的实现能正确得出所有消失的数字,而且代码好写好懂; - 和最优解的区别:最优解(之前给你的 “负数标记法”)是利用数组本身做标记,不需要额外空间(空间 O (1)),而你的思路需要
HashSet
的额外空间(空间 O (n))—— 但这只是 “优不优” 的区别,不是 “能不能用” 的区别。
按照这个思路实现的代码:
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
// 创建一个哈希集合,用来存数组中出现过的数字
Set<Integer> seen = new HashSet<>();
// 把数组里所有数字都放进集合(自动去重)
for (int num : nums) {
seen.add(num);
}
// 准备一个列表,用来存消失的数字
List<Integer> result = new ArrayList<>();
// 从1到n逐个检查,如果不在集合里,就是消失的数字
for (int i = 1; i <= nums.length; i++) {
if (!seen.contains(i)) {
result.add(i);
}
}
return result;
}
}
这段代码的逻辑和你想的完全一致:
- 先用 HashSet 记住数组里出现过的所有数字(重复的会自动忽略)
- 然后从 1 到 n 一个一个检查,没在 HashSet 里的就是消失的数字
- 把这些消失的数字收集起来返回
比如输入 [4,3,2,7,8,2,3,1]:
- 集合里会有 {1,2,3,4,7,8}
- 检查 1 到 8 时,发现 5 和 6 不在集合里,就把它们加入结果
4 利用数组本身来做标记
问题是要找出 1 到 n 中那些没有出现在数组里的数字,其中 n 是数组的长度。
思路很巧妙:我们可以利用数组本身来做标记,这样就不用额外的空间了。
具体做法:
- 遍历数组中的每个数字
- 把这个数字对应的数组位置做个标记(表示这个数字存在)
- 最后再遍历一次,看看哪些位置没有被标记,那些位置对应的数字就是消失的数字
举个例子,如果你看到数字 3,就去把数组第 3 个位置(索引 2)的数改成负数,表示 3 存在。
下面是代码实现:
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
// 遍历数组中的每个数字
for (int i = 0; i < nums.length; i++) {
// 取当前数字的绝对值(因为可能已经被标记成负数了)
int num = Math.abs(nums[i]);
// 计算这个数字对应的索引位置(减1是因为数组从0开始)
int index = num - 1;
// 如果这个位置的数是正数,就把它变成负数,做个标记
if (nums[index] > 0) {
nums[index] = -nums[index];
}
}
// 收集所有消失的数字
List<Integer> result = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
// 如果某个位置还是正数,说明对应的数字没出现过
if (nums[i] > 0) {
// 索引加1就是对应的数字
result.add(i + 1);
}
}
return result;
}
}
这样解释是不是很清楚?这个方法既不用额外空间,又能在 O (n) 时间内完成,完全符合题目的进阶要求。
简单说就是:看到谁就给谁家里做个标记,最后没标记的家庭就是我们要找的。
5 原地修改法
核心思路和 “负数标记法” 类似,但用了 “加 n” 的方式来做标记,我用大白话给你解释下:
class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
int n = nums.length;
for (int num : nums) {
int x = (num - 1) % n;
if (nums[x] <= n) {
nums[x] += n;
}
}
List<Integer> ret = new ArrayList<Integer>();
for (int i = 0; i < n; i++) {
if (nums[i] <= n) {
ret.add(i + 1);
}
}
return ret;
}
}
核心逻辑:用 “加 n” 做标记,区分 “出现过” 和 “没出现过”
数组长度是 n,数字范围是 1~n。
- 如果某个数字
num
出现过,就给它对应位置(num-1
)的数 “加 n” - 最后检查:如果某个位置的数没超过 n,说明对应的数字没出现过
分步拆解(拿示例 1 nums = [4,3,2,7,8,2,3,1]
举例)
第一次遍历:给出现过的数字做标记
遍历每个数字num
:- 计算它应该标记的位置:
x = (num - 1) % n
(取模是因为之前的数可能已经被加过 n 了,需要还原真实值) - 如果这个位置的数还没被标记(
<=n
),就给它加 n(变成>n
,表示 “对应数字出现过”)
比如:
- 第一个数是 4 → 位置是 3 →
nums[3]
原本是 7 → 7+8=15(现在nums[3]=15
,表示 4 出现过) - 第二个数是 3 → 位置是 2 →
nums[2]
原本是 2 → 2+8=10(表示 3 出现过) - ... 以此类推,最后数组会变成:
[12, 19, 18, 15, 8, 2, 11, 9]
- 计算它应该标记的位置:
第二次遍历:找没被标记的位置
遍历数组,看每个位置的数是否<=n
:- 如果
<=n
,说明这个位置对应的数字没出现过(因为没被加过 n) - 位置 i 对应的数字是
i+1
,加入结果
示例中:
- 位置 4 的数是 8(<=8)→ 对应数字 5 没出现
- 位置 5 的数是 2(<=8)→ 对应数字 6 没出现
- 所以结果是
[5,6]
- 如果
为什么这样可行?
- 不额外占空间:直接在原数组上修改,用 “加 n” 标记,不用 HashSet 或新数组
- 时间 O (n):两次遍历数组,效率很高
- 标记不会冲突:原本的数最大是 n,加 n 后一定
>n
,能明确区分是否被标记
简单说就是:谁出现过,就给他 “老家”(对应位置)的数涨工资(加 n),最后没涨工资的 “老家”,就是没人回来过(数字没出现)~
【更详细地介绍以上标记法则】
先明确前提
- 数组
nums
的长度是n
(比如示例 1 中n=8
) - 数组里的数字范围是
1~n
(比如示例 1 中数字只可能是 1~8) - 我们要找的是
1~n
中没出现在数组里的数字
核心原理
用数组自己的位置来 “记录” 某个数字是否出现过:
- 对于数字
num
(1~n),它 “应该在” 的位置是num-1
(因为数组索引从 0 开始) - 如果
num
出现过,就给num-1
这个位置的数 “加 n”(做个标记) - 最后,没被 “加 n” 的位置,对应的数字就是消失的数字
逐行解析代码(结合示例 1:nums = [4,3,2,7,8,2,3,1]
,n=8
)
第一步:第一次遍历数组,给出现过的数字做标记
int n = nums.length; // n=8(数组长度)
for (int num : nums) { // 遍历数组里的每个数字
int x = (num - 1) % n; // 计算当前数字应该标记的位置
if (nums[x] <= n) { // 如果这个位置还没被标记
nums[x] += n; // 给这个位置的数加n(标记为“出现过”)
}
}
我们一步一步走这个循环:
第一个数字
num=4
:- 计算位置:
x = (4-1) % 8 = 3 % 8 = 3
(对应索引 3) - 检查
nums[3]
的值:原本是 7(7 <= 8,没被标记) - 标记:
nums[3] = 7 + 8 = 15
现在数组变成:[4,3,2,15,8,2,3,1]
- 计算位置:
第二个数字
num=3
:- 计算位置:
x = (3-1) % 8 = 2 % 8 = 2
(对应索引 2) - 检查
nums[2]
的值:原本是 2(2 <= 8,没被标记) - 标记:
nums[2] = 2 + 8 = 10
现在数组变成:[4,3,10,15,8,2,3,1]
- 计算位置:
第三个数字
num=2
:- 计算位置:
x = (2-1) % 8 = 1 % 8 = 1
(对应索引 1) - 检查
nums[1]
的值:原本是 3(3 <= 8,没被标记) - 标记:
nums[1] = 3 + 8 = 11
现在数组变成:[4,11,10,15,8,2,3,1]
- 计算位置:
第四个数字
num=7
:- 计算位置:
x = (7-1) % 8 = 6 % 8 = 6
(对应索引 6) - 检查
nums[6]
的值:原本是 3(3 <= 8,没被标记) - 标记:
nums[6] = 3 + 8 = 11
现在数组变成:[4,11,10,15,8,2,11,1]
- 计算位置:
第五个数字
num=8
:- 计算位置:
x = (8-1) % 8 = 7 % 8 = 7
(对应索引 7) - 检查
nums[7]
的值:原本是 1(1 <= 8,没被标记) - 标记:
nums[7] = 1 + 8 = 9
现在数组变成:[4,11,10,15,8,2,11,9]
- 计算位置:
第六个数字
num=2
:- 计算位置:
x = (2-1) % 8 = 1 % 8 = 1
(对应索引 1) - 检查
nums[1]
的值:现在是 11(11 > 8,已经被标记过了) - 不做操作,数组保持不变
- 计算位置:
第七个数字
num=3
:- 计算位置:
x = (3-1) % 8 = 2 % 8 = 2
(对应索引 2) - 检查
nums[2]
的值:现在是 10(10 > 8,已经被标记过了) - 不做操作,数组保持不变
- 计算位置:
第八个数字
num=1
:- 计算位置:
x = (1-1) % 8 = 0 % 8 = 0
(对应索引 0) - 检查
nums[0]
的值:原本是 4(4 <= 8,没被标记) - 标记:
nums[0] = 4 + 8 = 12
现在数组变成:[12,11,10,15,8,2,11,9]
- 计算位置:
第二步:第二次遍历数组,收集消失的数字
java
运行
List<Integer> ret = new ArrayList<Integer>(); // 准备存放结果
for (int i = 0; i < n; i++) { // 遍历每个位置
if (nums[i] <= n) { // 如果这个位置的数没被标记(没加过n)
ret.add(i + 1); // 这个位置对应的数字就是消失的数字
}
}
return ret;
遍历修改后的数组 [12,11,10,15,8,2,11,9]
:
- 索引 0:
nums[0]=12
(12 > 8 → 被标记过)→ 对应数字 1 出现过 - 索引 1:
nums[1]=11
(11 > 8 → 被标记过)→ 对应数字 2 出现过 - 索引 2:
nums[2]=10
(10 > 8 → 被标记过)→ 对应数字 3 出现过 - 索引 3:
nums[3]=15
(15 > 8 → 被标记过)→ 对应数字 4 出现过 - 索引 4:
nums[4]=8
(8 <= 8 → 没被标记过)→ 对应数字 5 消失了,加入结果 - 索引 5:
nums[5]=2
(2 <= 8 → 没被标记过)→ 对应数字 6 消失了,加入结果 - 索引 6:
nums[6]=11
(11 > 8 → 被标记过)→ 对应数字 7 出现过 - 索引 7:
nums[7]=9
(9 > 8 → 被标记过)→ 对应数字 8 出现过
最终结果就是 [5,6]
,和示例一致。
关键细节解释
为什么要用
(num - 1) % n
?
因为第一次遍历中,有些数字可能已经被 “加 n” 了(比如原本是 3,加 n 后变成 11),%n
能把它还原成原本的数字(11%8=3),保证计算的位置正确。为什么
nums[x] <= n
就表示没被标记?
原数组的数字最大是 n,一旦被标记(加 n),就会变成>n
,所以用<=n
能准确判断是否被标记过。为什么最后
i+1
是消失的数字?
因为索引 i 对应的 “应该出现的数字” 是i+1
(比如索引 0 对应 1,索引 1 对应 2,…,索引 4 对应 5)。
这个方法的精髓就是 “利用数组自身空间做标记”,既不额外占内存,又能在 O (n) 时间内解决问题,非常巧妙~