先说个结论:这道题我刷了三遍才彻底想明白。不是因为解法多难,而是我第一次拿到题时陷入了思维定式——上来就想着开个哈希表记录出现次数,然后遍历找缺失值。等看到“要求O(1)空间”才意识到,力扣hot100这个级别的题目,每一道都在教你一件事:怎样在资源受限的情况下,把手头已有的东西用到极致。
448. 找到所有数组中消失的数字,题面简单到让人放松警惕:给你一个长度为 n 的整数数组 nums,其中 nums[i] 在区间 [1, n] 内,请你找出所有在 [1, n] 范围内但没有出现在 nums 中的数字,以数组形式返回结果。
举个例子:输入 nums = [4,3,2,7,8,2,3,1],输出 [5,6]。就这么一道题,牵扯出数组哈希、索引映射、原地修改、值域与下标的关系,几乎把数组类题目的底层思维全串起来了。我自己在刷这题时踩过不少坑,也总结了一套能直接用的模板,下面从头到脚拆一遍。
1. 题目本质上在考什么:从暴力解到哈希表的思维跳变
1.1 题意还原与最直观的暴力方案
先把题目的约束条件摆清楚。数组长度是 n,元素值的范围被限定在 [1, n] 之间,注意这个“值域等于索引域”的设定——它是整道题的灵魂。你需要找出 [1, n] 中哪些数字没有在数组中出现过。
最没技术含量的做法是双层循环:外层枚举 1 到 n 的每个数字,内层扫描数组看它是否出现过。时间复杂度 O(n²),在 n 达到 10⁵ 甚至 10⁶ 的规模下直接超时。这种方案唯一的优点是脑死亡式好写,适合用来确认自己对题意的理解没有偏差。
第二种是排序后比较:先对数组排序,然后从 1 到 n 依次核对。排序本身 O(n log n),空间 O(1)(原地排序的话)。这个方案能过,但面试官问一句“能不能 O(n) 时间 O(1) 空间”就会卡住。
第三种是哈希集合:遍历数组把所有出现过的值塞进 Set,再遍历 1 到 n,不在 Set 里的就是缺失值。时间 O(n),空间 O(n),非常直观。但题目通常标注了进阶要求:能否在 O(n) 时间且仅使用额外 O(1) 空间的条件下解决?
1.2 哈希集合方案:牺牲空间换时间
用哈希集合是最容易想到的解法,尤其适合刚接触这类题目的人。具体步骤很简单:
- 初始化一个空 Set。
- 遍历数组,把所有值 add 进 Set。
- 从 1 循环到 n,逐个检查 Set.has(i),没有就 push 到结果数组。
这样写没有任何难度,而且边界情况也好处理。但它违背了题目的进阶意图。LeetCode 上这道题明确写了“你能在不使用额外空间且时间复杂度为 O(n) 的情况下解决这个问题吗?”,这句话不是摆设,它希望你把数组本身当成哈希表。
我在实际刷题时会先想清楚一个问题:哈希表在这里到底做了什么?答案是提供了“某个值是否存在”的 O(1) 查询。如果我们能把“查询存在性”这件事转移到输入数组本身上,那就不需要额外空间了。这个转移的核心依据是什么?值域 [1, n] 与索引域 [0, n-1] 恰好是一一对应的偏移关系。
1.3 关键约束:为什么 O(1) 空间成了必选项
很多人不理解为什么要纠结那 O(n) 的空间。如果是在力扣上刷题,哈希集合解法已经能通过,平台不会因为空间多用了 O(n) 就不给你过。但真实场景下,海量数据场景内存是稀缺资源;面试考察的是你能否在极端约束下设计算法。更重要的是,O(1) 空间解法所要求的思维模式——利用数据本身的特征进行原地标记——是一种可迁移的高频技巧,后续你会反复遇到。
我自己的经验是:遇到“值域与数组长度一致”这个特征,就要敏锐起来。这不是巧合,而是出题人故意设置的条件。如果你只是无脑用哈希表,等于放弃了理解出题意图的机会。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 原地标记法的核心原理:把数组本身变成一张哈希表
2.1 值域与索引的映射关系拆解
原地标记法的核心是看穿一层映射:数字 v 应当对应索引 v-1。
为什么是 v-1 而不是 v?因为数组索引从 0 开始。如果数组里有数字 1,它对应的索引就是 0;有数字 5,对应索引就是 4。换句话说,题目给的数值范围 [1, n] 刚好偏移一位就能映射到 [0, n-1] 的索引范围。这个偏移看似简单,但实际编码时非常容易出错——我见过不少人取模、减一、加一之间把自己绕晕。
标记法的具体思路是:遍历数组,遇到值 x,就把 x-1 这个索引位置的元素标记为“已存在”。怎么标记?由于数组元素本来就是 [1, n] 之间的数,正负号没有被使用,我们可以把 nums[x-1] 变成负数,表示“数字 x 出现过”。遍历结束后,所有仍为正数的索引位置 i,说明数字 i+1 没有出现过,收集它们就是答案。
2.2 标记操作的防重与防越界:mod 运算的必要性
这里有个非常关键的细节:当一个位置的元素已经被标记为负数后,它原本的值就丢了。但我们后续还可能遇到这个值,比如 [4,3,2,7,8,2,3,1] 中数字 2 出现两次,第一次遇到 2 时把 nums[1] 标记为负数,第二次再遇到 2 时,如果直接取 nums[1] 的值,得到的是负数,用它做索引就出大问题了。
解决方案是在每次读取元素时取绝对值:int x = Math.abs(nums[i]);。无论当前位置被标记成负数,取绝对值后就能恢复它原本代表的数字,再对 x-1 索引位置做标记。标记操作也要加上绝对值:nums[x - 1] = -Math.abs(nums[x - 1]);。这样即使同一个索引被重复标记,也只会从负数变到更小的负数,不会翻回正数。
用数学语言描述:设原数组为 arr,标记后变成 arr',则 arr'[i] = -|arr[i]| 当且仅当数字 i+1 出现过至少一次。重复标记不会破坏这个性质,因为负数取绝对后再取负还是负数。
2.3 三步走流程:标记、恢复、扫描
我把这套流程整理成标准三步,配合伪代码方便理解:
第一步,遍历数组,对每个值 x = abs(nums[i]),检查 nums[x-1] 是否为正:
- 如果为正,把它改成负数。
- 如果为负,说明以前已经标记过了,保持不变。
第二步,遍历数组,找到仍然为正的索引 i,那么 i+1 就是缺失的数字。因为如果数字 i+1 出现过,nums[i] 一定已经被标记为负;只有没出现过的数字对应的位置才保持为正。
第三步,把收集到的 i+1 按顺序返回。
伪代码:
code复制for i = 0 to n-1:
x = abs(nums[i])
nums[x-1] = -abs(nums[x-1])
result = []
for i = 0 to n-1:
if nums[i] > 0:
result.append(i+1)
return result
这个方案的时间复杂度 O(n),空间复杂度 O(1)(只用了结果数组和几个临时变量)。整个算法的巧妙之处在于:不需要记住哪些数字出现过,只需要标记它们对应的位置,最后剩下没被标记的位置就是答案。
3. 另一种原地思路:置换归位法的实现与陷阱
3.1 置换归位的核心逻辑
除了标记负数,还有一类原地解法叫“置换归位”,思路更粗暴:把每个数字放到它“应该待着的位置”。数字 1 应该放在索引 0,数字 2 应该放在索引 1,数字 v 应该放在索引 v-1。遍历数组,每次遇到不归位的元素,就和正确位置上那个元素交换。最终所有出现过一次以上的数字会占据它们该在的索引,而缺失数字对应的索引位置会被重复数字占据。
归位完成后,再遍历一遍数组,凡是 nums[i] != i+1 的位置,i+1 就是缺失的数字。
具体来说,交换发生在当前元素 num 与目标位置元素 nums[num-1] 不相等时:
code复制for i = 0 to n-1:
while nums[i] != i+1:
targetIndex = nums[i] - 1
if nums[targetIndex] == nums[i]:
break
swap(nums[i], nums[targetIndex])
这个代码的细节比标记法更隐蔽,容易出问题。
3.2 循环处理 nums[i] != nums[nums[i]-1]
我在第一次写这个方案时,就死在无限循环上。原因是我只在 for 循环里做单次判断,没有用 while 把元素一路置换到它该去的位置。举例:nums[0] = 4,交换后 4 到了索引 3 的位置,但索引 0 换回的数字可能依然不在正确位置,需要继续交换。如果只交换一次就进入下一轮循环,就会出现很多元素没归位,最终结果错得离谱。
另一个坑是 break 条件:如果当前位置的数字和它该去位置上的数字相等,说明这个数字已经是重复的,再交换也是白交换,而且会陷入死循环。比如 nums = [1,1,3,2] 这种场景,当索引 1 的值是 1,而 nums[0] 也是 1 时,二者相等,交换无法改变状态,必须直接 break。
3.3 对比两种 O(1) 空间方案各自的优劣
我实际用下来,两种方法的取舍很明显:
| 对比维度 | 标记负数法 | 置换归位法 |
|---|---|---|
| 代码直观度 | 需要理解正负号含义 | 更接近“物理放位置”的直觉 |
| 是否会覆盖原值 | 会,但用 abs 找回 | 不会,只是交换位置 |
| 边界处理难度 | 需要处理重复标记 | 需要处理死循环与重复值 |
| 适用场景 | 任何数组都能用 | 要求数组元素范围与长度对齐 |
| 面试表达 | 容易解释,逻辑简洁 | 细节多,容易写崩 |
我个人更推荐标记负数法,因为它在代码实现上更不容易出 bug。置换归位法虽然看起来更“机械”,但 while 循环和 break 条件的组合一旦写错,排查成本很高。
4. 完整代码实现与样例走查
4.1 多语言实现示例
JavaScript 版标记法:
javascript复制function findDisappearedNumbers(nums) {
const n = nums.length;
for (let i = 0; i < n; i++) {
const x = Math.abs(nums[i]);
nums[x - 1] = -Math.abs(nums[x - 1]);
}
const result = [];
for (let i = 0; i < n; i++) {
if (nums[i] > 0) {
result.push(i + 1);
}
}
return result;
}
Python 版同样简洁:
python复制def find_disappeared_numbers(nums):
n = len(nums)
for i in range(n):
x = abs(nums[i])
nums[x - 1] = -abs(nums[x - 1])
return [i + 1 for i in range(n) if nums[i] > 0]
C++ 版:
cpp复制class Solution {
public:
vector<int> findDisappearedNumbers(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n; i++) {
int x = abs(nums[i]);
nums[x - 1] = -abs(nums[x - 1]);
}
vector<int> result;
for (int i = 0; i < n; i++) {
if (nums[i] > 0) result.push_back(i + 1);
}
return result;
}
};
Java 版:
java复制class Solution {
public List<Integer> findDisappearedNumbers(int[] nums) {
int n = nums.length;
for (int i = 0; i < n; i++) {
int x = Math.abs(nums[i]);
nums[x - 1] = -Math.abs(nums[x - 1]);
}
List<Integer> result = new ArrayList<>();
for (int i = 0; i < n; i++) {
if (nums[i] > 0) result.add(i + 1);
}
return result;
}
}
4.2 样例模拟与手动走查
拿官方示例 [4,3,2,7,8,2,3,1] 走一遍,感受整个过程:
初始数组:4, 3, 2, 7, 8, 2, 3, 1
遍历 i=0,x=abs(4)=4,标记 nums[3] = -7:
数组变为:4, 3, 2, -7, 8, 2, 3, 1
遍历 i=1,x=abs(3)=3,标记 nums[2] = -2:
数组变为:4, 3, -2, -7, 8, 2, 3, 1
遍历 i=2,x=abs(-2)=2,标记 nums[1] = -3:
数组变为:4, -3, -2, -7, 8, 2, 3, 1
遍历 i=3,x=abs(-7)=7,标记 nums[6] = -3:
数组变为:4, -3, -2, -7, 8, 2, -3, 1
遍历 i=4,x=abs(8)=8,标记 nums[7] = -1:
数组变为:4, -3, -2, -7, 8, 2, -3, -1
遍历 i=5,x=abs(2)=2,标记 nums[1],但 nums[1] 已经是 -3,继续取负还是 -3:
数组变为:4, -3, -2, -7, 8, 2, -3, -1(注意这个位置没有变化)
遍历 i=6,x=abs(-3)=3,标记 nums[2],nums[2] 已经是 -2,继续保持:
数组不变
遍历 i=7,x=abs(-1)=1,标记 nums[0] = -4:
数组变为:-4, -3, -2, -7, 8, 2, -3, -1
最后检查:nums[4] = 8 和 nums[5] = 2 是正数,对应索引 4 和 5,即数字 5 和 6 缺失。
这个手动走查能直观看到重复元素被标记多次但没有出错的原因——绝对值恢复。
4.3 边界情况的测试列表
我用下面这几个测试用例验证代码是否健壮:
- 输入 [1,2,3,4]:所有数字都在,输出 []。
- 输入 [4,3,2,7,8,2,3,1]:标准用例,输出 [5,6]。
- 输入 [1,1]:数字 2 缺失,输出 [2]。
- 输入 [2,2]:数字 1 缺失,输出 [1]。
- 输入 [1]:n=1,数字 1 存在,输出 []。
- 输入 [1,1,1,1]:输出 [2,3,4]。
- 输入 [2,2,2,2]:输出 [1,3,4]。
每个用例跑一遍都没问题。注意 [1,1] 这种情况:遍历 i=0 时 x=1,标记 nums[0] 为 -1;遍历 i=1 时 x=abs(-1)=1,又会标记 nums[0],但它已经是 -1,取负还是 -1。最终 nums[1] 是正数 1,输出 2。完全符合预期。
5. 这些坑我踩过:提交 LeetCode 时的实际排查经验
5.1 负数标记后忘记取绝对值
这是我最初写代码时犯的第一个错误。我写出这样的代码:
python复制for x in nums:
nums[x - 1] = -nums[x - 1]
看起来没什么问题,但当数组里有重复元素时就会翻车。举个例子,nums = [2, 2],遍历 i=0 时 x=2,nums[1] = -2;遍历 i=1 时 x=-2,x-1 = -3,索引越界。
修复方式就是在读取元素时取绝对值:x = abs(nums[i])。这个 bug 排查起来其实不难,难的是提前预判到它。我的建议是:只要一个位置的数值会被修改,之后又要根据它做索引运算,就必须在读取时恢复它的原始语义。
5.2 索引从 0 开始 vs 数值从 1 开始的偏移
我第一次写标记法时,写完第一轮遍历后,在第二轮里直接判断 nums[i] > 0 就把 i 本身放进结果。结果输出 [0,1] 而不是 [1,2],因为忘了把 i 加 1。
这个偏移问题不只是这道题有,几乎每道值域与索引相关的数组题都会碰到。我的建议是:先把映射关系写在纸上,比如“数字 v → 索引 v-1”,再写代码。写完后用一个长度为 3 的小数组模拟一遍,基本能查出来。
5.3 对输入数组不可变场景的思考
如果题目要求“不能修改原数组”,标记法就用不了了。这时候有几种替代方案:
第一种,用额外 O(n) 空间的 Set,前面提到过。第二种,用位图 bitmap,用每一位表示一个数字是否出现,空间是 O(n/8)。第三种,如果数组是有序的,可以用二分法判断每个数字是否出现,时间会退化成 O(n log n)。第四种,数学法:如果只有一个缺失数字,可以用求和公式;如果有多个缺失,可以用平方和或者异或技巧,但实现复杂,不推荐。
实际刷题中,如果题目没明确禁止修改输入数组,标记法都适用。遇到不可变数组,我会优先考虑位图方案,它在空间和时间之间取得了一个不错的平衡。
5.4 多语言实现的性能差异和坑
我写过 JS、Python、Java 三种版本,性能差异主要体现在取绝对值操作的频率和数据类型上。Python 的列表元素是对象,反复索引访问有额外开销,但 LeetCode 上这题数据量不大,AC 没问题。C++ 和 Java 的数组访问是最快的。
值得注意的一个细节是:JavaScript 里 Math.abs(-0) 返回 0,但数组元素不会出现 -0,所以没有影响。另外,如果数组元素本身可能包含值 0(虽然题目说不会),标记法会失效,因为 0 的索引是 -1,越界。遇到这种变形题要重新设计方案。
6. 从 448 延伸开:hot100 数组题家族的一鱼多吃
6.1 和 442、287、41 的内在联系
力扣 hot100 里有一组“数组 + 值域对齐”的题,解法核心都是一套标记思想:
- 442. 找到所有数组中重复的数字:除了不能用额外空间,还要求找到所有出现两次的数字。解法就是标记法反过来用:遍历数组,对 x = abs(nums[i]),如果 nums[x-1] 已经是负数,说明 x 重复;否则标记为负。把重复数字收集起来即可。
- 287. 寻找重复数:数组长度为 n+1,元素范围是 1 到 n,找出唯一重复的数字。这道题有更高级的解法(快慢指针),但也可以用标记法的变体做。
- 41. 缺失的第一个正数:数组长度 n,找缺失的最小正整数。核心思路是先把所有 <=0 和 >n 的数替换成 n+1,然后使用标记法。这道题需要处理的情况更多,但底层逻辑和 448 一脉相承。
我把这四道题称为“数组标记四兄弟”,刷完 448 后再去看其余三道,上手速度快很多。
6.2 三步标记法的通用模板
结合 448 和 442,我自己总结了一套通用的三步模板:
第一步,预处理数组(有时候需要,比如把无效值统一替换)。
第二步,遍历数组,对值 x = abs(nums[i]),访问 nums[x-1],做正负号标记或比对。
第三步,第二轮遍历,根据标记状态输出结果。
这套模板可以直接套到上面提到的三兄弟上,只需要微调第二步和第三步的条件。
拿 442 举例:第二步里,如果 nums[x-1] 已经是负数,就把 x 加入结果列表;否则把 nums[x-1] 改成负数。这一步就把“重复”筛选出来了,连第三步都不用。
拿 41 举例:预处理阶段把无效元素替换成 n+1,然后走标记流程,最后遍历找第一个正数索引,索引加 1 就是结果。
6.3 刷题策略:这类题目到底练什么
以我刷题的经验来看,448 这类题练的并不是“知道解法”,而是建立一种条件反射:看到数组长度和值域对齐,立刻想到利用索引本身做哈希。这种直觉的培养,靠刷题数量,更靠总结归纳。
我建议初学者按这个顺序来消化:
- 先理解哈希集合解法的思路,确保能独立写出来。
- 再啃标记法,手动模拟一遍例子,感受内存变化。
- 最后把 442、41 拿出来对比,找出共性,形成模板。
最后说点实在的:这道题在力扣 hot100 里难度不大,但它的思想密度很高。理解透它,比你刷十道“遍历 + 哈希表”的题更有价值。如果你正卡在数组类题目的瓶颈期,建议静下心把这题和它的“兄弟们”一起啃透,会把很多零散的知识点串起来。
我自己就是在这道题上手写了三次解法之后,才对“利用原数组做标记”这个技巧有了肌肉记忆。刷题这件事,不在于你过了多少题,而在于你有没有真正把每一类题背后的模式吃进去。448 值得你花一个下午,好好琢磨。
